mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Wiring up AMS module
This commit is contained in:
@@ -631,11 +631,8 @@ async def ams_refresh_tray(
|
||||
await get_printer_or_404(printer_id, db)
|
||||
client = get_mqtt_client_or_503(printer_id)
|
||||
|
||||
success = client.ams_refresh_tray(request.ams_id, request.tray_id)
|
||||
return ControlResponse(
|
||||
success=success,
|
||||
message=f"Refreshing AMS {request.ams_id} tray {request.tray_id}" if success else "Failed to refresh tray"
|
||||
)
|
||||
success, message = client.ams_refresh_tray(request.ams_id, request.tray_id)
|
||||
return ControlResponse(success=success, message=message)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -306,6 +306,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
|
||||
ams_mapping=ams_mapping,
|
||||
ams_extruder_map=ams_extruder_map,
|
||||
tray_now=tray_now,
|
||||
ams_status_main=state.ams_status_main,
|
||||
ams_status_sub=state.ams_status_sub,
|
||||
mc_print_sub_stage=state.mc_print_sub_stage,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -48,8 +48,15 @@ class AMSTray(BaseModel):
|
||||
id: int
|
||||
tray_color: str | None = None
|
||||
tray_type: str | None = None
|
||||
tray_sub_brands: str | None = None # Full name like "PLA Basic", "PETG HF"
|
||||
tray_id_name: str | None = None # Bambu filament ID like "A00-Y2" (can decode to color)
|
||||
tray_info_idx: str | None = None # Filament preset ID like "GFA00"
|
||||
remain: int = 0
|
||||
k: float | None = None # Pressure advance value
|
||||
tag_uid: str | None = None # RFID tag UID (any tag)
|
||||
tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex)
|
||||
nozzle_temp_min: int | None = None # Min nozzle temperature
|
||||
nozzle_temp_max: int | None = None # Max nozzle temperature
|
||||
|
||||
|
||||
class AMSUnit(BaseModel):
|
||||
@@ -127,3 +134,11 @@ class PrinterStatus(BaseModel):
|
||||
ams_extruder_map: dict[str, int] = {}
|
||||
# Currently loaded tray (global ID): 254 = external spool, 255 = no filament
|
||||
tray_now: int = 255
|
||||
# AMS status for filament change tracking
|
||||
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
|
||||
ams_status_main: int = 0
|
||||
# Sub status: specific step within filament change (when main=1)
|
||||
# Known values: 4=retraction, 6=load verification, 7=purge
|
||||
ams_status_sub: int = 0
|
||||
# mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
|
||||
mc_print_sub_stage: int = 0
|
||||
|
||||
@@ -116,6 +116,15 @@ class PrinterState:
|
||||
tray_now: int = 255
|
||||
# Pending load target - used to track what tray we're loading for H2D disambiguation
|
||||
pending_tray_target: int | None = None
|
||||
# AMS status for filament change tracking (from print.ams.ams_status field)
|
||||
# ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
|
||||
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration, etc.
|
||||
ams_status: int = 0
|
||||
ams_status_main: int = 0 # (ams_status >> 8) & 0xFF
|
||||
ams_status_sub: int = 0 # ams_status & 0xFF
|
||||
# mc_print_sub_stage - filament change step indicator from print.mc_print_sub_stage
|
||||
# Used by OrcaSlicer/BambuStudio to track progress during filament load/unload
|
||||
mc_print_sub_stage: int = 0
|
||||
|
||||
|
||||
# Stage name mapping from BambuStudio DeviceManager.cpp
|
||||
@@ -362,6 +371,30 @@ class BambuMQTTClient:
|
||||
logger.info(f"[{self.serial_number}] vt_tray data: {vt_tray}")
|
||||
self._vt_tray_logged = True
|
||||
|
||||
# Parse ams_status directly from print data (NOT from print.ams)
|
||||
# ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
|
||||
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
|
||||
# Sub status (when main=1): 2=heating, 3=AMS feeding, 4=retract, 6=push, 7=purge
|
||||
if "ams_status" in print_data:
|
||||
raw_ams_status = print_data["ams_status"]
|
||||
if isinstance(raw_ams_status, str):
|
||||
try:
|
||||
self.state.ams_status = int(raw_ams_status)
|
||||
except ValueError:
|
||||
self.state.ams_status = 0
|
||||
else:
|
||||
self.state.ams_status = raw_ams_status if raw_ams_status is not None else 0
|
||||
|
||||
# Compute main and sub status
|
||||
self.state.ams_status_sub = self.state.ams_status & 0xFF
|
||||
self.state.ams_status_main = (self.state.ams_status >> 8) & 0xFF
|
||||
|
||||
# Log when ams_status changes (for filament change tracking debug)
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] ams_status: {self.state.ams_status} "
|
||||
f"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})"
|
||||
)
|
||||
|
||||
# Check for K-profile response (extrusion_cali)
|
||||
if "command" in print_data:
|
||||
logger.debug(f"[{self.serial_number}] Received command response: {print_data.get('command')}")
|
||||
@@ -590,6 +623,26 @@ class BambuMQTTClient:
|
||||
non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
|
||||
if non_list_fields:
|
||||
logger.info(f"[{self.serial_number}] AMS dict fields: {non_list_fields}")
|
||||
|
||||
# IMPORTANT: Parse ams_status FIRST before tray_now, so we have fresh status
|
||||
# when checking if we're in filament change mode for tray_now disambiguation
|
||||
if "ams_status" in ams_data:
|
||||
raw_ams_status = ams_data["ams_status"]
|
||||
if isinstance(raw_ams_status, str):
|
||||
try:
|
||||
self.state.ams_status = int(raw_ams_status)
|
||||
except ValueError:
|
||||
self.state.ams_status = 0
|
||||
else:
|
||||
self.state.ams_status = raw_ams_status if raw_ams_status is not None else 0
|
||||
# Compute main and sub status
|
||||
self.state.ams_status_sub = self.state.ams_status & 0xFF
|
||||
self.state.ams_status_main = (self.state.ams_status >> 8) & 0xFF
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] ams_status: {self.state.ams_status} "
|
||||
f"(main={self.state.ams_status_main}, sub={self.state.ams_status_sub})"
|
||||
)
|
||||
|
||||
# Parse tray_now from AMS dict - this is the currently loaded tray global ID
|
||||
if "tray_now" in ams_data:
|
||||
raw_tray_now = ams_data["tray_now"]
|
||||
@@ -628,21 +681,35 @@ class BambuMQTTClient:
|
||||
# Clear pending target since it's stale
|
||||
self.state.pending_tray_target = None
|
||||
else:
|
||||
# No pending target, use slot number as-is
|
||||
# This happens when filament was loaded before app started or via printer touchscreen
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] H2D tray_now: no pending_tray_target, "
|
||||
f"using slot {parsed_tray_now} as global ID (may be incorrect for H2D)"
|
||||
)
|
||||
self.state.tray_now = parsed_tray_now
|
||||
# No pending target - check if we already have a resolved global ID
|
||||
# that matches this slot (from a previous successful disambiguation)
|
||||
current_tray = self.state.tray_now
|
||||
if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
|
||||
# Current tray_now is already a valid global ID that matches this slot
|
||||
# Keep it (don't overwrite with raw slot number)
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
|
||||
f"(matches incoming slot {parsed_tray_now})"
|
||||
)
|
||||
else:
|
||||
# No match or no existing global ID, use slot number as-is
|
||||
# This happens when filament was loaded before app started or via printer touchscreen
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] H2D tray_now: no pending_tray_target, "
|
||||
f"using slot {parsed_tray_now} as global ID (may be incorrect for H2D)"
|
||||
)
|
||||
self.state.tray_now = parsed_tray_now
|
||||
else:
|
||||
# tray_now > 3 means it's already a global ID, or 255 means unloaded
|
||||
if parsed_tray_now == 255:
|
||||
# Filament unloaded - clear pending target
|
||||
self.state.pending_tray_target = None
|
||||
# Note: Do NOT clear pending_tray_target on tray_now=255 here.
|
||||
# During filament change, the printer sends 255 first (unload), then the slot.
|
||||
# We only clear pending_tray_target explicitly in ams_unload_filament().
|
||||
self.state.tray_now = parsed_tray_now
|
||||
|
||||
logger.debug(f"[{self.serial_number}] tray_now updated: {self.state.tray_now}")
|
||||
|
||||
# NOTE: ams_status is parsed BEFORE tray_now (see above) to ensure correct
|
||||
# state when checking filament change mode for H2D disambiguation
|
||||
elif isinstance(ams_data, list):
|
||||
ams_list = ams_data
|
||||
else:
|
||||
@@ -721,6 +788,14 @@ class BambuMQTTClient:
|
||||
self.state.progress = float(data["mc_percent"])
|
||||
if "mc_remaining_time" in data:
|
||||
self.state.remaining_time = int(data["mc_remaining_time"])
|
||||
if "mc_print_sub_stage" in data:
|
||||
new_sub_stage = int(data["mc_print_sub_stage"])
|
||||
if new_sub_stage != self.state.mc_print_sub_stage:
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] mc_print_sub_stage changed: "
|
||||
f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
|
||||
)
|
||||
self.state.mc_print_sub_stage = new_sub_stage
|
||||
if "layer_num" in data:
|
||||
self.state.layer_num = int(data["layer_num"])
|
||||
if "total_layer_num" in data:
|
||||
@@ -729,10 +804,10 @@ class BambuMQTTClient:
|
||||
# Calibration stage tracking
|
||||
if "stg_cur" in data:
|
||||
new_stg = data["stg_cur"]
|
||||
# Always log ANY stg_cur change for debugging filament operations
|
||||
if new_stg != self.state.stg_cur:
|
||||
logger.info(
|
||||
f"[{self.serial_number}] Calibration stage changed: "
|
||||
f"{self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
|
||||
f"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
|
||||
)
|
||||
self.state.stg_cur = new_stg
|
||||
if "stg" in data:
|
||||
@@ -2314,10 +2389,11 @@ class BambuMQTTClient:
|
||||
slot_id = tray_id % 4 # Slot within AMS (0, 1, 2, 3)
|
||||
|
||||
# Build command with ams_id and slot_id format (per HA-Bambulab integration)
|
||||
# Note: curr_nozzle is NOT included - ha-bambulab doesn't use it and it may cause issues
|
||||
command = {
|
||||
"print": {
|
||||
"command": "ams_change_filament",
|
||||
"target": tray_id, # Keep target for compatibility
|
||||
"target": tray_id, # Global tray ID (ams_id * 4 + slot_id)
|
||||
"ams_id": ams_id,
|
||||
"slot_id": slot_id,
|
||||
"curr_temp": 220,
|
||||
@@ -2326,8 +2402,10 @@ class BambuMQTTClient:
|
||||
}
|
||||
}
|
||||
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
logger.info(f"[{self.serial_number}] Loading filament from AMS {ams_id} slot {slot_id} (global tray {tray_id})")
|
||||
command_json = json.dumps(command)
|
||||
logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}")
|
||||
self._client.publish(self.topic_publish, command_json)
|
||||
logger.info(f"[{self.serial_number}] Loading filament from AMS {ams_id} slot {slot_id} (global tray {tray_id}) to extruder {curr_nozzle}")
|
||||
|
||||
# Track this load request for H2D dual-nozzle disambiguation
|
||||
# H2D reports only slot number (0-3) in tray_now, so we use our tracked value
|
||||
@@ -2392,7 +2470,7 @@ class BambuMQTTClient:
|
||||
logger.info(f"[{self.serial_number}] AMS control: {action}")
|
||||
return True
|
||||
|
||||
def ams_refresh_tray(self, ams_id: int, tray_id: int) -> bool:
|
||||
def ams_refresh_tray(self, ams_id: int, tray_id: int) -> tuple[bool, str]:
|
||||
"""Trigger RFID re-read for a specific AMS tray.
|
||||
|
||||
Args:
|
||||
@@ -2400,11 +2478,27 @@ class BambuMQTTClient:
|
||||
tray_id: Tray ID within the AMS (0-3)
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: not connected")
|
||||
return False
|
||||
return False, "Printer not connected"
|
||||
|
||||
# Check if filament is currently loaded (tray_now != 255)
|
||||
# RFID refresh requires the AMS to move filament, which can't happen if one is loaded
|
||||
tray_now = self.state.tray_now
|
||||
if tray_now != 255:
|
||||
# Decode which tray is loaded for the message
|
||||
if tray_now == 254:
|
||||
loaded_tray = "external spool"
|
||||
elif tray_now >= 0 and tray_now < 128:
|
||||
loaded_ams = tray_now // 4
|
||||
loaded_slot = tray_now % 4
|
||||
loaded_tray = f"AMS {loaded_ams + 1} slot {loaded_slot + 1}"
|
||||
else:
|
||||
loaded_tray = f"tray {tray_now}"
|
||||
logger.warning(f"[{self.serial_number}] Cannot refresh AMS tray: filament loaded from {loaded_tray}")
|
||||
return False, f"Please unload filament first. Currently loaded: {loaded_tray}"
|
||||
|
||||
# Use ams_get_rfid command to trigger RFID re-read
|
||||
# This command is used by Bambu Studio to re-read the RFID tag
|
||||
@@ -2418,7 +2512,7 @@ class BambuMQTTClient:
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
|
||||
return True
|
||||
return True, f"Refreshing AMS {ams_id} tray {tray_id}"
|
||||
|
||||
def set_timelapse(self, enable: bool) -> bool:
|
||||
"""Enable or disable timelapse recording.
|
||||
|
||||
@@ -140,6 +140,12 @@ export interface PrinterStatus {
|
||||
ams_extruder_map: Record<string, number>;
|
||||
// Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
|
||||
tray_now: number;
|
||||
// AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
|
||||
ams_status_main: number;
|
||||
// AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
|
||||
ams_status_sub: number;
|
||||
// mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
|
||||
mc_print_sub_stage: number;
|
||||
}
|
||||
|
||||
export interface PrinterCreate {
|
||||
|
||||
@@ -5,12 +5,8 @@ import type { PrinterStatus, AMSUnit, AMSTray } from '../../api/client';
|
||||
import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react';
|
||||
import { AMSHumidityModal } from './AMSHumidityModal';
|
||||
import { AMSMaterialsModal } from './AMSMaterialsModal';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
// Filament change stages from MQTT stg_cur
|
||||
const STAGE_HEATING_NOZZLE = 7;
|
||||
const STAGE_FILAMENT_UNLOADING = 22;
|
||||
const STAGE_FILAMENT_LOADING = 24;
|
||||
const STAGE_CHANGING_FILAMENT = 4;
|
||||
|
||||
interface AMSSectionDualProps {
|
||||
printerId: number;
|
||||
@@ -70,7 +66,11 @@ function HumidityIcon({ humidity }: { humidity: number }) {
|
||||
// Filament change progress card - appears during load/unload operations
|
||||
interface FilamentChangeCardProps {
|
||||
isLoading: boolean; // true = loading, false = unloading
|
||||
currentStage: number;
|
||||
amsStatusMain: number; // AMS status: 0=idle, 1=filament_change, 2=rfid_identifying, etc.
|
||||
amsStatusSub: number; // ams_status_sub from MQTT - step within filament change operation
|
||||
trayNow: number; // Currently loaded tray (255 = none, 254 = external)
|
||||
targetTrayId: number | null; // Target tray we're trying to load (null for unload)
|
||||
onComplete: () => void; // Called when operation completes
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
@@ -80,69 +80,137 @@ interface StepInfo {
|
||||
stepNumber: number;
|
||||
}
|
||||
|
||||
function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChangeCardProps) {
|
||||
function FilamentChangeCard({ isLoading, amsStatusMain, amsStatusSub, trayNow, targetTrayId, onComplete, onRetry }: FilamentChangeCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
// Track the highest progress step reached to show proper progression
|
||||
// 0 = initial, 1 = push started, 2 = heating, 3 = purging
|
||||
const [progressStep, setProgressStep] = useState(0);
|
||||
const prevStageRef = useRef(currentStage);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const prevAmsStatusRef = useRef(amsStatusMain);
|
||||
const prevTrayNowRef = useRef(trayNow);
|
||||
const completionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update progress based on stage transitions
|
||||
// ams_status_sub values for filament change steps
|
||||
// Observed progression: 5 -> 6 -> 2 -> 7 -> 0
|
||||
// 2: Heating nozzle
|
||||
// 3: AMS feeding filament to hub
|
||||
// 4: Retraction / extruder pulling filament
|
||||
// 5: Initial filament push / preparation
|
||||
// 6: Load verification / extruder pushing
|
||||
// 7: Purging
|
||||
const SUB_HEATING = 2;
|
||||
const SUB_FEEDING = 3;
|
||||
const SUB_RETRACT = 4;
|
||||
const SUB_PUSH_PREP = 5;
|
||||
const SUB_PUSH = 6;
|
||||
const SUB_PURGE = 7;
|
||||
|
||||
// Log status updates for debugging
|
||||
useEffect(() => {
|
||||
// For loading: track progression through stages
|
||||
if (isLoading) {
|
||||
if (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_CHANGING_FILAMENT) {
|
||||
// Push/loading stage - at least step 1
|
||||
if (progressStep < 1) setProgressStep(1);
|
||||
} else if (currentStage === STAGE_HEATING_NOZZLE) {
|
||||
// Heating stage - at least step 2
|
||||
if (progressStep < 2) setProgressStep(2);
|
||||
} else if (progressStep >= 2 && currentStage === STAGE_CHANGING_FILAMENT) {
|
||||
// After heating, back to changing = purge stage
|
||||
setProgressStep(3);
|
||||
}
|
||||
} else {
|
||||
// For unloading: track heating -> unloading
|
||||
if (currentStage === STAGE_HEATING_NOZZLE || currentStage === STAGE_CHANGING_FILAMENT) {
|
||||
if (progressStep < 1) setProgressStep(1);
|
||||
} else if (currentStage === STAGE_FILAMENT_UNLOADING) {
|
||||
if (progressStep < 2) setProgressStep(2);
|
||||
console.log(`[FilamentChangeCard] Status: main=${amsStatusMain}, sub=${amsStatusSub}, trayNow=${trayNow}, isLoading=${isLoading}`);
|
||||
}, [amsStatusMain, amsStatusSub, trayNow, isLoading]);
|
||||
|
||||
// Detect completion via ams_status_main transition from 1 (filament_change) to 0 (idle)
|
||||
// Also use tray_now as a secondary indicator
|
||||
useEffect(() => {
|
||||
const wasActive = prevAmsStatusRef.current === 1;
|
||||
const isNowIdle = amsStatusMain === 0;
|
||||
const trayChanged = trayNow !== prevTrayNowRef.current;
|
||||
|
||||
// Primary completion detection: ams_status_main transitions from 1 to 0
|
||||
if (wasActive && isNowIdle) {
|
||||
console.log(`[FilamentChangeCard] ams_status_main transitioned 1->0, operation complete!`);
|
||||
setIsCompleted(true);
|
||||
// Auto-close after brief delay
|
||||
completionTimeoutRef.current = setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1500);
|
||||
}
|
||||
// Secondary completion detection: tray_now matches target (for load) or becomes 255 (for unload)
|
||||
else if (trayChanged) {
|
||||
if (isLoading && targetTrayId !== null && trayNow === targetTrayId) {
|
||||
console.log(`[FilamentChangeCard] Load completed! tray_now=${trayNow} matches target=${targetTrayId}`);
|
||||
setIsCompleted(true);
|
||||
completionTimeoutRef.current = setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1500);
|
||||
} else if (!isLoading && trayNow === 255) {
|
||||
console.log(`[FilamentChangeCard] Unload completed! tray_now=255 (no filament)`);
|
||||
setIsCompleted(true);
|
||||
completionTimeoutRef.current = setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset progress when stage returns to idle
|
||||
if (currentStage === -1 && prevStageRef.current !== -1) {
|
||||
// Operation completed, reset for next time
|
||||
setProgressStep(0);
|
||||
}
|
||||
prevAmsStatusRef.current = amsStatusMain;
|
||||
prevTrayNowRef.current = trayNow;
|
||||
}, [amsStatusMain, trayNow, isLoading, targetTrayId, onComplete]);
|
||||
|
||||
prevStageRef.current = currentStage;
|
||||
}, [currentStage, isLoading, progressStep]);
|
||||
|
||||
// Initialize progress to step 1 when card first shows
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
if (progressStep === 0) {
|
||||
setProgressStep(1);
|
||||
}
|
||||
return () => {
|
||||
if (completionTimeoutRef.current) {
|
||||
clearTimeout(completionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Determine step status based on tracked progress
|
||||
// Determine step based on ams_status_sub from MQTT
|
||||
// ams_status_sub values indicate the current step in filament change:
|
||||
// Load sequence (actual order): 3/6 (push) -> 2 (heating) -> 7 (purging)
|
||||
// Unload sequence: 2 (heating) -> 4 (retracting)
|
||||
const getStepFromAmsStatusSub = (): number => {
|
||||
if (isCompleted) return 99; // All done
|
||||
|
||||
// If not in filament change mode, not started
|
||||
if (amsStatusMain !== 1) return 0;
|
||||
|
||||
if (isLoading) {
|
||||
// Loading sequence: Push -> Heat -> Purge (matches Bambu Studio/OrcaSlicer display)
|
||||
// Observed progression: 5 -> 6 -> 2 -> 7
|
||||
// Map sub status to steps: 5/6 -> step 1, 2 -> step 2, 7 -> step 3
|
||||
if (amsStatusSub === SUB_PUSH_PREP || amsStatusSub === SUB_PUSH || amsStatusSub === SUB_FEEDING) return 1; // Step 1: Pushing
|
||||
if (amsStatusSub === SUB_HEATING) return 2; // Step 2: Heating
|
||||
if (amsStatusSub === SUB_PURGE) return 3; // Step 3: Purging
|
||||
// Default to step 1 when in filament_change mode
|
||||
return 1;
|
||||
} else {
|
||||
// Unloading sequence: Heat -> Retract
|
||||
// Map sub status to steps: 2 -> step 1, 4 -> step 2
|
||||
if (amsStatusSub === SUB_HEATING) return 1; // Step 1: Heating
|
||||
if (amsStatusSub === SUB_RETRACT) return 2; // Step 2: Retracting
|
||||
// Default to step 1 when in filament_change mode
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current step from ams_status_sub
|
||||
const currentStep = getStepFromAmsStatusSub();
|
||||
|
||||
// Debug: log step calculation
|
||||
console.log(`[FilamentChangeCard] Step calculation: currentStep=${currentStep}, amsStatusSub=${amsStatusSub}`);
|
||||
|
||||
// Determine step status based on ams_status_sub
|
||||
const getLoadingSteps = (): StepInfo[] => {
|
||||
// Loading sequence: Push filament -> Heat nozzle -> Purge
|
||||
// Loading sequence: Push -> Heat -> Purge (matches Bambu Studio/OrcaSlicer display order)
|
||||
// ams_status_sub progression: 3/6 (push) -> 2 (heating) -> 7 (purging)
|
||||
let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
|
||||
let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
|
||||
let step3Status: 'completed' | 'in_progress' | 'pending' = 'pending';
|
||||
|
||||
if (progressStep >= 3) {
|
||||
if (currentStep >= 99) {
|
||||
// All completed
|
||||
step1Status = 'completed';
|
||||
step2Status = 'completed';
|
||||
step3Status = 'completed';
|
||||
} else if (currentStep >= 3) {
|
||||
// Purging - steps 1 & 2 done, step 3 active
|
||||
step1Status = 'completed';
|
||||
step2Status = 'completed';
|
||||
step3Status = 'in_progress';
|
||||
} else if (progressStep >= 2) {
|
||||
} else if (currentStep >= 2) {
|
||||
// Heating - step 1 done, step 2 active
|
||||
step1Status = 'completed';
|
||||
step2Status = 'in_progress';
|
||||
} else if (progressStep >= 1) {
|
||||
} else if (currentStep >= 1) {
|
||||
// Pushing - step 1 active
|
||||
step1Status = 'in_progress';
|
||||
}
|
||||
@@ -158,11 +226,15 @@ function FilamentChangeCard({ isLoading, currentStage, onRetry }: FilamentChange
|
||||
let step1Status: 'completed' | 'in_progress' | 'pending' = 'pending';
|
||||
let step2Status: 'completed' | 'in_progress' | 'pending' = 'pending';
|
||||
|
||||
if (progressStep >= 2) {
|
||||
// Unloading - step 1 done, step 2 active
|
||||
if (currentStep >= 99) {
|
||||
// All completed
|
||||
step1Status = 'completed';
|
||||
step2Status = 'completed';
|
||||
} else if (currentStep >= 2) {
|
||||
// Retracting - step 1 done, step 2 active
|
||||
step1Status = 'completed';
|
||||
step2Status = 'in_progress';
|
||||
} else if (progressStep >= 1) {
|
||||
} else if (currentStep >= 1) {
|
||||
// Heating - step 1 active
|
||||
step1Status = 'in_progress';
|
||||
}
|
||||
@@ -263,6 +335,7 @@ interface AMSPanelContentProps {
|
||||
onHumidityClick: (humidity: number, temp: number) => void;
|
||||
onSlotRefresh: (amsId: number, slotId: number) => void;
|
||||
onEyeClick: (tray: AMSTray, slotLabel: string, amsId: number) => void;
|
||||
refreshingSlot: { amsId: number; trayId: number } | null;
|
||||
}
|
||||
|
||||
// Panel content - NO wiring, just slots and info
|
||||
@@ -300,6 +373,7 @@ function AMSPanelContent({
|
||||
onHumidityClick,
|
||||
onSlotRefresh,
|
||||
onEyeClick,
|
||||
refreshingSlot,
|
||||
}: AMSPanelContentProps) {
|
||||
const selectedUnit = units[selectedAmsIndex];
|
||||
const isHT = selectedUnit ? isAmsHT(selectedUnit.id) : false;
|
||||
@@ -359,14 +433,22 @@ function AMSPanelContent({
|
||||
<div className={`flex gap-2 mb-1.5 ${isHT ? 'justify-start pl-2' : 'justify-center'}`}>
|
||||
{selectedUnit.tray.map((tray, index) => {
|
||||
const slotLabel = getSlotLabel(selectedUnit.id, index);
|
||||
const isRefreshing = refreshingSlot?.amsId === selectedUnit.id && refreshingSlot?.trayId === tray.id;
|
||||
return (
|
||||
<button
|
||||
key={tray.id}
|
||||
onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
|
||||
className="w-14 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary transition-colors"
|
||||
disabled={isRefreshing}
|
||||
className={`w-14 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary transition-colors ${
|
||||
isRefreshing ? 'opacity-70 cursor-wait' : 'hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{slotLabel}
|
||||
<img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
|
||||
<img
|
||||
src="/icons/reload.svg"
|
||||
alt=""
|
||||
className={`w-2.5 h-2.5 icon-theme ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -677,6 +759,7 @@ function WiringLayer({
|
||||
}
|
||||
|
||||
export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }: AMSSectionDualProps) {
|
||||
const { showToast } = useToast();
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isPrinting = status?.state === 'RUNNING';
|
||||
const isDualNozzle = nozzleCount > 1;
|
||||
@@ -688,26 +771,27 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
|
||||
// Distribute AMS units based on ams_extruder_map
|
||||
// Each AMS unit's info field tells us which extruder it's connected to:
|
||||
// In Bambu slicer convention: extruder 0 = left nozzle, extruder 1 = right nozzle
|
||||
// UI layout: Left panel shows extruder 0 AMS units, Right panel shows extruder 1 AMS units
|
||||
// Note: Internal nozzle IDs are different (T0=right physical nozzle, T1=left physical nozzle)
|
||||
const leftUnits = (() => {
|
||||
if (!isDualNozzle) return amsUnits;
|
||||
if (Object.keys(amsExtruderMap).length > 0) {
|
||||
// Filter AMS units assigned to extruder 0 (left nozzle in slicer convention)
|
||||
// Filter AMS units assigned to extruder 0 (left UI panel)
|
||||
// JSON keys are strings, so convert unit.id to string
|
||||
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 0);
|
||||
}
|
||||
// Fallback: even indices go to left (extruder 0)
|
||||
// Fallback: even indices go to left
|
||||
return amsUnits.filter((_, i) => i % 2 === 0);
|
||||
})();
|
||||
|
||||
const rightUnits = (() => {
|
||||
if (!isDualNozzle) return [];
|
||||
if (Object.keys(amsExtruderMap).length > 0) {
|
||||
// Filter AMS units assigned to extruder 1 (right nozzle in slicer convention)
|
||||
// Filter AMS units assigned to extruder 1 (right UI panel)
|
||||
// JSON keys are strings, so convert unit.id to string
|
||||
return amsUnits.filter(unit => amsExtruderMap[String(unit.id)] === 1);
|
||||
}
|
||||
// Fallback: odd indices go to right (extruder 1)
|
||||
// Fallback: odd indices go to right
|
||||
return amsUnits.filter((_, i) => i % 2 === 1);
|
||||
})();
|
||||
|
||||
@@ -719,10 +803,12 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
const [humidityModal, setHumidityModal] = useState<{ humidity: number; temp: number } | null>(null);
|
||||
const [materialsModal, setMaterialsModal] = useState<{ tray: AMSTray; slotLabel: string; amsId: number } | null>(null);
|
||||
|
||||
// Track refreshing slot - cleared when tray data updates from MQTT
|
||||
const [refreshingSlotState, setRefreshingSlotState] = useState<{ amsId: number; trayId: number; startTime: number } | null>(null);
|
||||
|
||||
// Track user-initiated filament change operations (for showing progress card immediately)
|
||||
const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean } | null>(null);
|
||||
// Track the previous stage for detecting when operation completes
|
||||
const prevStageRef = useRef<number>(-1);
|
||||
// Store both the operation type (load/unload) and the target tray ID for load operations
|
||||
const [userFilamentChange, setUserFilamentChange] = useState<{ isLoading: boolean; targetTrayId: number | null } | null>(null);
|
||||
|
||||
// Track if we've done initial sync from tray_now
|
||||
const initialSyncDone = useRef(false);
|
||||
@@ -747,6 +833,40 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
}
|
||||
}, [status?.tray_now]);
|
||||
|
||||
// Watch for AMS data updates to clear the refresh spinner
|
||||
// When the printer reports updated tray data after RFID read, clear the spinner
|
||||
const prevAmsDataRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!refreshingSlotState) return;
|
||||
|
||||
// Find the refreshing slot's current data
|
||||
const { amsId, trayId, startTime } = refreshingSlotState;
|
||||
const unit = amsUnits.find(u => u.id === amsId);
|
||||
const tray = unit?.tray?.find(t => t.id === trayId);
|
||||
|
||||
if (!tray) return;
|
||||
|
||||
// Create a signature of the tray data to detect changes
|
||||
const traySignature = JSON.stringify({
|
||||
tag_uid: tray.tag_uid,
|
||||
tray_uuid: tray.tray_uuid,
|
||||
tray_id_name: tray.tray_id_name,
|
||||
tray_type: tray.tray_type,
|
||||
tray_color: tray.tray_color,
|
||||
});
|
||||
|
||||
// If we have previous data and it changed, the refresh is complete
|
||||
// Also require at least 500ms to have passed (to avoid false positives from initial render)
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (prevAmsDataRef.current && prevAmsDataRef.current !== traySignature && elapsed > 500) {
|
||||
console.log(`[AMSSectionDual] RFID refresh complete for AMS ${amsId} tray ${trayId} (took ${elapsed}ms)`);
|
||||
setRefreshingSlotState(null);
|
||||
}
|
||||
|
||||
// Update the ref for next comparison
|
||||
prevAmsDataRef.current = traySignature;
|
||||
}, [refreshingSlotState, amsUnits]);
|
||||
|
||||
const loadMutation = useMutation({
|
||||
mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
|
||||
api.amsLoadFilament(printerId, trayId, extruderId),
|
||||
@@ -801,17 +921,23 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
const extruderId = getExtruderIdForTray(selectedTray);
|
||||
console.log(`[AMSSectionDual] Calling loadMutation.mutate(tray: ${selectedTray}, extruder: ${extruderId})`);
|
||||
// Show filament change card immediately
|
||||
setUserFilamentChange({ isLoading: true });
|
||||
setUserFilamentChange({ isLoading: true, targetTrayId: selectedTray });
|
||||
loadMutation.mutate({ trayId: selectedTray, extruderId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnload = () => {
|
||||
// Show filament change card immediately
|
||||
setUserFilamentChange({ isLoading: false });
|
||||
// Show filament change card immediately (no target tray for unload)
|
||||
setUserFilamentChange({ isLoading: false, targetTrayId: null });
|
||||
unloadMutation.mutate();
|
||||
};
|
||||
|
||||
// Callback for FilamentChangeCard to close itself
|
||||
const handleFilamentChangeComplete = () => {
|
||||
console.log(`[AMSSectionDual] FilamentChangeCard completed, closing card`);
|
||||
setUserFilamentChange(null);
|
||||
};
|
||||
|
||||
const isLoading = loadMutation.isPending || unloadMutation.isPending;
|
||||
|
||||
// Handlers for modals and actions
|
||||
@@ -823,63 +949,79 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
|
||||
api.refreshAmsTray(printerId, amsId, trayId),
|
||||
onSuccess: (data, variables) => {
|
||||
console.log(`[AMSSectionDual] Tray refresh success (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data);
|
||||
console.log(`[AMSSectionDual] Tray refresh response (AMS ${variables.amsId}, Tray ${variables.trayId}):`, data);
|
||||
if (data.success) {
|
||||
showToast(data.message || 'RFID refresh started', 'success');
|
||||
} else {
|
||||
showToast(data.message || 'Failed to refresh tray', 'error');
|
||||
}
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
console.error(`[AMSSectionDual] Tray refresh error (AMS ${variables.amsId}, Tray ${variables.trayId}):`, error);
|
||||
showToast('Failed to refresh tray', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSlotRefresh = (amsId: number, slotId: number) => {
|
||||
// Trigger RFID re-read for the specific tray
|
||||
console.log(`[AMSSectionDual] Slot refresh triggered: AMS ${amsId}, Slot ${slotId}, printerId: ${printerId}`);
|
||||
// Reset the previous data ref so we can detect the next change
|
||||
prevAmsDataRef.current = '';
|
||||
// Show spinner immediately - will be cleared when AMS data updates from MQTT
|
||||
const startTime = Date.now();
|
||||
setRefreshingSlotState({ amsId, trayId: slotId, startTime });
|
||||
refreshMutation.mutate({ amsId, trayId: slotId });
|
||||
// Fallback timeout (15s) in case data doesn't change (e.g., same spool re-read)
|
||||
setTimeout(() => {
|
||||
setRefreshingSlotState(prev => {
|
||||
if (prev && prev.startTime === startTime) {
|
||||
console.log(`[AMSSectionDual] RFID refresh timeout for AMS ${amsId} tray ${slotId}`);
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
const handleEyeClick = (tray: AMSTray, slotLabel: string, amsId: number) => {
|
||||
setMaterialsModal({ tray, slotLabel, amsId });
|
||||
};
|
||||
|
||||
// Determine if we're in a filament change stage (from MQTT)
|
||||
const currentStage = status?.stg_cur ?? -1;
|
||||
const isMqttFilamentChangeActive = [
|
||||
STAGE_HEATING_NOZZLE,
|
||||
STAGE_FILAMENT_UNLOADING,
|
||||
STAGE_FILAMENT_LOADING,
|
||||
STAGE_CHANGING_FILAMENT,
|
||||
].includes(currentStage);
|
||||
// Determine if we're in a filament change state (from MQTT ams_status)
|
||||
// ams_status_main: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
|
||||
// mc_print_sub_stage: step indicator used by BambuStudio/OrcaSlicer for filament change progress
|
||||
const amsStatusMain = status?.ams_status_main ?? 0;
|
||||
const isMqttFilamentChangeActive = amsStatusMain === 1;
|
||||
|
||||
// Auto-close card when operation completes
|
||||
// Track when we transition from an active filament change stage back to idle
|
||||
// Track when we transition from filament change active to idle (ams_status_main 1 -> 0)
|
||||
const prevAmsStatusMainRef = useRef(amsStatusMain);
|
||||
useEffect(() => {
|
||||
const wasInFilamentChange = [
|
||||
STAGE_HEATING_NOZZLE,
|
||||
STAGE_FILAMENT_UNLOADING,
|
||||
STAGE_FILAMENT_LOADING,
|
||||
STAGE_CHANGING_FILAMENT,
|
||||
].includes(prevStageRef.current);
|
||||
const wasActive = prevAmsStatusMainRef.current === 1;
|
||||
|
||||
if (isMqttFilamentChangeActive) {
|
||||
// MQTT is now reporting a stage, clear user-triggered state
|
||||
// MQTT is now reporting filament change, clear user-triggered state
|
||||
// Card will continue showing because isMqttFilamentChangeActive is true
|
||||
setUserFilamentChange(null);
|
||||
} else if (wasInFilamentChange && !isMqttFilamentChangeActive) {
|
||||
// Transition from active stage to idle (any non-filament-change stage, not just -1)
|
||||
} else if (wasActive && !isMqttFilamentChangeActive) {
|
||||
// Transition from active (1) to idle (0)
|
||||
// Close the card by clearing user state
|
||||
console.log(`[AMSSectionDual] ams_status_main transitioned 1->0, closing card`);
|
||||
setUserFilamentChange(null);
|
||||
}
|
||||
|
||||
// Update previous stage for next comparison
|
||||
prevStageRef.current = currentStage;
|
||||
}, [isMqttFilamentChangeActive, currentStage]);
|
||||
// Update previous status for next comparison
|
||||
prevAmsStatusMainRef.current = amsStatusMain;
|
||||
}, [isMqttFilamentChangeActive, amsStatusMain]);
|
||||
|
||||
// Show FilamentChangeCard when either MQTT reports active stage OR user just clicked load/unload
|
||||
// Show FilamentChangeCard when either MQTT reports active ams_status OR user just clicked load/unload
|
||||
const showFilamentChangeCard = isMqttFilamentChangeActive || userFilamentChange !== null;
|
||||
|
||||
// Determine if loading or unloading for the card display
|
||||
// Use user intent if available, otherwise default to loading (most common operation)
|
||||
const isFilamentLoading = userFilamentChange !== null
|
||||
? userFilamentChange.isLoading
|
||||
: (currentStage === STAGE_FILAMENT_LOADING || currentStage === STAGE_HEATING_NOZZLE);
|
||||
: true; // Default to loading when detected via MQTT
|
||||
|
||||
// Get the loaded tray info for wire coloring
|
||||
// Wire coloring should show the path from the currently loaded filament to the extruder
|
||||
@@ -905,7 +1047,8 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
const tray = unit.tray[slotIndex];
|
||||
const color = tray?.tray_color ?? null;
|
||||
|
||||
// Determine if this AMS is on left or right side
|
||||
// Determine if this AMS is on left or right UI panel
|
||||
// UI layout: extruder 0 = left panel, extruder 1 = right panel
|
||||
const extruderId = amsExtruderMap[String(unit.id)];
|
||||
|
||||
// Check if this AMS unit is the one currently displayed in the panel
|
||||
@@ -913,7 +1056,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
const currentRightUnit = rightUnits[rightAmsIndex];
|
||||
|
||||
if (extruderId === 0) {
|
||||
// Left side (extruder 0) - leftUnits filters for amsExtruderMap === 0
|
||||
// Left UI panel (extruder 0) - leftUnits filters for amsExtruderMap === 0
|
||||
// Only show colored wiring if the currently displayed AMS unit is the one with loaded filament
|
||||
const isDisplayed = currentLeftUnit?.id === unit.id;
|
||||
return {
|
||||
@@ -923,7 +1066,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
rightFilamentColor: null
|
||||
};
|
||||
} else {
|
||||
// Right side (extruder 1) - rightUnits filters for amsExtruderMap === 1
|
||||
// Right UI panel (extruder 1) - rightUnits filters for amsExtruderMap === 1
|
||||
const isDisplayed = currentRightUnit?.id === unit.id;
|
||||
return {
|
||||
leftActiveSlot: null,
|
||||
@@ -940,6 +1083,9 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
|
||||
const { leftActiveSlot, rightActiveSlot, leftFilamentColor, rightFilamentColor } = getLoadedTrayInfo();
|
||||
|
||||
// Use state-based refreshing slot for spinner visibility (minimum 1.5s display time)
|
||||
const refreshingSlot = refreshingSlotState;
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark-tertiary rounded-[10px] p-3">
|
||||
{/* Dual Panel Layout - just the panels, no wiring */}
|
||||
@@ -955,6 +1101,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
onHumidityClick={handleHumidityClick}
|
||||
onSlotRefresh={handleSlotRefresh}
|
||||
onEyeClick={handleEyeClick}
|
||||
refreshingSlot={refreshingSlot}
|
||||
/>
|
||||
|
||||
{isDualNozzle && (
|
||||
@@ -969,6 +1116,7 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
onHumidityClick={handleHumidityClick}
|
||||
onSlotRefresh={handleSlotRefresh}
|
||||
onEyeClick={handleEyeClick}
|
||||
refreshingSlot={refreshingSlot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1044,7 +1192,11 @@ export function AMSSectionDual({ printerId, printerModel, status, nozzleCount }:
|
||||
{showFilamentChangeCard && (
|
||||
<FilamentChangeCard
|
||||
isLoading={isFilamentLoading}
|
||||
currentStage={currentStage}
|
||||
amsStatusMain={amsStatusMain}
|
||||
amsStatusSub={status?.ams_status_sub ?? 0}
|
||||
trayNow={trayNow}
|
||||
targetTrayId={userFilamentChange?.targetTrayId ?? null}
|
||||
onComplete={handleFilamentChangeComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BMICGIP2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BEhbt1yZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-CLt_WkAI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BREH29lW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user