Wired motion and extruder controls

This commit is contained in:
Martin Ziegler
2025-12-03 20:07:30 +01:00
parent e8a11d273a
commit 3917f0a3d1
18 changed files with 677 additions and 479 deletions
+26
View File
@@ -105,6 +105,10 @@ class SpeedRequest(BaseModel):
mode: int = Field(..., ge=1, le=4, description="Speed mode: 1=silent, 2=standard, 3=sport, 4=ludicrous")
class ExtruderRequest(BaseModel):
extruder: int = Field(..., ge=0, le=1, description="Extruder index (0=right, 1=left for H2D)")
class FanRequest(BaseModel):
speed: int = Field(..., ge=0, le=100, description="Fan speed percentage (0-100)")
@@ -324,6 +328,28 @@ async def set_print_speed(
)
# =============================================================================
# Extruder Control Endpoint
# =============================================================================
@router.post("/{printer_id}/control/extruder", response_model=ControlResponse)
async def select_extruder(
printer_id: int,
request: ExtruderRequest,
db: AsyncSession = Depends(get_db),
):
"""Select the active extruder for dual-nozzle printers."""
await get_printer_or_404(printer_id, db)
client = get_mqtt_client_or_503(printer_id)
extruder_names = {0: "Right", 1: "Left"}
success = client.select_extruder(request.extruder)
return ControlResponse(
success=success,
message=f"Selected {extruder_names[request.extruder]} extruder" if success else "Failed to select extruder"
)
# =============================================================================
# Fan Control Endpoints
# =============================================================================
+1
View File
@@ -244,6 +244,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
airduct_mode=state.airduct_mode,
speed_level=state.speed_level,
chamber_light=state.chamber_light,
active_extruder=state.active_extruder,
)
+2
View File
@@ -118,3 +118,5 @@ class PrinterStatus(BaseModel):
speed_level: int = 2
# Chamber light on/off
chamber_light: bool = False
# Active extruder for dual nozzle (0=right, 1=left)
active_extruder: int = 0
+66
View File
@@ -109,6 +109,8 @@ class PrinterState:
speed_level: int = 2
# Chamber light on/off
chamber_light: bool = False
# Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow
active_extruder: int = 0
# Stage name mapping from BambuStudio DeviceManager.cpp
@@ -278,6 +280,10 @@ 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
logger.info(f"[{self.serial_number}] FULL MQTT PAYLOAD DUMP:\n{json.dumps(payload, indent=2)}")
# Log message if logging is enabled
if self._logging_enabled:
self._message_log.append(MQTTLogEntry(
@@ -647,6 +653,33 @@ class BambuMQTTClient:
if nozzle_fields and not hasattr(self, '_nozzle_fields_logged'):
logger.info(f"[{self.serial_number}] Nozzle/hardware fields in MQTT data: {nozzle_fields}")
self._nozzle_fields_logged = True
# Parse active extruder from device.extruder.state bit 8
# bit 8 = 0 → RIGHT extruder (active_extruder=0)
# bit 8 = 1 → LEFT extruder (active_extruder=1)
if "device" in data and isinstance(data.get("device"), dict):
device = data["device"]
if "extruder" in device and "state" in device["extruder"]:
state_val = device["extruder"]["state"]
# Extract bit 8 for extruder position
new_extruder = (state_val >> 8) & 0x1
if new_extruder != self.state.active_extruder:
logger.info(f"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]")
self.state.active_extruder = new_extruder
# Log device.extruder structure for active extruder
if "device" in data and isinstance(data.get("device"), dict):
device = data["device"]
if "extruder" in device:
ext_data = device["extruder"]
# Log 'state' field - OrcaSlicer uses bits 12-14 for switch state
if "state" in ext_data:
state_val = ext_data["state"]
# Extract bits 12-14 (3 bits) for switch state
switch_state = (state_val >> 12) & 0x7
logger.info(f"[{self.serial_number}] device.extruder.state={state_val} (switch_state bits 12-14: {switch_state})")
# Log 'cur' field if present (might indicate current/active extruder)
if "cur" in ext_data:
logger.info(f"[{self.serial_number}] device.extruder.cur: {ext_data['cur']}")
if "bed_temper" in data:
temps["bed"] = float(data["bed_temper"])
if "bed_target_temper" in data:
@@ -2058,6 +2091,39 @@ class BambuMQTTClient:
logger.info(f"[{self.serial_number}] Set chamber lights {'on' if on else 'off'} (seq={self._sequence_id})")
return True
def select_extruder(self, extruder: int) -> bool:
"""Select the active extruder for dual-nozzle printers (H2D).
Args:
extruder: Extruder index (0=right, 1=left for H2D)
Returns:
True if command was sent, False otherwise
"""
if extruder not in (0, 1):
logger.warning(f"[{self.serial_number}] Invalid extruder: {extruder}")
return False
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot switch extruder: not connected")
return False
# H2D extruder switching via select_extruder command
# Command format captured from OrcaSlicer:
# {"print": {"command": "select_extruder", "extruder_index": 0, "sequence_id": "..."}}
# extruder_index: 0 = RIGHT, 1 = LEFT
self._sequence_id += 1
command = {
"print": {
"command": "select_extruder",
"extruder_index": extruder,
"sequence_id": str(self._sequence_id)
}
}
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info(f"[{self.serial_number}] Sent select_extruder command: extruder_index={extruder} (0=right, 1=left)")
return True
def home_axes(self, axes: str = "XYZ") -> bool:
"""Home the specified axes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+7
View File
@@ -121,6 +121,8 @@ export interface PrinterStatus {
speed_level: number;
// Chamber light on/off
chamber_light: boolean;
// Active extruder for dual nozzle (0=right, 1=left)
active_extruder: number;
}
export interface PrinterCreate {
@@ -1179,6 +1181,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ on }),
}),
selectExtruder: (printerId: number, extruder: number) =>
request<ControlResponse>(`/printers/${printerId}/control/extruder`, {
method: 'POST',
body: JSON.stringify({ extruder }),
}),
homeAxes: (printerId: number, axes = 'XYZ', confirmToken?: string) =>
request<ControlResult>(`/printers/${printerId}/control/home`, {
method: 'POST',
@@ -50,35 +50,35 @@ export function BedControls({ printerId, status, disabled = false }: BedControls
<>
<div className="flex items-center gap-2">
<button
onClick={() => handleMove(10)}
onClick={() => handleMove(-10)}
disabled={isDisabled}
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
title="Z+10"
title="Bed up 10mm"
>
↑10
</button>
<button
onClick={() => handleMove(1)}
onClick={() => handleMove(-1)}
disabled={isDisabled}
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
title="Z+1"
title="Bed up 1mm"
>
↑1
</button>
<span className="px-2 py-2 text-sm text-bambu-gray">Bed</span>
<button
onClick={() => handleMove(-1)}
onClick={() => handleMove(1)}
disabled={isDisabled}
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
title="Z-1"
title="Bed down 1mm"
>
↓1
</button>
<button
onClick={() => handleMove(-10)}
onClick={() => handleMove(10)}
disabled={isDisabled}
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
title="Z-10"
title="Bed down 10mm"
>
↓10
</button>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api, isConfirmationRequired } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { ChevronUp, ChevronDown } from 'lucide-react';
@@ -13,23 +13,43 @@ interface ExtruderControlsProps {
}
export function ExtruderControls({ printerId, status, nozzleCount, disabled = false }: ExtruderControlsProps) {
const queryClient = useQueryClient();
const isConnected = (status?.connected ?? false) && !disabled;
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
const isDualNozzle = nozzleCount > 1;
const [selectedNozzle, setSelectedNozzle] = useState<'left' | 'right'>('left');
// Active extruder from live status: 0=right, 1=left
const activeExtruder = status?.active_extruder ?? 0;
const [confirmModal, setConfirmModal] = useState<{
token: string;
warning: string;
distance: number;
} | null>(null);
const selectExtruderMutation = useMutation({
mutationFn: (extruder: number) => {
console.log('selectExtruder called with:', extruder);
return api.selectExtruder(printerId, extruder);
},
onSuccess: (data) => {
console.log('selectExtruder success:', data);
// Invalidate printer statuses to refresh the active extruder display
// Add a small delay to allow the printer to process the switch and MQTT to update
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
}, 2000);
},
onError: (error) => {
console.error('selectExtruder error:', error);
},
});
const extrudeMutation = useMutation({
mutationFn: ({ distance, token }: { distance: number; token?: string }) => {
// G-code for extrusion: relative mode, extrude, back to absolute
// T0/T1 selects the tool for dual nozzle
const toolSelect = isDualNozzle ? `T${selectedNozzle === 'left' ? 0 : 1}\n` : '';
const gcode = `${toolSelect}G91\nG1 E${distance} F300\nG90`;
// Uses currently active extruder
const gcode = `G91\nG1 E${distance} F300\nG90`;
return api.sendGcode(printerId, gcode, token);
},
onSuccess: (result, variables) => {
@@ -55,18 +75,26 @@ export function ExtruderControls({ printerId, status, nozzleCount, disabled = fa
};
const isDisabled = !isConnected || isPrinting || extrudeMutation.isPending;
const isSwitching = selectExtruderMutation.isPending;
// Get extruder image based on active state
const getExtruderImage = () => {
if (!isDualNozzle) return "/icons/single-extruder1.png";
// activeExtruder: 0=right, 1=left
return activeExtruder === 1 ? "/icons/dual-extruder-left.png" : "/icons/dual-extruder-right.png";
};
return (
<>
<div className="flex flex-col items-center gap-1.5 justify-center">
{/* Left/Right Toggle - only for dual nozzle */}
{isDualNozzle && (
<div className={`flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0 ${isDisabled ? 'opacity-50' : ''}`}>
<div className={`flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0 ${isDisabled || isSwitching ? 'opacity-50' : ''}`}>
<button
onClick={() => setSelectedNozzle('left')}
disabled={isDisabled}
onClick={() => selectExtruderMutation.mutate(1)}
disabled={isDisabled || isSwitching}
className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors disabled:cursor-not-allowed ${
selectedNozzle === 'left'
activeExtruder === 1
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark-secondary'
}`}
@@ -74,10 +102,10 @@ export function ExtruderControls({ printerId, status, nozzleCount, disabled = fa
Left
</button>
<button
onClick={() => setSelectedNozzle('right')}
disabled={isDisabled}
onClick={() => selectExtruderMutation.mutate(0)}
disabled={isDisabled || isSwitching}
className={`px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed ${
selectedNozzle === 'right'
activeExtruder === 0
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary disabled:hover:bg-bambu-dark-secondary'
}`}
@@ -100,8 +128,8 @@ export function ExtruderControls({ printerId, status, nozzleCount, disabled = fa
{/* Extruder Image */}
<div className="h-[120px] flex items-center justify-center">
<img
src={isDualNozzle ? "/icons/dual-extruder.png" : "/icons/single-extruder1.png"}
alt={isDualNozzle ? "Dual Extruder" : "Single Extruder"}
src={getExtruderImage()}
alt={isDualNozzle ? `${activeExtruder === 1 ? 'Left' : 'Right'} Extruder Active` : "Single Extruder"}
className="h-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
+129 -60
View File
@@ -1,7 +1,7 @@
import { useMutation } from '@tanstack/react-query';
import { api, isConfirmationRequired } from '../../api/client';
import type { PrinterStatus } from '../../api/client';
import { useState } from 'react';
import { useState, useId } from 'react';
import { ConfirmModal } from '../ConfirmModal';
interface JogPadProps {
@@ -10,8 +10,57 @@ interface JogPadProps {
disabled?: boolean;
}
// Image map coordinates for 220x220 jog pad
// The jog pad has concentric rings: outer (10mm), inner (1mm), center (home)
const SIZE = 220;
const CENTER = SIZE / 2; // 110
// Ring radii (approximate based on typical jog pad design)
const OUTER_RADIUS = 108; // Outer edge
const OUTER_INNER = 72; // Inner edge of outer ring (10mm zone)
const INNER_INNER = 35; // Inner edge of inner ring (1mm zone)
const HOME_RADIUS = 28; // Home button radius
// Generate polygon points for a ring segment (pie slice)
function ringSegment(
cx: number, cy: number,
innerR: number, outerR: number,
startAngle: number, endAngle: number,
steps: number = 8
): string {
const points: string[] = [];
// Outer arc (clockwise)
for (let i = 0; i <= steps; i++) {
const angle = startAngle + (endAngle - startAngle) * (i / steps);
const x = Math.round(cx + outerR * Math.cos(angle));
const y = Math.round(cy + outerR * Math.sin(angle));
points.push(`${x},${y}`);
}
// Inner arc (counter-clockwise)
for (let i = steps; i >= 0; i--) {
const angle = startAngle + (endAngle - startAngle) * (i / steps);
const x = Math.round(cx + innerR * Math.cos(angle));
const y = Math.round(cy + innerR * Math.sin(angle));
points.push(`${x},${y}`);
}
return points.join(',');
}
// Angle definitions (in radians, 0 = right, going clockwise)
// Each direction covers 90 degrees (π/2), offset by 45 degrees (π/4)
const ANGLES = {
up: { start: -Math.PI * 3/4, end: -Math.PI / 4 }, // Top: -135° to -45°
right: { start: -Math.PI / 4, end: Math.PI / 4 }, // Right: -45° to 45°
down: { start: Math.PI / 4, end: Math.PI * 3/4 }, // Bottom: 45° to 135°
left: { start: Math.PI * 3/4, end: Math.PI * 5/4 }, // Left: 135° to 225° (or -135°)
};
export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
const isConnected = (status?.connected ?? false) && !disabled;
const mapId = useId();
const [confirmModal, setConfirmModal] = useState<{
action: string;
@@ -56,10 +105,12 @@ export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
});
const handleHome = () => {
if (isDisabled) return;
homeMutation.mutate({ axes: 'XY' });
};
const handleMove = (axis: string, distance: number) => {
if (isDisabled) return;
moveMutation.mutate({ axis, distance });
};
@@ -73,76 +124,94 @@ export function JogPad({ printerId, status, disabled = false }: JogPadProps) {
const isLoading = homeMutation.isPending || moveMutation.isPending;
const isDisabled = !isConnected || isLoading;
// Generate coordinates for circle (home button)
const homeCoords = Array.from({ length: 16 }, (_, i) => {
const angle = (i / 16) * Math.PI * 2;
const x = Math.round(CENTER + HOME_RADIUS * Math.cos(angle));
const y = Math.round(CENTER + HOME_RADIUS * Math.sin(angle));
return `${x},${y}`;
}).join(',');
return (
<>
<div className="relative w-[220px] h-[220px] mb-3.5">
{/* Use the actual jogpad.svg from mockup */}
<img
src="/icons/jogpad.svg"
alt="Jog Pad"
useMap={`#${mapId}`}
className="w-full h-full jogpad-theme"
/>
{/* Invisible clickable areas overlaid on the SVG */}
{/* Outer ring - 10mm moves */}
<button
onClick={() => handleMove('Y', 10)}
disabled={isDisabled}
className="absolute top-[8px] left-1/2 -translate-x-1/2 w-[40px] h-[30px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="Y+10"
/>
<button
onClick={() => handleMove('Y', -10)}
disabled={isDisabled}
className="absolute bottom-[8px] left-1/2 -translate-x-1/2 w-[40px] h-[30px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="Y-10"
/>
<button
onClick={() => handleMove('X', -10)}
disabled={isDisabled}
className="absolute left-[8px] top-1/2 -translate-y-1/2 w-[30px] h-[40px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="X-10"
/>
<button
onClick={() => handleMove('X', 10)}
disabled={isDisabled}
className="absolute right-[8px] top-1/2 -translate-y-1/2 w-[30px] h-[40px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="X+10"
/>
<map name={mapId}>
{/* Outer ring - 10mm moves */}
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.up.start, ANGLES.up.end)}
onClick={() => handleMove('Y', 10)}
title="Y+10mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.down.start, ANGLES.down.end)}
onClick={() => handleMove('Y', -10)}
title="Y-10mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.left.start, ANGLES.left.end)}
onClick={() => handleMove('X', -10)}
title="X-10mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, OUTER_INNER, OUTER_RADIUS, ANGLES.right.start, ANGLES.right.end)}
onClick={() => handleMove('X', 10)}
title="X+10mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
{/* Inner ring - 1mm moves */}
<button
onClick={() => handleMove('Y', 1)}
disabled={isDisabled}
className="absolute top-[42px] left-1/2 -translate-x-1/2 w-[35px] h-[25px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="Y+1"
/>
<button
onClick={() => handleMove('Y', -1)}
disabled={isDisabled}
className="absolute bottom-[42px] left-1/2 -translate-x-1/2 w-[35px] h-[25px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="Y-1"
/>
<button
onClick={() => handleMove('X', -1)}
disabled={isDisabled}
className="absolute left-[42px] top-1/2 -translate-y-1/2 w-[25px] h-[35px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="X-1"
/>
<button
onClick={() => handleMove('X', 1)}
disabled={isDisabled}
className="absolute right-[42px] top-1/2 -translate-y-1/2 w-[25px] h-[35px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="X+1"
/>
{/* Inner ring - 1mm moves */}
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.up.start, ANGLES.up.end)}
onClick={() => handleMove('Y', 1)}
title="Y+1mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.down.start, ANGLES.down.end)}
onClick={() => handleMove('Y', -1)}
title="Y-1mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.left.start, ANGLES.left.end)}
onClick={() => handleMove('X', -1)}
title="X-1mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
<area
shape="poly"
coords={ringSegment(CENTER, CENTER, INNER_INNER, OUTER_INNER, ANGLES.right.start, ANGLES.right.end)}
onClick={() => handleMove('X', 1)}
title="X+1mm"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
{/* Home button in center - clickable overlay */}
<button
onClick={handleHome}
disabled={isDisabled}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[50px] h-[50px] rounded-full opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
title="Home XY"
/>
{/* Center - Home button */}
<area
shape="poly"
coords={homeCoords}
onClick={handleHome}
title="Home XY"
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
/>
</map>
</div>
{/* Confirmation Modal */}
@@ -63,7 +63,7 @@ export function SpeedModal({ printer, status, onClose }: SpeedModalProps) {
onClick={onClose}
>
<div
className="relative w-full max-w-md bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
className="relative w-full max-w-lg bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
+1 -1
View File
@@ -120,5 +120,5 @@ body {
}
.dark .jogpad-theme {
filter: brightness(0.7) contrast(1.1);
filter: brightness(0.35) contrast(1.2);
}
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-BGXbFbC2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DyjgQh1m.css">
<script type="module" crossorigin src="/assets/index-KY24LvJj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BAV1KG6Z.css">
</head>
<body>
<div id="root"></div>