mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +02:00
@@ -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