Improvements AMS card

This commit is contained in:
Martin Ziegler
2025-12-05 15:20:09 +01:00
parent fc9f12d3f2
commit 8f3c36e920
8 changed files with 682 additions and 144 deletions
+39
View File
@@ -141,6 +141,18 @@ class AMSRefreshTrayRequest(BaseModel):
tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
class AMSFilamentSettingRequest(BaseModel):
ams_id: int = Field(..., ge=0, le=128, description="AMS unit ID (0-3, or 128 for H2D external)")
tray_id: int = Field(..., ge=0, le=3, description="Tray ID within the AMS (0-3)")
tray_info_idx: str = Field(..., description="Filament preset ID (e.g., 'GFA00')")
tray_type: str = Field(..., description="Filament type (e.g., 'PLA', 'PETG')")
tray_sub_brands: str = Field(default="", description="Sub-brand name (e.g., 'PLA Basic')")
tray_color: str = Field(..., description="Color in RRGGBBAA hex format")
nozzle_temp_min: int = Field(..., ge=150, le=350, description="Minimum nozzle temperature")
nozzle_temp_max: int = Field(..., ge=150, le=350, description="Maximum nozzle temperature")
k: float = Field(..., ge=0, le=1, description="Pressure advance (K) value")
class GcodeRequest(ConfirmableRequest):
command: str = Field(..., min_length=1, max_length=500, description="G-code command(s)")
@@ -635,6 +647,33 @@ async def ams_refresh_tray(
return ControlResponse(success=success, message=message)
@router.post("/{printer_id}/control/ams/filament-setting", response_model=ControlResponse)
async def ams_set_filament_setting(
printer_id: int,
request: AMSFilamentSettingRequest,
db: AsyncSession = Depends(get_db),
):
"""Set filament settings for an AMS tray including K (pressure advance) value."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
success = client.ams_set_filament_setting(
ams_id=request.ams_id,
tray_id=request.tray_id,
tray_info_idx=request.tray_info_idx,
tray_type=request.tray_type,
tray_sub_brands=request.tray_sub_brands,
tray_color=request.tray_color,
nozzle_temp_min=request.nozzle_temp_min,
nozzle_temp_max=request.nozzle_temp_max,
k=request.k,
)
return ControlResponse(
success=success,
message=f"Updated AMS {request.ams_id} tray {request.tray_id} with K={request.k}" if success else "Failed to update filament setting"
)
# =============================================================================
# Advanced: G-code Command
# =============================================================================
+54
View File
@@ -2627,6 +2627,60 @@ class BambuMQTTClient:
logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
return True, f"Refreshing AMS {ams_id} tray {tray_id}"
def ams_set_filament_setting(
self,
ams_id: int,
tray_id: int,
tray_info_idx: str,
tray_type: str,
tray_sub_brands: str,
tray_color: str,
nozzle_temp_min: int,
nozzle_temp_max: int,
k: float,
) -> bool:
"""Set AMS tray filament settings including K (pressure advance) value.
Args:
ams_id: AMS unit ID (0-3)
tray_id: Tray ID within the AMS (0-3)
tray_info_idx: Filament preset ID (e.g., "GFA00")
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)
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 AMS filament setting: not connected")
return False
command = {
"print": {
"command": "ams_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,
"k": k,
"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}")
self._client.publish(self.topic_publish, command_json)
return True
def set_timelapse(self, enable: bool) -> bool:
"""Enable or disable timelapse recording.
+15
View File
@@ -1281,6 +1281,21 @@ export const api = {
}),
amsUnloadFilament: (printerId: number) =>
request<ControlResponse>(`/printers/${printerId}/control/ams/unload`, { method: 'POST' }),
amsSetFilamentSetting: (printerId: number, data: {
ams_id: number;
tray_id: number;
tray_info_idx: string;
tray_type: string;
tray_sub_brands: string;
tray_color: string;
nozzle_temp_min: number;
nozzle_temp_max: number;
k: number;
}) =>
request<ControlResponse>(`/printers/${printerId}/control/ams/filament-setting`, {
method: 'POST',
body: JSON.stringify(data),
}),
sendGcode: (printerId: number, command: string, confirmToken?: string) =>
request<ControlResult>(`/printers/${printerId}/control/gcode`, {
method: 'POST',
@@ -74,6 +74,12 @@ function trayColorToHex(trayColor: string | null): string {
return `#${cleanHex}`;
}
function hexToTrayColor(hex: string): string {
// Convert #RRGGBB to RRGGBBFF (with full opacity)
const cleanHex = hex.replace('#', '').toUpperCase();
return cleanHex.length === 6 ? `${cleanHex}FF` : `${cleanHex.substring(0, 6)}FF`;
}
// Check if tray has valid Bambu Lab UUID (32-char hex)
function isBambuLabSpool(trayUuid: string | null): boolean {
if (!trayUuid) return false;
@@ -210,16 +216,40 @@ export function AMSMaterialsModal({
},
});
// Mutation for saving filament settings to printer (including K value)
const saveFilamentSettingMutation = useMutation({
mutationFn: (data: {
ams_id: number;
tray_id: number;
tray_info_idx: string;
tray_type: string;
tray_sub_brands: string;
tray_color: string;
nozzle_temp_min: number;
nozzle_temp_max: number;
k: number;
}) => api.amsSetFilamentSetting(printerId, data),
onSuccess: (result) => {
console.log('[AMSMaterialsModal] saveFilamentSettingMutation SUCCESS:', result);
},
onError: (error) => {
console.error('[AMSMaterialsModal] saveFilamentSettingMutation ERROR:', error);
},
});
// Get filament presets from cloud settings:
// 1. Show ALL filament presets (like Bambu Studio does in cloud profiles)
// 1. Filter out presets starting with "#" (experimental/special presets)
// 2. Prioritize current printer model, then deduplicate by base name
// 3. Sort alphabetically
const filamentPresets = (() => {
const allPresets = cloudSettings?.filament || [];
// Filter out presets starting with "#" (experimental/special presets)
const filteredPresets = allPresets.filter(p => !p.name.startsWith('# '));
// Sort presets: current printer model first, then others
const modelPattern = new RegExp(`@.*\\b${printerModel}\\b`, 'i');
const sortedByModel = [...allPresets].sort((a, b) => {
const sortedByModel = [...filteredPresets].sort((a, b) => {
const aMatches = modelPattern.test(a.name);
const bMatches = modelPattern.test(b.name);
if (aMatches && !bMatches) return -1;
@@ -323,6 +353,22 @@ export function AMSMaterialsModal({
const bambuColorName = getColorNameFromTrayId(tray.tray_id_name);
useEffect(() => {
// First, try to find a profile matching the tray's current K value (from printer)
// This preserves the user's selection when reopening the modal
const currentK = tray.k;
if (currentK && currentK > 0) {
const matchingKProfile = matchingProfiles.find(p => {
const profileK = parseFloat(p.k_value);
return Math.abs(profileK - currentK) < 0.0001; // Allow small floating point tolerance
});
if (matchingKProfile) {
setSelectedKProfile(matchingKProfile.name);
setKValue(currentK);
return;
}
}
// Fall back to name-based matching if no K-value match found
// For Bambu spools, use tray_sub_brands (e.g., "PLA Basic") and color name
// For non-Bambu, use clean name from preset (e.g., "Bambu PLA Basic" without #) for K-profile matching
const subBrands = isBambuSpool
@@ -340,7 +386,7 @@ export function AMSMaterialsModal({
setSelectedKProfile('Default');
setKValue(0);
}
}, [profiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, isBambuSpool, bambuColorName]);
}, [profiles, matchingProfiles, selectedFilamentType, selectedPresetName, tray.tray_type, tray.tray_sub_brands, tray.tray_id_name, tray.k, isBambuSpool, bambuColorName]);
// Close on Escape key
useEffect(() => {
@@ -360,14 +406,44 @@ export function AMSMaterialsModal({
};
const handleConfirm = () => {
console.log('[AMSMaterialsModal] handleConfirm called');
console.log('[AMSMaterialsModal] amsId:', amsId, 'tray.id:', tray.id, 'kValue:', kValue);
// Save preset mapping for non-Bambu slots
if (isEditable && selectedPresetId && selectedPresetName) {
console.log('[AMSMaterialsModal] Saving preset mapping');
savePresetMutation.mutate({
presetId: selectedPresetId,
presetName: selectedPresetName,
});
}
// Send filament settings to printer (including K value)
// Use current tray data for Bambu spools, or edited values for non-Bambu
const trayColor = isBambuSpool
? (tray.tray_color || '808080FF')
: hexToTrayColor(selectedColor);
const trayType = isBambuSpool
? (tray.tray_type || 'PLA')
: (selectedFilamentType || 'PLA');
const traySubBrands = isBambuSpool
? (tray.tray_sub_brands || '')
: (selectedPresetName ? getCleanFilamentName(selectedPresetName) : '');
const payload = {
ams_id: amsId,
tray_id: tray.id,
tray_info_idx: tray.tray_info_idx || '',
tray_type: trayType,
tray_sub_brands: traySubBrands,
tray_color: trayColor,
nozzle_temp_min: nozzleTempMin,
nozzle_temp_max: nozzleTempMax,
k: kValue,
};
console.log('[AMSMaterialsModal] Calling saveFilamentSettingMutation with payload:', payload);
saveFilamentSettingMutation.mutate(payload);
onConfirm?.({
filamentType: selectedFilamentType,
color: selectedColor,
@@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { PrinterStatus, AMSUnit, AMSTray, KProfile } from '../../api/client';
import { Loader2, ChevronDown, ChevronUp, RotateCw } from 'lucide-react';
import { AMSHumidityModal } from './AMSHumidityModal';
import { AMSMaterialsModal } from './AMSMaterialsModal';
import { useToast } from '../../contexts/ToastContext';
import { useAmsOperations } from './useAmsOperations';
interface AMSSectionDualProps {
@@ -519,7 +520,10 @@ function AMSPanelContent({
return (
<button
key={tray.id}
onClick={() => onSlotRefresh(selectedUnit.id, tray.id)}
onClick={(e) => {
e.stopPropagation();
onSlotRefresh(selectedUnit.id, tray.id);
}}
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'
@@ -927,20 +931,22 @@ 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);
// Get ams_status values from printer status
const amsStatusMain = status?.ams_status_main ?? 0;
const trayNow = status?.tray_now ?? 255;
// Track user-initiated filament change operations (for showing progress card immediately)
// 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);
// AMS Operations hook - manages state machine for refresh/load/unload
const amsOps = useAmsOperations({
printerId,
amsUnits,
amsStatusMain,
trayNow,
onToast: showToast,
});
// Track if we've done initial sync from tray_now
const initialSyncDone = useRef(false);
// Track intended operation type synchronously (refs update immediately, unlike state)
// This prevents race conditions where MQTT updates arrive before React state updates
const intendedOperationRef = useRef<'load' | 'unload' | null>(null);
// Sync selectedTray from status.tray_now on initial load
// tray_now: 255 = no filament loaded, 0-253 = valid tray ID, 254 = external spool
useEffect(() => {
@@ -0,0 +1,348 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { AMSUnit } from '../../api/client';
/**
* AMS Operation State Machine
*
* States:
* - IDLE: No operation in progress, all buttons enabled
* - REFRESHING: RFID refresh in progress for a specific slot
* - LOADING: Filament load in progress
* - UNLOADING: Filament unload in progress
*
* Completion detection:
* - REFRESH: AMS tray data changes (tag_uid, tray_uuid, etc.) OR timeout (15s)
* - LOAD/UNLOAD: ams_status_main transitions from 1 (filament_change) to 0 (idle) OR timeout (60s)
*
* Rules:
* - Only one operation at a time
* - All operations have timeout fallback
* - Operation can be cancelled/reset manually
*/
export type OperationState = 'IDLE' | 'REFRESHING' | 'LOADING' | 'UNLOADING';
export interface RefreshTarget {
amsId: number;
trayId: number;
}
export interface OperationContext {
// For REFRESHING: which slot is being refreshed
refreshTarget?: RefreshTarget;
// For LOADING: target tray ID we're loading
loadTargetTrayId?: number;
// Timestamp when operation started
startTime: number;
}
interface UseAmsOperationsProps {
printerId: number;
amsUnits: AMSUnit[];
amsStatusMain: number;
trayNow: number;
onToast: (message: string, type: 'success' | 'error') => void;
}
interface UseAmsOperationsReturn {
// Current state
state: OperationState;
context: OperationContext | null;
// Operation triggers
startRefresh: (amsId: number, trayId: number) => void;
startLoad: (trayId: number, extruderId?: number) => void;
startUnload: () => void;
// Manual reset (e.g., for retry)
reset: () => void;
// Derived state helpers
isOperationInProgress: boolean;
isRefreshingSlot: (amsId: number, trayId: number) => boolean;
// For FilamentChangeCard - which type of operation
isLoadOperation: boolean;
loadTargetTrayId: number | null;
// Mutation error states (for UI feedback)
loadError: Error | null;
unloadError: Error | null;
refreshError: Error | null;
}
// Timeouts for different operations
const REFRESH_TIMEOUT_MS = 15000; // 15 seconds for RFID refresh
const FILAMENT_CHANGE_TIMEOUT_MS = 120000; // 2 minutes for load/unload (these can take a while with heating)
export function useAmsOperations({
printerId,
amsUnits,
amsStatusMain,
trayNow,
onToast,
}: UseAmsOperationsProps): UseAmsOperationsReturn {
const [state, setState] = useState<OperationState>('IDLE');
const [context, setContext] = useState<OperationContext | null>(null);
// Track previous values for transition detection
const prevAmsStatusMainRef = useRef(amsStatusMain);
const prevTrayDataRef = useRef<string>('');
// Timeout ref for cleanup
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear any pending timeout
const clearOperationTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
// Reset to IDLE state
const reset = useCallback(() => {
clearOperationTimeout();
setState('IDLE');
setContext(null);
prevTrayDataRef.current = '';
}, [clearOperationTimeout]);
// Set up timeout for current operation
const startOperationTimeout = useCallback((timeoutMs: number) => {
clearOperationTimeout();
const startTime = context?.startTime ?? Date.now();
timeoutRef.current = setTimeout(() => {
console.log(`[useAmsOperations] Operation timed out after ${timeoutMs}ms`);
reset();
}, timeoutMs);
}, [clearOperationTimeout, reset, context?.startTime]);
// === Mutations ===
const refreshMutation = useMutation({
mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
api.refreshAmsTray(printerId, amsId, trayId),
onSuccess: (data) => {
if (data.success) {
onToast(data.message || 'RFID refresh started', 'success');
} else {
onToast(data.message || 'Failed to refresh tray', 'error');
reset();
}
},
onError: (error) => {
console.error('[useAmsOperations] Refresh error:', error);
onToast('Failed to refresh tray', 'error');
reset();
},
});
const loadMutation = useMutation({
mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
api.amsLoadFilament(printerId, trayId, extruderId),
onSuccess: (data) => {
console.log('[useAmsOperations] Load request sent:', data);
// Don't reset here - wait for ams_status_main transition
},
onError: (error) => {
console.error('[useAmsOperations] Load error:', error);
reset();
},
});
const unloadMutation = useMutation({
mutationFn: () => api.amsUnloadFilament(printerId),
onSuccess: (data) => {
console.log('[useAmsOperations] Unload request sent:', data);
// Don't reset here - wait for ams_status_main transition
},
onError: (error) => {
console.error('[useAmsOperations] Unload error:', error);
reset();
},
});
// === Operation Triggers ===
const startRefresh = useCallback((amsId: number, trayId: number) => {
if (state !== 'IDLE') {
console.log('[useAmsOperations] Cannot start refresh - operation in progress:', state);
return;
}
console.log(`[useAmsOperations] Starting refresh: AMS ${amsId}, Tray ${trayId}`);
// Capture current tray data signature for change detection
const unit = amsUnits.find(u => u.id === amsId);
const tray = unit?.tray?.find(t => t.id === trayId);
if (tray) {
prevTrayDataRef.current = 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,
});
}
const startTime = Date.now();
setState('REFRESHING');
setContext({ refreshTarget: { amsId, trayId }, startTime });
// Set timeout
timeoutRef.current = setTimeout(() => {
console.log(`[useAmsOperations] Refresh timeout for AMS ${amsId} tray ${trayId}`);
reset();
}, REFRESH_TIMEOUT_MS);
refreshMutation.mutate({ amsId, trayId });
}, [state, amsUnits, reset, refreshMutation]);
const startLoad = useCallback((trayId: number, extruderId?: number) => {
if (state !== 'IDLE') {
console.log('[useAmsOperations] Cannot start load - operation in progress:', state);
return;
}
console.log(`[useAmsOperations] Starting load: tray ${trayId}, extruder ${extruderId}`);
const startTime = Date.now();
setState('LOADING');
setContext({ loadTargetTrayId: trayId, startTime });
// Set timeout
timeoutRef.current = setTimeout(() => {
console.log(`[useAmsOperations] Load timeout for tray ${trayId}`);
reset();
}, FILAMENT_CHANGE_TIMEOUT_MS);
loadMutation.mutate({ trayId, extruderId });
}, [state, reset, loadMutation]);
const startUnload = useCallback(() => {
if (state !== 'IDLE') {
console.log('[useAmsOperations] Cannot start unload - operation in progress:', state);
return;
}
console.log('[useAmsOperations] Starting unload');
const startTime = Date.now();
setState('UNLOADING');
setContext({ startTime });
// Set timeout
timeoutRef.current = setTimeout(() => {
console.log('[useAmsOperations] Unload timeout');
reset();
}, FILAMENT_CHANGE_TIMEOUT_MS);
unloadMutation.mutate();
}, [state, reset, unloadMutation]);
// === Completion Detection ===
// Detect REFRESH completion via tray data change
useEffect(() => {
if (state !== 'REFRESHING' || !context?.refreshTarget) return;
const { amsId, trayId } = context.refreshTarget;
const unit = amsUnits.find(u => u.id === amsId);
const tray = unit?.tray?.find(t => t.id === trayId);
if (!tray) return;
const currentSignature = 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,
});
// Require minimum 500ms to avoid false positives from initial render
const elapsed = Date.now() - context.startTime;
if (prevTrayDataRef.current && prevTrayDataRef.current !== currentSignature && elapsed > 500) {
console.log(`[useAmsOperations] Refresh complete: data changed for AMS ${amsId} tray ${trayId} (took ${elapsed}ms)`);
reset();
}
}, [state, context, amsUnits, reset]);
// Detect LOAD/UNLOAD completion via ams_status_main transition 1 → 0
useEffect(() => {
if (state !== 'LOADING' && state !== 'UNLOADING') {
prevAmsStatusMainRef.current = amsStatusMain;
return;
}
const wasActive = prevAmsStatusMainRef.current === 1;
const isNowIdle = amsStatusMain === 0;
if (wasActive && isNowIdle) {
console.log(`[useAmsOperations] ${state} complete: ams_status_main transitioned 1→0`);
reset();
}
prevAmsStatusMainRef.current = amsStatusMain;
}, [state, amsStatusMain, reset]);
// Secondary completion detection for LOAD: tray_now matches target
useEffect(() => {
if (state !== 'LOADING' || !context?.loadTargetTrayId) return;
if (trayNow === context.loadTargetTrayId) {
console.log(`[useAmsOperations] Load complete: tray_now=${trayNow} matches target`);
reset();
}
}, [state, context, trayNow, reset]);
// Secondary completion detection for UNLOAD: tray_now becomes 255
useEffect(() => {
if (state !== 'UNLOADING') return;
// Only trigger if we're past the initial phase (give it 1s to start)
const elapsed = context?.startTime ? Date.now() - context.startTime : 0;
if (trayNow === 255 && elapsed > 1000) {
console.log('[useAmsOperations] Unload complete: tray_now=255');
reset();
}
}, [state, context, trayNow, reset]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearOperationTimeout();
};
}, [clearOperationTimeout]);
// === Derived State ===
const isOperationInProgress = state !== 'IDLE';
const isRefreshingSlot = useCallback((amsId: number, trayId: number) => {
if (state !== 'REFRESHING' || !context?.refreshTarget) return false;
return context.refreshTarget.amsId === amsId && context.refreshTarget.trayId === trayId;
}, [state, context]);
const isLoadOperation = state === 'LOADING';
const loadTargetTrayId = context?.loadTargetTrayId ?? null;
return {
state,
context,
startRefresh,
startLoad,
startUnload,
reset,
isOperationInProgress,
isRefreshingSlot,
isLoadOperation,
loadTargetTrayId,
loadError: loadMutation.error,
unloadError: unloadMutation.error,
refreshError: refreshMutation.error,
};
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<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-CXerUj0x.js"></script>
<script type="module" crossorigin src="/assets/index-CLNJVLog.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpSfhfce.css">
</head>
<body>