mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
**Configure AMS Slot**
Manually configure AMS slots for third-party or generic filaments: 1. Hover over an AMS slot on the printer card 2. Click the menu button (:material-dots-vertical:) that appears 3. Select **Configure Slot** 4. Choose a filament preset from your Bambu Studio cloud presets 5. Select a matching K profile (pressure advance calibration) 6. Optionally set a custom color using the color picker 7. Click **Configure Slot** to apply **Color Picker Features:** - Enter custom hex codes or color names (e.g., "brown", "FF8800") - Live preview of selected color - Expandable color picker in Configure AMS Slot modal: - 8 basic colors shown by default - 24 additional colors available via expand button - Tests for ConfigureAmsSlotModal component - Tests for AMS change callback - Updated README with AMS slot configuration feature - Wiki documentation for Configure AMS Slot feature - Multi plate issue where plate names showed incorrect. #93 - Items from Queue end up as "source" files in archive. #107 - Added env variable support to change network port. #108 Docs -> https://wiki.bambuddy.cool/getting-started/docker/?h=port#custom-port
This commit is contained in:
parent
9bfb969169
commit
494da46b09
27 changed files with 2218 additions and 494 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -2,6 +2,22 @@
|
|||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b10] - 2026-01-20
|
||||
|
||||
### New Features
|
||||
- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
|
||||
- 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
|
||||
- Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
|
||||
- Click "-" to collapse back to basic colors
|
||||
|
||||
### Fixed
|
||||
- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
|
||||
- Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
|
||||
- User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
|
||||
- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
|
||||
- Added WebSocket broadcast to AMS change callback for instant UI updates
|
||||
- Removed unnecessary delayed refetch that was causing slow updates
|
||||
|
||||
## [0.1.6b9] - 2026-01-19
|
||||
|
||||
### New Features
|
||||
|
|
|
|||
|
|
@ -43,13 +43,15 @@ RUN mkdir -p /app/data /app/logs
|
|||
ENV PYTHONUNBUFFERED=1
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_DIR=/app/logs
|
||||
ENV PORT=8000
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
# Health check (uses PORT env var via shell)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
CMD python -c "import urllib.request, os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\", \"8000\")}/health')" || exit 1
|
||||
|
||||
# Run the application
|
||||
# Use standard asyncio loop (uvloop has permission issues in some Docker environments)
|
||||
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--loop", "asyncio"]
|
||||
# Port is configurable via PORT environment variable (default: 8000)
|
||||
CMD sh -c "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@
|
|||
- Resizable printer cards (S/M/L/XL)
|
||||
- Skip objects during print
|
||||
- AMS slot RFID re-read
|
||||
- AMS slot configuration (custom presets, K profiles, color picker)
|
||||
- HMS error monitoring with history
|
||||
- Print success rates & trends
|
||||
- Filament usage tracking
|
||||
|
|
@ -287,6 +288,8 @@ Open **http://localhost:8000** in your browser.
|
|||
|
||||
> **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.
|
||||
|
||||
> **Linux users:** If you get "permission denied" errors, either prefix commands with `sudo` (e.g., `sudo docker compose up -d`) or [add your user to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker Configuration & Commands</strong></summary>
|
||||
|
||||
|
|
@ -295,6 +298,7 @@ Open **http://localhost:8000** in your browser.
|
|||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |
|
||||
| `PORT` | `8000` | Port BamBuddy runs on (with host networking mode) |
|
||||
| `DEBUG` | `false` | Enable debug logging |
|
||||
| `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
|
||||
|
|
|
|||
|
|
@ -2010,14 +2010,39 @@ async def get_archive_plates(
|
|||
|
||||
plate_indices.sort()
|
||||
|
||||
# Parse model_settings.config for plate names
|
||||
# Plate names are stored with plater_id and plater_name keys
|
||||
plate_names = {} # plater_id -> name
|
||||
if "Metadata/model_settings.config" in namelist:
|
||||
try:
|
||||
model_content = zf.read("Metadata/model_settings.config").decode()
|
||||
model_root = ET.fromstring(model_content)
|
||||
for plate_elem in model_root.findall(".//plate"):
|
||||
plater_id = None
|
||||
plater_name = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
key = meta.get("key")
|
||||
value = meta.get("value")
|
||||
if key == "plater_id" and value:
|
||||
try:
|
||||
plater_id = int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
elif key == "plater_name" and value:
|
||||
plater_name = value.strip()
|
||||
if plater_id is not None and plater_name:
|
||||
plate_names[plater_id] = plater_name
|
||||
except Exception:
|
||||
pass # model_settings.config parsing is optional
|
||||
|
||||
# Parse slice_info.config for plate metadata
|
||||
plate_metadata = {} # plate_index -> {filaments, prediction, weight, name}
|
||||
plate_metadata = {} # plate_index -> {filaments, prediction, weight, name, objects}
|
||||
if "Metadata/slice_info.config" in namelist:
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
for plate_elem in root.findall(".//plate"):
|
||||
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None}
|
||||
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
|
||||
|
||||
# Get plate index from metadata
|
||||
plate_index = None
|
||||
|
|
@ -2067,12 +2092,23 @@ async def get_archive_plates(
|
|||
# Sort filaments by slot ID
|
||||
plate_info["filaments"].sort(key=lambda x: x["slot_id"])
|
||||
|
||||
# Get first object name as plate name hint
|
||||
first_obj = plate_elem.find("object")
|
||||
if first_obj is not None:
|
||||
plate_info["name"] = first_obj.get("name")
|
||||
# Collect all object names on this plate
|
||||
for obj_elem in plate_elem.findall("object"):
|
||||
obj_name = obj_elem.get("name")
|
||||
if obj_name and obj_name not in plate_info["objects"]:
|
||||
plate_info["objects"].append(obj_name)
|
||||
|
||||
# Set plate name: prefer custom name from model_settings.config,
|
||||
# fall back to first object name if no custom name was set
|
||||
if plate_index is not None:
|
||||
custom_name = plate_names.get(plate_index)
|
||||
if custom_name:
|
||||
plate_info["name"] = custom_name
|
||||
else:
|
||||
# Fall back to first object name as hint
|
||||
if plate_info["objects"]:
|
||||
plate_info["name"] = plate_info["objects"][0]
|
||||
|
||||
plate_metadata[plate_index] = plate_info
|
||||
|
||||
# Build plate list
|
||||
|
|
@ -2084,6 +2120,7 @@ async def get_archive_plates(
|
|||
{
|
||||
"index": idx,
|
||||
"name": meta.get("name"),
|
||||
"objects": meta.get("objects", []),
|
||||
"has_thumbnail": has_thumbnail,
|
||||
"thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
|
||||
if has_thumbnail
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,169 @@ async def delete_slot_preset(
|
|||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
|
||||
async def configure_ams_slot(
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
tray_info_idx: str = Query(...),
|
||||
tray_type: str = Query(...),
|
||||
tray_sub_brands: str = Query(...),
|
||||
tray_color: str = Query(...),
|
||||
nozzle_temp_min: int = Query(...),
|
||||
nozzle_temp_max: int = Query(...),
|
||||
cali_idx: int = Query(-1),
|
||||
nozzle_diameter: str = Query("0.4"),
|
||||
setting_id: str = Query(""),
|
||||
kprofile_filament_id: str = Query(""),
|
||||
kprofile_setting_id: str = Query(""),
|
||||
k_value: float = Query(0.0),
|
||||
):
|
||||
"""Configure an AMS slot with a specific filament setting and K profile.
|
||||
|
||||
This sends two commands to the printer:
|
||||
1. ams_filament_setting - sets filament type, color, temperature
|
||||
2. extrusion_cali_sel - sets the K profile (pressure advance value)
|
||||
|
||||
Args:
|
||||
printer_id: Database ID of the printer
|
||||
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
|
||||
tray_id: Tray ID within the AMS (0-3)
|
||||
tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
|
||||
tray_type: Filament type (e.g., "PLA", "PETG")
|
||||
tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
|
||||
tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
|
||||
nozzle_temp_min: Minimum nozzle temperature
|
||||
nozzle_temp_max: Maximum nozzle temperature
|
||||
cali_idx: K profile calibration index (-1 for default 0.020)
|
||||
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
|
||||
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
|
||||
kprofile_filament_id: K profile's filament_id for proper K profile linking
|
||||
k_value: Direct K value to set (0.0 to skip direct K value setting)
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[configure_ams_slot] printer_id={printer_id}, ams_id={ams_id}, tray_id={tray_id}")
|
||||
logger.info(
|
||||
f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
|
||||
)
|
||||
logger.info(
|
||||
f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
|
||||
)
|
||||
|
||||
# Get MQTT client for this printer
|
||||
client = printer_manager.get_client(printer_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=400, detail="Printer not connected")
|
||||
|
||||
# Send the filament setting command (type, color, temp)
|
||||
success = 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=nozzle_temp_min,
|
||||
nozzle_temp_max=nozzle_temp_max,
|
||||
setting_id=setting_id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
|
||||
|
||||
# Send the calibration/K-profile commands
|
||||
# Use the K profile's filament_id if provided, otherwise use tray_info_idx
|
||||
filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
|
||||
|
||||
# Method 1: Select existing calibration profile by cali_idx
|
||||
# IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
|
||||
# Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
|
||||
client.extrusion_cali_sel(
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
cali_idx=cali_idx,
|
||||
filament_id=filament_id_for_kprofile,
|
||||
nozzle_diameter=nozzle_diameter,
|
||||
setting_id=kprofile_setting_id if kprofile_setting_id else None,
|
||||
)
|
||||
|
||||
# Method 2: Also directly set the K value if provided (for better compatibility)
|
||||
if k_value > 0:
|
||||
# Calculate global tray ID for extrusion_cali_set
|
||||
if ams_id <= 3:
|
||||
global_tray_id = ams_id * 4 + tray_id
|
||||
elif ams_id >= 128 and ams_id <= 135:
|
||||
global_tray_id = (ams_id - 128) * 4 + tray_id
|
||||
else:
|
||||
global_tray_id = tray_id
|
||||
|
||||
client.extrusion_cali_set(
|
||||
tray_id=global_tray_id,
|
||||
k_value=k_value,
|
||||
n_coef=0.0,
|
||||
nozzle_diameter=nozzle_diameter,
|
||||
bed_temp=60,
|
||||
nozzle_temp=nozzle_temp_max,
|
||||
max_volumetric_speed=20.0,
|
||||
)
|
||||
|
||||
# Request fresh status push from printer so frontend gets updated data via WebSocket
|
||||
logger.info("[configure_ams_slot] Requesting status update from printer")
|
||||
update_result = client.request_status_update()
|
||||
logger.info(f"[configure_ams_slot] Status update request result: {update_result}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
|
||||
async def reset_ams_slot(
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reset an AMS slot to empty/unconfigured state.
|
||||
|
||||
This clears the filament configuration from the slot.
|
||||
"""
|
||||
# Get MQTT client for this printer
|
||||
client = printer_manager.get_client(printer_id)
|
||||
if not client:
|
||||
raise HTTPException(status_code=400, detail="Printer not connected")
|
||||
|
||||
# Reset the slot
|
||||
success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send reset command")
|
||||
|
||||
# Also delete any saved slot preset mapping
|
||||
result = await db.execute(
|
||||
select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer_id,
|
||||
SlotPresetMapping.ams_id == ams_id,
|
||||
SlotPresetMapping.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping:
|
||||
await db.delete(mapping)
|
||||
await db.commit()
|
||||
|
||||
# Request fresh status push from printer so frontend gets updated data via WebSocket
|
||||
client.request_status_update()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Reset AMS {ams_id} tray {tray_id}",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{printer_id}/debug/simulate-print-complete")
|
||||
async def debug_simulate_print_complete(
|
||||
printer_id: int,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from pathlib import Path
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "0.1.6-final"
|
||||
APP_VERSION = "0.1.6b10"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
|
||||
# App directory - where the application is installed (for static files)
|
||||
|
|
|
|||
|
|
@ -280,6 +280,19 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
|||
except Exception:
|
||||
pass # Don't fail AMS callback if MQTT fails
|
||||
|
||||
# Broadcast AMS change via WebSocket (bypasses status_key deduplication)
|
||||
# This ensures frontend gets immediate updates when AMS slots are configured
|
||||
try:
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if state:
|
||||
logger.info(f"[Printer {printer_id}] Broadcasting AMS change via WebSocket")
|
||||
await ws_manager.send_printer_status(
|
||||
printer_id,
|
||||
printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to broadcast AMS change for printer {printer_id}: {e}")
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ class BambuMQTTClient:
|
|||
# Track last message time - receiving a message proves we're connected
|
||||
self._last_message_time = time.time()
|
||||
self.state.connected = True
|
||||
|
||||
# TEMP: Dump full payload once to find extruder state field
|
||||
if not hasattr(self, "_payload_dumped"):
|
||||
self._payload_dumped = True
|
||||
|
|
@ -1858,7 +1859,9 @@ class BambuMQTTClient:
|
|||
True if the request was sent, False if not connected.
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] request_status_update: not connected")
|
||||
return False
|
||||
logger.info(f"[{self.serial_number}] Requesting status update (pushall)")
|
||||
self._request_push_all()
|
||||
# Note: get_accessories returns stale nozzle data on H2D.
|
||||
# The correct nozzle data comes from push_status response.
|
||||
|
|
@ -3155,20 +3158,22 @@ class BambuMQTTClient:
|
|||
tray_color: str,
|
||||
nozzle_temp_min: int,
|
||||
nozzle_temp_max: int,
|
||||
k: float,
|
||||
setting_id: str = "",
|
||||
) -> bool:
|
||||
"""Set AMS tray filament settings including K (pressure advance) value.
|
||||
"""Set AMS tray filament settings (type, color, temperature).
|
||||
|
||||
Note: K value is set separately via extrusion_cali_sel command.
|
||||
|
||||
Args:
|
||||
ams_id: AMS unit ID (0-3)
|
||||
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
|
||||
tray_id: Tray ID within the AMS (0-3)
|
||||
tray_info_idx: Filament preset ID (e.g., "GFA00")
|
||||
tray_info_idx: Filament ID short format (e.g., "GFL05")
|
||||
tray_type: Filament type (e.g., "PLA", "PETG")
|
||||
tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
|
||||
tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
|
||||
nozzle_temp_min: Minimum nozzle temperature
|
||||
nozzle_temp_max: Maximum nozzle temperature
|
||||
k: Pressure advance (K) value (e.g., 0.020)
|
||||
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
|
|
@ -3177,25 +3182,194 @@ class BambuMQTTClient:
|
|||
logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
|
||||
return False
|
||||
|
||||
# Calculate slot_id based on AMS type
|
||||
if ams_id <= 3:
|
||||
slot_id = tray_id
|
||||
else:
|
||||
# AMS-HT or external: slot_id = 0
|
||||
slot_id = 0
|
||||
|
||||
command = {
|
||||
"print": {
|
||||
"command": "ams_filament_setting",
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
"slot_id": slot_id,
|
||||
"tray_info_idx": tray_info_idx,
|
||||
"tray_type": tray_type,
|
||||
"tray_sub_brands": tray_sub_brands,
|
||||
"tray_color": tray_color,
|
||||
"nozzle_temp_min": nozzle_temp_min,
|
||||
"nozzle_temp_max": nozzle_temp_max,
|
||||
"k": k,
|
||||
"sequence_id": "0",
|
||||
}
|
||||
}
|
||||
|
||||
# Include setting_id if provided (helps slicer show correct profile)
|
||||
if setting_id:
|
||||
command["print"]["setting_id"] = setting_id
|
||||
|
||||
command_json = json.dumps(command)
|
||||
logger.info(
|
||||
f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}"
|
||||
)
|
||||
logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
|
||||
def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool:
|
||||
"""Reset an AMS slot to empty/unconfigured state.
|
||||
|
||||
Args:
|
||||
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
|
||||
tray_id: Tray ID within the AMS (0-3)
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot reset AMS slot: not connected")
|
||||
return False
|
||||
|
||||
# Calculate slot_id based on AMS type
|
||||
if ams_id <= 3:
|
||||
slot_id = tray_id
|
||||
else:
|
||||
slot_id = 0
|
||||
|
||||
command = {
|
||||
"print": {
|
||||
"command": "ams_filament_setting",
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id,
|
||||
"slot_id": slot_id,
|
||||
"tray_info_idx": "",
|
||||
"tray_type": "",
|
||||
"tray_sub_brands": "",
|
||||
"tray_color": "00000000",
|
||||
"nozzle_temp_min": 0,
|
||||
"nozzle_temp_max": 0,
|
||||
"sequence_id": "0",
|
||||
}
|
||||
}
|
||||
|
||||
command_json = json.dumps(command)
|
||||
logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
|
||||
logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
|
||||
logger.info(f"[{self.serial_number}] Resetting AMS slot: AMS {ams_id}, tray {tray_id}")
|
||||
logger.debug(f"[{self.serial_number}] reset_ams_slot command: {command_json}")
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
|
||||
def extrusion_cali_sel(
|
||||
self,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
cali_idx: int,
|
||||
filament_id: str,
|
||||
nozzle_diameter: str = "0.4",
|
||||
setting_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Set calibration profile (K value) for an AMS slot.
|
||||
|
||||
This command selects a K profile from the printer's calibration list.
|
||||
Use cali_idx=-1 to use the default K value (0.020).
|
||||
|
||||
Args:
|
||||
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
|
||||
tray_id: Tray ID within the AMS (0-3)
|
||||
cali_idx: Calibration profile index (-1 for default)
|
||||
filament_id: Filament preset ID (same as tray_info_idx)
|
||||
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
|
||||
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot set calibration: not connected")
|
||||
return False
|
||||
|
||||
# Calculate slot_id based on AMS type
|
||||
# tray_id in the command should be the local tray index (0-3)
|
||||
if ams_id <= 3:
|
||||
slot_id = tray_id
|
||||
elif ams_id >= 128 and ams_id <= 135:
|
||||
slot_id = 0
|
||||
else:
|
||||
slot_id = 0
|
||||
|
||||
command = {
|
||||
"print": {
|
||||
"command": "extrusion_cali_sel",
|
||||
"cali_idx": cali_idx,
|
||||
"filament_id": filament_id,
|
||||
"nozzle_diameter": nozzle_diameter,
|
||||
"ams_id": ams_id,
|
||||
"tray_id": tray_id, # Local tray index (0-3), not global
|
||||
"slot_id": slot_id,
|
||||
"sequence_id": "0",
|
||||
}
|
||||
}
|
||||
|
||||
# Include setting_id if provided (helps slicer show correct K profile)
|
||||
if setting_id:
|
||||
command["print"]["setting_id"] = setting_id
|
||||
|
||||
command_json = json.dumps(command)
|
||||
logger.info(
|
||||
f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}"
|
||||
)
|
||||
logger.debug(f"[{self.serial_number}] extrusion_cali_sel command: {command_json}")
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
|
||||
def extrusion_cali_set(
|
||||
self,
|
||||
tray_id: int,
|
||||
k_value: float,
|
||||
n_coef: float = 0.0,
|
||||
nozzle_diameter: str = "0.4",
|
||||
bed_temp: int = 60,
|
||||
nozzle_temp: int = 220,
|
||||
max_volumetric_speed: float = 20.0,
|
||||
) -> bool:
|
||||
"""Directly set K value (pressure advance) for a tray.
|
||||
|
||||
This command sets the K value directly without selecting from stored profiles.
|
||||
Use this when you want to apply a specific K value to a tray.
|
||||
|
||||
Args:
|
||||
tray_id: Global tray ID (ams_id * 4 + slot)
|
||||
k_value: Pressure advance K value (e.g., 0.020)
|
||||
n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration)
|
||||
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
|
||||
bed_temp: Bed temperature for calibration reference
|
||||
nozzle_temp: Nozzle temperature for calibration reference
|
||||
max_volumetric_speed: Max volumetric speed for calibration reference
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot set K value: not connected")
|
||||
return False
|
||||
|
||||
command = {
|
||||
"print": {
|
||||
"command": "extrusion_cali_set",
|
||||
"tray_id": tray_id,
|
||||
"k_value": k_value,
|
||||
"n_coef": n_coef,
|
||||
"nozzle_diameter": nozzle_diameter,
|
||||
"bed_temp": bed_temp,
|
||||
"nozzle_temp": nozzle_temp,
|
||||
"max_volumetric_speed": max_volumetric_speed,
|
||||
"sequence_id": "0",
|
||||
}
|
||||
}
|
||||
|
||||
command_json = json.dumps(command)
|
||||
logger.info(f"[{self.serial_number}] Publishing extrusion_cali_set: tray {tray_id}, k_value={k_value}")
|
||||
logger.debug(f"[{self.serial_number}] extrusion_cali_set command: {command_json}")
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -916,3 +916,34 @@ class TestInitPrinterConnections:
|
|||
await init_printer_connections(mock_db)
|
||||
|
||||
mock_manager.connect_printer.assert_not_called()
|
||||
|
||||
|
||||
class TestAmsChangeCallback:
|
||||
"""Tests for AMS change callback functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
"""Create a fresh PrinterManager instance."""
|
||||
return PrinterManager()
|
||||
|
||||
def test_ams_change_callback_is_triggered(self, manager):
|
||||
"""Verify AMS change callback is called when AMS data changes."""
|
||||
callback = MagicMock()
|
||||
manager.set_ams_change_callback(callback)
|
||||
|
||||
# Verify callback was set
|
||||
assert manager._on_ams_change == callback
|
||||
|
||||
def test_ams_change_callback_receives_correct_data(self, manager):
|
||||
"""Verify AMS change callback receives the correct AMS data format."""
|
||||
received_data = []
|
||||
|
||||
def capture_callback(printer_id, ams_data):
|
||||
received_data.append((printer_id, ams_data))
|
||||
|
||||
manager.set_ams_change_callback(capture_callback)
|
||||
|
||||
# The callback should accept printer_id and ams_data
|
||||
# This tests the callback signature
|
||||
assert manager._on_ams_change is not None
|
||||
assert callable(manager._on_ams_change)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ services:
|
|||
# Comment out "network_mode: host" above and uncomment "ports:" below.
|
||||
# Note: Printer discovery won't work - add printers manually by IP.
|
||||
#ports:
|
||||
# - "8000:8000"
|
||||
# - "${PORT:-8000}:8000"
|
||||
volumes:
|
||||
- bambuddy_data:/app/data
|
||||
- bambuddy_logs:/app/logs
|
||||
|
|
@ -24,6 +24,9 @@ services:
|
|||
- ./virtual_printer:/app/data/virtual_printer
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
# Port BamBuddy runs on (default: 8000)
|
||||
# Usage: PORT=8080 docker compose up -d
|
||||
- PORT=${PORT:-8000}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
|
|
|||
207
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
Normal file
207
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Tests for the ConfigureAmsSlotModal component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getCloudSettings: vi.fn(),
|
||||
getKProfiles: vi.fn(),
|
||||
configureAmsSlot: vi.fn(),
|
||||
getCloudSettingDetail: vi.fn(),
|
||||
saveSlotPreset: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCloudSettings = {
|
||||
filament: [
|
||||
{
|
||||
setting_id: 'GFSL05_09',
|
||||
name: 'Bambu PLA Basic @BBL X1C',
|
||||
filament_id: 'GFL05',
|
||||
},
|
||||
{
|
||||
setting_id: 'PFUScd84f663d2c2ef',
|
||||
name: '# Overture Matte PLA @BBL H2D',
|
||||
filament_id: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockKProfiles = {
|
||||
profiles: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'PLA Basic',
|
||||
k_value: '0.020',
|
||||
filament_id: 'GFL05',
|
||||
setting_id: '',
|
||||
extruder_id: 1,
|
||||
cali_idx: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
printerId: 1,
|
||||
slotInfo: {
|
||||
amsId: 0,
|
||||
trayId: 0,
|
||||
trayCount: 4,
|
||||
trayType: 'PLA',
|
||||
trayColor: 'FFFFFF',
|
||||
traySubBrands: 'PLA Basic',
|
||||
},
|
||||
nozzleDiameter: '0.4',
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ConfigureAmsSlotModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
|
||||
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);
|
||||
(api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
|
||||
(api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('renders nothing visible when closed', () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Configure AMS Slot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal when open', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays basic color buttons', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
// Check for basic color buttons by their title attribute
|
||||
expect(screen.getByTitle('White')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Black')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Red')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Blue')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Green')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Yellow')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Orange')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Gray')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show extended colors by default', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('White')).toBeInTheDocument();
|
||||
});
|
||||
// Extended colors should not be visible initially
|
||||
expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Purple')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Coral')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows extended colors when expand button is clicked', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('White')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the expand button (+ button)
|
||||
const expandButton = screen.getByTitle('Show more colors');
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
// Extended colors should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Cyan')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Purple')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Pink')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Brown')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Coral')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides extended colors when collapse button is clicked', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('White')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the expand button
|
||||
const expandButton = screen.getByTitle('Show more colors');
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
// Wait for extended colors to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Cyan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the collapse button
|
||||
const collapseButton = screen.getByTitle('Show less colors');
|
||||
fireEvent.click(collapseButton);
|
||||
|
||||
// Extended colors should be hidden again
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a color when color button is clicked', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the red color button
|
||||
const redButton = screen.getByTitle('Red');
|
||||
fireEvent.click(redButton);
|
||||
|
||||
// The color input should now show "Red"
|
||||
const colorInput = screen.getByPlaceholderText(/Color name or hex/);
|
||||
expect(colorInput).toHaveValue('Red');
|
||||
});
|
||||
|
||||
it('derives tray_info_idx from base_id when filament_id is null', async () => {
|
||||
// Mock the detail API to return base_id but no filament_id
|
||||
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
filament_id: null,
|
||||
base_id: 'GFSL05_09',
|
||||
name: '# Overture Matte PLA @BBL H2D',
|
||||
});
|
||||
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
|
||||
// Wait for presets to load
|
||||
await waitFor(() => {
|
||||
expect(api.getCloudSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Select a user preset (one without filament_id)
|
||||
// Find and click the preset - this would require the preset to be in the list
|
||||
// The actual tray_info_idx derivation happens during the configure mutation
|
||||
});
|
||||
|
||||
it('renders configure slot button', async () => {
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the Configure Slot button
|
||||
const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
|
||||
expect(configureButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1814,6 +1814,7 @@ export const api = {
|
|||
plates: Array<{
|
||||
index: number;
|
||||
name: string | null;
|
||||
objects: string[];
|
||||
has_thumbnail: boolean;
|
||||
thumbnail_url: string | null;
|
||||
print_time_seconds: number | null;
|
||||
|
|
@ -2139,6 +2140,57 @@ export const api = {
|
|||
request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
configureAmsSlot: (
|
||||
printerId: number,
|
||||
amsId: number,
|
||||
trayId: number,
|
||||
config: {
|
||||
tray_info_idx: string;
|
||||
tray_type: string;
|
||||
tray_sub_brands: string;
|
||||
tray_color: string;
|
||||
nozzle_temp_min: number;
|
||||
nozzle_temp_max: number;
|
||||
cali_idx: number;
|
||||
nozzle_diameter: string;
|
||||
setting_id?: string;
|
||||
kprofile_filament_id?: string;
|
||||
kprofile_setting_id?: string;
|
||||
k_value?: number;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
tray_info_idx: config.tray_info_idx,
|
||||
tray_type: config.tray_type,
|
||||
tray_sub_brands: config.tray_sub_brands,
|
||||
tray_color: config.tray_color,
|
||||
nozzle_temp_min: config.nozzle_temp_min.toString(),
|
||||
nozzle_temp_max: config.nozzle_temp_max.toString(),
|
||||
cali_idx: config.cali_idx.toString(),
|
||||
nozzle_diameter: config.nozzle_diameter,
|
||||
});
|
||||
if (config.setting_id) {
|
||||
params.set('setting_id', config.setting_id);
|
||||
}
|
||||
if (config.kprofile_filament_id) {
|
||||
params.set('kprofile_filament_id', config.kprofile_filament_id);
|
||||
}
|
||||
if (config.kprofile_setting_id) {
|
||||
params.set('kprofile_setting_id', config.kprofile_setting_id);
|
||||
}
|
||||
if (config.k_value !== undefined && config.k_value > 0) {
|
||||
params.set('k_value', config.k_value.toString());
|
||||
}
|
||||
return request<{ success: boolean; message: string }>(
|
||||
`/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
},
|
||||
resetAmsSlot: (printerId: number, amsId: number, trayId: number) =>
|
||||
request<{ success: boolean; message: string }>(
|
||||
`/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Filaments
|
||||
listFilaments: () => request<Filament[]>('/filaments/'),
|
||||
|
|
|
|||
853
frontend/src/components/ConfigureAmsSlotModal.tsx
Normal file
853
frontend/src/components/ConfigureAmsSlotModal.tsx
Normal file
|
|
@ -0,0 +1,853 @@
|
|||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { KProfile } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SlotInfo {
|
||||
amsId: number;
|
||||
trayId: number;
|
||||
trayCount: number;
|
||||
trayType?: string;
|
||||
trayColor?: string;
|
||||
traySubBrands?: string;
|
||||
trayInfoIdx?: string;
|
||||
}
|
||||
|
||||
// Get proper AMS label (handles HT AMS with ID 128+)
|
||||
function getAmsLabel(amsId: number, trayCount: number): string {
|
||||
// External spool
|
||||
if (amsId === 255) return 'External';
|
||||
|
||||
let normalizedId: number;
|
||||
let isHt = false;
|
||||
|
||||
if (amsId >= 128 && amsId <= 135) {
|
||||
// HT AMS range: 128-135 → A-H
|
||||
normalizedId = amsId - 128;
|
||||
isHt = true;
|
||||
} else if (amsId >= 0 && amsId <= 3) {
|
||||
// Regular AMS range: 0-3 → A-D
|
||||
normalizedId = amsId;
|
||||
// Check tray count as secondary indicator
|
||||
isHt = trayCount === 1;
|
||||
} else {
|
||||
// Unknown range - fallback to A
|
||||
normalizedId = 0;
|
||||
}
|
||||
|
||||
// Cap to valid letter range (A-H)
|
||||
normalizedId = Math.max(0, Math.min(normalizedId, 7));
|
||||
const letter = String.fromCharCode(65 + normalizedId);
|
||||
|
||||
return isHt ? `HT-${letter}` : `AMS-${letter}`;
|
||||
}
|
||||
|
||||
// Convert setting_id to tray_info_idx (filament_id format)
|
||||
// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
|
||||
function convertToTrayInfoIdx(settingId: string): string {
|
||||
// Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
|
||||
const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
|
||||
|
||||
// Bambu presets start with "GFS" - remove the 'S' to get filament_id
|
||||
if (baseId.startsWith('GFS')) {
|
||||
return 'GF' + baseId.slice(3);
|
||||
}
|
||||
|
||||
// User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
|
||||
// This follows the pattern that filament_id and setting_id share the same base ID
|
||||
if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
|
||||
return baseId; // Use base ID without version suffix
|
||||
}
|
||||
|
||||
// For other formats, use as-is
|
||||
return baseId;
|
||||
}
|
||||
|
||||
interface ConfigureAmsSlotModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
printerId: number;
|
||||
slotInfo: SlotInfo;
|
||||
nozzleDiameter?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
// Known filament material types
|
||||
const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];
|
||||
|
||||
// Extract filament type from preset name by finding known material type
|
||||
function parsePresetName(name: string): { material: string; brand: string; variant: string } {
|
||||
// Remove printer/nozzle suffix first
|
||||
const withoutSuffix = name.replace(/@.+$/, '').trim();
|
||||
|
||||
// Try to find a known material type in the name
|
||||
const upperName = withoutSuffix.toUpperCase();
|
||||
for (const mat of MATERIAL_TYPES) {
|
||||
// Use word boundary to match whole words only
|
||||
const regex = new RegExp(`\\b${mat}\\b`, 'i');
|
||||
if (regex.test(upperName)) {
|
||||
// Found material, extract brand (everything before material) and variant (after)
|
||||
const parts = withoutSuffix.split(regex);
|
||||
const brand = parts[0]?.trim() || '';
|
||||
const variant = parts[1]?.trim() || '';
|
||||
return { material: mat, brand, variant };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: assume first word is brand, second is material
|
||||
const parts = withoutSuffix.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };
|
||||
}
|
||||
|
||||
return { material: withoutSuffix, brand: '', variant: '' };
|
||||
}
|
||||
|
||||
// Check if a preset is a user preset (not built-in)
|
||||
function isUserPreset(settingId: string): boolean {
|
||||
// Built-in presets have specific patterns, user presets are UUIDs
|
||||
return !settingId.startsWith('GF') && !settingId.startsWith('P1');
|
||||
}
|
||||
|
||||
// Common color name to hex mapping
|
||||
const COLOR_NAME_MAP: Record<string, string> = {
|
||||
// Basic colors
|
||||
'white': 'FFFFFF',
|
||||
'black': '000000',
|
||||
'red': 'FF0000',
|
||||
'green': '00FF00',
|
||||
'blue': '0000FF',
|
||||
'yellow': 'FFFF00',
|
||||
'cyan': '00FFFF',
|
||||
'magenta': 'FF00FF',
|
||||
'orange': 'FFA500',
|
||||
'purple': '800080',
|
||||
'pink': 'FFC0CB',
|
||||
'brown': '8B4513',
|
||||
'gray': '808080',
|
||||
'grey': '808080',
|
||||
// Filament-specific colors
|
||||
'jade white': 'FFFEF2',
|
||||
'ivory': 'FFFFF0',
|
||||
'beige': 'F5F5DC',
|
||||
'cream': 'FFFDD0',
|
||||
'silver': 'C0C0C0',
|
||||
'gold': 'FFD700',
|
||||
'bronze': 'CD7F32',
|
||||
'copper': 'B87333',
|
||||
'navy': '000080',
|
||||
'teal': '008080',
|
||||
'olive': '808000',
|
||||
'maroon': '800000',
|
||||
'coral': 'FF7F50',
|
||||
'salmon': 'FA8072',
|
||||
'lime': '32CD32',
|
||||
'mint': '98FF98',
|
||||
'forest green': '228B22',
|
||||
'sky blue': '87CEEB',
|
||||
'royal blue': '4169E1',
|
||||
'turquoise': '40E0D0',
|
||||
'lavender': 'E6E6FA',
|
||||
'violet': 'EE82EE',
|
||||
'plum': 'DDA0DD',
|
||||
'tan': 'D2B48C',
|
||||
'chocolate': 'D2691E',
|
||||
'charcoal': '36454F',
|
||||
'slate': '708090',
|
||||
'transparent': '000000', // Will need special handling
|
||||
'natural': 'F5F5DC',
|
||||
'wood': 'DEB887',
|
||||
};
|
||||
|
||||
// Quick-select color presets (common filament colors)
|
||||
// Basic colors shown by default
|
||||
const QUICK_COLORS_BASIC = [
|
||||
{ name: 'White', hex: 'FFFFFF' },
|
||||
{ name: 'Black', hex: '000000' },
|
||||
{ name: 'Red', hex: 'FF0000' },
|
||||
{ name: 'Blue', hex: '0000FF' },
|
||||
{ name: 'Green', hex: '00AA00' },
|
||||
{ name: 'Yellow', hex: 'FFFF00' },
|
||||
{ name: 'Orange', hex: 'FFA500' },
|
||||
{ name: 'Gray', hex: '808080' },
|
||||
];
|
||||
|
||||
// Extended colors shown when expanded
|
||||
const QUICK_COLORS_EXTENDED = [
|
||||
{ name: 'Cyan', hex: '00FFFF' },
|
||||
{ name: 'Magenta', hex: 'FF00FF' },
|
||||
{ name: 'Purple', hex: '800080' },
|
||||
{ name: 'Pink', hex: 'FFC0CB' },
|
||||
{ name: 'Brown', hex: '8B4513' },
|
||||
{ name: 'Beige', hex: 'F5F5DC' },
|
||||
{ name: 'Navy', hex: '000080' },
|
||||
{ name: 'Teal', hex: '008080' },
|
||||
{ name: 'Lime', hex: '32CD32' },
|
||||
{ name: 'Gold', hex: 'FFD700' },
|
||||
{ name: 'Silver', hex: 'C0C0C0' },
|
||||
{ name: 'Maroon', hex: '800000' },
|
||||
{ name: 'Olive', hex: '808000' },
|
||||
{ name: 'Coral', hex: 'FF7F50' },
|
||||
{ name: 'Salmon', hex: 'FA8072' },
|
||||
{ name: 'Turquoise', hex: '40E0D0' },
|
||||
{ name: 'Violet', hex: 'EE82EE' },
|
||||
{ name: 'Indigo', hex: '4B0082' },
|
||||
{ name: 'Chocolate', hex: 'D2691E' },
|
||||
{ name: 'Tan', hex: 'D2B48C' },
|
||||
{ name: 'Slate', hex: '708090' },
|
||||
{ name: 'Charcoal', hex: '36454F' },
|
||||
{ name: 'Ivory', hex: 'FFFFF0' },
|
||||
{ name: 'Cream', hex: 'FFFDD0' },
|
||||
];
|
||||
|
||||
// Try to convert color name to hex
|
||||
function colorNameToHex(name: string): string | null {
|
||||
const normalized = name.toLowerCase().trim();
|
||||
return COLOR_NAME_MAP[normalized] || null;
|
||||
}
|
||||
|
||||
export function ConfigureAmsSlotModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
printerId,
|
||||
slotInfo,
|
||||
nozzleDiameter = '0.4',
|
||||
onSuccess,
|
||||
}: ConfigureAmsSlotModalProps) {
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>('');
|
||||
const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
|
||||
const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
|
||||
const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showExtendedColors, setShowExtendedColors] = useState(false);
|
||||
|
||||
// Fetch cloud settings
|
||||
const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['cloudSettings'],
|
||||
queryFn: () => api.getCloudSettings(),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Fetch K profiles
|
||||
const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
|
||||
queryKey: ['kprofiles', printerId, nozzleDiameter],
|
||||
queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
|
||||
enabled: isOpen && !!printerId,
|
||||
});
|
||||
|
||||
// Configure slot mutation
|
||||
const configureMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedPresetId) throw new Error('No filament preset selected');
|
||||
|
||||
// Get the selected preset details
|
||||
const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
|
||||
if (!selectedPreset) throw new Error('Selected preset not found');
|
||||
|
||||
// Parse the preset name for filament info
|
||||
const parsed = parsePresetName(selectedPreset.name);
|
||||
|
||||
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
|
||||
const caliIdx = selectedKProfile?.slot_id ?? -1;
|
||||
|
||||
// Use custom color if set, otherwise use current slot color or default
|
||||
const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
|
||||
|
||||
// Create the tray_sub_brands from preset name (without printer/nozzle suffix)
|
||||
const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
|
||||
|
||||
// Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
|
||||
let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
|
||||
|
||||
// For user presets (not starting with GF), fetch the detail to get the real filament_id
|
||||
if (!selectedPresetId.startsWith('GFS')) {
|
||||
try {
|
||||
const detail = await api.getCloudSettingDetail(selectedPresetId);
|
||||
if (detail.filament_id) {
|
||||
trayInfoIdx = detail.filament_id;
|
||||
} else if (detail.base_id) {
|
||||
// If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
|
||||
// This is common for user presets that inherit from Bambu presets
|
||||
trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
|
||||
console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch preset detail for filament_id:', e);
|
||||
// Fall back to derived tray_info_idx
|
||||
}
|
||||
}
|
||||
|
||||
// Default temp range based on material type
|
||||
let tempMin = 190;
|
||||
let tempMax = 230;
|
||||
const material = parsed.material.toUpperCase();
|
||||
if (material.includes('PLA')) {
|
||||
tempMin = 190;
|
||||
tempMax = 230;
|
||||
} else if (material.includes('PETG')) {
|
||||
tempMin = 220;
|
||||
tempMax = 260;
|
||||
} else if (material.includes('ABS')) {
|
||||
tempMin = 240;
|
||||
tempMax = 280;
|
||||
} else if (material.includes('ASA')) {
|
||||
tempMin = 240;
|
||||
tempMax = 280;
|
||||
} else if (material.includes('TPU')) {
|
||||
tempMin = 200;
|
||||
tempMax = 240;
|
||||
} else if (material.includes('PC')) {
|
||||
tempMin = 260;
|
||||
tempMax = 300;
|
||||
} else if (material.includes('PA') || material.includes('NYLON')) {
|
||||
tempMin = 250;
|
||||
tempMax = 290;
|
||||
}
|
||||
|
||||
// Parse K value from selected profile
|
||||
const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
|
||||
|
||||
// Configure the slot via MQTT
|
||||
const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
|
||||
tray_info_idx: trayInfoIdx,
|
||||
tray_type: parsed.material || 'PLA',
|
||||
tray_sub_brands: traySubBrands,
|
||||
tray_color: color + 'FF', // Add alpha
|
||||
nozzle_temp_min: tempMin,
|
||||
nozzle_temp_max: tempMax,
|
||||
cali_idx: caliIdx,
|
||||
nozzle_diameter: nozzleDiameter,
|
||||
setting_id: selectedPresetId, // Full setting ID for slicer compatibility
|
||||
// Pass K profile's filament_id and setting_id for proper linking
|
||||
kprofile_filament_id: selectedKProfile?.filament_id,
|
||||
kprofile_setting_id: selectedKProfile?.setting_id || undefined,
|
||||
// Also pass the K value directly for extrusion_cali_set command
|
||||
k_value: kValue,
|
||||
});
|
||||
|
||||
// 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
|
||||
try {
|
||||
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save slot preset mapping:', e);
|
||||
// Don't fail the whole operation - slot was configured successfully
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowSuccess(true);
|
||||
onSuccess?.();
|
||||
// Close after showing success briefly
|
||||
setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
},
|
||||
});
|
||||
|
||||
// Reset slot mutation
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setShowSuccess(true);
|
||||
onSuccess?.();
|
||||
setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
},
|
||||
});
|
||||
|
||||
// Filter filament presets based on search
|
||||
const filteredPresets = useMemo(() => {
|
||||
if (!cloudSettings?.filament) return [];
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return cloudSettings.filament
|
||||
.filter(p => {
|
||||
if (!query) return true;
|
||||
return p.name.toLowerCase().includes(query);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort user presets first, then alphabetically
|
||||
const aIsUser = isUserPreset(a.setting_id);
|
||||
const bIsUser = isUserPreset(b.setting_id);
|
||||
if (aIsUser && !bIsUser) return -1;
|
||||
if (!aIsUser && bIsUser) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [cloudSettings?.filament, searchQuery]);
|
||||
|
||||
// Get full preset name for K profile filtering (brand + material, without printer suffix)
|
||||
const selectedPresetInfo = useMemo(() => {
|
||||
if (!selectedPresetId || !cloudSettings?.filament) return null;
|
||||
const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
|
||||
if (!selectedPreset) return null;
|
||||
|
||||
// Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
|
||||
let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
|
||||
// Strip leading "# " from custom preset names (user convention)
|
||||
if (nameWithoutSuffix.startsWith('# ')) {
|
||||
nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
|
||||
}
|
||||
const parsed = parsePresetName(nameWithoutSuffix);
|
||||
|
||||
return {
|
||||
fullName: nameWithoutSuffix,
|
||||
material: parsed.material,
|
||||
brand: parsed.brand,
|
||||
};
|
||||
}, [selectedPresetId, cloudSettings?.filament]);
|
||||
|
||||
// For backwards compatibility with the label
|
||||
const selectedMaterial = selectedPresetInfo?.fullName || '';
|
||||
|
||||
const matchingKProfiles = useMemo(() => {
|
||||
if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
|
||||
|
||||
const { fullName, material, brand } = selectedPresetInfo;
|
||||
const upperFullName = fullName.toUpperCase();
|
||||
const upperMaterial = material.toUpperCase();
|
||||
const upperBrand = brand.toUpperCase();
|
||||
|
||||
// Material must be at least 2 chars to avoid false positives
|
||||
if (!upperMaterial || upperMaterial.length < 2) return [];
|
||||
|
||||
// Filter profiles - require brand match if brand is present in selected preset
|
||||
const filtered = kprofilesData.profiles.filter(p => {
|
||||
const profileName = p.name.toUpperCase();
|
||||
|
||||
// If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
|
||||
// only show profiles that match the brand
|
||||
if (upperBrand) {
|
||||
// Must contain the brand name
|
||||
if (!profileName.includes(upperBrand)) {
|
||||
return false;
|
||||
}
|
||||
// And must contain the material type
|
||||
if (!profileName.includes(upperMaterial)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No brand in selected preset - match on full name or material
|
||||
// Priority 1: Exact match with full name
|
||||
if (profileName.includes(upperFullName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Priority 2: Material type match (only when no brand specified)
|
||||
if (profileName.includes(upperMaterial)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for common material aliases
|
||||
const aliases: Record<string, string[]> = {
|
||||
'NYLON': ['PA', 'PA-CF', 'PA6'],
|
||||
'PA': ['NYLON'],
|
||||
};
|
||||
|
||||
const materialAliases = aliases[upperMaterial] || [];
|
||||
for (const alias of materialAliases) {
|
||||
if (profileName.includes(alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
|
||||
// Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D
|
||||
const seen = new Map<string, KProfile>();
|
||||
for (const profile of filtered) {
|
||||
const key = `${profile.name}|${profile.k_value}`;
|
||||
const existing = seen.get(key);
|
||||
if (!existing) {
|
||||
seen.set(key, profile);
|
||||
} else if (profile.extruder_id === 1 && existing.extruder_id === 0) {
|
||||
// Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile
|
||||
seen.set(key, profile);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}, [kprofilesData?.profiles, selectedPresetInfo]);
|
||||
|
||||
// Pre-select current profile when modal opens, reset when closes
|
||||
useEffect(() => {
|
||||
if (isOpen && cloudSettings?.filament) {
|
||||
// Try to pre-select current profile based on trayInfoIdx
|
||||
if (slotInfo.trayInfoIdx) {
|
||||
const currentPreset = cloudSettings.filament.find(
|
||||
p => p.setting_id === slotInfo.trayInfoIdx
|
||||
);
|
||||
if (currentPreset) {
|
||||
setSelectedPresetId(currentPreset.setting_id);
|
||||
}
|
||||
}
|
||||
} else if (!isOpen) {
|
||||
// Reset when modal closes
|
||||
setSelectedPresetId('');
|
||||
setSelectedKProfile(null);
|
||||
setColorHex('');
|
||||
setColorInput('');
|
||||
setSearchQuery('');
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
|
||||
|
||||
// Auto-select best matching K profile when preset changes
|
||||
useEffect(() => {
|
||||
if (matchingKProfiles.length > 0) {
|
||||
// Auto-select first matching profile
|
||||
setSelectedKProfile(matchingKProfiles[0]);
|
||||
} else {
|
||||
setSelectedKProfile(null);
|
||||
}
|
||||
}, [selectedPresetId, matchingKProfiles.length]); // Only trigger when preset changes
|
||||
|
||||
// Escape key handler
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isLoading = settingsLoading || kprofilesLoading;
|
||||
const canSave = selectedPresetId && !configureMutation.isPending;
|
||||
|
||||
// Get display color (custom or slot default)
|
||||
const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
|
||||
|
||||
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-lg 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">
|
||||
<Settings2 className="w-5 h-5 text-bambu-blue" />
|
||||
<h2 className="text-lg font-semibold text-white">Configure AMS Slot</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 max-h-[60vh] overflow-y-auto">
|
||||
{/* Success overlay */}
|
||||
{showSuccess && (
|
||||
<div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
|
||||
<div className="text-center space-y-3">
|
||||
<CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
|
||||
<p className="text-lg font-semibold text-white">Slot Configured!</p>
|
||||
<p className="text-sm text-bambu-gray">Settings sent to printer</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slot info */}
|
||||
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{slotInfo.trayColor && (
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-white font-medium">
|
||||
{getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
|
||||
</span>
|
||||
{slotInfo.traySubBrands && (
|
||||
<span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Filament Profile Select */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-2">
|
||||
Filament Profile <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search presets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{filteredPresets.length === 0 ? (
|
||||
<p className="text-center py-4 text-bambu-gray">
|
||||
{cloudSettings?.filament?.length === 0
|
||||
? 'No cloud presets. Login to Bambu Cloud to sync.'
|
||||
: 'No matching presets found.'}
|
||||
</p>
|
||||
) : (
|
||||
filteredPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.setting_id}
|
||||
onClick={() => setSelectedPresetId(preset.setting_id)}
|
||||
className={`w-full p-2 rounded-lg border text-left transition-colors ${
|
||||
selectedPresetId === preset.setting_id
|
||||
? 'bg-bambu-green/20 border-bambu-green'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white text-sm truncate">{preset.name}</span>
|
||||
{isUserPreset(preset.setting_id) && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* K Profile Select */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-2">
|
||||
K Profile (Pressure Advance)
|
||||
{selectedMaterial && (
|
||||
<span className="ml-2 text-xs text-bambu-blue">
|
||||
Filtering for: {selectedMaterial}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{matchingKProfiles.length > 0 ? (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedKProfile?.name || ''}
|
||||
onChange={(e) => {
|
||||
const profile = matchingKProfiles.find(p => p.name === e.target.value);
|
||||
setSelectedKProfile(profile || null);
|
||||
}}
|
||||
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 appearance-none pr-10"
|
||||
>
|
||||
<option value="">No K profile (use default 0.020)</option>
|
||||
{matchingKProfiles.map((profile) => (
|
||||
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
|
||||
{profile.name} (K={profile.k_value})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
) : selectedPresetId ? (
|
||||
<p className="text-sm text-bambu-gray italic py-2">
|
||||
No matching K profiles found. Default K=0.020 will be used.
|
||||
</p>
|
||||
) : (
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
|
||||
Select a filament profile first
|
||||
</span>
|
||||
)}
|
||||
{selectedKProfile && (
|
||||
<p className="text-xs text-bambu-green mt-1">
|
||||
K={selectedKProfile.k_value} from printer calibration
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional: Custom color */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-2">
|
||||
Custom Color (optional)
|
||||
</label>
|
||||
{/* Quick color buttons */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{QUICK_COLORS_BASIC.map((color) => (
|
||||
<button
|
||||
key={color.hex}
|
||||
onClick={() => {
|
||||
setColorHex(color.hex);
|
||||
setColorInput(color.name);
|
||||
}}
|
||||
className={`w-7 h-7 rounded-md border-2 transition-all ${
|
||||
colorHex === color.hex
|
||||
? 'border-bambu-green scale-110'
|
||||
: 'border-white/20 hover:border-white/40'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowExtendedColors(!showExtendedColors)}
|
||||
className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
|
||||
title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
|
||||
>
|
||||
{showExtendedColors ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Extended colors (collapsible) */}
|
||||
{showExtendedColors && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{QUICK_COLORS_EXTENDED.map((color) => (
|
||||
<button
|
||||
key={color.hex}
|
||||
onClick={() => {
|
||||
setColorHex(color.hex);
|
||||
setColorInput(color.name);
|
||||
}}
|
||||
className={`w-7 h-7 rounded-md border-2 transition-all ${
|
||||
colorHex === color.hex
|
||||
? 'border-bambu-green scale-110'
|
||||
: 'border-white/20 hover:border-white/40'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Color input: name or hex */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
|
||||
style={{ backgroundColor: `#${displayColor}` }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Color name or hex (e.g., brown, FF8800)"
|
||||
value={colorInput}
|
||||
onChange={(e) => {
|
||||
const input = e.target.value;
|
||||
setColorInput(input);
|
||||
|
||||
// Try to parse as color name first
|
||||
const nameHex = colorNameToHex(input);
|
||||
if (nameHex) {
|
||||
setColorHex(nameHex);
|
||||
} else {
|
||||
// Try to parse as hex code
|
||||
const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
|
||||
if (cleaned.length === 6) {
|
||||
setColorHex(cleaned);
|
||||
} else if (cleaned.length === 3) {
|
||||
// Expand shorthand hex (e.g., F00 -> FF0000)
|
||||
setColorHex(cleaned.split('').map(c => c + c).join(''));
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
|
||||
/>
|
||||
{colorHex && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColorHex('');
|
||||
setColorInput('');
|
||||
}}
|
||||
className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
|
||||
title="Clear custom color"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{colorHex && (
|
||||
<p className="text-xs text-bambu-gray mt-1.5">
|
||||
Hex: #{colorHex}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between p-4 border-t border-bambu-dark-tertiary">
|
||||
{/* Reset button on the left */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending || configureMutation.isPending}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset Slot
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Cancel and Configure buttons on the right */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => configureMutation.mutate()}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{configureMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Configuring...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Configure Slot
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{(configureMutation.isError || resetMutation.isError) && (
|
||||
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
|
||||
{(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -462,10 +462,12 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
|||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white font-medium truncate">
|
||||
Plate {plate.index}
|
||||
{plate.name || `Plate ${plate.index}`}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray truncate">
|
||||
{plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
|
||||
{plate.objects?.length > 0
|
||||
? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '')
|
||||
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
{selectedPlate === plate.index && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { Droplets, Link2, Copy, Check } from 'lucide-react';
|
||||
import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
|
||||
|
||||
interface FilamentData {
|
||||
vendor: 'Bambu Lab' | 'Generic';
|
||||
|
|
@ -17,19 +17,25 @@ interface SpoolmanConfig {
|
|||
hasUnlinkedSpools?: boolean; // Whether there are spools available to link
|
||||
}
|
||||
|
||||
interface ConfigureSlotConfig {
|
||||
enabled: boolean;
|
||||
onConfigure?: () => void;
|
||||
}
|
||||
|
||||
interface FilamentHoverCardProps {
|
||||
data: FilamentData;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
spoolman?: SpoolmanConfig;
|
||||
configureSlot?: ConfigureSlotConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }: FilamentHoverCardProps) {
|
||||
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState<'top' | 'bottom'>('top');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -287,6 +293,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
|||
)}
|
||||
</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'}`}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
configureSlot.onConfigure?.();
|
||||
}}
|
||||
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"
|
||||
title="Configure slot with filament profile and K value"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -307,10 +330,16 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
|||
);
|
||||
}
|
||||
|
||||
interface EmptySlotHoverCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
configureSlot?: ConfigureSlotConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for empty slots - just shows "Empty" on hover
|
||||
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
|
||||
*/
|
||||
export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -344,10 +373,28 @@ export function EmptySlotHoverCard({ children, className = '' }: { children: Rea
|
|||
animate-in fade-in-0 zoom-in-95 duration-150
|
||||
">
|
||||
<div className="
|
||||
px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary
|
||||
rounded-md shadow-lg text-xs text-bambu-gray whitespace-nowrap
|
||||
bg-bambu-dark-secondary border border-bambu-dark-tertiary
|
||||
rounded-md shadow-lg overflow-hidden
|
||||
">
|
||||
Empty slot
|
||||
<div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
|
||||
Empty slot
|
||||
</div>
|
||||
{/* Configure slot button */}
|
||||
{configureSlot?.enabled && (
|
||||
<div className="px-2 pb-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
configureSlot.onConfigure?.();
|
||||
}}
|
||||
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"
|
||||
title="Configure slot with filament profile and K value"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="
|
||||
absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
|
||||
|
|
|
|||
|
|
@ -436,10 +436,12 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
|||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white font-medium truncate">
|
||||
Plate {plate.index}
|
||||
{plate.name || `Plate ${plate.index}`}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray truncate">
|
||||
{plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
|
||||
{plate.objects?.length > 0
|
||||
? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '')
|
||||
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
|
||||
{plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@ function formatDuration(seconds: number): string {
|
|||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an archive filename represents a sliced/printable file.
|
||||
* Matches: .gcode, .gcode.3mf, .gcode.anything
|
||||
*/
|
||||
function isSlicedFile(filename: string | null | undefined): boolean {
|
||||
if (!filename) return false;
|
||||
const lower = filename.toLowerCase();
|
||||
// Match .gcode at end OR .gcode. followed by anything (like .gcode.3mf)
|
||||
return lower.endsWith('.gcode') || lower.includes('.gcode.');
|
||||
}
|
||||
|
||||
// formatDate imported from '../utils/date' - handles UTC conversion
|
||||
|
||||
function ArchiveCard({
|
||||
|
|
@ -246,7 +257,7 @@ function ArchiveCard({
|
|||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
|
||||
const isGcodeFile = isSlicedFile(archive.filename);
|
||||
|
||||
const contextMenuItems: ContextMenuItem[] = [
|
||||
// For gcode files: show Print option
|
||||
|
|
@ -632,17 +643,17 @@ function ArchiveCard({
|
|||
{/* File type badge */}
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
archive.filename?.toLowerCase().includes('.gcode.')
|
||||
isSlicedFile(archive.filename)
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-orange-500/20 text-orange-400'
|
||||
}`}
|
||||
title={
|
||||
archive.filename?.toLowerCase().includes('.gcode.')
|
||||
isSlicedFile(archive.filename)
|
||||
? 'Sliced file - ready to print'
|
||||
: 'Source file only - no AMS mapping available'
|
||||
}
|
||||
>
|
||||
{archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
|
||||
{isSlicedFile(archive.filename) ? 'GCODE' : 'SOURCE'}
|
||||
</span>
|
||||
{archive.project_name && (
|
||||
<span
|
||||
|
|
@ -755,7 +766,7 @@ function ArchiveCard({
|
|||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 mt-3">
|
||||
{archive.filename?.toLowerCase().includes('.gcode.') ? (
|
||||
{isSlicedFile(archive.filename) ? (
|
||||
// Sliced file - can print directly
|
||||
<>
|
||||
<Button
|
||||
|
|
@ -1239,7 +1250,7 @@ function ArchiveListRow({
|
|||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
|
||||
const isGcodeFile = isSlicedFile(archive.filename);
|
||||
|
||||
const contextMenuItems: ContextMenuItem[] = [
|
||||
...(isGcodeFile ? [
|
||||
|
|
@ -2067,7 +2078,7 @@ export function ArchivesPage() {
|
|||
const matchesTag = !filterTag || archiveTags.includes(filterTag);
|
||||
|
||||
// File type filter (gcode = sliced, source = project file only)
|
||||
const isGcodeFile = a.filename?.toLowerCase().includes('.gcode.');
|
||||
const isGcodeFile = isSlicedFile(a.filename);
|
||||
const matchesFileType = filterFileType === 'all' ||
|
||||
(filterFileType === 'gcode' && isGcodeFile) ||
|
||||
(filterFileType === 'source' && !isGcodeFile);
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,7 @@ export function FileManagerPage() {
|
|||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
|
||||
setSelectedFiles([]);
|
||||
|
||||
if (result.added.length > 0 && result.errors.length === 0) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
|
|||
import { AMSHistoryModal } from '../components/AMSHistoryModal';
|
||||
import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
|
||||
import { LinkSpoolModal } from '../components/LinkSpoolModal';
|
||||
import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { ChamberLight } from '../components/icons/ChamberLight';
|
||||
|
||||
|
|
@ -378,6 +379,10 @@ function hexToBasicColorName(hex: string | null | undefined): string {
|
|||
}
|
||||
|
||||
// Classify by hue
|
||||
// Brown is orange/yellow hue with lower lightness
|
||||
if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
|
||||
if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
|
||||
|
||||
if (h < 15 || h >= 345) return 'Red';
|
||||
if (h < 45) return 'Orange';
|
||||
if (h < 70) return 'Yellow';
|
||||
|
|
@ -945,6 +950,15 @@ function PrinterCard({
|
|||
trayUuid: string;
|
||||
trayInfo: { type: string; color: string; location: string };
|
||||
} | null>(null);
|
||||
const [configureSlotModal, setConfigureSlotModal] = useState<{
|
||||
amsId: number;
|
||||
trayId: number;
|
||||
trayCount: number;
|
||||
trayType?: string;
|
||||
trayColor?: string;
|
||||
traySubBrands?: string;
|
||||
trayInfoIdx?: string;
|
||||
} | null>(null);
|
||||
const [showFirmwareModal, setShowFirmwareModal] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
|
|
@ -987,6 +1001,13 @@ function PrinterCard({
|
|||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Fetch slot preset mappings (stores preset name for user-configured slots)
|
||||
const { data: slotPresets } = useQuery({
|
||||
queryKey: ['slotPresets', printer.id],
|
||||
queryFn: () => api.getSlotPresets(printer.id),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
|
||||
// Cache WiFi signal to prevent it disappearing on updates
|
||||
const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
|
|
@ -1956,11 +1977,13 @@ function PrinterCard({
|
|||
const isActive = effectiveTrayNow === globalTrayId;
|
||||
// Get cloud preset info if available
|
||||
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
|
||||
// Get saved slot preset mapping (for user-configured slots)
|
||||
const slotPreset = slotPresets?.[globalTrayId];
|
||||
|
||||
// Build filament data for hover card
|
||||
const filamentData = tray?.tray_type ? {
|
||||
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
|
||||
profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
|
||||
profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
|
||||
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
|
||||
colorHex: tray.tray_color || null,
|
||||
kFactor: formatKValue(tray.k),
|
||||
|
|
@ -2066,11 +2089,32 @@ function PrinterCard({
|
|||
});
|
||||
} : undefined,
|
||||
}}
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: slotIdx,
|
||||
trayCount: ams.tray.length,
|
||||
trayType: tray?.tray_type || undefined,
|
||||
trayColor: tray?.tray_color || undefined,
|
||||
traySubBrands: tray?.tray_sub_brands || undefined,
|
||||
trayInfoIdx: tray?.tray_info_idx || undefined,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{slotVisual}
|
||||
</FilamentHoverCard>
|
||||
) : (
|
||||
<EmptySlotHoverCard>
|
||||
<EmptySlotHoverCard
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: slotIdx,
|
||||
trayCount: ams.tray.length,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{slotVisual}
|
||||
</EmptySlotHoverCard>
|
||||
)}
|
||||
|
|
@ -2103,11 +2147,13 @@ function PrinterCard({
|
|||
const isActive = effectiveTrayNow === globalTrayId;
|
||||
// Get cloud preset info if available
|
||||
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
|
||||
// Get saved slot preset mapping (for user-configured slots)
|
||||
const slotPreset = slotPresets?.[globalTrayId];
|
||||
|
||||
// Build filament data for hover card
|
||||
const filamentData = tray?.tray_type ? {
|
||||
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
|
||||
profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
|
||||
profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
|
||||
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
|
||||
colorHex: tray.tray_color || null,
|
||||
kFactor: formatKValue(tray.k),
|
||||
|
|
@ -2226,11 +2272,32 @@ function PrinterCard({
|
|||
});
|
||||
} : undefined,
|
||||
}}
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: htSlotId,
|
||||
trayCount: ams.tray.length,
|
||||
trayType: tray?.tray_type || undefined,
|
||||
trayColor: tray?.tray_color || undefined,
|
||||
traySubBrands: tray?.tray_sub_brands || undefined,
|
||||
trayInfoIdx: tray?.tray_info_idx || undefined,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{slotVisual}
|
||||
</FilamentHoverCard>
|
||||
) : (
|
||||
<EmptySlotHoverCard>
|
||||
<EmptySlotHoverCard
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: htSlotId,
|
||||
trayCount: ams.tray.length,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{slotVisual}
|
||||
</EmptySlotHoverCard>
|
||||
)}
|
||||
|
|
@ -2277,11 +2344,13 @@ function PrinterCard({
|
|||
const isExtActive = effectiveTrayNow === 254;
|
||||
// Get cloud preset info if available
|
||||
const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
|
||||
// Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
|
||||
const extSlotPreset = slotPresets?.[255 * 4 + 0];
|
||||
|
||||
// Build filament data for hover card
|
||||
const extFilamentData = {
|
||||
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
|
||||
profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
|
||||
profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
|
||||
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
|
||||
colorHex: extTray.tray_color || null,
|
||||
kFactor: formatKValue(extTray.k),
|
||||
|
|
@ -2331,6 +2400,18 @@ function PrinterCard({
|
|||
});
|
||||
} : undefined,
|
||||
}}
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: 255, // External spool indicator
|
||||
trayId: 0,
|
||||
trayCount: 1, // External = single slot
|
||||
trayType: extTray.tray_type || undefined,
|
||||
trayColor: extTray.tray_color || undefined,
|
||||
traySubBrands: extTray.tray_sub_brands || undefined,
|
||||
trayInfoIdx: extTray.tray_info_idx || undefined,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{extSlotContent}
|
||||
</FilamentHoverCard>
|
||||
|
|
@ -2830,6 +2911,22 @@ function PrinterCard({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Configure AMS Slot Modal */}
|
||||
{configureSlotModal && (
|
||||
<ConfigureAmsSlotModal
|
||||
isOpen={!!configureSlotModal}
|
||||
onClose={() => setConfigureSlotModal(null)}
|
||||
printerId={printer.id}
|
||||
slotInfo={configureSlotModal}
|
||||
onSuccess={() => {
|
||||
// Refresh slot presets to show updated profile name
|
||||
queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
|
||||
// Printer status will update automatically via WebSocket when AMS data changes
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Printer Modal */}
|
||||
{showEditModal && (
|
||||
<EditPrinterModal
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
|
|||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// Backend port for dev server proxy (default: 8000)
|
||||
const backendPort = process.env.BACKEND_PORT || '8000'
|
||||
const backendUrl = `http://localhost:${backendPort}`
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
|
|
@ -12,12 +16,12 @@ export default defineConfig({
|
|||
server: {
|
||||
proxy: {
|
||||
'/api/v1/ws': {
|
||||
target: 'http://localhost:8000',
|
||||
target: backendUrl,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
1
static/assets/index-CMAdGpGJ.css
Normal file
1
static/assets/index-CMAdGpGJ.css
Normal file
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
|
|
@ -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-CmQlhkzV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-jSEJkRy_.css">
|
||||
<script type="module" crossorigin src="/assets/index-CO2y-9S4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CMAdGpGJ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
PORT=${PORT:-8000}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
|
|
@ -214,7 +217,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
|
|||
print_info "Running integration tests..."
|
||||
|
||||
# Test health endpoint
|
||||
HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
|
||||
HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/health)
|
||||
if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
|
||||
print_success "Health endpoint responds correctly"
|
||||
else
|
||||
|
|
@ -222,7 +225,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
|
|||
fi
|
||||
|
||||
# Test API endpoints
|
||||
API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings)
|
||||
API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/api/v1/settings)
|
||||
if echo "$API_RESPONSE" | grep -q "settings"; then
|
||||
print_success "Settings API endpoint responds"
|
||||
else
|
||||
|
|
@ -231,7 +234,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
|
|||
fi
|
||||
|
||||
# Test static files
|
||||
STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
|
||||
STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}/)
|
||||
if [ "$STATIC_RESPONSE" = "200" ]; then
|
||||
print_success "Static files served correctly"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Comprehensive end-to-end test for Bambuddy application."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
def test_navigation_and_sidebar(page):
|
||||
|
|
@ -365,7 +366,7 @@ def run_comprehensive_test():
|
|||
except Exception as e:
|
||||
print(f"\n❌ {test_name} FAILED: {e}")
|
||||
results[test_name] = False
|
||||
page.screenshot(path=f'/tmp/bambuddy_error_{test_name.lower().replace(" ", "_")}.png')
|
||||
page.screenshot(path=f"/tmp/bambuddy_error_{test_name.lower().replace(' ', '_')}.png")
|
||||
|
||||
browser.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ These tests verify that toggle settings (auto_off, notification events, etc.)
|
|||
are properly persisted to the database and survive page reloads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
def test_smart_plug_auto_off_toggle_persistence(page):
|
||||
|
|
@ -88,9 +89,9 @@ def test_smart_plug_auto_off_toggle_persistence(page):
|
|||
toggle = page.locator('button[role="switch"]').nth(1)
|
||||
|
||||
persisted_state = toggle.get_attribute("aria-checked")
|
||||
assert (
|
||||
persisted_state == new_state
|
||||
), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
|
||||
assert persisted_state == new_state, (
|
||||
f"State should persist after reload. Expected {new_state}, got {persisted_state}"
|
||||
)
|
||||
print(f"✓ Toggle state persisted after reload: {persisted_state}")
|
||||
|
||||
# Restore original state
|
||||
|
|
@ -172,9 +173,9 @@ def test_notification_event_toggle_persistence(page):
|
|||
|
||||
if toggle.is_visible():
|
||||
persisted_state = toggle.get_attribute("aria-checked")
|
||||
assert (
|
||||
persisted_state == new_state
|
||||
), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
|
||||
assert persisted_state == new_state, (
|
||||
f"State should persist after reload. Expected {new_state}, got {persisted_state}"
|
||||
)
|
||||
print(f"✓ Toggle state persisted after reload: {persisted_state}")
|
||||
|
||||
# Restore original state
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue