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
+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")