mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 04:31:28 +02:00
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user