v0.2.2.2 (#830)
This commit is contained in:
MartinNYHC
2026-03-27 09:27:47 +01:00
committed by GitHub
parent 8270030687
commit 67f91d94de
112 changed files with 18595 additions and 9993 deletions
+65 -15
View File
@@ -52,6 +52,13 @@ _active_external_streams: set[int] = set()
# Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
_spawned_ffmpeg_pids: dict[int, float] = {}
# Track disconnect events per stream_id — allows stop endpoint and cleanup
# to signal generators to stop reconnecting instead of just killing the process
_disconnect_events: dict[str, asyncio.Event] = {}
# Track last frame time per stream_id (not just per printer_id) for stale detection
_stream_last_frame_times: dict[str, float] = {}
def get_buffered_frame(printer_id: int) -> bytes | None:
"""Get the last buffered frame for a printer from an active stream.
@@ -85,6 +92,10 @@ async def generate_chamber_mjpeg_stream(
"""
logger.info("Starting chamber image stream for %s (stream_id=%s, model=%s)", ip_address, stream_id, model)
# Register disconnect event so stop endpoint can signal us
if stream_id and disconnect_event:
_disconnect_events[stream_id] = disconnect_event
connection = await generate_chamber_image_stream(ip_address, access_code, fps)
if connection is None:
logger.error("Failed to connect to chamber image stream for %s", ip_address)
@@ -145,9 +156,11 @@ async def generate_chamber_mjpeg_stream(
except Exception as e:
logger.exception("Chamber image stream error: %s", e)
finally:
# Remove from active streams
if stream_id and stream_id in _active_chamber_streams:
del _active_chamber_streams[stream_id]
# Remove from active streams and disconnect events
if stream_id:
_active_chamber_streams.pop(stream_id, None)
_disconnect_events.pop(stream_id, None)
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
@@ -263,6 +276,10 @@ async def generate_rtsp_mjpeg_stream(
"-", # Output to stdout
]
# Register disconnect event so stop endpoint can signal us
if stream_id and disconnect_event:
_disconnect_events[stream_id] = disconnect_event
logger.info(
"Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s)", ip_address, stream_id, model, fps
)
@@ -377,6 +394,8 @@ async def generate_rtsp_mjpeg_stream(
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
if stream_id:
_stream_last_frame_times[stream_id] = time.time()
yield (
b"--frame\r\n"
@@ -408,6 +427,11 @@ async def generate_rtsp_mjpeg_stream(
if client_gone:
break
# Check if stream was explicitly stopped (e.g., by stop endpoint)
if stream_id and stream_id not in _active_streams:
logger.info("Stream %s removed from active streams, stopping reconnect", stream_id)
break
if stream_ended:
reconnect_count += 1
continue
@@ -433,9 +457,11 @@ async def generate_rtsp_mjpeg_stream(
except Exception as e:
logger.exception("Camera stream error: %s", e)
finally:
# Remove from active streams
if stream_id and stream_id in _active_streams:
del _active_streams[stream_id]
# Remove from active streams and disconnect events
if stream_id:
_active_streams.pop(stream_id, None)
_disconnect_events.pop(stream_id, None)
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
@@ -639,6 +665,10 @@ async def stop_camera_stream(
for stream_id, process in list(_active_streams.items()):
if stream_id.startswith(f"{printer_id}-"):
to_remove.append(stream_id)
# Signal the generator to stop reconnecting BEFORE killing the process
event = _disconnect_events.get(stream_id)
if event:
event.set()
if process.returncode is None:
try:
process.terminate()
@@ -658,12 +688,18 @@ async def stop_camera_stream(
for stream_id in to_remove:
_active_streams.pop(stream_id, None)
_disconnect_events.pop(stream_id, None)
_stream_last_frame_times.pop(stream_id, None)
# Stop chamber image streams
to_remove_chamber = []
for stream_id, (_reader, writer) in list(_active_chamber_streams.items()):
if stream_id.startswith(f"{printer_id}-"):
to_remove_chamber.append(stream_id)
# Signal the generator to stop
event = _disconnect_events.get(stream_id)
if event:
event.set()
try:
writer.close()
stopped += 1
@@ -673,6 +709,8 @@ async def stop_camera_stream(
for stream_id in to_remove_chamber:
_active_chamber_streams.pop(stream_id, None)
_disconnect_events.pop(stream_id, None)
_stream_last_frame_times.pop(stream_id, None)
logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
return {"stopped": stopped}
@@ -1311,24 +1349,36 @@ async def cleanup_orphaned_streams():
_spawned_ffmpeg_pids.pop(proc.pid, None)
cleaned += 1
# 4. Kill stale active streams (alive but no frames for >60s)
# 4. Kill stale active streams (alive but no frames for >30s)
# Uses per-stream timestamps to avoid false "fresh" readings from newer streams
for sid, proc in list(_active_streams.items()):
if proc.returncode is not None:
continue
try:
printer_id = int(sid.split("-", 1)[0])
except (ValueError, IndexError):
continue
start_time = _stream_start_times.get(printer_id, now)
last_frame = _last_frame_times.get(printer_id, start_time)
if now - start_time > 120 and now - last_frame > 60:
logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - last_frame)
# Per-stream frame time is authoritative; fall back to per-printer
stream_last_frame = _stream_last_frame_times.get(sid)
if stream_last_frame is None:
try:
printer_id = int(sid.split("-", 1)[0])
except (ValueError, IndexError):
continue
stream_last_frame = _last_frame_times.get(printer_id)
spawn_time = _spawned_ffmpeg_pids.get(proc.pid, now)
if stream_last_frame is None:
stream_last_frame = spawn_time
if now - spawn_time > 60 and now - stream_last_frame > 30:
logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - stream_last_frame)
# Signal the generator to stop reconnecting
event = _disconnect_events.get(sid)
if event:
event.set()
try:
proc.kill()
await proc.wait()
except (ProcessLookupError, OSError):
pass
_active_streams.pop(sid, None)
_disconnect_events.pop(sid, None)
_stream_last_frame_times.pop(sid, None)
_spawned_ffmpeg_pids.pop(proc.pid, None)
cleaned += 1
+52 -8
View File
@@ -13,6 +13,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.core.websocket import ws_manager
from backend.app.models.ams_label import AmsLabel
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.spool import Spool
@@ -32,6 +33,7 @@ from backend.app.schemas.spool import (
)
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
logger = logging.getLogger(__name__)
@@ -1099,6 +1101,16 @@ async def assign_spool(
resp = result.scalar_one()
response = SpoolAssignmentResponse.model_validate(resp)
response.configured = configured
await ws_manager.broadcast(
{
"type": "spool_assignment_changed",
"printer_id": data.printer_id,
"ams_id": data.ams_id,
"tray_id": data.tray_id,
}
)
return response
@@ -1124,6 +1136,16 @@ async def unassign_spool(
await db.delete(assignment)
await db.commit()
await ws_manager.broadcast(
{
"type": "spool_assignment_changed",
"printer_id": printer_id,
"ams_id": ams_id,
"tray_id": tray_id,
}
)
return {"status": "deleted"}
@@ -1137,6 +1159,22 @@ class LinkTagRequest(BaseModel):
data_origin: str | None = "nfc_link"
def _validate_tag_input(
raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
) -> None:
if raw_value is None:
return
raw = str(raw_value).strip()
if not raw:
return
if normalized_value is None:
raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
if len(normalized_value) % 2 != 0:
raise HTTPException(422, f"{field_name} must have an even number of hex characters")
if exact_len is not None and len(normalized_value) != exact_len:
raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
@router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
async def link_tag_to_spool(
spool_id: int,
@@ -1152,11 +1190,17 @@ async def link_tag_to_spool(
if spool.archived_at:
raise HTTPException(400, "Cannot link tag to archived spool")
normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
_validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
_validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
# Check for conflicts: tag already linked to another active spool
if data.tag_uid:
if normalized_tag_uid:
conflict = await db.execute(
select(Spool).where(
Spool.tag_uid == data.tag_uid,
func.upper(Spool.tag_uid) == normalized_tag_uid,
Spool.id != spool_id,
Spool.archived_at.is_(None),
)
@@ -1166,7 +1210,7 @@ async def link_tag_to_spool(
# Auto-clear from archived spools (tag recycling)
archived_with_tag = await db.execute(
select(Spool).where(
Spool.tag_uid == data.tag_uid,
func.upper(Spool.tag_uid) == normalized_tag_uid,
Spool.id != spool_id,
Spool.archived_at.is_not(None),
)
@@ -1174,10 +1218,10 @@ async def link_tag_to_spool(
for old_spool in archived_with_tag.scalars().all():
old_spool.tag_uid = None
if data.tray_uuid:
if normalized_tray_uuid:
conflict = await db.execute(
select(Spool).where(
Spool.tray_uuid == data.tray_uuid,
func.upper(Spool.tray_uuid) == normalized_tray_uuid,
Spool.id != spool_id,
Spool.archived_at.is_(None),
)
@@ -1186,7 +1230,7 @@ async def link_tag_to_spool(
raise HTTPException(409, "Tray UUID already linked to another active spool")
archived_with_uuid = await db.execute(
select(Spool).where(
Spool.tray_uuid == data.tray_uuid,
func.upper(Spool.tray_uuid) == normalized_tray_uuid,
Spool.id != spool_id,
Spool.archived_at.is_not(None),
)
@@ -1195,9 +1239,9 @@ async def link_tag_to_spool(
old_spool.tray_uuid = None
if data.tag_uid is not None:
spool.tag_uid = data.tag_uid
spool.tag_uid = normalized_tag_uid
if data.tray_uuid is not None:
spool.tray_uuid = data.tray_uuid
spool.tray_uuid = normalized_tray_uuid
if data.tag_type is not None:
spool.tag_type = data.tag_type
if data.data_origin is not None:
+332 -21
View File
@@ -39,6 +39,7 @@ from backend.app.schemas.library import (
BatchThumbnailResult,
BulkDeleteRequest,
BulkDeleteResponse,
ExternalFolderCreate,
FileDuplicate,
FileListResponse,
FileMoveRequest,
@@ -278,6 +279,9 @@ async def list_folders(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
file_count=file_counts.get(folder.id, 0),
children=[],
)
@@ -326,6 +330,10 @@ async def get_folders_by_project(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=None,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -367,6 +375,10 @@ async def get_folders_by_archive(
archive_id=folder.archive_id,
project_name=None,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -426,6 +438,10 @@ async def create_folder(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=0,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -464,6 +480,10 @@ async def get_folder(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -556,6 +576,10 @@ async def update_folder(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -579,6 +603,9 @@ async def delete_folder(
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
# External folders: only remove DB records, never delete files from external path
is_ext = folder.is_external
# Get all files in this folder and subfolders to delete from disk
async def get_all_file_ids(fid: int) -> list[int]:
"""Recursively get all file IDs in a folder tree."""
@@ -586,20 +613,21 @@ async def delete_folder(
# Get files in this folder
files_result = await db.execute(
select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path).where(
select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path, LibraryFile.is_external).where(
LibraryFile.folder_id == fid
)
)
for file_id, file_path, thumb_path in files_result.all():
file_ids.append(file_id)
# Delete actual files
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
if thumb_path and os.path.exists(thumb_path):
os.remove(thumb_path)
except OSError as e:
logger.warning("Failed to delete file: %s", e)
for fid_val, file_path, thumb_path, file_is_ext in files_result.all():
file_ids.append(fid_val)
# Only delete non-external files from disk
if not is_ext and not file_is_ext:
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
if thumb_path and os.path.exists(thumb_path):
os.remove(thumb_path)
except OSError as e:
logger.warning("Failed to delete file: %s", e)
# Get child folders and recurse
children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
@@ -616,6 +644,272 @@ async def delete_folder(
return {"status": "success", "message": "Folder deleted"}
# ============ External Folder Endpoints ============
# Blocked system directories that cannot be mounted
_BLOCKED_PREFIXES = (
"/proc",
"/sys",
"/dev",
"/run",
"/boot",
"/sbin",
"/bin",
"/usr/sbin",
"/usr/bin",
"/lib",
"/etc",
)
# Supported file extensions for external folder scanning
_SCANNABLE_EXTENSIONS = {
".3mf",
".gcode",
".gcode.3mf",
".stl",
".obj",
".step",
".stp",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
}
def _validate_external_path(path_str: str) -> Path:
"""Validate an external path is safe to mount."""
path = Path(path_str).resolve()
if not path.is_absolute():
raise HTTPException(status_code=400, detail="Path must be absolute")
for prefix in _BLOCKED_PREFIXES:
if str(path).startswith(prefix):
raise HTTPException(status_code=400, detail=f"Cannot mount system directory: {prefix}")
if not path.exists():
raise HTTPException(status_code=400, detail=f"Path does not exist: {path}")
if not path.is_dir():
raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")
# Check readability
if not os.access(path, os.R_OK):
raise HTTPException(status_code=400, detail=f"Path is not readable: {path}")
return path
@router.post("/folders/external", response_model=FolderResponse)
async def create_external_folder(
data: ExternalFolderCreate,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
):
"""Create an external folder that points to a host directory."""
resolved = _validate_external_path(data.external_path)
# Check no other external folder already points to this path
existing = await db.execute(
select(LibraryFolder).where(
LibraryFolder.is_external.is_(True),
LibraryFolder.external_path == str(resolved),
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="An external folder already exists for this path")
# Verify parent exists if specified
if data.parent_id is not None:
parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id))
if not parent_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Parent folder not found")
folder = LibraryFolder(
name=data.name,
parent_id=data.parent_id,
is_external=True,
external_path=str(resolved),
external_readonly=data.readonly,
external_show_hidden=data.show_hidden,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
return FolderResponse(
id=folder.id,
name=folder.name,
parent_id=folder.parent_id,
project_id=None,
archive_id=None,
is_external=True,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=0,
created_at=folder.created_at,
updated_at=folder.updated_at,
)
@router.post("/folders/{folder_id}/scan")
async def scan_external_folder(
folder_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
):
"""Scan an external folder and sync files to the database.
Discovers new files, removes DB entries for deleted files.
Does not copy files — stores the external path directly.
"""
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
if not folder.is_external or not folder.external_path:
raise HTTPException(status_code=400, detail="Not an external folder")
ext_path = Path(folder.external_path)
if not ext_path.exists() or not ext_path.is_dir():
raise HTTPException(status_code=400, detail=f"External path is not accessible: {folder.external_path}")
# Get existing DB files for this folder
existing_result = await db.execute(
select(LibraryFile).where(LibraryFile.folder_id == folder_id, LibraryFile.is_external.is_(True))
)
existing_files = {f.file_path: f for f in existing_result.scalars().all()}
# Scan the directory
added = 0
removed = 0
found_paths = set()
for dirpath, _dirnames, filenames in os.walk(ext_path):
for filename in filenames:
# Skip hidden files unless configured
if not folder.external_show_hidden and filename.startswith("."):
continue
filepath = Path(dirpath) / filename
ext = filepath.suffix.lower()
# Check for compound extensions like .gcode.3mf
if ext not in _SCANNABLE_EXTENSIONS:
# Check compound
compound = "".join(filepath.suffixes[-2:]).lower() if len(filepath.suffixes) >= 2 else ""
if compound not in _SCANNABLE_EXTENSIONS:
continue
# Resolve symlinks and ensure still under external_path
try:
real_path = filepath.resolve()
real_path.relative_to(ext_path.resolve())
except (ValueError, OSError):
continue # Symlink escapes the external dir
file_path_str = str(filepath)
found_paths.add(file_path_str)
if file_path_str in existing_files:
continue # Already tracked
# Get file info
try:
stat = filepath.stat()
except OSError:
continue
file_type = ext[1:] if ext else "unknown"
# For compound extensions, use the meaningful part
if file_type in ("3mf",) and len(filepath.suffixes) >= 2:
inner = filepath.suffixes[-2].lower()
if inner == ".gcode":
file_type = "gcode.3mf"
# Extract thumbnail for 3mf files
thumbnail_path = None
file_metadata = None
if file_type == "3mf":
try:
parser = ThreeMFParser(str(filepath))
meta = parser.parse()
if meta:
file_metadata = meta
thumb_data = parser.extract_thumbnail()
if thumb_data:
thumb_dir = get_library_thumbnails_dir()
thumb_filename = f"{uuid.uuid4().hex}.png"
thumb_full = thumb_dir / thumb_filename
thumb_full.write_bytes(thumb_data)
thumbnail_path = to_relative_path(thumb_full)
except Exception as e:
logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
# Generate thumbnail for STL files
if file_type == "stl" and thumbnail_path is None:
try:
thumb_dir = get_library_thumbnails_dir()
thumb_result = generate_stl_thumbnail(str(filepath), str(thumb_dir))
if thumb_result:
thumbnail_path = to_relative_path(Path(thumb_result))
except Exception as e:
logger.debug("Failed to generate STL thumbnail for external %s: %s", filepath, e)
# Extract gcode thumbnail
if file_type == "gcode" and thumbnail_path is None:
thumb_data = extract_gcode_thumbnail(filepath)
if thumb_data:
thumb_dir = get_library_thumbnails_dir()
thumb_filename = f"{uuid.uuid4().hex}.png"
thumb_full = thumb_dir / thumb_filename
thumb_full.write_bytes(thumb_data)
thumbnail_path = to_relative_path(thumb_full)
# Create thumbnail for image files
if ext.lower() in IMAGE_EXTENSIONS and thumbnail_path is None:
thumbnail_path_str = create_image_thumbnail(filepath, get_library_thumbnails_dir())
if thumbnail_path_str:
thumbnail_path = to_relative_path(Path(thumbnail_path_str))
db_file = LibraryFile(
folder_id=folder_id,
is_external=True,
filename=filename,
file_path=file_path_str,
file_type=file_type,
file_size=stat.st_size,
file_hash=None, # Skip hashing external files for performance
thumbnail_path=thumbnail_path,
file_metadata=file_metadata,
)
db.add(db_file)
added += 1
# Remove DB entries for files that no longer exist on disk
for path_str, db_file in existing_files.items():
if path_str not in found_paths:
# Clean up thumbnail if we generated one
if db_file.thumbnail_path:
try:
abs_thumb = to_absolute_path(db_file.thumbnail_path)
if abs_thumb and abs_thumb.exists():
abs_thumb.unlink()
except OSError:
pass
await db.delete(db_file)
removed += 1
await db.commit()
return {"status": "success", "added": added, "removed": removed}
# ============ File Endpoints ============
@@ -678,6 +972,7 @@ async def list_files(
FileListResponse(
id=f.id,
folder_id=f.folder_id,
is_external=f.is_external,
filename=f.filename,
file_type=f.file_type,
file_size=f.file_size,
@@ -719,8 +1014,11 @@ async def upload_file(
# Verify folder exists if specified
if folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
if not folder_result.scalar_one_or_none():
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
raise HTTPException(status_code=404, detail="Folder not found")
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot upload to a read-only external folder")
# Generate unique filename for storage
unique_filename = f"{uuid.uuid4().hex}{ext}"
@@ -859,8 +1157,11 @@ async def extract_zip_file(
# Verify target folder exists if specified
if folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
if not folder_result.scalar_one_or_none():
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
raise HTTPException(status_code=404, detail="Target folder not found")
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
# Save ZIP to temp file
try:
@@ -1994,12 +2295,14 @@ async def delete_file(
if file.created_by_id != user.id:
raise HTTPException(status_code=403, detail="You can only delete your own files")
# Delete actual files
# External files: only remove DB entry and thumbnail, never delete the actual file
try:
abs_file_path = to_absolute_path(file.file_path)
if not file.is_external:
abs_file_path = to_absolute_path(file.file_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
# Always clean up thumbnails we generated
abs_thumb_path = to_absolute_path(file.thumbnail_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
if abs_thumb_path and abs_thumb_path.exists():
abs_thumb_path.unlink()
except OSError as e:
@@ -2180,8 +2483,11 @@ async def move_files(
# Verify folder exists if specified
if data.folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
if not folder_result.scalar_one_or_none():
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
raise HTTPException(status_code=404, detail="Folder not found")
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot move files to a read-only external folder")
# Update files
moved = 0
@@ -2194,6 +2500,10 @@ async def move_files(
if not can_modify_all and file.created_by_id != user.id:
skipped += 1
continue
# Cannot move external files out of their folder
if file.is_external:
skipped += 1
continue
file.folder_id = data.folder_id
moved += 1
@@ -2231,10 +2541,11 @@ async def bulk_delete(
continue
try:
abs_file_path = to_absolute_path(file.file_path)
if not file.is_external:
abs_file_path = to_absolute_path(file.file_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
abs_thumb_path = to_absolute_path(file.thumbnail_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
if abs_thumb_path and abs_thumb_path.exists():
abs_thumb_path.unlink()
except OSError as e:
+2
View File
@@ -43,6 +43,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"on_print_failed": provider.on_print_failed,
"on_print_stopped": provider.on_print_stopped,
"on_print_progress": provider.on_print_progress,
"on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
# Printer status events
"on_printer_offline": provider.on_printer_offline,
"on_printer_error": provider.on_printer_error,
@@ -122,6 +123,7 @@ async def create_notification_provider(
on_print_failed=provider_data.on_print_failed,
on_print_stopped=provider_data.on_print_stopped,
on_print_progress=provider_data.on_print_progress,
on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
# Printer status events
on_printer_offline=provider_data.on_printer_offline,
on_printer_error=provider_data.on_printer_error,
+1
View File
@@ -418,6 +418,7 @@ async def get_printer_status(
nozzle_temp_max=tray_data.get("nozzle_temp_max"),
drying_temp=tray_data.get("drying_temp"),
drying_time=tray_data.get("drying_time"),
state=tray_data.get("state"),
)
)
# Prefer humidity_raw (percentage) over humidity (index 1-5)
+276 -62
View File
@@ -1,7 +1,11 @@
"""SpoolBuddy device management API routes."""
import asyncio
import json
import logging
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
@@ -17,12 +21,15 @@ from backend.app.schemas.spoolbuddy import (
CalibrationResponse,
DeviceRegisterRequest,
DeviceResponse,
DiagnosticResultRequest,
DisplaySettingsRequest,
HeartbeatRequest,
HeartbeatResponse,
ScaleReadingRequest,
SetCalibrationFactorRequest,
SetTareRequest,
SystemCommandResultRequest,
SystemConfigRequest,
TagRemovedRequest,
TagScannedRequest,
UpdateSpoolWeightRequest,
@@ -36,6 +43,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
OFFLINE_THRESHOLD_SECONDS = 30
ONLINE_BROADCAST_INTERVAL_SECONDS = 10
_spoolbuddy_online_last_broadcast: dict[str, float] = {}
_diagnostic_results: dict[tuple[str, str], dict] = {}
def _is_online(device: SpoolBuddyDevice) -> bool:
@@ -59,6 +69,7 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
calibration_factor=device.calibration_factor,
nfc_reader_type=device.nfc_reader_type,
nfc_connection=device.nfc_connection,
backend_url=device.backend_url,
display_brightness=device.display_brightness,
display_blank_timeout=device.display_blank_timeout,
has_backlight=device.has_backlight,
@@ -70,12 +81,26 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
uptime_s=device.uptime_s,
update_status=device.update_status,
update_message=device.update_message,
system_stats=json.loads(device.system_stats) if device.system_stats else None,
online=_is_online(device),
created_at=device.created_at,
updated_at=device.updated_at,
)
def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
if force:
_spoolbuddy_online_last_broadcast[device_id] = time.time()
return True
now_ts = time.time()
last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)
if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:
_spoolbuddy_online_last_broadcast[device_id] = now_ts
return True
return False
# --- Device endpoints ---
@@ -98,8 +123,14 @@ async def register_device(
device.has_scale = req.has_scale
device.nfc_reader_type = req.nfc_reader_type
device.nfc_connection = req.nfc_connection
if req.backend_url:
device.backend_url = req.backend_url
device.has_backlight = req.has_backlight
device.last_seen = now
# Clear stale update status on re-registration (daemon restarted after update)
if device.update_status in ("pending", "updating", "complete", "error"):
device.update_status = None
device.update_message = None
logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
else:
device = SpoolBuddyDevice(
@@ -114,6 +145,7 @@ async def register_device(
nfc_reader_type=req.nfc_reader_type,
nfc_connection=req.nfc_connection,
has_backlight=req.has_backlight,
backend_url=req.backend_url,
last_seen=now,
)
db.add(device)
@@ -122,6 +154,7 @@ async def register_device(
await db.commit()
await db.refresh(device)
_spoolbuddy_online_last_broadcast[device.device_id] = time.time()
await ws_manager.broadcast(
{
"type": "spoolbuddy_online",
@@ -130,7 +163,17 @@ async def register_device(
}
)
return _device_to_response(device)
response = _device_to_response(device)
# Include SSH public key so the daemon can auto-deploy it
try:
from backend.app.services.spoolbuddy_ssh import get_public_key
response.ssh_public_key = await get_public_key()
except Exception:
pass # Key not generated yet — daemon can still work without it
return response
@router.get("/devices", response_model=list[DeviceResponse])
@@ -172,25 +215,39 @@ async def device_heartbeat(
device.nfc_reader_type = req.nfc_reader_type
if req.nfc_connection:
device.nfc_connection = req.nfc_connection
if req.backend_url:
device.backend_url = req.backend_url
if req.system_stats is not None:
device.system_stats = json.dumps(req.system_stats)
# Return and clear pending command
pending = device.pending_command
pending_write = None
pending_system = None
if pending == "write_tag" and device.pending_write_payload:
# Parse the stored JSON payload to include in response
import json
try:
pending_write = json.loads(device.pending_write_payload)
except (json.JSONDecodeError, TypeError):
pending_write = None
# Don't clear write_tag command — it gets cleared by write-result
elif pending == "apply_system_config" and device.pending_system_payload:
try:
pending_system = json.loads(device.pending_system_payload)
except (json.JSONDecodeError, TypeError):
pending_system = None
# Don't clear config command — it gets cleared by daemon command-result callback
elif pending and pending.startswith("run_") and pending.endswith("_diag"):
# Don't clear diagnostic commands — they get cleared by the device reporting results
pass
else:
device.pending_command = None
await db.commit()
if was_offline:
# Emit online presence on offline->online transitions immediately, and
# periodically while online so newly connected UIs can bootstrap state.
if _should_broadcast_online(device.device_id, force=was_offline):
await ws_manager.broadcast(
{
"type": "spoolbuddy_online",
@@ -198,10 +255,13 @@ async def device_heartbeat(
"hostname": device.hostname,
}
)
if was_offline:
logger.info("SpoolBuddy device back online: %s", device.device_id)
return HeartbeatResponse(
pending_command=pending,
pending_write_payload=pending_write,
pending_system_payload=pending_system,
tare_offset=device.tare_offset,
calibration_factor=device.calibration_factor,
display_brightness=device.display_brightness,
@@ -251,7 +311,15 @@ async def nfc_tag_scanned(
"tag_type": req.tag_type,
}
)
logger.info("SpoolBuddy unknown tag: %s", req.tag_uid)
logger.info(
"SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
req.tag_uid,
len(req.tag_uid or ""),
req.tray_uuid,
len(req.tray_uuid or ""),
req.tag_type,
req.sak,
)
return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
@@ -567,21 +635,185 @@ async def update_display_settings(
return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
@router.post("/devices/{device_id}/system/config")
async def queue_system_config_update(
device_id: str,
req: SystemConfigRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Queue update of SpoolBuddy .env config on the device."""
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
parsed = urlparse(req.backend_url.strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise HTTPException(
status_code=400,
detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local",
)
payload = {
"backend_url": req.backend_url.strip(),
}
if req.api_key is not None and req.api_key.strip():
payload["api_key"] = req.api_key.strip()
device.pending_system_payload = json.dumps(payload)
device.pending_command = "apply_system_config"
await db.commit()
logger.info("Queued system config update for device %s", device_id)
return {"status": "queued", "message": "System config update queued"}
@router.post("/devices/{device_id}/system/command-result")
async def system_command_result(
device_id: str,
req: SystemCommandResultRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Receive completion status for queued system command from daemon."""
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
if not device.pending_command:
logger.info("System command result from %s with no pending command: %s", device_id, req.command)
return {"status": "ok", "message": "No pending command"}
if req.command != device.pending_command:
raise HTTPException(
status_code=409,
detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'",
)
if req.command == "apply_system_config":
device.pending_system_payload = None
device.pending_command = None
await db.commit()
logger.info(
"System command result from %s: %s success=%s message=%s",
device_id,
req.command,
req.success,
req.message,
)
return {"status": "ok"}
# --- Diagnostics ---
@router.post("/diagnostics/{device_id}/run")
async def queue_diagnostic(
device_id: str,
diagnostic: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Queue a hardware diagnostic to run on the SpoolBuddy device.
Args:
device_id: The device ID
diagnostic: 'scale' or 'nfc' to select which diagnostic to run
Returns:
Status message indicating diagnostic was queued
"""
if diagnostic not in ("scale", "nfc", "read_tag"):
raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
device.pending_command = f"run_{diagnostic}_diag"
_diagnostic_results.pop((device_id, diagnostic), None)
await db.commit()
logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic)
return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"}
@router.get("/diagnostics/{device_id}/result")
async def get_diagnostic_result(
device_id: str,
diagnostic: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get the latest diagnostic result for a device.
Args:
device_id: The device ID
diagnostic: 'scale' or 'nfc'
Returns:
Diagnostic result or 404 if not found
"""
if diagnostic not in ("scale", "nfc", "read_tag"):
raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
diag_result = _diagnostic_results.get((device_id, diagnostic))
if not diag_result:
raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet")
return diag_result
@router.post("/diagnostics/{device_id}/result")
async def report_diagnostic_result(
device_id: str,
req: DiagnosticResultRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Report diagnostic result from SpoolBuddy device."""
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
if req.diagnostic not in ("nfc", "scale", "read_tag"):
raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
_diagnostic_results[(device_id, req.diagnostic)] = {
"diagnostic": req.diagnostic,
"success": req.success,
"output": req.output,
"exit_code": req.exit_code,
}
device.pending_command = None
await db.commit()
logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success)
return {"status": "ok", "message": "Diagnostic result recorded"}
# --- Update check ---
@router.get("/devices/{device_id}/update-check")
async def check_daemon_update(
device_id: str,
include_beta: bool = False,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Check if a newer daemon version is available on GitHub."""
import httpx
from backend.app.api.routes.updates import is_newer_version, parse_version
from backend.app.core.config import GITHUB_REPO
"""Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
from backend.app.api.routes.updates import is_newer_version
from backend.app.core.config import APP_VERSION
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
@@ -590,61 +822,27 @@ async def check_daemon_update(
current = device.firmware_version or "0.0.0"
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
headers={"Accept": "application/vnd.github.v3+json"},
timeout=10.0,
)
response.raise_for_status()
releases = response.json()
release_data = None
for release in releases:
tag = release.get("tag_name", "")
if include_beta:
release_data = release
break
else:
parsed = parse_version(tag)
if parsed[4] == 0: # is_prerelease == 0
release_data = release
break
if not release_data:
return {
"current_version": current,
"latest_version": None,
"update_available": False,
"release_url": None,
}
latest = release_data.get("tag_name", "").lstrip("v")
return {
"current_version": current,
"latest_version": latest,
"update_available": is_newer_version(latest, current),
"release_url": release_data.get("html_url"),
}
except Exception as e:
logger.warning("Failed to check for daemon updates: %s", e)
return {
"current_version": current,
"latest_version": None,
"update_available": False,
"release_url": None,
"error": str(e),
}
return {
"current_version": current,
"latest_version": APP_VERSION,
"update_available": is_newer_version(APP_VERSION, current),
}
@router.post("/devices/{device_id}/update")
async def trigger_daemon_update(
device_id: str,
req: dict | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Trigger a daemon update on the SpoolBuddy device via pending_command."""
"""Trigger a SpoolBuddy update over SSH.
Bambuddy SSHes into the device, pulls the matching branch, installs deps,
and restarts the daemon. Progress is broadcast via WebSocket.
"""
from backend.app.services.spoolbuddy_ssh import perform_ssh_update
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
device = result.scalar_one_or_none()
if not device:
@@ -656,12 +854,11 @@ async def trigger_daemon_update(
if device.update_status == "updating":
return {"status": "already_updating", "message": "Update already in progress"}
device.pending_command = "update"
device.update_status = "pending"
device.update_message = "Waiting for device to pick up update command..."
device.update_message = "Starting SSH update..."
await db.commit()
logger.info("SpoolBuddy %s: update command queued", device_id)
logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
await ws_manager.broadcast(
{
"type": "spoolbuddy_update",
@@ -670,7 +867,24 @@ async def trigger_daemon_update(
}
)
return {"status": "ok", "message": "Update command sent to device"}
# Run the SSH update in the background
asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
return {"status": "ok", "message": "SSH update started"}
@router.get("/ssh/public-key")
async def get_ssh_public_key(
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
):
"""Return the SSH public key for SpoolBuddy pairing."""
from backend.app.services.spoolbuddy_ssh import get_public_key
try:
key = await get_public_key()
return {"public_key": key}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
@router.post("/devices/{device_id}/update-status")