- 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:
maziggy
2025-12-28 13:50:29 +01:00
parent 530a7a4608
commit b3ef6ccb32
8 changed files with 720 additions and 419 deletions
+2
View File
@@ -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.
+1 -1
View File
@@ -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
+69
View File
@@ -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,
+12
View File
@@ -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}`,
+205 -2
View File
@@ -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">
+16 -1
View File
@@ -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
View File
@@ -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>