mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
- Add AMS filament preview for reprints and file type badges
- AMS Filament Preview:
- Add GET /archives/{id}/filament-requirements endpoint to extract
filament data from archived 3MF slice_info.config
- Show filament comparison in reprint modal when printer is selected
- Compare both filament type AND color with visual indicators:
- Green checkmark: Type and color match
- Yellow warning: Same type, different color
- Orange warning: Different type or empty slot
- Handle AMS-HT units (id >= 128) and virtual tray (slot 254)
- Normalize color formats between API (RRGGBBAA) and 3MF (#RRGGBB)
- File Type Badge:
- Archive cards show GCODE (green) or SOURCE (orange) badge
- Helps users identify which files have print data vs source-only
- Badge appears next to printer name in card content area
- Camera Stream Stability:
- Increase ffmpeg read timeout from 10s to 30s
- Add ffmpeg RTSP options: -timeout, -buffer_size, -max_delay
- Add frontend auto-reconnection with exponential backoff (2s-30s)
- Show reconnection UI with countdown and attempt counter
This commit is contained in:
@@ -6,6 +6,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
### Added
|
||||
- **Timelapse editor** - Edit timelapse videos with trim, speed adjustment (0.25x-4x), and music overlay. Uses FFmpeg for server-side processing with browser-based preview.
|
||||
- **AMS filament preview** - Reprint modal shows filament comparison between what the print requires and what's currently loaded in the AMS. Compares both type and color with visual indicators (green=match, yellow=color mismatch, orange=type mismatch).
|
||||
- **File type badge** - Archive cards now show GCODE (green) or SOURCE (orange) badge to indicate whether the file is a sliced print-ready file or source-only.
|
||||
- **Docker printer discovery** - Subnet scanning for discovering printers when running in Docker with `network_mode: host`. Automatically detects Docker environment and shows subnet input field in Add Printer dialog.
|
||||
- **Printer model mapping** - Discovery now shows friendly model names (X1C, H2D, P1S) instead of raw SSDP codes (BL-P001, O1D, C11).
|
||||
- **Discovery API tests** - Comprehensive test coverage for discovery endpoints.
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
- Duplicate detection & full-text search
|
||||
- Photo attachments & failure analysis
|
||||
- Timelapse editor (trim, speed, music)
|
||||
- Re-print to any connected printer
|
||||
- Re-print to any connected printer with AMS filament preview
|
||||
- Archive comparison (side-by-side diff)
|
||||
|
||||
### 📊 Monitoring & Stats
|
||||
|
||||
@@ -1733,6 +1733,75 @@ async def upload_archives_bulk(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{archive_id}/filament-requirements")
|
||||
async def get_filament_requirements(
|
||||
archive_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get filament requirements from the archived 3MF file.
|
||||
|
||||
Returns the filaments used in this print with their slot IDs, types, colors,
|
||||
and usage amounts. This can be compared with current AMS state before reprinting.
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "Archive file not found")
|
||||
|
||||
filaments = []
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
# Parse slice_info.config for filament requirements
|
||||
if "Metadata/slice_info.config" in zf.namelist():
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Extract filament elements
|
||||
# Format: <filament id="1" type="PLA" color="#FFFFFF" used_g="100" used_m="10" />
|
||||
for filament_elem in root.findall(".//filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
used_m = filament_elem.get("used_m", "0")
|
||||
|
||||
# Only include filaments that are actually used
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
except (ValueError, TypeError):
|
||||
used_grams = 0
|
||||
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"used_grams": round(used_grams, 1),
|
||||
"used_meters": float(used_m) if used_m else 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by slot ID
|
||||
filaments.sort(key=lambda x: x["slot_id"])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse filament requirements from archive {archive_id}: {e}")
|
||||
|
||||
return {
|
||||
"archive_id": archive_id,
|
||||
"filename": archive.filename,
|
||||
"filaments": filaments,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{archive_id}/reprint")
|
||||
async def reprint_archive(
|
||||
archive_id: int,
|
||||
|
||||
@@ -1576,6 +1576,18 @@ export const api = {
|
||||
`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
|
||||
getArchiveForSlicer: (id: number, filename: string) =>
|
||||
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
|
||||
getArchiveFilamentRequirements: (archiveId: number) =>
|
||||
request<{
|
||||
archive_id: number;
|
||||
filename: string;
|
||||
filaments: Array<{
|
||||
slot_id: number;
|
||||
type: string;
|
||||
color: string;
|
||||
used_grams: number;
|
||||
used_meters: number;
|
||||
}>;
|
||||
}>(`/archives/${archiveId}/filament-requirements`),
|
||||
reprintArchive: (archiveId: number, printerId: number) =>
|
||||
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
|
||||
`/archives/${archiveId}/reprint?printer_id=${printerId}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { X, Printer, Loader2 } from 'lucide-react';
|
||||
import { X, Printer, Loader2, AlertTriangle, Check, Circle } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
@@ -29,6 +29,19 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Fetch filament requirements from the archived 3MF
|
||||
const { data: filamentReqs } = useQuery({
|
||||
queryKey: ['archive-filaments', archiveId],
|
||||
queryFn: () => api.getArchiveFilamentRequirements(archiveId),
|
||||
});
|
||||
|
||||
// Fetch printer status when a printer is selected
|
||||
const { data: printerStatus } = useQuery({
|
||||
queryKey: ['printer-status', selectedPrinter],
|
||||
queryFn: () => api.getPrinterStatus(selectedPrinter!),
|
||||
enabled: !!selectedPrinter,
|
||||
});
|
||||
|
||||
const reprintMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedPrinter) throw new Error('No printer selected');
|
||||
@@ -42,6 +55,109 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
||||
|
||||
const activePrinters = printers?.filter((p) => p.is_active) || [];
|
||||
|
||||
// Build a map of AMS slot positions to loaded filaments
|
||||
// Bambu Lab slot numbering: slot = amsId * 4 + trayId + 1 (for regular AMS)
|
||||
// AMS-HT (id >= 128) is special - uses its position in the array
|
||||
// External spool: slot 254, No filament: slot 255
|
||||
const loadedFilaments = useMemo(() => {
|
||||
if (!printerStatus?.ams) return new Map<number, { type: string; color: string }>();
|
||||
|
||||
const map = new Map<number, { type: string; color: string }>();
|
||||
|
||||
// Sort AMS units by ID to get consistent ordering, filter out AMS-HT for now
|
||||
const regularAms = printerStatus.ams
|
||||
.filter((ams) => ams.id < 128)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
|
||||
// Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
|
||||
const normalizeColor = (color: string | null | undefined): string => {
|
||||
if (!color) return '#808080';
|
||||
// Remove alpha channel if present (8-char hex to 6-char)
|
||||
const hex = color.replace('#', '').substring(0, 6);
|
||||
return `#${hex}`;
|
||||
};
|
||||
|
||||
regularAms.forEach((amsUnit) => {
|
||||
amsUnit.tray.forEach((tray) => {
|
||||
// Calculate global slot ID (1-based to match 3MF)
|
||||
// AMS 0 tray 0 = slot 1, AMS 0 tray 1 = slot 2, etc.
|
||||
const globalSlotId = amsUnit.id * 4 + tray.id + 1;
|
||||
if (tray.tray_type) {
|
||||
map.set(globalSlotId, {
|
||||
type: tray.tray_type,
|
||||
color: normalizeColor(tray.tray_color),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// AMS-HT units get slots after regular AMS slots
|
||||
const amsHtUnits = printerStatus.ams.filter((ams) => ams.id >= 128);
|
||||
let htSlotBase = regularAms.length * 4 + 1;
|
||||
amsHtUnits.forEach((amsUnit) => {
|
||||
amsUnit.tray.forEach((tray) => {
|
||||
if (tray.tray_type) {
|
||||
map.set(htSlotBase + tray.id, {
|
||||
type: tray.tray_type,
|
||||
color: normalizeColor(tray.tray_color),
|
||||
});
|
||||
}
|
||||
});
|
||||
htSlotBase += amsUnit.tray.length;
|
||||
});
|
||||
|
||||
// Add virtual tray (external spool) as slot 254 (Bambu standard)
|
||||
if (printerStatus.vt_tray?.tray_type) {
|
||||
map.set(254, {
|
||||
type: printerStatus.vt_tray.tray_type,
|
||||
color: normalizeColor(printerStatus.vt_tray.tray_color),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [printerStatus]);
|
||||
|
||||
// Compare required filaments with loaded filaments
|
||||
const filamentComparison = useMemo(() => {
|
||||
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
|
||||
|
||||
// Helper to normalize color for comparison (case-insensitive, strip #)
|
||||
const normalizeColorForCompare = (color: string | undefined): string => {
|
||||
if (!color) return '';
|
||||
return color.replace('#', '').toLowerCase();
|
||||
};
|
||||
|
||||
return filamentReqs.filaments.map((req) => {
|
||||
const loaded = loadedFilaments.get(req.slot_id);
|
||||
const hasFilament = !!loaded;
|
||||
const typeMatch = hasFilament && loaded?.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const colorMatch = hasFilament && normalizeColorForCompare(loaded?.color) === normalizeColorForCompare(req.color);
|
||||
|
||||
// Status: match (both), type_only (type ok, color different), mismatch (type wrong), empty
|
||||
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
|
||||
if (!hasFilament) {
|
||||
status = 'empty';
|
||||
} else if (typeMatch && colorMatch) {
|
||||
status = 'match';
|
||||
} else if (typeMatch) {
|
||||
status = 'type_only'; // Same type, different color
|
||||
} else {
|
||||
status = 'mismatch'; // Different type
|
||||
}
|
||||
|
||||
return {
|
||||
...req,
|
||||
loaded,
|
||||
hasFilament,
|
||||
typeMatch,
|
||||
colorMatch,
|
||||
status,
|
||||
};
|
||||
});
|
||||
}, [filamentReqs, loadedFilaments]);
|
||||
|
||||
const hasAnyMismatch = filamentComparison.some((f) => f.status !== 'match');
|
||||
const hasEmptySlots = filamentComparison.some((f) => f.status === 'empty');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
|
||||
<Card className="w-full max-w-md">
|
||||
@@ -105,6 +221,93 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament comparison - show when printer selected and has filament requirements */}
|
||||
{selectedPrinter && filamentComparison.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm text-bambu-gray">Filament Check</span>
|
||||
{hasEmptySlots ? (
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Empty slots
|
||||
</span>
|
||||
) : filamentComparison.some((f) => f.status === 'mismatch') ? (
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Type mismatch
|
||||
</span>
|
||||
) : filamentComparison.some((f) => f.status === 'type_only') ? (
|
||||
<span className="text-xs text-yellow-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Color mismatch
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-bambu-green flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
Ready
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
|
||||
{filamentComparison.map((item) => (
|
||||
<div
|
||||
key={item.slot_id}
|
||||
className="grid items-center gap-2"
|
||||
style={{ gridTemplateColumns: '48px 16px 1fr auto 16px 56px 16px' }}
|
||||
>
|
||||
{/* Slot label */}
|
||||
<span className="text-bambu-gray">Slot {item.slot_id}</span>
|
||||
{/* Required color */}
|
||||
<Circle
|
||||
className="w-3 h-3 flex-shrink-0"
|
||||
fill={item.color}
|
||||
stroke={item.color}
|
||||
/>
|
||||
{/* Required type + grams */}
|
||||
<span className="text-white truncate">
|
||||
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
|
||||
</span>
|
||||
{/* Arrow */}
|
||||
<span className="text-bambu-gray">→</span>
|
||||
{/* Loaded color */}
|
||||
{item.loaded ? (
|
||||
<Circle
|
||||
className="w-3 h-3 flex-shrink-0"
|
||||
fill={item.loaded.color}
|
||||
stroke={item.loaded.color}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{/* Loaded type */}
|
||||
<span className={
|
||||
item.status === 'match' ? 'text-bambu-green' :
|
||||
item.status === 'type_only' ? 'text-yellow-400' :
|
||||
'text-orange-400'
|
||||
}>
|
||||
{item.loaded?.type || 'Empty'}
|
||||
</span>
|
||||
{/* Status icon */}
|
||||
{item.status === 'match' ? (
|
||||
<Check className="w-3 h-3 text-bambu-green" />
|
||||
) : item.status === 'type_only' ? (
|
||||
<span title="Color mismatch">
|
||||
<AlertTriangle className="w-3 h-3 text-yellow-400" />
|
||||
</span>
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3 text-orange-400" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(hasAnyMismatch || hasEmptySlots) && (
|
||||
<p className="text-xs text-orange-400 mt-2">
|
||||
The printer may load different filaments than expected.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{reprintMutation.isError && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
|
||||
|
||||
@@ -526,8 +526,23 @@ function ArchiveCard({
|
||||
<h3 className="font-medium text-white mb-1 truncate">
|
||||
{archive.print_name || archive.filename}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<p className="text-xs text-bambu-gray">{printerName}</p>
|
||||
{/* File type badge */}
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
archive.filename?.toLowerCase().includes('.gcode.')
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-orange-500/20 text-orange-400'
|
||||
}`}
|
||||
title={
|
||||
archive.filename?.toLowerCase().includes('.gcode.')
|
||||
? 'Sliced file - ready to print'
|
||||
: 'Source file only - no AMS mapping available'
|
||||
}
|
||||
>
|
||||
{archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
|
||||
</span>
|
||||
{archive.project_name && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full truncate max-w-[120px]"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-D3FBMKgQ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Br04PEYW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cwam9OQ3.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user