feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)

feat(inventory): replace Spoolman iframe with internal inventory UI

When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
This commit is contained in:
Sn0rrii
2026-04-24 14:00:45 +02:00
committed by GitHub
parent 9c5c2a765f
commit 89f14c57ad
47 changed files with 7247 additions and 342 deletions
+15 -2
View File
@@ -34,7 +34,7 @@ All notable changes to Bambuddy will be documented in this file.
- **"Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters** ([#1059](https://github.com/maziggy/bambuddy/issues/1059)) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: `.3mf` files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); `.stl` files greyed the button out; `.step` couldn't be previewed at all. The protocol-handler URL emitted by `frontend/src/utils/slicer.ts` for OrcaSlicer (`orcaslicer://open?file=<URL>`) and Windows/Linux Bambu Studio (`bambustudio://open?file=<URL>`) was built by plain string concatenation with no `encodeURIComponent()` — the macOS `bambustudioopen://<URL>` branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls `url_decode()` on the received query (BS `post_init()` calls `url_decode` then `split_str`; OrcaSlicer's Downloader regex-extracts then `url_decode`). Any already-percent-encoded character in the download URL — most commonly `%20` from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now `encodeURIComponent()` the file URL, so the slicer sees the correctly-encoded URL after its own `url_decode`. The comment block is corrected to document the actual invariant. Regression test in `slicer.test.ts` feeds the exact issue reproduction URL (`Toothpick%20Launcher%20Print-in-Place.3mf`) and asserts `%2520` appears in the generated `orcaslicer://` href — so any future refactor that drops the encoding fails CI. Thanks to @jsapede for the double-encoding diagnosis and @AllanonBrooks and @lunaticds for the original reports.
## [0.2.3.2] - 2020-04-22
## [0.2.3.2] - 2026-04-22
### Improved
- **GCode Viewer Reshaped as an Archive Preview Tool** ([#963](https://github.com/maziggy/bambuddy/pull/963) follow-up) — PR #963 landed the embedded PrettyGCode viewer with a library file picker, a connected-printer selector with live WebSocket status, and auto-load of the currently-printing file. In practice those three didn't match Bambuddy's data model: the library file picker only listed `.gcode` files (Bambuddy stores `.gcode.3mf`), the printer selector wasn't useful when the real goal is previewing an existing archive, and the auto-load path had the same `.gcode`-filter gap as the picker. The viewer is now scoped to a single focused workflow — "show me the G-code for this archive" — reached from the Archives page 3D-preview button (menu item + the card-corner badge + list-row menu, all three paths navigate the same way). Entry URL is `/gcode-viewer?archive=<id>[&plate=<N>]`; the route falls through to the SPA catch-all so a full-page reload keeps the Bambuddy layout shell, with the iframe at `/gcode-viewer/?archive=<id>…` serving the raw viewer. Bed size is fetched from `GET /archives/{id}/capabilities.build_volume` (already parsing `printable_area` + `printable_height` from the 3MF's `Metadata/project_settings.config`) so any printer model renders the correct bed — 350×320×325 for H2D etc. — with no hardcoded per-model map to maintain. Multi-plate archives now surface a dedicated plate picker modal (`components/PlatePickerModal.tsx`) with thumbnails and object lists matching the existing Re-print modal's visual language; source-only 3MFs (no sliced gcode) show a `archives.platePicker.noGcode` toast instead of sending the user to an empty viewer. Behind the scenes: `GET /archives/{id}/gcode` accepts `?plate=N` and resolves the filename by integer-matching the suffix (zero-padded names like `Metadata/plate_01.gcode` now resolve as plate 1, fixing a class of picker-claimed-but-404 archives); `GET /archives/{id}/plates` gained a top-level `has_gcode: bool` flag so the frontend can suppress the picker when the archive is source-only; `printer_state_to_dict` now injects `name` and `model` into every WebSocket snapshot so consumers don't race a separate `/printers` fetch for proper labels. Removed from the viewer: printer selector + WS subscription, library file picker, `BAMBU_BED_SIZES` hardcoded map, auto-load-currently-printing, sidebar nav entry, 32 orphaned `gcodeViewer` locale keys, and the unreachable `ModelViewerModal` render paths on archive cards (the File Manager still uses `ModelViewerModal` for library file previews — scope preserved). Added test coverage: `?plate=N` happy path, zero-padded filename resolution, missing-plate 404, no-plate fallback to first, `?plate=0` 400 rejection, `has_gcode=true/false` branch, plus `PlatePickerModal.test.tsx` (6 tests covering render, plate-name label, onSelect payload, backdrop close, thumbnail fallback) and `printer_state_to_dict` name/model surfacing tests. A toast replaces the old silent empty viewer for source-only archives; reload stays in the Bambuddy layout; H2D previews no longer overflow the bed.
@@ -60,7 +60,20 @@ All notable changes to Bambuddy will be documented in this file.
- **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-pr
## [0.2.3.1] - 2020-04-20
### Added
- **Spoolman Unified Inventory UI** — Replaced the Spoolman iframe with a native inventory UI that matches the local spool experience exactly. The Filament Inventory page auto-detects the active backend (local DB or Spoolman) and renders spools, filters, deep-links, and NFC write flows identically regardless of source. Spoolman spools are fully editable — material, weight, colour, storage location, cost — via a PATCH proxy that re-links the Spoolman filament on metadata changes. Bulk-create, archive, restore, and delete are all supported. A 207 Multi-Status response on partial bulk-create includes `requested_count` and `failed_count` so the UI can surface a useful "Created N of M" message.
- **Storage Location field** — Spoolman's `location` field is now exposed as `storage_location` in the unified inventory schema and editable from the spool detail panel.
- **Deep-link from AMS slot hover card** — Clicking the filament chip on an AMS slot hover card deep-links directly to the matching spool in the inventory, whether it lives in the local DB or Spoolman. The link resolves by spool ID so it survives list reloads; a 404 shows a "Spool not found" toast instead of a generic error.
- **Spoolman-aware NFC write** — The SpoolBuddy Write Tag flow now falls back to Spoolman when a spool is not in the local DB. The NDEF payload is built from the Spoolman filament metadata; incomplete fields (missing `color_name`, `nozzle_temp_min`, etc.) produce per-field warnings surfaced as a UI toast. After a successful NFC write the tag UID is persisted back to Spoolman's `extra.tag` via a safe key-merge that preserves all other custom extra fields.
- **Spoolman-aware scale weight sync** — The SpoolBuddy scale endpoint reads `filament.spool_weight` from Spoolman to compute the tare, falling back to 250 g when unset. Weight is written back to Spoolman's `remaining_weight`.
### Fixed
- **Tag-clear preserves other Spoolman extra keys** — Clearing a tag UID from the inventory PATCH endpoint previously sent `extra: {}`, destroying any custom Spoolman extra fields. The endpoint now fetches the current extra dict, drops only the `tag` key, and PATCHes the remainder.
- **Malformed Spoolman spool no longer 500s the entire inventory list** — A spool with a missing or non-positive `id` field caused `_map_spoolman_spool` to raise `ValueError`, crashing `GET /spoolman/inventory/spools` with HTTP 500. The list endpoint now logs-and-skips individual bad rows so the rest of the list is returned normally.
- **delete / archive / restore correctly return 404 on missing spool** — Previously these endpoints returned HTTP 500 when Spoolman responded with 404. The service layer now raises `SpoolmanNotFoundError` on 404 and `SpoolmanUnavailableError` on other failures; routes map them to 404 and 503 respectively.
- **SSRF guard applied to all SpoolBuddy Spoolman paths** — `_get_spoolman_client_or_none` now runs `assert_safe_spoolman_url` before initialising the client; unsafe URLs are silently ignored with a warning log so devices continue operating.
## [0.2.3.1] - 2026-04-20
### Fixed
- **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-preview thumbnail inside the Skip Objects modal opened a lightbox that showed a broken-image icon instead of the full-size plate preview. The thumbnail `<img>` wrapped its `src` with `withStreamToken()` (which appends the short-lived camera-stream token to `/api/v1/` URLs that `<img>` tags can't attach an `Authorization` header to), but the enlarged lightbox `<img>` used a bare `${status.cover_url}?view=top` so the browser's unauthenticated request was rejected by the backend. Both images now go through `withStreamToken()`. Thanks to @elit3ge for the report and screenshot.
+222
View File
@@ -0,0 +1,222 @@
"""Pure helper functions for Spoolman spool mapping.
No heavy dependencies — importable in unit tests without the full backend stack.
"""
from __future__ import annotations
import ipaddress
import logging
import math
import re
from datetime import datetime, timezone
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
def assert_safe_spoolman_url(url: str) -> None:
"""Raise ValueError if *url* should be blocked as an SSRF risk.
Checks performed:
- Scheme must be http or https.
- Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
(e.g. ``0x7f000001``) are rejected — Python's ``ipaddress`` module raises
``ValueError`` for these forms so they would otherwise bypass the IP-range
guard, but libc resolves them as valid IPv4 addresses.
- Bare numeric IP hosts in loopback (127.x, ::1), link-local (169.254.x,
fe80::), private (RFC-1918), multicast (224.x, ff::/8), or unspecified
(0.0.0.0, ::) ranges are rejected.
- IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped to their IPv4
equivalent and subject to the same checks.
Hostname-based addresses ("localhost", "internal.corp") require DNS resolution
and are outside the scope of this guard — they are mitigated by network-level
controls in the deployment environment. "localhost" is intentionally *not*
blocked here because running Spoolman on the same host is a common and
supported topology.
"""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError("Spoolman URL must use http or https")
hostname = (parsed.hostname or "").lower()
# Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
# http://0x7f000001/). Python's ipaddress.ip_address() raises ValueError for
# these forms so they slip past the except-clause below, but the C library
# (and browsers) parse them as valid IPv4 addresses.
if re.match(r"^(0x[0-9a-f]+|[0-9]+)$", hostname, re.I):
raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
# Not a bare IP — hostname-based addresses are out of scope.
return
# Unwrap IPv4-mapped IPv6 (::ffff:169.254.x.x etc.) so their IPv4
# properties are evaluated correctly.
effective: ipaddress.IPv4Address | ipaddress.IPv6Address = addr
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
effective = addr.ipv4_mapped
if (
effective.is_loopback
or effective.is_link_local
or effective.is_private
or effective.is_multicast
or effective.is_unspecified
):
raise ValueError(
"Spoolman URL must not point to a private, loopback, link-local, multicast, or unspecified address"
)
_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
_TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
def _safe_int(value: object, fallback: int) -> int:
"""Convert value to int, returning fallback for None/NaN/Inf/non-numeric."""
try:
f = float(value) # type: ignore[arg-type]
if math.isfinite(f):
return int(f)
except (TypeError, ValueError):
pass
return fallback
def _safe_float(value: object, fallback: float) -> float:
"""Convert value to float, returning fallback for None/NaN/Inf/non-numeric."""
try:
f = float(value) # type: ignore[arg-type]
if math.isfinite(f):
return f
except (TypeError, ValueError):
pass
return fallback
def _safe_optional_float(value: object) -> float | None:
"""Convert value to finite float, or None if missing/NaN/Infinite/non-numeric.
Used for optional monetary fields (price) to prevent Infinity/NaN from
reaching JSON serialisation, which raises ValueError with allow_nan=False.
"""
if value is None:
return None
try:
f = float(value) # type: ignore[arg-type]
if math.isfinite(f):
return f
except (TypeError, ValueError):
pass
return None
def _map_spoolman_spool(spool: dict) -> dict:
"""Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
Fields not supported by Spoolman (k_profiles, slicer_filament, …) are
returned as None / empty so the frontend can still render them without
errors. The ``data_origin`` field is set to ``"spoolman"`` so UI code can
distinguish these spools from local ones.
"""
raw_id = spool.get("id")
if raw_id is None:
raise ValueError("Spoolman spool is missing required 'id' field")
try:
spool_id: int = int(raw_id)
except (TypeError, ValueError):
raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}")
if spool_id <= 0:
raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}")
filament: dict = spool.get("filament") or {}
if not filament:
logger.warning(
"Spoolman spool %s has no filament data — all filament fields will use defaults",
spool_id,
)
vendor: dict = filament.get("vendor") or {}
extra: dict = spool.get("extra") or {}
# RFID tag stored as JSON-encoded string in Spoolman extra.tag.
# 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID.
# Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars,
# 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags.
raw_tag: str = (extra.get("tag") or "").strip('"').upper()
_raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
# Subtype = filament name with material prefix stripped
material: str = (filament.get("material") or "").strip()
filament_name: str = (filament.get("name") or "").strip()
if material and filament_name.upper().startswith(material.upper()):
subtype: str | None = filament_name[len(material) :].strip() or None
else:
subtype = filament_name or None
# Colour: validate as 6-char hex; fall back to neutral grey for invalid values
raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
rgba: str = color_hex + "FF"
label_weight: int = _safe_int(filament.get("weight"), 1000)
used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
# Archived state – Spoolman uses a boolean ``archived`` field
archived: bool = spool.get("archived", False)
archived_at: str | None = None
if archived:
archived_at = spool.get("last_used") or spool.get("registered")
if not archived_at:
archived_at = datetime.now(timezone.utc).isoformat()
created_at: str = spool.get("registered") or datetime.now(timezone.utc).isoformat()
color_name: str | None = filament.get("color_name") or None
nozzle_temp_raw = filament.get("settings_extruder_temp")
nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
return {
"id": spool_id,
"material": material,
"subtype": subtype,
"color_name": color_name,
"rgba": rgba,
"brand": vendor.get("name") or None,
"label_weight": label_weight,
"core_weight": _safe_int(filament.get("spool_weight"), 250),
"core_weight_catalog_id": None,
"weight_used": used_weight,
"weight_locked": False,
"last_scale_weight": None,
"last_weighed_at": None,
# slicer_filament_name carries the Spoolman filament name for display
"slicer_filament": None,
"slicer_filament_name": filament_name or None,
"nozzle_temp_min": nozzle_temp_min,
"nozzle_temp_max": None,
"note": spool.get("comment") or None,
"added_full": None,
"last_used": spool.get("last_used"),
# encode_time semantics differ: local records NFC write time; Spoolman first_used
# records first print use — different events; using first_used as best available proxy.
"encode_time": spool.get("first_used"),
"tag_uid": tag_uid,
"tray_uuid": tray_uuid,
"data_origin": "spoolman",
"tag_type": "spoolman",
"archived_at": archived_at,
"created_at": created_at,
# Spoolman has no updated_at field; use registered timestamp as best available proxy
"updated_at": created_at,
"cost_per_kg": _safe_optional_float(spool.get("price")),
"storage_location": spool.get("location") or None,
"k_profiles": [],
}
+372 -75
View File
@@ -7,6 +7,7 @@ import time
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,10 +35,12 @@ from backend.app.schemas.spoolbuddy import (
TagRemovedRequest,
TagScannedRequest,
UpdateSpoolWeightRequest,
UpdateStatusRequest,
WriteTagRequest,
WriteTagResultRequest,
)
from backend.app.services.spool_tag_matcher import get_spool_by_tag
from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
logger = logging.getLogger(__name__)
@@ -49,6 +52,45 @@ _spoolbuddy_online_last_broadcast: dict[str, float] = {}
_diagnostic_results: dict[tuple[str, str], dict] = {}
async def _get_spoolman_client_or_none(db: AsyncSession):
"""Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
from backend.app.models.settings import Settings
from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
settings_result = await db.execute(select(Settings))
settings_dict = {s.key: s.value for s in settings_result.scalars().all()}
spoolman_url = settings_dict.get("spoolman_url", "").strip()
spoolman_enabled = settings_dict.get("spoolman_enabled", "false").lower() == "true" and bool(spoolman_url)
if not spoolman_enabled:
return None
# SSRF guard: reject dangerous schemes and private/loopback/link-local/multicast IPs.
try:
assert_safe_spoolman_url(spoolman_url)
except ValueError as exc:
logger.warning(
"Spoolman integration disabled: URL %r rejected by SSRF guard: %s",
spoolman_url,
exc,
)
return None
client = await get_spoolman_client()
if not client or client.base_url != spoolman_url.rstrip("/"):
try:
client = await init_spoolman_client(spoolman_url)
except ValueError as exc:
logger.warning(
"Spoolman integration disabled: URL %r rejected on re-initialisation: %s",
spoolman_url,
exc,
)
return None
return client
def _is_online(device: SpoolBuddyDevice) -> bool:
if not device.last_seen:
return False
@@ -171,8 +213,8 @@ async def register_device(
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
except Exception as exc:
logger.warning("Could not attach SSH public key to heartbeat response: %s", exc)
return response
@@ -321,28 +363,89 @@ async def nfc_tag_scanned(
},
}
)
logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
else:
await ws_manager.broadcast(
{
"type": "spoolbuddy_unknown_tag",
"device_id": req.device_id,
"tag_uid": req.tag_uid,
"sak": req.sak,
"tag_type": req.tag_type,
}
)
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,
)
logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
return {"status": "ok", "matched": True, "spool_id": spool.id}
return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
# Local DB miss — fall back to Spoolman when enabled
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
client = await _get_spoolman_client_or_none(db)
if client is not None:
try:
cached_spools = await client.get_spools()
sm_spool: dict | None = None
if req.tray_uuid:
sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
if sm_spool is None and req.tag_uid:
sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
if sm_spool is not None:
mapped = _map_spoolman_spool(sm_spool)
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_matched",
"device_id": req.device_id,
"tag_uid": req.tag_uid,
"spool": {
"id": mapped["id"],
"material": mapped["material"],
"subtype": mapped["subtype"],
"color_name": mapped["color_name"],
"rgba": mapped["rgba"],
"brand": mapped["brand"],
"label_weight": mapped["label_weight"],
"core_weight": mapped["core_weight"],
"weight_used": mapped["weight_used"],
},
}
)
logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
except ValueError as exc:
logger.error(
"Spoolman returned malformed spool data during tag lookup for %s: %s",
req.tag_uid,
exc,
)
return {"status": "ok", "matched": False, "spool_id": None}
except (httpx.RequestError, httpx.HTTPStatusError, SpoolmanUnavailableError):
logger.warning(
"Spoolman unreachable during tag lookup for %s",
req.tag_uid,
)
# Degrade gracefully on any Spoolman connectivity failure — device must not receive 500.
# Also suppresses unknown_tag broadcast: the UI cannot distinguish a Spoolman outage
# from "spool not registered", which would trigger duplicate-registration flows.
return {"status": "ok", "matched": False, "spool_id": None}
except Exception as exc:
logger.error(
"Spoolman tag lookup failed unexpectedly for %s: %s",
req.tag_uid,
exc,
)
# Same silent-return policy: an unexpected error must not break device operation
# or trigger spurious duplicate-registration flows in the UI.
return {"status": "ok", "matched": False, "spool_id": None}
await ws_manager.broadcast(
{
"type": "spoolbuddy_unknown_tag",
"device_id": req.device_id,
"tag_uid": req.tag_uid,
"sak": req.sak,
"tag_type": req.tag_type,
}
)
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": False, "spool_id": None}
@router.post("/nfc/tag-removed")
@@ -368,38 +471,95 @@ async def nfc_write_tag(
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Queue an NFC tag write command for a SpoolBuddy device."""
import json
from backend.app.models.spool import Spool
from backend.app.services.opentag3d import encode_opentag3d
from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
# Find the spool
result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(status_code=404, detail="Spool not found")
# Find the device
# Find the device first (required regardless of spool source)
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
# Encode OpenTag3D NDEF data
ndef_data = encode_opentag3d(spool)
# Try local DB first
result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = result.scalar_one_or_none()
nfc_warnings: list[str] = []
if spool:
ndef_data = encode_opentag3d(spool)
data_origin = "local"
else:
# Local DB miss — fall back to Spoolman when enabled
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
sm_client = await _get_spoolman_client_or_none(db)
if sm_client is None:
raise HTTPException(status_code=404, detail="Spool not found")
try:
sm_spool = await sm_client.get_spool(req.spool_id)
except SpoolmanNotFoundError:
raise HTTPException(status_code=404, detail="Spool not found")
except SpoolmanUnavailableError:
raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
try:
mapped = _map_spoolman_spool(sm_spool)
except ValueError as exc:
logger.warning("Spoolman returned invalid spool for write-tag: %s", exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data")
if not mapped.get("material"):
raise HTTPException(
status_code=400,
detail="Spoolman spool has no material set — cannot encode NFC tag",
)
ndef_data = encode_opentag3d_from_mapped(mapped)
data_origin = "spoolman"
# Warn when fields that drive NFC content are absent in Spoolman.
if not mapped.get("color_name"):
nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
if not mapped.get("nozzle_temp_min"):
nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
if not mapped.get("subtype"):
nfc_warnings.append("subtype not set in Spoolman — tag encodes empty subtype")
if not mapped.get("brand"):
nfc_warnings.append("brand/vendor not set in Spoolman — tag encodes empty brand")
if not mapped.get("rgba"):
nfc_warnings.append("rgba not set in Spoolman — tag encodes default colour")
if not mapped.get("label_weight"):
nfc_warnings.append("label_weight not set in Spoolman — tag encodes 0 g")
if nfc_warnings:
logger.warning(
"NFC encode for Spoolman spool %d has incomplete data: %s",
req.spool_id,
"; ".join(nfc_warnings),
)
# Store write payload and set pending command
device.pending_write_payload = json.dumps(
{
"spool_id": spool.id,
"spool_id": req.spool_id,
"ndef_data_hex": ndef_data.hex(),
"data_origin": data_origin,
}
)
device.pending_command = "write_tag"
await db.commit()
logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
return {"status": "queued"}
logger.info(
"Write tag queued for device %s, spool %d (%s, %d bytes)",
req.device_id,
req.spool_id,
data_origin,
len(ndef_data),
)
result: dict = {"status": "queued"}
if nfc_warnings:
result["warnings"] = nfc_warnings
return result
@router.post("/nfc/write-result")
@@ -409,37 +569,137 @@ async def nfc_write_result(
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Handle NFC tag write result from SpoolBuddy daemon."""
# Find the device and clear pending state
# Find the device
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
device = result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
# Capture data_origin before clearing the payload
try:
payload_dict = json.loads(device.pending_write_payload or "{}")
except (json.JSONDecodeError, TypeError):
payload_dict = {}
logger.warning("Malformed pending_write_payload for device %s — treating as local", req.device_id)
data_origin = payload_dict.get("data_origin", "local")
device.pending_command = None
device.pending_write_payload = None
if req.success:
# Link the tag to the spool
from backend.app.models.spool import Spool
if data_origin == "spoolman":
# Update Spoolman extra.tag with the written NFC UID using a safe merge
# (fetches current extra first to avoid overwriting other custom fields).
sm_client = await _get_spoolman_client_or_none(db)
if sm_client is None:
logger.warning("Spoolman not configured; cannot persist tag link for spool %d", req.spool_id)
await db.commit()
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_link_failed",
"device_id": req.device_id,
"spool_id": req.spool_id,
"tag_uid": req.tag_uid,
"message": "Spoolman not configured",
}
)
raise HTTPException(
status_code=502,
detail="Tag written to NFC but Spoolman is not configured; link not persisted",
)
_tag_link_ok = False
try:
tag_value = json.dumps(req.tag_uid.upper())
await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
logger.info(
"Spoolman tag written and linked: spool %d -> tag %s",
req.spool_id,
req.tag_uid,
)
_tag_link_ok = True
except SpoolmanNotFoundError:
logger.error(
"Spoolman spool %d deleted before tag write-back could be persisted",
req.spool_id,
)
# fall through to broadcast + raise 502 below
except SpoolmanUnavailableError:
logger.error(
"Spoolman unreachable during tag write-back for spool %d",
req.spool_id,
)
# fall through to broadcast + raise 502 below
except Exception:
logger.exception(
"Unexpected error during Spoolman tag write-back for spool %d",
req.spool_id,
)
# fall through to broadcast + raise 502 below
await db.commit()
if _tag_link_ok:
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_written",
"device_id": req.device_id,
"spool_id": req.spool_id,
"tag_uid": req.tag_uid,
}
)
else:
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_link_failed",
"device_id": req.device_id,
"spool_id": req.spool_id,
"tag_uid": req.tag_uid,
# Generic message — full exception (may contain internal URLs/hostnames)
# is logged server-side only to prevent information leakage via WebSocket.
"message": "Spoolman link failed",
}
)
raise HTTPException(
status_code=502,
detail="Tag written to NFC but Spoolman link failed",
)
else:
# Link the tag to the local DB spool
from backend.app.models.spool import Spool
result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = result.scalar_one_or_none()
if spool is None:
logger.warning(
"NFC tag written for spool %d but it no longer exists in local DB; tag is orphaned",
req.spool_id,
)
await db.commit()
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_link_failed",
"device_id": req.device_id,
"spool_id": req.spool_id,
"message": "Spool not found",
}
)
return {"status": "ok", "linked": False, "message": "Spool not found"}
result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = result.scalar_one_or_none()
if spool:
spool.tag_uid = req.tag_uid.upper()
spool.tag_type = "ntag"
spool.data_origin = "opentag3d"
spool.encode_time = datetime.now(timezone.utc)
logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
await db.commit()
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_written",
"device_id": req.device_id,
"spool_id": req.spool_id,
"tag_uid": req.tag_uid,
}
)
await db.commit()
await ws_manager.broadcast(
{
"type": "spoolbuddy_tag_written",
"device_id": req.device_id,
"spool_id": req.spool_id,
"tag_uid": req.tag_uid,
}
)
else:
await db.commit()
await ws_manager.broadcast(
@@ -504,10 +764,47 @@ async def update_spool_weight(
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Update spool's used weight from scale reading."""
from backend.app.api.routes._spoolman_helpers import _safe_float
sm_client = await _get_spoolman_client_or_none(db)
if sm_client is not None:
try:
sm_spool = await sm_client.get_spool(req.spool_id)
except SpoolmanNotFoundError:
raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
except SpoolmanUnavailableError:
raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
filament = sm_spool.get("filament") or {}
raw_spool_weight = filament.get("spool_weight")
if not raw_spool_weight:
logger.warning(
"Spoolman spool %d has no spool_weight set; using 250g fallback for tare",
req.spool_id,
)
core_weight = _safe_float(raw_spool_weight, 250.0)
label_weight = _safe_float(filament.get("weight"), 1000.0)
remaining_weight = max(0.0, req.weight_grams - core_weight)
result = await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
if result is None:
raise HTTPException(status_code=502, detail="Failed to update spool weight in Spoolman")
weight_used = max(0.0, label_weight - remaining_weight)
logger.info(
"SpoolBuddy updated Spoolman spool %d: %.1fg on scale, core=%.1fg → %.1fg remaining",
req.spool_id,
req.weight_grams,
core_weight,
remaining_weight,
)
return {"status": "ok", "weight_used": weight_used}
# Local DB mode
from backend.app.models.spool import Spool
result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = result.scalar_one_or_none()
db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
spool = db_result.scalar_one_or_none()
if not spool:
raise HTTPException(status_code=404, detail="Spool not found")
@@ -959,13 +1256,14 @@ async def get_ssh_public_key(
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
logger.error("Failed to get SSH public key: %s", e)
raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
@router.post("/devices/{device_id}/update-status")
async def report_update_status(
device_id: str,
req: dict,
req: UpdateStatusRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
@@ -975,25 +1273,24 @@ async def report_update_status(
if not device:
raise HTTPException(status_code=404, detail="Device not registered")
status = req.get("status", "")
message = req.get("message", "")
device.update_status = req.status
device.update_message = req.message
# Only "complete" clears pending_command here. "error" leaves it set so the user can retry
# via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
# because it owns the full update lifecycle end-to-end.
if req.status == "complete":
device.pending_command = None
await db.commit()
if status in ("updating", "complete", "error"):
device.update_status = status
device.update_message = message[:255] if message else None
if status == "complete":
device.pending_command = None
await db.commit()
logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
await ws_manager.broadcast(
{
"type": "spoolbuddy_update",
"device_id": device_id,
"update_status": status,
"update_message": message,
}
)
logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
await ws_manager.broadcast(
{
"type": "spoolbuddy_update",
"device_id": device_id,
"update_status": req.status,
"update_message": req.message,
}
)
return {"status": "ok"}
@@ -0,0 +1,531 @@
"""Spoolman inventory proxy endpoints.
Translates between Spoolman's data model and Bambuddy's internal
InventorySpool format so the frontend can use a single unified inventory UI
regardless of whether data comes from the local database or Spoolman.
"""
from __future__ import annotations
import logging
import re
import time
from contextlib import asynccontextmanager
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes._spoolman_helpers import (
_map_spoolman_spool,
_safe_float,
_safe_int,
assert_safe_spoolman_url,
)
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.spoolman import (
SpoolmanClient,
SpoolmanClientError,
SpoolmanNotFoundError,
SpoolmanUnavailableError,
get_spoolman_client,
init_spoolman_client,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"])
# Cache the last successful health-check timestamp to avoid a round-trip on
# every request. A failed check clears the cache immediately.
_health_check_cache: dict[str, float] = {}
_HEALTH_CHECK_TTL = 30.0 # seconds
def _tag_cleared(val: str | None) -> bool:
"""Return True when a PATCH field explicitly removes a tag (null or empty string)."""
return val is None or val == ""
async def _get_client(db: AsyncSession) -> SpoolmanClient:
"""Return an authenticated Spoolman client or raise an HTTP error."""
result = await db.execute(select(Settings))
settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()}
enabled = settings.get("spoolman_enabled", "false").lower() == "true"
url = settings.get("spoolman_url", "").strip()
if not enabled:
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
if not url:
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
# SSRF guard: reject dangerous schemes and bare private/loopback/link-local/multicast IPs.
# Raises ValueError with a descriptive message on any violation.
try:
assert_safe_spoolman_url(url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation).
client = await get_spoolman_client()
if not client or client.base_url != url.rstrip("/"):
try:
client = await init_spoolman_client(url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Only call health_check() when the cached result has expired.
# Evict stale entries when URL changes (only one Spoolman URL is active at a time).
if url not in _health_check_cache and _health_check_cache:
_health_check_cache.clear()
now = time.monotonic()
last_ok = _health_check_cache.get(url, 0.0)
if now - last_ok > _HEALTH_CHECK_TTL:
if not await client.health_check():
_health_check_cache.pop(url, None)
raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
_health_check_cache[url] = now
return client
@asynccontextmanager
async def _translate_spoolman_errors():
"""Translate Spoolman exceptions to HTTP responses for all inventory endpoints.
Maps SpoolmanNotFoundError → 404 and SpoolmanUnavailableError → 503.
Add new SpoolmanClient exception mappings here rather than in individual handlers.
"""
try:
yield
except SpoolmanNotFoundError as exc:
raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
except SpoolmanClientError as exc:
raise HTTPException(status_code=exc.status_code, detail="Spoolman rejected the request") from exc
except SpoolmanUnavailableError as exc:
raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> dict:
"""Patch the spool's price via a follow-up update when cost_per_kg is provided.
Bambuddy's SpoolmanClient.create_spool() does not forward price to Spoolman's POST /spool
endpoint, so a follow-up PATCH via update_spool_full is needed to set it.
On failure, logs an error and returns the original spool (caller gets HTTP 200 without price).
"""
if cost_per_kg is None:
return spool
try:
async with _translate_spoolman_errors():
return await client.update_spool_full(spool["id"], price=cost_per_kg)
except HTTPException:
logger.error(
"Price update failed for spool %d; spool created without price (cost_per_kg=%s)",
spool["id"],
cost_per_kg,
)
return spool
# ---------------------------------------------------------------------------
# Request / response schemas
# ---------------------------------------------------------------------------
_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")
def _validate_rgba(v: str | None) -> str | None:
if v is None:
return v
clean = v.removeprefix("#")
if not _HEX_RE.match(clean):
raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)")
return clean.upper()
def _validate_storage_location(v: str | None) -> str | None:
if v is not None and any(c in v for c in ("\r", "\n", "\x00")):
raise ValueError("storage_location must not contain control characters")
return v
class SpoolmanInventoryCreate(BaseModel):
material: str = Field(..., min_length=1, max_length=64)
subtype: str | None = Field(None, max_length=64)
brand: str | None = Field(None, max_length=128)
color_name: str | None = Field(None, max_length=64)
rgba: str | None = Field(None, max_length=8)
label_weight: int = Field(1000, ge=1, le=100_000)
core_weight: int = Field(
250, ge=0, le=10_000
) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
note: str | None = Field(None, max_length=1000)
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
storage_location: str | None = Field(None, max_length=255)
@field_validator("rgba")
@classmethod
def validate_rgba(cls, v: str | None) -> str | None:
return _validate_rgba(v)
@field_validator("storage_location")
@classmethod
def validate_storage_location(cls, v: str | None) -> str | None:
return _validate_storage_location(v)
@model_validator(mode="after")
def validate_weight_consistency(self) -> SpoolmanInventoryCreate:
if self.weight_used > self.label_weight:
raise ValueError("weight_used must not exceed label_weight")
return self
class SpoolmanInventoryUpdate(BaseModel):
material: str | None = Field(None, min_length=1, max_length=64)
subtype: str | None = Field(None, max_length=64)
brand: str | None = Field(None, max_length=128)
color_name: str | None = Field(None, max_length=64)
rgba: str | None = Field(None, max_length=8)
label_weight: int | None = Field(None, ge=1, le=100_000)
core_weight: int | None = Field(
None, ge=0, le=10_000
) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
note: str | None = Field(None, max_length=1000)
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
storage_location: str | None = Field(None, max_length=255)
@field_validator("rgba")
@classmethod
def validate_rgba(cls, v: str | None) -> str | None:
return _validate_rgba(v)
@field_validator("storage_location")
@classmethod
def validate_storage_location(cls, v: str | None) -> str | None:
return _validate_storage_location(v)
@model_validator(mode="after")
def validate_weight_consistency(self) -> SpoolmanInventoryUpdate:
if self.weight_used is not None and self.label_weight is not None:
if self.weight_used > self.label_weight:
raise ValueError("weight_used must not exceed label_weight")
return self
class SpoolmanInventoryBulkCreate(BaseModel):
spool: SpoolmanInventoryCreate
quantity: int = Field(1, ge=1, le=50)
class SpoolWeightUpdate(BaseModel):
weight_grams: float = Field(..., ge=0.0, le=100_000.0)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/spools")
async def list_spools(
include_archived: bool = Query(False),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> list[dict]:
"""Return all Spoolman spools in the InventorySpool format."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spools = await client.get_all_spools(allow_archived=include_archived)
result = []
for s in spools:
try:
result.append(_map_spoolman_spool(s))
except ValueError as exc:
logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
return result
@router.get("/spools/{spool_id}")
async def get_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
) -> dict:
"""Return a single Spoolman spool in the InventorySpool format."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.get_spool(spool_id)
try:
return _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
@router.post("/spools")
async def create_spool(
data: SpoolmanInventoryCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
client = await _get_client(db)
color_hex = (data.rgba or "808080FF")[:6]
async with _translate_spoolman_errors():
filament_id = await client.find_or_create_filament(
material=data.material,
subtype=data.subtype or "",
brand=data.brand,
color_hex=color_hex,
label_weight=data.label_weight,
color_name=data.color_name,
)
if not filament_id:
raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
remaining = max(0.0, data.label_weight - data.weight_used)
spool = await client.create_spool(
filament_id=filament_id,
remaining_weight=remaining,
comment=data.note or None,
location=data.storage_location or None,
)
if not spool:
raise HTTPException(status_code=500, detail="Failed to create spool in Spoolman")
spool = await _apply_price_if_set(client, spool, data.cost_per_kg)
return _map_spoolman_spool(spool)
@router.post("/spools/bulk")
async def bulk_create_spools(
payload: SpoolmanInventoryBulkCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> Response:
"""Create multiple identical spools in Spoolman."""
client = await _get_client(db)
data = payload.spool
color_hex = (data.rgba or "808080FF")[:6]
async with _translate_spoolman_errors():
filament_id = await client.find_or_create_filament(
material=data.material,
subtype=data.subtype or "",
brand=data.brand,
color_hex=color_hex,
label_weight=data.label_weight,
)
if not filament_id:
raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
remaining = max(0.0, data.label_weight - data.weight_used)
created: list[dict] = []
for _ in range(payload.quantity):
spool = await client.create_spool(
filament_id=filament_id,
remaining_weight=remaining,
comment=data.note or None,
location=data.storage_location or None,
)
if spool:
spool = await _apply_price_if_set(client, spool, data.cost_per_kg)
created.append(_map_spoolman_spool(spool))
if not created:
raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
if len(created) < payload.quantity:
# Some spool creations failed — return 207 Multi-Status so the caller
# can distinguish a full success from a partial one and show a useful message.
return JSONResponse(
status_code=207,
content={
"created": created,
"requested_count": payload.quantity,
"failed_count": payload.quantity - len(created),
},
)
return JSONResponse(status_code=200, content=created)
@router.patch("/spools/{spool_id}")
async def update_spool(
*,
spool_id: int = Path(..., gt=0),
data: SpoolmanInventoryUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Update an existing Spoolman spool, re-linking the filament if metadata changed."""
client = await _get_client(db)
async with _translate_spoolman_errors():
current = await client.get_spool(spool_id)
cur_filament: dict = current.get("filament") or {}
cur_vendor: dict = cur_filament.get("vendor") or {}
cur_mat: str = (cur_filament.get("material") or "").strip()
cur_name: str = (cur_filament.get("name") or "").strip()
if cur_mat and cur_name.upper().startswith(cur_mat.upper()):
cur_subtype: str = cur_name[len(cur_mat) :].strip()
else:
cur_subtype = cur_name
# Resolve final values: use request value if provided, else keep current
material = data.material if data.material is not None else cur_mat
subtype = data.subtype if data.subtype is not None else cur_subtype
brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None)
cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0)
note = data.note if data.note is not None else current.get("comment")
storage_location_changed = "storage_location" in data.model_fields_set
storage_location = data.storage_location if storage_location_changed else current.get("location")
color_hex = rgba[:6]
async with _translate_spoolman_errors():
filament_id = await client.find_or_create_filament(
material=material,
subtype=subtype or "",
brand=brand,
color_hex=color_hex,
label_weight=label_weight,
color_name=color_name,
)
if not filament_id:
raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
remaining = max(0.0, label_weight - weight_used)
# Tag removal: clear only the "tag" key so other custom Spoolman extra fields
# set outside Bambuddy are preserved.
tag_nulled = (
("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set)
and _tag_cleared(data.tag_uid)
and _tag_cleared(data.tray_uuid)
)
# Serialise tag-clear + PATCH under the per-spool extra lock to prevent a
# concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting
# the tag key between our read and our write.
async with client.extra_lock(spool_id):
if tag_nulled:
# Re-fetch inside the lock so we work with fresh extra data.
async with _translate_spoolman_errors():
fresh = await client.get_spool(spool_id)
cur_extra = dict(fresh.get("extra") or {})
cur_extra.pop("tag", None)
extra: dict | None = cur_extra
else:
extra = None
async with _translate_spoolman_errors():
updated = await client.update_spool_full(
spool_id=spool_id,
filament_id=filament_id,
remaining_weight=remaining,
comment=note or "",
price=data.cost_per_kg,
extra=extra,
location=storage_location or None,
clear_location=storage_location_changed and not storage_location,
)
return _map_spoolman_spool(updated)
@router.delete("/spools/{spool_id}")
async def delete_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Permanently delete a spool from Spoolman."""
client = await _get_client(db)
async with _translate_spoolman_errors():
await client.delete_spool(spool_id)
return {"status": "deleted"}
@router.post("/spools/{spool_id}/archive")
async def archive_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Archive a spool in Spoolman (soft-delete)."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.set_spool_archived(spool_id, archived=True)
try:
return _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
@router.post("/spools/{spool_id}/restore")
async def restore_spool(
spool_id: int = Path(..., gt=0),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Restore an archived spool in Spoolman."""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.set_spool_archived(spool_id, archived=False)
try:
return _map_spoolman_spool(spool)
except ValueError as exc:
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
@router.patch("/spools/{spool_id}/weight")
async def sync_spool_weight(
*,
spool_id: int = Path(..., gt=0),
data: SpoolWeightUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Update a spool's remaining weight from a measured gross weight.
Computes remaining = gross_weight - filament.spool_weight (empty-spool
weight from Spoolman; falls back to 250 g when unset) and updates
Spoolman accordingly.
"""
client = await _get_client(db)
async with _translate_spoolman_errors():
current = await client.get_spool(spool_id)
cur_filament = current.get("filament") or {}
core_weight = _safe_float(cur_filament.get("spool_weight"), 250.0)
remaining = max(0.0, data.weight_grams - core_weight)
async with _translate_spoolman_errors():
updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
upd_filament = updated.get("filament") or {}
label_weight = _safe_int(upd_filament.get("weight"), 1000)
weight_used = max(0.0, label_weight - remaining)
return {"status": "ok", "weight_used": weight_used}
+44 -2
View File
@@ -25,6 +25,45 @@ from backend.app.models.user import User
logger = logging.getLogger(__name__)
# Permissions that cannot be accessed via API key.
# API keys are limited to device/inventory operations. The following capabilities
# are restricted to fully-authenticated users: user/group/API-key management,
# settings read/backup/restore, firmware updates, and GitHub-backed backup/restore.
_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
{
Permission.SETTINGS_READ,
Permission.SETTINGS_UPDATE,
Permission.SETTINGS_BACKUP,
Permission.SETTINGS_RESTORE,
Permission.USERS_READ,
Permission.USERS_CREATE,
Permission.USERS_UPDATE,
Permission.USERS_DELETE,
Permission.GROUPS_READ,
Permission.GROUPS_CREATE,
Permission.GROUPS_UPDATE,
Permission.GROUPS_DELETE,
Permission.API_KEYS_CREATE,
Permission.API_KEYS_UPDATE,
Permission.API_KEYS_DELETE,
Permission.API_KEYS_READ,
Permission.GITHUB_BACKUP,
Permission.GITHUB_RESTORE,
Permission.FIRMWARE_UPDATE,
}
)
def _check_apikey_permissions(perm_strings: list[str]) -> None:
"""Raise 403 if any required permission is admin-only (not accessible via API key)."""
denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings)
if denied:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API keys cannot be used for administrative operations",
)
# Password hashing
# Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
# pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
@@ -631,8 +670,7 @@ async def get_api_key(
detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
)
# M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all
# enabled keys — same fix as in _validate_api_key (L-1 from previous review).
# Pre-filter by key_prefix to avoid O(n) pbkdf2 hashes across all enabled keys.
key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
result = await db.execute(
select(APIKey).where(
@@ -756,6 +794,7 @@ def require_permission(*permissions: str | Permission):
if x_api_key:
api_key = await _validate_api_key(db, x_api_key)
if api_key:
_check_apikey_permissions(perm_strings)
return None # API key valid, allow access
credentials_exception = HTTPException(
@@ -772,6 +811,7 @@ def require_permission(*permissions: str | Permission):
if token.startswith("bb_"):
api_key = await _validate_api_key(db, token)
if api_key:
_check_apikey_permissions(perm_strings)
return None # API key valid, allow access
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -837,6 +877,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
if x_api_key:
api_key = await _validate_api_key(db, x_api_key)
if api_key:
_check_apikey_permissions(perm_strings)
return None # API key valid, allow access
# Check for Bearer token (could be JWT or API key)
@@ -846,6 +887,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
if token.startswith("bb_"):
api_key = await _validate_api_key(db, token)
if api_key:
_check_apikey_permissions(perm_strings)
return None # API key valid, allow access
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+10
View File
@@ -1107,6 +1107,13 @@ async def run_migrations(conn):
# Migration: Add cost tracking fields to spool table
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
# Migration: Add user-editable storage location to spool table
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
# Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
# UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
# SQLite ignores VARCHAR sizes; this is a no-op there but needed for PostgreSQL.
await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)")
# Migration: Add cost field to spool_usage_history table
await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
# Migration: Add archive_id field to spool_usage_history table
@@ -1206,6 +1213,9 @@ async def run_migrations(conn):
# Migration: Add system_stats JSON blob column to spoolbuddy_devices
await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT")
# Migration: Add SSH host key for TOFU verification (H1 security fix)
await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN ssh_host_key VARCHAR(500)")
# Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
# Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
# PostgreSQL gets the correct schema from create_all(), so skip this
+2
View File
@@ -50,6 +50,7 @@ from backend.app.api.routes import (
smart_plugs,
spoolbuddy,
spoolman,
spoolman_inventory,
support,
system,
updates,
@@ -4543,6 +4544,7 @@ app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
app.include_router(camera.router, prefix=app_settings.api_prefix)
+3 -1
View File
@@ -36,9 +36,11 @@ class Spool(Base):
# Cost tracking
cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram
storage_location: Mapped[str | None] = mapped_column(String(255)) # User-editable storage location
last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print
encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag
tag_uid: Mapped[str | None] = mapped_column(String(16)) # RFID tag UID (16 hex chars)
tag_uid: Mapped[str | None] = mapped_column(String(32)) # RFID tag UID (up to 32 hex chars)
tray_uuid: Mapped[str | None] = mapped_column(String(32)) # Bambu Lab spool UUID (32 hex chars)
data_origin: Mapped[str | None] = mapped_column(String(20)) # How data was populated: manual, rfid_auto, nfc_link
tag_type: Mapped[str | None] = mapped_column(String(20)) # Tag vendor: bambulab, generic, etc.
+1
View File
@@ -37,5 +37,6 @@ class SpoolBuddyDevice(Base):
scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
uptime_s: Mapped[int] = mapped_column(Integer, default=0)
system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)
ssh_host_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+5 -3
View File
@@ -18,7 +18,7 @@ class SpoolBase(BaseModel):
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
note: str | None = None
tag_uid: str | None = None
tag_uid: str | None = Field(default=None, max_length=32)
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
@@ -26,6 +26,7 @@ class SpoolBase(BaseModel):
weight_locked: bool = False
last_scale_weight: int | None = None
last_weighed_at: datetime | None = None
storage_location: str | None = Field(default=None, max_length=255)
class SpoolCreate(SpoolBase):
@@ -52,12 +53,13 @@ class SpoolUpdate(BaseModel):
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
note: str | None = None
tag_uid: str | None = None
tag_uid: str | None = Field(default=None, max_length=32)
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
cost_per_kg: float | None = Field(default=None, ge=0)
weight_locked: bool | None = None
storage_location: str | None = Field(default=None, max_length=255)
class SpoolKProfileBase(BaseModel):
@@ -90,7 +92,7 @@ class SpoolResponse(SpoolBase):
added_full: bool | None = None
last_used: datetime | None = None
encode_time: datetime | None = None
tag_uid: str | None = None
tag_uid: str | None = Field(default=None, max_length=32)
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
+47 -31
View File
@@ -1,6 +1,8 @@
import json
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# --- Device schemas ---
@@ -9,14 +11,14 @@ class DeviceRegisterRequest(BaseModel):
device_id: str = Field(..., min_length=1, max_length=50)
hostname: str = Field(..., min_length=1, max_length=100)
ip_address: str = Field(..., min_length=1, max_length=45)
firmware_version: str | None = None
firmware_version: str | None = Field(None, max_length=20)
has_nfc: bool = True
has_scale: bool = True
tare_offset: int = 0
calibration_factor: float = 1.0
nfc_reader_type: str | None = None
nfc_connection: str | None = None
backend_url: str | None = None
nfc_reader_type: str | None = Field(None, max_length=20)
nfc_connection: str | None = Field(None, max_length=20)
backend_url: str | None = Field(None, max_length=255)
has_backlight: bool = False
@@ -58,13 +60,20 @@ class HeartbeatRequest(BaseModel):
nfc_ok: bool = False
scale_ok: bool = False
uptime_s: int = 0
firmware_version: str | None = None
ip_address: str | None = None
nfc_reader_type: str | None = None
nfc_connection: str | None = None
backend_url: str | None = None
firmware_version: str | None = Field(None, max_length=20)
ip_address: str | None = Field(None, max_length=45)
nfc_reader_type: str | None = Field(None, max_length=20)
nfc_connection: str | None = Field(None, max_length=20)
backend_url: str | None = Field(None, max_length=255)
system_stats: dict | None = None
@field_validator("system_stats")
@classmethod
def _limit_system_stats_size(cls, v: dict | None) -> dict | None:
if v is not None and len(json.dumps(v)) > 4096:
raise ValueError("system_stats must not exceed 4096 bytes when JSON-encoded")
return v
class HeartbeatResponse(BaseModel):
pending_command: str | None = None
@@ -80,31 +89,31 @@ class HeartbeatResponse(BaseModel):
class TagScannedRequest(BaseModel):
device_id: str
tag_uid: str
tray_uuid: str | None = None
device_id: str = Field(..., max_length=50)
tag_uid: str = Field(..., max_length=32)
tray_uuid: str | None = Field(None, max_length=32, pattern=r"^[0-9A-Fa-f]*$")
sak: int | None = None
tag_type: str | None = None
tag_type: str | None = Field(None, max_length=50)
raw_blocks: dict | None = None
class TagRemovedRequest(BaseModel):
device_id: str
tag_uid: str
device_id: str = Field(..., max_length=50)
tag_uid: str = Field(..., max_length=32)
# --- Scale schemas ---
class ScaleReadingRequest(BaseModel):
device_id: str
weight_grams: float
device_id: str = Field(..., max_length=50)
weight_grams: float = Field(..., ge=0.0, le=100_000.0)
stable: bool = False
raw_adc: int | None = None
class UpdateSpoolWeightRequest(BaseModel):
spool_id: int
spool_id: int = Field(..., gt=0)
weight_grams: float
@@ -130,16 +139,16 @@ class CalibrationResponse(BaseModel):
class WriteTagRequest(BaseModel):
device_id: str
spool_id: int
device_id: str = Field(..., max_length=50)
spool_id: int = Field(..., gt=0)
class WriteTagResultRequest(BaseModel):
device_id: str
spool_id: int
tag_uid: str
device_id: str = Field(..., max_length=50)
spool_id: int = Field(..., gt=0)
tag_uid: str = Field(..., min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
success: bool
message: str | None = None
message: str | None = Field(None, max_length=500)
class DisplaySettingsRequest(BaseModel):
@@ -153,20 +162,27 @@ class SystemConfigRequest(BaseModel):
class SystemCommandRequest(BaseModel):
command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser")
command: str = Field(
..., max_length=50, description="System command: reboot, shutdown, restart_daemon, restart_browser"
)
class SystemCommandResultRequest(BaseModel):
command: str
command: str = Field(..., max_length=50)
success: bool
message: str | None = None
message: str | None = Field(None, max_length=500)
class UpdateStatusRequest(BaseModel):
status: Literal["updating", "complete", "error"]
message: str | None = Field(None, max_length=255)
# --- Diagnostics schemas ---
class DiagnosticResultRequest(BaseModel):
diagnostic: str # 'nfc', 'scale', or 'read_tag'
diagnostic: str = Field(..., max_length=50, description="Diagnostic type: 'nfc', 'scale', or 'read_tag'")
success: bool
output: str
exit_code: int
output: str = Field(..., max_length=10_000)
exit_code: int = Field(..., ge=-255, le=255)
+64 -22
View File
@@ -13,6 +13,7 @@ NDEF structure:
"""
import struct
from typing import TypedDict
from backend.app.models.spool import Spool
@@ -21,33 +22,50 @@ PAYLOAD_SIZE = 102
TAG_VERSION = 1000 # v1.000
def _build_payload(spool: Spool) -> bytes:
"""Build 102-byte OpenTag3D core payload from spool fields."""
class MappedSpoolFields(TypedDict, total=False):
"""Fields consumed by the OpenTag3D encoder from a mapped Spoolman spool dict."""
material: str | None
subtype: str | None
brand: str | None
color_name: str | None
rgba: str | None
label_weight: int | None
nozzle_temp_min: int | None
def _build_payload_from_dict(data: dict) -> bytes:
"""Build 102-byte OpenTag3D core payload from a plain field dict.
Accepted keys: material, subtype, brand, color_name, rgba,
label_weight, nozzle_temp_min. All are optional and default to
safe zero/empty values when missing.
"""
buf = bytearray(PAYLOAD_SIZE)
# 0x00: Tag Version (2 bytes, big-endian)
struct.pack_into(">H", buf, 0x00, TAG_VERSION)
# 0x02: Base Material (5 bytes, UTF-8, space-padded)
material = (spool.material or "")[:5].ljust(5)
material = (data.get("material") or "")[:5].ljust(5)
buf[0x02:0x07] = material.encode("utf-8")[:5]
# 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
modifiers = (spool.subtype or "")[:5].ljust(5)
modifiers = (data.get("subtype") or "")[:5].ljust(5)
buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
# 0x0C: Reserved (15 bytes, zero-fill) — already zero
# 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
brand = (spool.brand or "")[:16].ljust(16)
brand = (data.get("brand") or "")[:16].ljust(16)
buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
# 0x2B: Color Name (32 bytes, UTF-8, space-padded)
color_name = (spool.color_name or "")[:32].ljust(32)
color_name = (data.get("color_name") or "")[:32].ljust(32)
buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
# 0x4B: Color 1 RGBA (4 bytes)
rgba_hex = spool.rgba or "00000000"
rgba_hex = data.get("rgba") or "00000000"
try:
rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
except ValueError:
@@ -59,11 +77,12 @@ def _build_payload(spool: Spool) -> bytes:
# 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
struct.pack_into(">H", buf, 0x5C, 1750)
# 0x5E: Target Weight (2 bytes, big-endian)
struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0)
# 0x5E: Target Weight (2 bytes, big-endian) — clamped to uint16 (0–65535)
label_weight = max(0, min(int(data.get("label_weight") or 0), 65535))
struct.pack_into(">H", buf, 0x5E, label_weight)
# 0x60: Print Temp (1 byte) — nozzle_temp_min / 5
buf[0x60] = (spool.nozzle_temp_min or 0) // 5
# 0x60: Print Temp (1 byte) — nozzle_temp_min / 5, clamped to 0–255
buf[0x60] = max(0, min(int((data.get("nozzle_temp_min") or 0) // 5), 255))
# 0x61: Bed Temp (1 byte) — not tracked
# 0x62: Density (2 bytes) — not tracked
@@ -73,17 +92,26 @@ def _build_payload(spool: Spool) -> bytes:
return bytes(buf)
def encode_opentag3d(spool: Spool) -> bytes:
"""Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator).
def _build_payload(spool: Spool) -> bytes:
"""Build 102-byte OpenTag3D core payload from a Spool ORM object."""
return _build_payload_from_dict(
{
"material": spool.material,
"subtype": spool.subtype,
"brand": spool.brand,
"color_name": spool.color_name,
"rgba": spool.rgba,
"label_weight": spool.label_weight,
"nozzle_temp_min": spool.nozzle_temp_min,
}
)
Returns raw bytes ready to write to NTAG starting at page 4.
"""
payload = _build_payload(spool)
def _encode_ndef(payload: bytes) -> bytes:
"""Wrap a 102-byte payload in CC + TLV + NDEF record + terminator."""
mime_type = OPENTAG3D_MIME_TYPE
# NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
# Type length = 21
# Payload length = 102 (fits in SR single byte)
record_header = bytes([0xD2, len(mime_type), len(payload)])
ndef_record = record_header + mime_type + payload
@@ -94,10 +122,24 @@ def encode_opentag3d(spool: Spool) -> bytes:
else:
tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
# Capability Container (page 4)
cc = bytes([0xE1, 0x10, 0x12, 0x00])
# Terminator TLV
terminator = bytes([0xFE])
return cc + tlv + ndef_record + terminator
def encode_opentag3d(spool: Spool) -> bytes:
"""Encode spool ORM object as OpenTag3D NDEF message.
Returns raw bytes ready to write to NTAG starting at page 4.
"""
return _encode_ndef(_build_payload(spool))
def encode_opentag3d_from_mapped(mapped: MappedSpoolFields) -> bytes:
"""Encode a Spoolman-mapped spool dict as OpenTag3D NDEF message.
Accepts the dict produced by ``_map_spoolman_spool`` (or any dict
with the same field names). Returns raw bytes ready to write to
NTAG starting at page 4.
"""
return _encode_ndef(_build_payload_from_dict(mapped))
+88 -22
View File
@@ -15,6 +15,7 @@ entries for root). asyncssh does all of its work in-process.
import asyncio
import logging
import os
import shlex
from pathlib import Path
import asyncssh
@@ -139,45 +140,61 @@ async def _run_ssh_command(
ip: str,
command: str,
private_key: Path,
*,
known_hosts: "asyncssh.SSHKnownHosts | None" = None,
timeout: int = 60,
) -> tuple[int, str, str]:
) -> tuple[int, str, str, str | None]:
"""Execute a command on a SpoolBuddy device via SSH.
Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
for the Docker/PUID rationale.
Returns (returncode, stdout, stderr). On connection failure the return
code is 255 (matching `ssh`'s own convention) and stderr carries the
asyncssh error message. On timeout the return code is -1.
Returns (returncode, stdout, stderr, observed_host_key).
observed_host_key is non-None only on a successful connection when known_hosts=None
was passed. Callers are responsible for also checking whether a stored key already
exists before persisting — use `observed_key and not stored_host_key` not just
`observed_key is not None`.
On connection failure rc=255; on timeout rc=-1.
"""
observed_host_key: str | None = None
try:
async with asyncio.timeout(timeout):
async with asyncssh.connect(
host=ip,
username=SSH_USER,
client_keys=[str(private_key)],
known_hosts=None, # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null
known_hosts=known_hosts,
config=[], # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs
connect_timeout=10,
) as conn:
if known_hosts is None:
# TOFU first-use: capture the host key for storage
server_key = conn.get_server_host_key()
if server_key:
observed_host_key = server_key.export_public_key("openssh").decode().strip()
result = await conn.run(command, check=False)
except asyncssh.HostKeyNotVerifiable:
logger.error("SSH host key mismatch for %s — possible MITM attack", ip)
return 255, "", "Host key mismatch — verify device identity before retrying", None
except TimeoutError:
return -1, "", "SSH command timed out"
return -1, "", "SSH command timed out", None
except (asyncssh.Error, OSError) as exc:
return 255, "", str(exc)
return 255, "", str(exc), None
stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b"").decode(errors="replace")
stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b"").decode(errors="replace")
# asyncssh's exit_status is None when the remote closed without setting one
returncode = result.exit_status if result.exit_status is not None else 0
return returncode, stdout, stderr
return returncode, stdout, stderr, observed_host_key
async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
"""SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
Updates device.update_status/update_message in the DB and broadcasts
progress via WebSocket at each step.
progress via WebSocket at each step. Host key verification uses TOFU:
the device's SSH public key is stored on first connect and verified on
all subsequent connections.
"""
from sqlalchemy import select
@@ -187,6 +204,27 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
install_path = install_path or DEFAULT_INSTALL_PATH
branch = detect_current_branch()
safe_branch = shlex.quote(branch)
safe_path = shlex.quote(install_path)
# Load the stored SSH host key for TOFU verification
stored_host_key: str | None = None
async with async_session() as db:
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
dev = result.scalar_one_or_none()
if dev:
stored_host_key = dev.ssh_host_key
known_hosts: asyncssh.SSHKnownHosts | None = None
if stored_host_key:
try:
known_hosts = asyncssh.import_known_hosts(f"{ip_address} {stored_host_key}\n".encode())
except (ValueError, asyncssh.Error) as exc:
logger.warning(
"Could not parse stored SSH host key for %s, falling back to TOFU: %s",
device_id,
exc,
)
async def _update_progress(status: str, message: str) -> None:
"""Update device status in DB and broadcast via WebSocket."""
@@ -214,17 +252,40 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
# Step 1: Test SSH connectivity
await _update_progress("updating", "Connecting via SSH...")
rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
rc, _, stderr, observed_key = await _run_ssh_command(
ip_address, "echo ok", private_key, known_hosts=known_hosts
)
if rc != 0:
await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
return
# TOFU: persist host key on first successful connect
if observed_key and not stored_host_key:
async with async_session() as db:
result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
d = result.scalar_one_or_none()
if d:
d.ssh_host_key = observed_key
await db.commit()
logger.info("TOFU: stored SSH host key for SpoolBuddy %s", device_id)
try:
known_hosts = asyncssh.import_known_hosts(f"{ip_address} {observed_key}\n".encode())
except (ValueError, asyncssh.Error) as exc:
logger.error(
"TOFU: could not parse just-stored host key for %s; "
"remaining SSH steps in this run will not verify host key: %s",
device_id,
exc,
)
known_hosts = None
# Step 2: Git fetch
await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
rc, _, stderr = await _run_ssh_command(
rc, _, stderr, _ = await _run_ssh_command(
ip_address,
f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
f"cd {safe_path} && git -c safe.directory={safe_path} fetch origin {safe_branch}",
private_key,
known_hosts=known_hosts,
timeout=120,
)
if rc != 0:
@@ -233,11 +294,12 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
# Step 3: Git checkout + reset
await _update_progress("updating", "Applying update...")
rc, _, stderr = await _run_ssh_command(
rc, _, stderr, _ = await _run_ssh_command(
ip_address,
f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
f"cd {safe_path} && git -c safe.directory={safe_path} checkout {safe_branch} "
f"&& git -c safe.directory={safe_path} reset --hard origin/{safe_branch}",
private_key,
known_hosts=known_hosts,
)
if rc != 0:
await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
@@ -245,11 +307,12 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
# Step 4: Install dependencies
await _update_progress("updating", "Installing dependencies...")
venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
rc, _, stderr = await _run_ssh_command(
venv_pip = shlex.quote(f"{install_path}/spoolbuddy/venv/bin/pip")
rc, _, stderr, _ = await _run_ssh_command(
ip_address,
f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
private_key,
known_hosts=known_hosts,
timeout=120,
)
if rc != 0:
@@ -257,10 +320,11 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
# Step 5: Restart daemon
await _update_progress("updating", "Restarting daemon...")
rc, _, stderr = await _run_ssh_command(
rc, _, stderr, _ = await _run_ssh_command(
ip_address,
"sudo /usr/bin/systemctl restart spoolbuddy.service",
private_key,
known_hosts=known_hosts,
)
if rc != 0:
await _update_progress("error", f"Service restart failed: {stderr[:200]}")
@@ -272,17 +336,19 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
ip_address,
"sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
private_key,
known_hosts=known_hosts,
)
rc, _, stderr = await _run_ssh_command(
rc, _, stderr, _ = await _run_ssh_command(
ip_address,
"sudo /usr/bin/systemctl restart getty@tty1.service",
private_key,
known_hosts=known_hosts,
)
if rc != 0:
logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
except Exception as e:
logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
await _update_progress("error", f"Update failed: {str(e)[:200]}")
except Exception:
logger.exception("SpoolBuddy %s: SSH update failed", device_id)
await _update_progress("error", "Update failed due to an internal error")
+321 -5
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
import httpx
@@ -56,6 +57,25 @@ class AMSTray:
tray_weight: int # Spool weight in grams (usually 1000)
class SpoolmanNotFoundError(Exception):
"""Raised when a spool ID does not exist in Spoolman (HTTP 404)."""
class SpoolmanUnavailableError(Exception):
"""Raised when Spoolman is unreachable or returns a server/network error."""
class SpoolmanClientError(Exception):
"""Raised when Spoolman returns a 4xx client error (not 404).
Indicates the request was malformed or rejected by Spoolman, not a connectivity failure.
"""
def __init__(self, message: str, status_code: int):
super().__init__(message)
self.status_code = status_code
class SpoolmanClient:
"""Client for interacting with Spoolman API."""
@@ -69,6 +89,8 @@ class SpoolmanClient:
self.api_url = f"{self.base_url}/api/v1"
self._client: httpx.AsyncClient | None = None
self._connected = False
# Per-spool locks for atomic read-modify-write in merge_spool_extra.
self._extra_locks: dict[int, asyncio.Lock] = {}
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client with connection pooling limits.
@@ -80,7 +102,9 @@ class SpoolmanClient:
"""
if self._client is None:
self._client = httpx.AsyncClient(
timeout=10.0,
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
follow_redirects=False,
verify=True,
limits=httpx.Limits(
max_keepalive_connections=5,
max_connections=10,
@@ -169,13 +193,16 @@ class SpoolmanClient:
await asyncio.sleep(retry_delay)
else:
logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
raise
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
async def get_filaments(self) -> list[dict]:
"""Get all internal filaments from Spoolman.
Returns:
List of filament dictionaries.
Raises:
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
try:
client = await self._get_client()
@@ -184,13 +211,16 @@ class SpoolmanClient:
return response.json()
except Exception as e:
logger.error("Failed to get filaments from Spoolman: %s", e)
return []
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
async def get_external_filaments(self) -> list[dict]:
"""Get external/library filaments from Spoolman.
Returns:
List of external filament dictionaries.
Raises:
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
try:
client = await self._get_client()
@@ -199,13 +229,16 @@ class SpoolmanClient:
return response.json()
except Exception as e:
logger.error("Failed to get external filaments from Spoolman: %s", e)
return []
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
async def get_vendors(self) -> list[dict]:
"""Get all vendors from Spoolman.
Returns:
List of vendor dictionaries.
Raises:
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
try:
client = await self._get_client()
@@ -214,7 +247,7 @@ class SpoolmanClient:
return response.json()
except Exception as e:
logger.error("Failed to get vendors from Spoolman: %s", e)
return []
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
async def create_vendor(self, name: str) -> dict | None:
"""Create a new vendor in Spoolman.
@@ -274,6 +307,7 @@ class SpoolmanClient:
vendor_id: int | None = None,
material: str | None = None,
color_hex: str | None = None,
color_name: str | None = None,
weight: float | None = None,
diameter: float = 1.75,
density: float | None = None,
@@ -285,6 +319,7 @@ class SpoolmanClient:
vendor_id: Vendor ID
material: Material type (PLA, PETG, etc.)
color_hex: Color in hex format (without #)
color_name: Human-readable colour name (e.g. "Bambu Green")
weight: Net weight in grams
diameter: Filament diameter in mm (default 1.75)
density: Filament density in g/cm³ (auto-calculated if not provided)
@@ -315,6 +350,8 @@ class SpoolmanClient:
# Strip alpha channel if present (RRGGBBAA -> RRGGBB)
color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
data["color_hex"] = color_hex
if color_name:
data["color_name"] = color_name
if weight:
data["weight"] = weight
@@ -421,6 +458,278 @@ class SpoolmanClient:
logger.error("Failed to update spool in Spoolman: %s", e)
return None
async def _request_spool(
self,
method: Literal["GET", "PATCH", "DELETE"],
spool_id: int,
*,
json_body: dict | None = None,
operation: str,
) -> httpx.Response:
"""Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions."""
try:
client = await self._get_client()
response = await client.request(
method,
f"{self.api_url}/spool/{spool_id}",
json=json_body,
)
if response.status_code == 404:
raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
response.raise_for_status()
return response
except SpoolmanNotFoundError:
raise
except httpx.HTTPStatusError as e:
if 400 <= e.response.status_code < 500:
logger.warning(
"Spoolman returned %d for %s spool %s",
e.response.status_code,
operation,
spool_id,
)
raise SpoolmanClientError(
f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})",
e.response.status_code,
) from e
else:
logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
except Exception as e:
logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
async def get_spool(self, spool_id: int) -> dict:
"""Get a single spool by ID from Spoolman.
Args:
spool_id: Spoolman spool ID
Returns:
Spool dictionary.
Raises:
SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
response = await self._request_spool("GET", spool_id, operation="get")
return response.json()
async def get_all_spools(self, allow_archived: bool = False) -> list[dict]:
"""Get all spools from Spoolman, optionally including archived ones.
Args:
allow_archived: If True, include archived spools in the result.
Returns:
List of spool dictionaries.
Raises:
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
try:
client = await self._get_client()
params: dict = {}
if allow_archived:
params["allow_archived"] = "true"
response = await client.get(f"{self.api_url}/spool", params=params or None)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error("Failed to get all spools from Spoolman: %s", e)
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
async def delete_spool(self, spool_id: int) -> None:
"""Delete a spool from Spoolman.
Args:
spool_id: Spoolman spool ID
Raises:
SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
await self._request_spool("DELETE", spool_id, operation="delete")
async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
"""Archive or restore a spool in Spoolman.
Args:
spool_id: Spoolman spool ID
archived: True to archive, False to restore.
Returns:
Updated spool dictionary.
Raises:
SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
response = await self._request_spool(
"PATCH",
spool_id,
json_body={"archived": archived},
operation="archive/restore",
)
return response.json()
async def update_spool_full(
self,
spool_id: int,
*,
filament_id: int | None = None,
remaining_weight: float | None = None,
comment: str | None = None,
price: float | None = None,
location: str | None = None,
clear_location: bool = False,
extra: dict | None = None,
) -> dict:
"""Update a spool in Spoolman with comprehensive field support.
Unlike update_spool, this method does not auto-set last_used and
supports updating filament_id, comment, and price.
Args:
spool_id: Spoolman spool ID
filament_id: New filament type ID
remaining_weight: New remaining weight in grams
comment: New comment/note (pass empty string to clear)
price: Cost per unit (maps to cost_per_kg usage)
location: New location string
clear_location: If True, sets location to None
extra: Extra fields dict to replace (overwrites the entire extra dict;
use merge_spool_extra to preserve other fields)
Returns:
Updated spool dictionary.
Raises:
SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
"""
data: dict = {}
if filament_id is not None:
data["filament_id"] = filament_id
if remaining_weight is not None:
data["remaining_weight"] = remaining_weight
if comment is not None:
data["comment"] = comment if comment else None
if price is not None:
data["price"] = price
if clear_location:
data["location"] = None
elif location is not None:
data["location"] = location
if extra is not None:
data["extra"] = extra
response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
return response.json()
def extra_lock(self, spool_id: int) -> asyncio.Lock:
"""Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra."""
return self._extra_locks.setdefault(spool_id, asyncio.Lock())
async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
"""Fetch current extra dict, merge new_fields in, then PATCH back to Spoolman.
Safe merge — never blindly overwrites other custom Spoolman extra fields.
The operation is serialised per spool_id with an asyncio.Lock to prevent
concurrent calls from clobbering each other's writes.
Args:
spool_id: Spoolman spool ID
new_fields: Fields to add/update in the extra dict
Returns:
Updated spool dictionary.
Raises:
SpoolmanNotFoundError: If the spool does not exist.
SpoolmanUnavailableError: If Spoolman is unreachable.
"""
async with self.extra_lock(spool_id):
current = await self.get_spool(spool_id) # raises on error
current_extra: dict = current.get("extra") or {}
merged = {**current_extra, **new_fields}
return await self.update_spool_full(spool_id=spool_id, extra=merged)
async def find_or_create_vendor(self, name: str) -> int | None:
"""Find an existing vendor by name or create a new one.
Args:
name: Vendor name (case-insensitive match)
Returns:
Vendor ID or None on failure.
"""
vendors = await self.get_vendors()
name_lower = name.strip().lower()
for vendor in vendors:
if vendor.get("name", "").strip().lower() == name_lower:
return vendor["id"]
created = await self.create_vendor(name.strip())
return created["id"] if created else None
async def find_or_create_filament(
self,
material: str,
subtype: str,
brand: str | None,
color_hex: str,
label_weight: int,
color_name: str | None = None,
) -> int | None:
"""Find a matching filament in Spoolman or create a new one.
Matching uses material + full name + vendor + color_hex (normalised).
A new filament is created only when no exact match is found.
Args:
material: Filament material (e.g. "PLA")
subtype: Filament subtype (e.g. "Basic"); combined with material as name
brand: Vendor/brand name; None skips vendor matching
color_hex: 6-char hex colour string (RRGGBB, no #)
label_weight: Net spool weight in grams
color_name: Human-readable colour name passed to create_filament when creating
Returns:
Filament ID or None on failure.
"""
name = f"{material} {subtype}".strip() if subtype else material
color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper()
vendor_id: int | None = None
if brand:
vendor_id = await self.find_or_create_vendor(brand)
filaments = await self.get_filaments()
for f in filaments:
f_material = (f.get("material") or "").upper()
f_name = (f.get("name") or "").strip()
f_color = (f.get("color_hex") or "").upper()[:6]
f_vendor = f.get("vendor") or {}
f_vendor_name = (f_vendor.get("name") or "").strip().lower()
material_match = f_material == material.upper()
name_match = f_name.lower() == name.lower()
color_match = f_color == color
vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
if material_match and name_match and color_match and vendor_match:
return f["id"]
filament = await self.create_filament(
name=name,
vendor_id=vendor_id,
material=material,
color_hex=color,
color_name=color_name,
weight=float(label_weight),
)
return filament["id"] if filament else None
async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
"""Record filament usage for a spool.
@@ -972,7 +1281,14 @@ async def init_spoolman_client(url: str) -> SpoolmanClient:
Returns:
Initialized SpoolmanClient instance.
Raises:
ValueError: If *url* is rejected by the SSRF guard.
"""
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
assert_safe_spoolman_url(url)
global _spoolman_client
if _spoolman_client:
await _spoolman_client.close()
+10 -2
View File
@@ -71,6 +71,7 @@ def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id
"""
tray_uuid = str(tray_info.get("tray_uuid", "") or "")
tag_uid = str(tray_info.get("tag_uid", "") or "")
if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
return tray_uuid
if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
@@ -312,9 +313,16 @@ async def _get_spoolman_client_with_fallback():
spoolman_url = await get_setting(db, "spoolman_url")
if spoolman_url:
client = await init_spoolman_client(spoolman_url)
try:
client = await init_spoolman_client(spoolman_url)
except ValueError as exc:
logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
return None
if not client or not await client.health_check():
if not client:
return None
if not await client.health_check():
logger.warning("Spoolman health check failed; skipping usage reporting")
return None
return client
@@ -0,0 +1,160 @@
"""Integration tests for API key RBAC enforcement (security fix C1)."""
import pytest
from httpx import AsyncClient
@pytest.fixture
async def api_key_data(async_client: AsyncClient, db_session):
"""Create an API key and return its full key value."""
from backend.app.core.auth import generate_api_key
from backend.app.models.api_key import APIKey
full_key, key_hash, key_prefix = generate_api_key()
api_key = APIKey(
name="test-key",
key_hash=key_hash,
key_prefix=key_prefix,
can_queue=True,
can_control_printer=True,
can_read_status=True,
enabled=True,
)
db_session.add(api_key)
await db_session.commit()
return full_key
@pytest.fixture
async def spoolman_settings(db_session):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
class TestApiKeyRbacDenied:
"""API keys must be refused for admin-only endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_cannot_access_settings_update_endpoint(
self, async_client: AsyncClient, db_session, api_key_data
):
"""API key must not be usable for settings:update endpoints (C1)."""
from backend.app.models.settings import Settings
db_session.add(Settings(key="auth_enabled", value="true"))
await db_session.commit()
resp = await async_client.put(
"/api/v1/settings/",
json={},
headers={"X-API-Key": api_key_data},
)
assert resp.status_code == 403
assert "administrative operations" in resp.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_bearer_cannot_access_settings_update(
self, async_client: AsyncClient, db_session, api_key_data
):
"""Bearer bb_ API key must also be refused for settings:update (C1)."""
from backend.app.models.settings import Settings
db_session.add(Settings(key="auth_enabled", value="true"))
await db_session.commit()
resp = await async_client.put(
"/api/v1/settings/",
json={},
headers={"Authorization": f"Bearer {api_key_data}"},
)
assert resp.status_code == 403
assert "administrative operations" in resp.json()["detail"]
class TestApiKeyRbacAllowed:
"""API keys must still work for non-admin endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_api_key_can_access_inventory_read(
self, async_client: AsyncClient, db_session, api_key_data, spoolman_settings
):
"""API key must be accepted for inventory:read endpoints (C1)."""
from unittest.mock import AsyncMock, MagicMock, patch
from backend.app.models.settings import Settings
db_session.add(Settings(key="auth_enabled", value="true"))
await db_session.commit()
mock_client = MagicMock()
mock_client.base_url = "http://localhost:7912"
mock_client.health_check = AsyncMock(return_value=True)
mock_client.get_all_spools = AsyncMock(return_value=[])
with patch(
"backend.app.api.routes.spoolman_inventory._get_client",
AsyncMock(return_value=mock_client),
):
resp = await async_client.get(
"/api/v1/spoolman/inventory/spools",
headers={"X-API-Key": api_key_data},
)
assert resp.status_code == 200
class TestApiKeyDenylistIntegrity:
"""Drift-detection: assert that admin-tier permissions remain in the denylist."""
def test_admin_permissions_are_denied_for_api_keys(self):
"""All known admin-tier permissions must be in _APIKEY_DENIED_PERMISSIONS (H1 guard)."""
from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
from backend.app.core.permissions import Permission
expected_denied = {
Permission.SETTINGS_READ,
Permission.SETTINGS_UPDATE,
Permission.SETTINGS_BACKUP,
Permission.SETTINGS_RESTORE,
Permission.USERS_READ,
Permission.USERS_CREATE,
Permission.USERS_UPDATE,
Permission.USERS_DELETE,
Permission.GROUPS_READ,
Permission.GROUPS_CREATE,
Permission.GROUPS_UPDATE,
Permission.GROUPS_DELETE,
Permission.API_KEYS_READ,
Permission.API_KEYS_CREATE,
Permission.API_KEYS_UPDATE,
Permission.API_KEYS_DELETE,
Permission.GITHUB_BACKUP,
Permission.GITHUB_RESTORE,
Permission.FIRMWARE_UPDATE,
}
missing = expected_denied - _APIKEY_DENIED_PERMISSIONS
assert not missing, (
f"Admin-tier permissions not in API key denylist (add them to _APIKEY_DENIED_PERMISSIONS): {missing}"
)
def test_operational_permissions_are_allowed_for_api_keys(self):
"""Core operational permissions must NOT be in the denylist."""
from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
from backend.app.core.permissions import Permission
expected_allowed = {
Permission.INVENTORY_READ,
Permission.INVENTORY_CREATE,
Permission.INVENTORY_UPDATE,
Permission.PRINTERS_READ,
Permission.PRINTERS_CONTROL,
Permission.ARCHIVES_READ,
}
incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
assert not incorrectly_denied, (
f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from backend.app.services.spoolman import AMSTray, SpoolmanClient
from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client
class TestIsBambuLabSpool:
@@ -444,3 +444,50 @@ class TestSpoolmanClient:
mock_close.assert_not_called()
# Should sleep once (after first failed attempt)
assert mock_sleep.call_count == 1
# ---------------------------------------------------------------------------
# init_spoolman_client — SSRF guard (B4 / T3)
# ---------------------------------------------------------------------------
class TestInitSpoolmanClientSSRFGuard:
"""init_spoolman_client must reject unsafe URLs before creating a client."""
@pytest.mark.asyncio
async def test_private_ip_raises_value_error(self):
with pytest.raises(ValueError, match="private|loopback|link-local|multicast|unspecified"):
await init_spoolman_client("http://10.0.0.1/")
@pytest.mark.asyncio
async def test_link_local_raises_value_error(self):
with pytest.raises(ValueError):
await init_spoolman_client("http://169.254.169.254/latest/meta-data/")
@pytest.mark.asyncio
async def test_loopback_ip_raises_value_error(self):
with pytest.raises(ValueError):
await init_spoolman_client("http://127.0.0.1:7912/")
@pytest.mark.asyncio
async def test_localhost_hostname_is_allowed(self):
# localhost (hostname, not bare IP) is a supported topology for same-host Spoolman
mock_instance = AsyncMock()
with (
patch("backend.app.services.spoolman._spoolman_client", None),
patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
):
client = await init_spoolman_client("http://localhost:7912/")
mock_cls.assert_called_once_with("http://localhost:7912/")
assert client is mock_instance
@pytest.mark.asyncio
async def test_public_url_is_allowed(self):
mock_instance = AsyncMock()
with (
patch("backend.app.services.spoolman._spoolman_client", None),
patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
):
client = await init_spoolman_client("http://spoolman.example.com:7912/")
mock_cls.assert_called_once_with("http://spoolman.example.com:7912/")
assert client is mock_instance
@@ -19,15 +19,15 @@ from backend.app.services.spoolman_tracking import (
class TestResolveSpoolTag:
"""Tests for _resolve_spool_tag()."""
def test_prefers_tray_uuid(self):
def test_prefers_tray_uuid_over_tag_uid(self):
tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"}
assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
def test_falls_back_to_tag_uid(self):
def test_falls_back_to_tag_uid_when_no_uuid(self):
tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"}
assert _resolve_spool_tag(tray) == "DEADBEEF"
def test_skips_zero_uuid(self):
def test_falls_back_to_tag_uid_when_uuid_zero(self):
tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"}
assert _resolve_spool_tag(tray) == "DEADBEEF"
@@ -45,6 +45,10 @@ class TestResolveSpoolTag:
# global_tray_id 5 -> ams_id 1, tray_id 1
assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001"
def test_prefers_tray_uuid_over_fallback_when_non_zero(self):
tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": ""}
assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
def test_empty_both(self):
tray = {"tray_uuid": "", "tag_uid": ""}
assert _resolve_spool_tag(tray) == ""
+179
View File
@@ -7,7 +7,9 @@ from backend.app.services.opentag3d import (
OPENTAG3D_MIME_TYPE,
PAYLOAD_SIZE,
_build_payload,
_build_payload_from_dict,
encode_opentag3d,
encode_opentag3d_from_mapped,
)
@@ -140,3 +142,180 @@ class TestEncodeOpentag3d:
data = encode_opentag3d(_make_spool())
ntag213_capacity = 36 * 4 # 144 bytes
assert len(data) <= ntag213_capacity
# ---------------------------------------------------------------------------
# _build_payload_from_dict / encode_opentag3d_from_mapped
# ---------------------------------------------------------------------------
MINIMAL_MAPPED = {
"material": "PLA",
"subtype": "Basic",
"brand": "Bambu Lab",
"color_name": None,
"rgba": "FF0000FF",
"label_weight": 1000,
"nozzle_temp_min": None,
}
class TestBuildPayloadFromDict:
def test_payload_is_102_bytes(self):
assert len(_build_payload_from_dict(MINIMAL_MAPPED)) == PAYLOAD_SIZE
def test_material_encoded(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "material": "PETG"})
assert payload[0x02:0x07].decode("utf-8") == "PETG "
def test_subtype_encoded(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "subtype": "Silk"})
assert payload[0x07:0x0C].decode("utf-8") == "Silk "
def test_brand_encoded(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "brand": "Polymaker"})
assert payload[0x1B:0x2B].decode("utf-8") == "Polymaker "
def test_rgba_encoded(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "rgba": "00FF00FF"})
assert payload[0x4B:0x4F] == bytes([0x00, 0xFF, 0x00, 0xFF])
def test_label_weight_encoded(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 750})
weight = struct.unpack_from(">H", payload, 0x5E)[0]
assert weight == 750
def test_none_color_name_zero_filled(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "color_name": None})
assert payload[0x2B:0x4B] == b" "
def test_missing_keys_produce_safe_defaults(self):
payload = _build_payload_from_dict({})
assert len(payload) == PAYLOAD_SIZE
assert payload[0x02:0x07] == b" "
weight = struct.unpack_from(">H", payload, 0x5E)[0]
assert weight == 0
def test_label_weight_overflow_clamped_to_65535(self):
"""label_weight > 65535 must not raise struct.error (uint16 overflow)."""
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 70000})
weight = struct.unpack_from(">H", payload, 0x5E)[0]
assert weight == 65535
def test_label_weight_negative_clamped_to_zero(self):
"""Negative label_weight must be clamped to 0, not raise struct.error."""
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": -1})
weight = struct.unpack_from(">H", payload, 0x5E)[0]
assert weight == 0
def test_label_weight_at_uint16_max_accepted(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 65535})
weight = struct.unpack_from(">H", payload, 0x5E)[0]
assert weight == 65535
def test_nozzle_temp_float_does_not_crash(self):
"""nozzle_temp_min as float (e.g. 220.5) must not raise TypeError."""
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": 220.5})
assert payload[0x60] == 44 # int(220.5 // 5) = 44
def test_nozzle_temp_overflow_clamped_to_255(self):
"""nozzle_temp_min causing byte > 255 must be clamped, not raise ValueError."""
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": 1280})
assert payload[0x60] == 255 # 1280 // 5 = 256 → clamped to 255
def test_nozzle_temp_negative_clamped_to_zero(self):
payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": -50})
assert payload[0x60] == 0
def test_matches_orm_path_for_same_data(self):
"""_build_payload_from_dict must produce identical bytes to _build_payload."""
spool = _make_spool(
material="PLA",
subtype="Matte",
brand="Polymaker",
color_name="Jade White",
rgba="00AE42FF",
label_weight=1000,
nozzle_temp_min=220,
)
orm_payload = _build_payload(spool)
dict_payload = _build_payload_from_dict(
{
"material": "PLA",
"subtype": "Matte",
"brand": "Polymaker",
"color_name": "Jade White",
"rgba": "00AE42FF",
"label_weight": 1000,
"nozzle_temp_min": 220,
}
)
assert orm_payload == dict_payload
class TestEncodeOpentag3dFromMapped:
def test_total_size(self):
data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
assert len(data) == 133
def test_starts_with_cc(self):
data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
def test_ends_with_terminator(self):
data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
assert data[-1] == 0xFE
def test_mime_type_present(self):
data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
assert b"application/opentag3d" in data
def test_fits_ntag213(self):
data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
assert len(data) <= 36 * 4 # 144 bytes
def test_identical_output_to_orm_path(self):
"""encode_opentag3d_from_mapped must produce the same bytes as encode_opentag3d."""
spool = _make_spool(
material="PLA",
subtype="Matte",
brand="Polymaker",
color_name="Jade White",
rgba="00AE42FF",
label_weight=1000,
nozzle_temp_min=220,
)
orm_bytes = encode_opentag3d(spool)
mapped_bytes = encode_opentag3d_from_mapped(
{
"material": "PLA",
"subtype": "Matte",
"brand": "Polymaker",
"color_name": "Jade White",
"rgba": "00AE42FF",
"label_weight": 1000,
"nozzle_temp_min": 220,
}
)
assert orm_bytes == mapped_bytes
def test_spoolman_mapped_dict_accepted(self):
"""Accepts the exact dict shape produced by _map_spoolman_spool."""
from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
raw = {
"id": 7,
"filament": {
"material": "PETG",
"name": "PETG Basic",
"color_hex": "00FF00",
"weight": 1000.0,
"vendor": {"name": "Bambu Lab"},
},
"used_weight": 100.0,
"archived": False,
"registered": "2024-01-01T00:00:00Z",
}
mapped = _map_spoolman_spool(raw)
data = encode_opentag3d_from_mapped(mapped)
assert len(data) == 133
assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
@@ -0,0 +1,197 @@
"""Unit tests for SpoolBuddy schema validation (security fixes H2, M2, M4).
Tests Pydantic model validation without requiring a running server or DB.
"""
import pytest
from pydantic import ValidationError
from backend.app.schemas.spoolbuddy import (
DeviceRegisterRequest,
HeartbeatRequest,
ScaleReadingRequest,
UpdateStatusRequest,
WriteTagResultRequest,
)
# ---------------------------------------------------------------------------
# H2 — UpdateStatusRequest: only valid Literal values accepted
# ---------------------------------------------------------------------------
class TestUpdateStatusRequestValidation:
def test_valid_status_updating(self):
req = UpdateStatusRequest(status="updating")
assert req.status == "updating"
def test_valid_status_complete(self):
req = UpdateStatusRequest(status="complete")
assert req.status == "complete"
def test_valid_status_error(self):
req = UpdateStatusRequest(status="error")
assert req.status == "error"
def test_invalid_status_rejected(self):
"""Arbitrary status strings must be rejected (H2: prevents unbounded WS injection)."""
with pytest.raises(ValidationError):
UpdateStatusRequest(status="hacked")
def test_empty_status_rejected(self):
with pytest.raises(ValidationError):
UpdateStatusRequest(status="")
def test_message_max_length_enforced(self):
"""message field must not exceed 255 chars."""
with pytest.raises(ValidationError):
UpdateStatusRequest(status="updating", message="x" * 256)
def test_message_at_max_length_accepted(self):
req = UpdateStatusRequest(status="complete", message="x" * 255)
assert len(req.message) == 255
# ---------------------------------------------------------------------------
# M2 — HeartbeatRequest: system_stats size limit (4096 bytes)
# ---------------------------------------------------------------------------
class TestHeartbeatSystemStatsValidation:
def test_none_accepted(self):
req = HeartbeatRequest(system_stats=None)
assert req.system_stats is None
def test_small_dict_accepted(self):
req = HeartbeatRequest(system_stats={"cpu": 12.5, "mem": 60.0})
assert req.system_stats["cpu"] == 12.5
def test_oversized_dict_rejected(self):
"""system_stats exceeding 4096 bytes JSON-encoded must be rejected (M2)."""
huge = {"data": "x" * 5000}
with pytest.raises(ValidationError, match="4096"):
HeartbeatRequest(system_stats=huge)
def test_exactly_4096_bytes_accepted(self):
"""A dict whose JSON is exactly 4096 bytes must pass."""
import json
# Build a dict whose JSON is exactly 4096 bytes
filler = "x" * (4096 - len('{"k": ""}'))
d = {"k": filler}
assert len(json.dumps(d)) == 4096
req = HeartbeatRequest(system_stats=d)
assert req.system_stats is not None
def test_one_byte_over_limit_rejected(self):
import json
filler = "x" * (4097 - len('{"k": ""}'))
d = {"k": filler}
assert len(json.dumps(d)) == 4097
with pytest.raises(ValidationError):
HeartbeatRequest(system_stats=d)
# ---------------------------------------------------------------------------
# M4 — DeviceRegisterRequest: max_length on device-sourced string fields
# ---------------------------------------------------------------------------
class TestDeviceRegisterRequestValidation:
VALID_BASE = {"device_id": "dev1", "hostname": "spoolbuddy.local", "ip_address": "192.168.1.50"}
def test_valid_minimal_accepted(self):
req = DeviceRegisterRequest(**self.VALID_BASE)
assert req.device_id == "dev1"
def test_firmware_version_too_long_rejected(self):
with pytest.raises(ValidationError):
DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 21)
def test_firmware_version_at_max_accepted(self):
req = DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 20)
assert req.firmware_version == "x" * 20
def test_nfc_reader_type_too_long_rejected(self):
with pytest.raises(ValidationError):
DeviceRegisterRequest(**self.VALID_BASE, nfc_reader_type="x" * 21)
def test_nfc_connection_too_long_rejected(self):
with pytest.raises(ValidationError):
DeviceRegisterRequest(**self.VALID_BASE, nfc_connection="x" * 21)
def test_backend_url_too_long_rejected(self):
with pytest.raises(ValidationError):
DeviceRegisterRequest(**self.VALID_BASE, backend_url="http://" + "x" * 249)
def test_backend_url_at_max_accepted(self):
url = "http://" + "x" * (255 - len("http://"))
req = DeviceRegisterRequest(**self.VALID_BASE, backend_url=url)
assert req.backend_url == url
def test_device_id_too_long_rejected(self):
with pytest.raises(ValidationError):
DeviceRegisterRequest(device_id="x" * 51, hostname="h", ip_address="1.2.3.4")
# ---------------------------------------------------------------------------
# M4 — WriteTagResultRequest: device_id max_length
# ---------------------------------------------------------------------------
class TestWriteTagResultRequestValidation:
def test_device_id_too_long_rejected(self):
with pytest.raises(ValidationError):
WriteTagResultRequest(device_id="x" * 51, spool_id=1, tag_uid="AABBCCDD", success=True)
def test_device_id_at_max_accepted(self):
req = WriteTagResultRequest(device_id="x" * 50, spool_id=1, tag_uid="AABBCCDD", success=True)
assert len(req.device_id) == 50
def test_tag_uid_hex_pattern_accepted(self):
req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABBCCDD", success=True)
assert req.tag_uid == "AABBCCDD"
def test_tag_uid_non_hex_rejected(self):
"""Non-hex characters in tag_uid must be rejected (prevents injection via NFC write-back)."""
with pytest.raises(ValidationError):
WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB; DROP", success=True)
def test_tag_uid_too_short_rejected(self):
with pytest.raises(ValidationError):
WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB", success=True)
def test_tag_uid_max_length_accepted(self):
req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 30, success=True)
assert len(req.tag_uid) == 30
def test_tag_uid_over_max_length_rejected(self):
with pytest.raises(ValidationError):
WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 31, success=True)
# ---------------------------------------------------------------------------
# M4 — ScaleReadingRequest: weight_grams bounds
# ---------------------------------------------------------------------------
class TestScaleReadingRequestValidation:
def test_valid_weight_accepted(self):
req = ScaleReadingRequest(device_id="sb1", weight_grams=250.0)
assert req.weight_grams == 250.0
def test_zero_weight_accepted(self):
req = ScaleReadingRequest(device_id="sb1", weight_grams=0.0)
assert req.weight_grams == 0.0
def test_max_weight_accepted(self):
req = ScaleReadingRequest(device_id="sb1", weight_grams=100_000.0)
assert req.weight_grams == 100_000.0
def test_negative_weight_rejected(self):
with pytest.raises(ValidationError):
ScaleReadingRequest(device_id="sb1", weight_grams=-1.0)
def test_over_max_weight_rejected(self):
with pytest.raises(ValidationError):
ScaleReadingRequest(device_id="sb1", weight_grams=100_001.0)
@@ -223,25 +223,30 @@ async def test_run_ssh_command_success(tmp_path):
mock_result.stderr = ""
mock_result.exit_status = 0
mock_server_key = MagicMock()
mock_server_key.export_public_key.return_value = b"ssh-ed25519 AAAA test"
mock_conn = AsyncMock()
mock_conn.run = AsyncMock(return_value=mock_result)
mock_conn.get_server_host_key = MagicMock(return_value=mock_server_key)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn) as mock_connect:
rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
rc, stdout, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
assert rc == 0
assert stdout == "hello\n"
assert stderr == ""
# TOFU mode (no known_hosts): returns observed key
assert observed_key == "ssh-ed25519 AAAA test"
kwargs = mock_connect.call_args.kwargs
assert kwargs["host"] == "10.0.0.1"
assert kwargs["username"] == "spoolbuddy"
assert kwargs["client_keys"] == [str(key_file)]
# Host-key verification is disabled (equivalent to StrictHostKeyChecking=no)
# TOFU default: known_hosts=None on first connect
assert kwargs["known_hosts"] is None
# ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary
# Docker PUIDs.
# ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary Docker PUIDs
assert kwargs["config"] == []
mock_conn.run.assert_awaited_once()
run_args = mock_conn.run.call_args
@@ -251,12 +256,10 @@ async def test_run_ssh_command_success(tmp_path):
@pytest.mark.asyncio
async def test_run_ssh_command_no_subprocess(tmp_path):
"""Regression guard: _run_ssh_command must not spawn any subprocess.
async def test_run_ssh_command_with_known_hosts_skips_capture(tmp_path):
"""When known_hosts is provided, observed_host_key must be None."""
import asyncssh
The whole point of switching to asyncssh is to avoid `ssh`/`ssh-keygen`
calling getpwuid() inside Docker containers with arbitrary PUIDs.
"""
key_file = tmp_path / "key"
key_file.write_text("KEY")
@@ -267,6 +270,52 @@ async def test_run_ssh_command_no_subprocess(tmp_path):
mock_conn = AsyncMock()
mock_conn.run = AsyncMock(return_value=mock_result)
mock_conn.get_server_host_key = MagicMock()
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
fake_kh = MagicMock(spec=asyncssh.SSHKnownHosts)
with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
rc, _, _, observed_key = await _run_ssh_command("10.0.0.1", "echo hi", key_file, known_hosts=fake_kh)
assert rc == 0
assert observed_key is None
mock_conn.get_server_host_key.assert_not_called()
@pytest.mark.asyncio
async def test_run_ssh_command_host_key_mismatch(tmp_path):
"""HostKeyNotVerifiable must surface as rc=255 with a safe message (H1)."""
import asyncssh
key_file = tmp_path / "key"
key_file.write_text("KEY")
with patch(
"backend.app.services.spoolbuddy_ssh.asyncssh.connect",
side_effect=asyncssh.HostKeyNotVerifiable(asyncssh.DISC_HOST_KEY_NOT_VERIFIABLE, "key mismatch"),
):
rc, _, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
assert rc == 255
assert "mismatch" in stderr.lower()
assert observed_key is None
@pytest.mark.asyncio
async def test_run_ssh_command_no_subprocess(tmp_path):
"""Regression guard: _run_ssh_command must not spawn any subprocess."""
key_file = tmp_path / "key"
key_file.write_text("KEY")
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = ""
mock_result.exit_status = 0
mock_conn = AsyncMock()
mock_conn.run = AsyncMock(return_value=mock_result)
mock_conn.get_server_host_key = MagicMock(return_value=None)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
@@ -291,7 +340,7 @@ async def test_run_ssh_command_connection_failure(tmp_path):
"backend.app.services.spoolbuddy_ssh.asyncssh.connect",
side_effect=asyncssh.Error(code=0, reason="Connection refused"),
):
rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
rc, stdout, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
assert rc == 255
assert stdout == ""
@@ -308,7 +357,7 @@ async def test_run_ssh_command_os_error(tmp_path):
"backend.app.services.spoolbuddy_ssh.asyncssh.connect",
side_effect=OSError("Network is unreachable"),
):
rc, _, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
assert rc == 255
assert "Network is unreachable" in stderr
@@ -320,9 +369,6 @@ async def test_run_ssh_command_timeout(tmp_path):
key_file = tmp_path / "key"
key_file.write_text("KEY")
# asyncssh.connect() returns a _ConnectionManager synchronously; the hang
# must happen inside __aenter__ so the surrounding asyncio.timeout can
# cancel it.
mock_conn = AsyncMock()
async def hang_enter():
@@ -332,7 +378,7 @@ async def test_run_ssh_command_timeout(tmp_path):
mock_conn.__aexit__ = AsyncMock(return_value=False)
with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
rc, _, stderr = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05)
rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05)
assert rc == -1
assert "timed out" in stderr
@@ -347,6 +393,7 @@ def _make_update_mocks(tmp_path):
mock_db_device.update_status = None
mock_db_device.update_message = None
mock_db_device.pending_command = None
mock_db_device.ssh_host_key = None # TOFU: no stored key
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_db_device
@@ -375,9 +422,9 @@ async def test_perform_ssh_update_success(tmp_path):
ssh_calls = []
async def mock_ssh(ip, cmd, key, timeout=60):
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
ssh_calls.append(cmd)
return 0, "ok", ""
return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey"
_, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
@@ -385,6 +432,7 @@ async def test_perform_ssh_update_success(tmp_path):
patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="dev"),
patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
patch("backend.app.core.database.async_session", return_value=mock_ctx),
patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
):
@@ -405,6 +453,82 @@ async def test_perform_ssh_update_success(tmp_path):
assert mock_ws.broadcast.call_count >= 4
@pytest.mark.asyncio
async def test_perform_ssh_update_branch_is_shell_quoted(tmp_path):
"""Branch name with shell-special chars must be quoted in all git commands (L1 fix)."""
import shlex
ssh_dir = tmp_path / "spoolbuddy" / "ssh"
ssh_dir.mkdir(parents=True)
(ssh_dir / "id_ed25519").write_text("PRIVATE")
(ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
# A branch name containing a semicolon — shell-injection without quoting
dangerous_branch = "dev; echo pwned"
safe_branch = shlex.quote(dangerous_branch) # expected: "'dev; echo pwned'"
ssh_calls = []
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
ssh_calls.append(cmd)
return 0, "ok", "", None
_, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
with (
patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value=dangerous_branch),
patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
patch("backend.app.core.database.async_session", return_value=mock_ctx),
patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
):
mock_settings.base_dir = tmp_path
await perform_ssh_update("sb-test", "10.0.0.1")
# All git commands must use the shell-quoted form, never the raw dangerous string
git_cmds = [c for c in ssh_calls if "fetch" in c or "checkout" in c or "reset" in c]
for cmd in git_cmds:
assert safe_branch in cmd, f"Branch not shell-quoted in: {cmd}"
assert dangerous_branch not in cmd.replace(safe_branch, ""), f"Raw dangerous branch in: {cmd}"
@pytest.mark.asyncio
async def test_perform_ssh_update_tofu_stores_host_key(tmp_path):
"""On first connect (no stored key), the observed host key must be persisted (H1)."""
ssh_dir = tmp_path / "spoolbuddy" / "ssh"
ssh_dir.mkdir(parents=True)
(ssh_dir / "id_ed25519").write_text("PRIVATE")
(ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
FAKE_HOST_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 fakehostkey"
call_count = 0
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
nonlocal call_count
call_count += 1
# Only first call returns the observed host key (TOFU)
observed = FAKE_HOST_KEY if call_count == 1 else None
return 0, "ok", "", observed
mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
mock_device.ssh_host_key = None # no stored key
with (
patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
patch("backend.app.core.database.async_session", return_value=mock_ctx),
patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
):
mock_settings.base_dir = tmp_path
await perform_ssh_update("sb-test", "10.0.0.1")
# Device's ssh_host_key should have been set to the observed key
assert mock_device.ssh_host_key == FAKE_HOST_KEY
@pytest.mark.asyncio
async def test_perform_ssh_update_ssh_failure(tmp_path):
"""SSH connectivity check fails — should set error status."""
@@ -413,10 +537,10 @@ async def test_perform_ssh_update_ssh_failure(tmp_path):
(ssh_dir / "id_ed25519").write_text("PRIVATE")
(ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
async def mock_ssh(ip, cmd, key, timeout=60):
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
if "echo ok" in cmd:
return 255, "", "Connection refused"
return 0, "", ""
return 255, "", "Connection refused", None
return 0, "", "", None
mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
@@ -446,11 +570,11 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path):
ssh_calls = []
async def mock_ssh(ip, cmd, key, timeout=60):
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
ssh_calls.append(cmd)
if "fetch" in cmd:
return 1, "", "fatal: could not read from remote"
return 0, "ok", ""
return 1, "", "fatal: could not read from remote", None
return 0, "ok", "", None
_, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
@@ -458,6 +582,7 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path):
patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
patch("backend.app.core.database.async_session", return_value=mock_ctx),
patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
):
@@ -467,3 +592,83 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path):
# Should stop after git fetch — no checkout, pip, restart
assert len(ssh_calls) == 2 # echo ok + git fetch
assert not any("checkout" in c for c in ssh_calls)
@pytest.mark.asyncio
async def test_perform_ssh_update_uses_stored_host_key(tmp_path):
"""When device already has ssh_host_key set, all SSH calls must receive non-None known_hosts (Gap 1)."""
ssh_dir = tmp_path / "spoolbuddy" / "ssh"
ssh_dir.mkdir(parents=True)
(ssh_dir / "id_ed25519").write_text("PRIVATE")
(ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
STORED_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 storedkey"
SENTINEL_KNOWN_HOSTS = MagicMock(name="known_hosts_sentinel")
received_known_hosts = []
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
received_known_hosts.append(known_hosts)
return 0, "ok", "", None # no new observed key (already stored)
mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
mock_device.ssh_host_key = STORED_KEY
with (
patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
patch(
"backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
return_value=SENTINEL_KNOWN_HOSTS,
),
patch("backend.app.core.database.async_session", return_value=mock_ctx),
patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
):
mock_settings.base_dir = tmp_path
await perform_ssh_update("sb-test", "10.0.0.1")
# Every SSH call must have received the sentinel known_hosts object (not None)
assert len(received_known_hosts) >= 2, "Expected at least 2 SSH calls"
for kh in received_known_hosts:
assert kh is SENTINEL_KNOWN_HOSTS, f"Expected sentinel known_hosts but got: {kh}"
@pytest.mark.asyncio
async def test_perform_ssh_update_corrupt_stored_key_falls_back_to_tofu(tmp_path):
"""When stored ssh_host_key can't be parsed, update continues with known_hosts=None (Gap 2)."""
ssh_dir = tmp_path / "spoolbuddy" / "ssh"
ssh_dir.mkdir(parents=True)
(ssh_dir / "id_ed25519").write_text("PRIVATE")
(ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
ssh_calls = []
async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
ssh_calls.append(cmd)
return 0, "ok", "", None
mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
mock_device.ssh_host_key = "THIS-IS-NOT-A-VALID-KEY"
with (
patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
patch(
"backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
side_effect=ValueError("Malformed key"),
),
patch("backend.app.core.database.async_session", return_value=mock_ctx),
patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
):
mock_settings.base_dir = tmp_path
# Must not raise — corrupt key degrades gracefully
await perform_ssh_update("sb-test", "10.0.0.1")
# Update must have completed all steps despite the corrupt key
assert any("echo ok" in c for c in ssh_calls)
assert any("fetch" in c for c in ssh_calls)
assert any("checkout" in c for c in ssh_calls)
# Broadcast must show success, not error
error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get("update_status") == "error"]
assert not error_broadcasts, f"Got unexpected error broadcast: {error_broadcasts}"
@@ -0,0 +1,246 @@
"""Unit tests for _safe_int, _safe_float, and _map_spoolman_spool helpers."""
import math
import pytest
from backend.app.api.routes._spoolman_helpers import (
_map_spoolman_spool,
_safe_float,
_safe_int,
)
# ---------------------------------------------------------------------------
# _safe_int
# ---------------------------------------------------------------------------
class TestSafeInt:
def test_normal_int(self):
assert _safe_int(1000, 0) == 1000
def test_float_rounds_down(self):
assert _safe_int(750.9, 0) == 750
def test_none_returns_fallback(self):
assert _safe_int(None, 999) == 999
def test_nan_returns_fallback(self):
assert _safe_int(math.nan, 999) == 999
def test_inf_returns_fallback(self):
assert _safe_int(math.inf, 999) == 999
def test_neg_inf_returns_fallback(self):
assert _safe_int(-math.inf, 999) == 999
def test_string_numeric(self):
assert _safe_int("500", 0) == 500
def test_string_non_numeric_returns_fallback(self):
assert _safe_int("abc", 42) == 42
def test_zero(self):
assert _safe_int(0, 999) == 0
# ---------------------------------------------------------------------------
# _safe_float
# ---------------------------------------------------------------------------
class TestSafeFloat:
def test_normal_float(self):
assert _safe_float(123.45, 0.0) == pytest.approx(123.45)
def test_none_returns_fallback(self):
assert _safe_float(None, -1.0) == -1.0
def test_nan_returns_fallback(self):
assert _safe_float(math.nan, -1.0) == -1.0
def test_inf_returns_fallback(self):
assert _safe_float(math.inf, -1.0) == -1.0
def test_neg_inf_returns_fallback(self):
assert _safe_float(-math.inf, -1.0) == -1.0
def test_string_numeric(self):
assert _safe_float("3.14", 0.0) == pytest.approx(3.14)
def test_string_non_numeric_returns_fallback(self):
assert _safe_float("bad", 0.0) == 0.0
def test_zero(self):
assert _safe_float(0.0, 99.0) == 0.0
# ---------------------------------------------------------------------------
# _map_spoolman_spool
# ---------------------------------------------------------------------------
MINIMAL_SPOOL = {
"id": 1,
"filament": {
"material": "PLA",
"name": "PLA Basic",
"color_hex": "FF0000",
"weight": 1000.0,
"vendor": {"name": "Bambu Lab"},
},
"used_weight": 250.0,
"archived": False,
"registered": "2024-01-01T00:00:00Z",
}
class TestMapSpoolmanSpool:
def test_basic_mapping(self):
result = _map_spoolman_spool(MINIMAL_SPOOL)
assert result["id"] == 1
assert result["material"] == "PLA"
assert result["rgba"] == "FF0000FF"
assert result["label_weight"] == 1000
assert result["weight_used"] == pytest.approx(250.0)
assert result["data_origin"] == "spoolman"
def test_missing_id_raises(self):
spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"}
with pytest.raises(ValueError, match="missing required 'id'"):
_map_spoolman_spool(spool)
def test_none_id_raises(self):
with pytest.raises(ValueError):
_map_spoolman_spool({**MINIMAL_SPOOL, "id": None})
def test_string_id_raises(self):
with pytest.raises(ValueError, match="not a valid integer"):
_map_spoolman_spool({**MINIMAL_SPOOL, "id": "abc"})
def test_zero_id_raises(self):
with pytest.raises(ValueError, match="positive integer"):
_map_spoolman_spool({**MINIMAL_SPOOL, "id": 0})
def test_negative_id_raises(self):
with pytest.raises(ValueError, match="positive integer"):
_map_spoolman_spool({**MINIMAL_SPOOL, "id": -5})
def test_numeric_string_id_accepted(self):
result = _map_spoolman_spool({**MINIMAL_SPOOL, "id": "42"})
assert result["id"] == 42
def test_zero_price_not_converted_to_none(self):
spool = {**MINIMAL_SPOOL, "price": 0.0}
result = _map_spoolman_spool(spool)
assert result["cost_per_kg"] == 0.0
def test_nonzero_price_preserved(self):
spool = {**MINIMAL_SPOOL, "price": 9.99}
result = _map_spoolman_spool(spool)
assert result["cost_per_kg"] == pytest.approx(9.99)
def test_none_price_stays_none(self):
spool = {**MINIMAL_SPOOL, "price": None}
result = _map_spoolman_spool(spool)
assert result["cost_per_kg"] is None
def test_infinity_weight_falls_back(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}}
result = _map_spoolman_spool(spool)
assert result["label_weight"] == 1000
def test_nan_used_weight_falls_back(self):
spool = {**MINIMAL_SPOOL, "used_weight": math.nan}
result = _map_spoolman_spool(spool)
assert result["weight_used"] == 0.0
def test_invalid_color_hex_falls_back_to_grey(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ZZZZZZ"}}
result = _map_spoolman_spool(spool)
assert result["rgba"] == "808080FF"
def test_short_color_hex_falls_back(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FFF"}}
result = _map_spoolman_spool(spool)
assert result["rgba"] == "808080FF"
def test_eight_char_color_hex_falls_back(self):
# Only 6-char hex is valid from Spoolman; 8-char (RGBA) should fall back
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF0000FF"}}
result = _map_spoolman_spool(spool)
assert result["rgba"] == "808080FF"
def test_color_hex_with_hash_prefix_stripped(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}}
result = _map_spoolman_spool(spool)
assert result["rgba"] == "00FF00FF"
def test_color_hex_lowercase_normalised(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ff0000"}}
result = _map_spoolman_spool(spool)
assert result["rgba"] == "FF0000FF"
def test_none_filament(self):
spool = {**MINIMAL_SPOOL, "filament": None}
result = _map_spoolman_spool(spool)
assert result["material"] == ""
assert result["rgba"] == "808080FF"
assert result["label_weight"] == 1000
def test_archived_spool_has_archived_at(self):
spool = {**MINIMAL_SPOOL, "archived": True}
result = _map_spoolman_spool(spool)
assert result["archived_at"] is not None
def test_subtype_strips_material_prefix(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "material": "PLA", "name": "PLA Basic"}}
result = _map_spoolman_spool(spool)
assert result["subtype"] == "Basic"
def test_brand_from_vendor(self):
result = _map_spoolman_spool(MINIMAL_SPOOL)
assert result["brand"] == "Bambu Lab"
def test_no_vendor_brand_is_none(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "vendor": None}}
result = _map_spoolman_spool(spool)
assert result["brand"] is None
def test_spoolman_location_mapped_to_storage_location(self):
spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
result = _map_spoolman_spool(spool)
assert result["storage_location"] == "Shelf A"
def test_no_location_gives_none_storage_location(self):
result = _map_spoolman_spool(MINIMAL_SPOOL)
assert result["storage_location"] is None
def test_empty_location_gives_none_storage_location(self):
spool = {**MINIMAL_SPOOL, "location": ""}
result = _map_spoolman_spool(spool)
assert result["storage_location"] is None
def test_spoolman_location_key_not_in_result(self):
spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
result = _map_spoolman_spool(spool)
assert "spoolman_location" not in result
def test_core_weight_from_filament_spool_weight(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
result = _map_spoolman_spool(spool)
assert result["core_weight"] == 196
def test_core_weight_fallback_when_spool_weight_missing(self):
result = _map_spoolman_spool(MINIMAL_SPOOL)
assert result["core_weight"] == 250
def test_core_weight_fallback_when_spool_weight_none(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
result = _map_spoolman_spool(spool)
assert result["core_weight"] == 250
def test_core_weight_float_truncated_to_int(self):
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 180.9}}
result = _map_spoolman_spool(spool)
assert result["core_weight"] == 180
@@ -0,0 +1,424 @@
"""Unit tests for new SpoolmanClient inventory methods.
Covers: get_spool, get_all_spools, delete_spool, set_spool_archived,
update_spool_full, find_or_create_vendor, find_or_create_filament.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from backend.app.services.spoolman import SpoolmanClient, SpoolmanUnavailableError
@pytest.fixture
def client():
return SpoolmanClient("http://localhost:7912")
def _make_response(json_data, status_code=200):
"""Build a mock httpx response."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.raise_for_status = MagicMock()
return resp
SAMPLE_SPOOL = {
"id": 42,
"remaining_weight": 750.0,
"used_weight": 250.0,
"archived": False,
"filament": {"id": 7, "name": "PLA Basic", "material": "PLA"},
}
SAMPLE_FILAMENT = {
"id": 7,
"name": "PLA Basic",
"material": "PLA",
"color_hex": "FF0000",
"weight": 1000.0,
"vendor": {"id": 3, "name": "Bambu Lab"},
}
SAMPLE_VENDOR = {"id": 3, "name": "Bambu Lab"}
# ---------------------------------------------------------------------------
# get_spool
# ---------------------------------------------------------------------------
class TestGetSpool:
@pytest.mark.asyncio
async def test_returns_spool_dict_on_success(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.get_spool(42)
assert result == SAMPLE_SPOOL
mock_http.request.assert_called_once_with("GET", "http://localhost:7912/api/v1/spool/42", json=None)
@pytest.mark.asyncio
async def test_raises_unavailable_on_http_error(self, client):
from backend.app.services.spoolman import SpoolmanUnavailableError
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("not found"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_spool(99)
@pytest.mark.asyncio
async def test_raises_not_found_on_404_response(self, client):
"""get_spool raises SpoolmanNotFoundError when Spoolman returns HTTP 404 (PT-I3)."""
from backend.app.services.spoolman import SpoolmanNotFoundError
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(None, status_code=404))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanNotFoundError),
):
await client.get_spool(99)
@pytest.mark.asyncio
async def test_raises_client_error_on_4xx_response(self, client):
"""get_spool raises SpoolmanClientError (not SpoolmanUnavailableError) on non-404 4xx (H2)."""
from backend.app.services.spoolman import SpoolmanClientError
mock_request = MagicMock()
mock_request.url = "http://localhost:7912/api/v1/spool/42"
mock_resp_obj = MagicMock()
mock_resp_obj.status_code = 422
mock_http = AsyncMock()
resp = _make_response(None, status_code=422)
resp.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("Unprocessable", request=mock_request, response=mock_resp_obj)
)
mock_http.request = AsyncMock(return_value=resp)
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanClientError) as exc_info,
):
await client.get_spool(42)
assert exc_info.value.status_code == 422
# ---------------------------------------------------------------------------
# get_all_spools
# ---------------------------------------------------------------------------
class TestGetAllSpools:
@pytest.mark.asyncio
async def test_returns_list_without_archived_by_default(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.get_all_spools()
assert result == [SAMPLE_SPOOL]
mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params=None)
@pytest.mark.asyncio
async def test_passes_allow_archived_param(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.get_all_spools(allow_archived=True)
mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params={"allow_archived": "true"})
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("connection error"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_all_spools()
# ---------------------------------------------------------------------------
# delete_spool
# ---------------------------------------------------------------------------
class TestDeleteSpool:
@pytest.mark.asyncio
async def test_returns_none_on_success(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(None))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.delete_spool(42)
assert result is None
mock_http.request.assert_called_once_with("DELETE", "http://localhost:7912/api/v1/spool/42", json=None)
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("server error"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.delete_spool(42)
# ---------------------------------------------------------------------------
# set_spool_archived
# ---------------------------------------------------------------------------
class TestSetSpoolArchived:
@pytest.mark.asyncio
async def test_archives_spool(self, client):
archived_spool = {**SAMPLE_SPOOL, "archived": True}
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(archived_spool))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.set_spool_archived(42, archived=True)
assert result == archived_spool
mock_http.request.assert_called_once_with(
"PATCH",
"http://localhost:7912/api/v1/spool/42",
json={"archived": True},
)
@pytest.mark.asyncio
async def test_restores_spool(self, client):
restored_spool = {**SAMPLE_SPOOL, "archived": False}
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(restored_spool))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
result = await client.set_spool_archived(42, archived=False)
assert result == restored_spool
mock_http.request.assert_called_once_with(
"PATCH",
"http://localhost:7912/api/v1/spool/42",
json={"archived": False},
)
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.set_spool_archived(42, archived=True)
# ---------------------------------------------------------------------------
# update_spool_full
# ---------------------------------------------------------------------------
class TestUpdateSpoolFull:
@pytest.mark.asyncio
async def test_sends_only_provided_fields(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, remaining_weight=600.0, comment="note")
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"remaining_weight": 600.0, "comment": "note"}
@pytest.mark.asyncio
async def test_clear_location_sets_none(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, clear_location=True)
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"location": None}
@pytest.mark.asyncio
async def test_location_set_when_not_clearing(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, location="Shelf A")
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"location": "Shelf A"}
@pytest.mark.asyncio
async def test_empty_comment_sent_as_none(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
await client.update_spool_full(42, comment="")
call_json = mock_http.request.call_args.kwargs["json"]
assert call_json == {"comment": None}
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.request = AsyncMock(side_effect=Exception("network"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.update_spool_full(42, remaining_weight=500.0)
# ---------------------------------------------------------------------------
# find_or_create_vendor
# ---------------------------------------------------------------------------
class TestFindOrCreateVendor:
@pytest.mark.asyncio
async def test_returns_existing_vendor_id(self, client):
with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
result = await client.find_or_create_vendor("Bambu Lab")
assert result == 3
@pytest.mark.asyncio
async def test_case_insensitive_match(self, client):
with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
result = await client.find_or_create_vendor("bambu lab")
assert result == 3
@pytest.mark.asyncio
async def test_creates_vendor_when_not_found(self, client):
new_vendor = {"id": 10, "name": "New Brand"}
with (
patch.object(client, "get_vendors", AsyncMock(return_value=[])),
patch.object(client, "create_vendor", AsyncMock(return_value=new_vendor)) as mock_create,
):
result = await client.find_or_create_vendor("New Brand")
assert result == 10
mock_create.assert_called_once_with("New Brand")
@pytest.mark.asyncio
async def test_returns_none_when_create_fails(self, client):
with (
patch.object(client, "get_vendors", AsyncMock(return_value=[])),
patch.object(client, "create_vendor", AsyncMock(return_value=None)),
):
result = await client.find_or_create_vendor("Ghost Brand")
assert result is None
# ---------------------------------------------------------------------------
# find_or_create_filament
# ---------------------------------------------------------------------------
class TestFindOrCreateFilament:
@pytest.mark.asyncio
async def test_returns_existing_filament_id(self, client):
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[SAMPLE_FILAMENT])),
):
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000)
assert result == 7
@pytest.mark.asyncio
async def test_creates_filament_when_no_match(self, client):
new_filament = {"id": 99, "name": "PETG Pro"}
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
):
result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
assert result == 99
mock_create.assert_called_once_with(
name="PETG Pro",
vendor_id=3,
material="PETG",
color_hex="00FF00",
color_name=None,
weight=1000.0,
)
@pytest.mark.asyncio
async def test_no_brand_skips_vendor_lookup(self, client):
filament_no_vendor = {
**SAMPLE_FILAMENT,
"vendor": None,
"name": "PLA Basic",
"color_hex": "FF0000",
}
with (
patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])),
):
result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000)
assert result == 7
@pytest.mark.asyncio
async def test_color_hex_normalised_to_uppercase(self, client):
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create,
):
await client.find_or_create_filament("ABS", "", None, "ff0000", 750)
mock_create.assert_called_once_with(
name="ABS",
vendor_id=None,
material="ABS",
color_hex="FF0000",
color_name=None,
weight=750.0,
)
@pytest.mark.asyncio
async def test_returns_none_when_create_fails(self, client):
with (
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
patch.object(client, "get_filaments", AsyncMock(return_value=[])),
patch.object(client, "create_filament", AsyncMock(return_value=None)),
):
result = await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
assert result is None
# ---------------------------------------------------------------------------
# get_filaments / get_vendors / get_external_filaments error propagation (H11)
# ---------------------------------------------------------------------------
class TestGetFilamentsRaisesOnError:
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_filaments()
class TestGetVendorsRaisesOnError:
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_vendors()
class TestGetExternalFilamentsRaisesOnError:
@pytest.mark.asyncio
async def test_raises_unavailable_on_error(self, client):
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=Exception("timeout"))
with (
patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
pytest.raises(SpoolmanUnavailableError),
):
await client.get_external_filaments()
@@ -0,0 +1,223 @@
"""Unit tests for Spoolman tracking service helpers."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.spoolman_tracking import (
_get_fallback_spool_tag,
_global_tray_id_to_ams_slot,
_hash_serial_to_hex32,
_resolve_global_tray_id,
_resolve_spool_tag,
build_ams_tray_lookup,
store_print_data,
)
class TestResolveSpoolTag:
"""Tests for _resolve_spool_tag()."""
def test_prefers_tray_uuid_over_tag_uid(self):
tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"}
assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
def test_falls_back_to_tag_uid_when_no_uuid(self):
tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"}
assert _resolve_spool_tag(tray) == "DEADBEEF"
def test_falls_back_to_tag_uid_when_uuid_zero(self):
tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"}
assert _resolve_spool_tag(tray) == "DEADBEEF"
def test_rejects_zero_tag_uid(self):
tray = {"tray_uuid": "", "tag_uid": "0000000000000000"}
assert _resolve_spool_tag(tray) == ""
def test_uses_fallback_tag_when_ids_missing(self):
tray = {"tray_uuid": "", "tag_uid": ""}
# global_tray_id 0 -> ams_id 0, tray_id 0
assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "ABA7845700000000"
def test_uses_fallback_tag_when_ids_zero(self):
tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "0000000000000000"}
# global_tray_id 5 -> ams_id 1, tray_id 1
assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001"
def test_prefers_tray_uuid_over_fallback_when_non_zero(self):
tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": ""}
assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
def test_empty_both(self):
tray = {"tray_uuid": "", "tag_uid": ""}
assert _resolve_spool_tag(tray) == ""
def test_missing_keys(self):
assert _resolve_spool_tag({}) == ""
def test_zero_uuid_no_tag(self):
tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": ""}
assert _resolve_spool_tag(tray) == ""
class TestResolveGlobalTrayId:
"""Tests for _resolve_global_tray_id()."""
def test_default_mapping(self):
"""slot 1 -> tray 0, slot 2 -> tray 1, etc."""
assert _resolve_global_tray_id(1, None) == 0
assert _resolve_global_tray_id(2, None) == 1
assert _resolve_global_tray_id(4, None) == 3
def test_custom_mapping(self):
"""Custom slot_to_tray overrides default."""
mapping = [5, 2, -1, 0]
assert _resolve_global_tray_id(1, mapping) == 5
assert _resolve_global_tray_id(2, mapping) == 2
assert _resolve_global_tray_id(4, mapping) == 0
def test_unmapped_slot(self):
"""Slot with -1 in mapping uses default."""
mapping = [5, -1, 2, 0]
assert _resolve_global_tray_id(2, mapping) == 1 # default: slot 2 -> tray 1
def test_slot_beyond_mapping(self):
"""Slot beyond mapping length uses default."""
mapping = [5, 2]
assert _resolve_global_tray_id(3, mapping) == 2 # default: slot 3 -> tray 2
def test_empty_mapping(self):
mapping = []
assert _resolve_global_tray_id(1, mapping) == 0
class TestFallbackTagHelpers:
"""Tests for frontend-mirrored fallback tag helpers."""
def test_hash_serial_matches_frontend_algorithm(self):
assert _hash_serial_to_hex32("01P00A000000000") == "ABA78457"
# Frontend trims and uppercases before hashing
assert _hash_serial_to_hex32(" 01p00a000000000 ") == "ABA78457"
def test_global_tray_to_ams_slot_standard_ams(self):
assert _global_tray_id_to_ams_slot(0) == (0, 0)
assert _global_tray_id_to_ams_slot(7) == (1, 3)
def test_global_tray_to_ams_slot_ams_ht(self):
assert _global_tray_id_to_ams_slot(128) == (128, 0)
assert _global_tray_id_to_ams_slot(135) == (135, 0)
def test_global_tray_to_ams_slot_external(self):
assert _global_tray_id_to_ams_slot(254) == (255, 0)
assert _global_tray_id_to_ams_slot(255) == (255, 1)
def test_get_fallback_spool_tag_standard(self):
assert _get_fallback_spool_tag("01P00A000000000", 5) == "ABA7845700010001"
def test_get_fallback_spool_tag_ams_ht(self):
assert _get_fallback_spool_tag("01P00A000000000", 128) == "ABA7845700800000"
def test_get_fallback_spool_tag_external(self):
assert _get_fallback_spool_tag("01P00A000000000", 255) == "ABA7845700FF0001"
class TestBuildAmsTrayLookup:
"""Tests for build_ams_tray_lookup()."""
def test_single_ams_unit(self):
raw = {
"ams": [
{
"id": 0,
"tray": [
{"id": 0, "tray_uuid": "AAA", "tag_uid": "111", "tray_type": "PLA"},
{"id": 1, "tray_uuid": "BBB", "tag_uid": "222", "tray_type": "ABS"},
],
}
]
}
lookup = build_ams_tray_lookup(raw)
assert lookup[0] == {"tray_uuid": "AAA", "tag_uid": "111", "tray_type": "PLA"}
assert lookup[1] == {"tray_uuid": "BBB", "tag_uid": "222", "tray_type": "ABS"}
def test_multiple_ams_units(self):
raw = {
"ams": [
{"id": 0, "tray": [{"id": 0, "tray_uuid": "A", "tag_uid": "", "tray_type": "PLA"}]},
{"id": 1, "tray": [{"id": 0, "tray_uuid": "B", "tag_uid": "", "tray_type": "PETG"}]},
]
}
lookup = build_ams_tray_lookup(raw)
assert 0 in lookup # AMS 0, tray 0
assert 4 in lookup # AMS 1, tray 0 (1*4+0)
assert lookup[4]["tray_uuid"] == "B"
def test_external_spool(self):
raw = {
"ams": [],
"vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}],
}
lookup = build_ams_tray_lookup(raw)
assert 254 in lookup
assert lookup[254]["tray_type"] == "TPU"
def test_empty_external_spool_skipped(self):
raw = {"ams": [], "vt_tray": [{"tray_type": ""}]}
lookup = build_ams_tray_lookup(raw)
assert 254 not in lookup
def test_no_ams_data(self):
assert build_ams_tray_lookup({}) == {}
assert build_ams_tray_lookup({"ams": []}) == {}
def test_missing_fields_default(self):
raw = {"ams": [{"id": 0, "tray": [{"id": 0}]}]}
lookup = build_ams_tray_lookup(raw)
assert lookup[0] == {"tray_uuid": "", "tag_uid": "", "tray_type": ""}
class TestStorePrintData:
"""Tests for store_print_data()."""
@pytest.mark.asyncio
async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
db = AsyncMock()
delete_result = MagicMock()
db.execute = AsyncMock(side_effect=[delete_result])
db.add = MagicMock()
db.commit = AsyncMock()
printer_manager = MagicMock()
printer_manager.get_status.return_value = SimpleNamespace(
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1, "tray_type": "PLA"}]}]}
)
mock_settings = MagicMock()
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__.return_value = mock_path
with (
patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true", "true"])),
patch(
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
return_value=[{"slot_id": 1, "used_g": 3.83, "type": "PLA", "color": "#FF0000"}],
),
patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
):
await store_print_data(
printer_id=1,
archive_id=15,
file_path="archives/test.3mf",
db=db,
printer_manager=printer_manager,
ams_mapping=[1, -1, -1, -1],
)
db.add.assert_called_once()
tracking = db.add.call_args.args[0]
assert tracking.slot_to_tray == [1, -1, -1, -1]
db.execute.assert_called_once()
@@ -28,6 +28,11 @@ vi.mock('../../api/client', () => ({
createSpool: vi.fn().mockResolvedValue({ id: 99 }),
updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
created: [{ id: 1, material: 'PLA' }],
requested_count: 1,
failed_count: 0,
}),
},
}));
@@ -415,6 +420,108 @@ describe('SpoolFormModal weightTouched', () => {
expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
});
it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => {
vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
created: [{ id: 1, material: 'PLA' } as InventorySpool],
requested_count: 3,
failed_count: 2,
});
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
currencySymbol="$"
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
});
// Enable Quick Add mode so the quantity field appears
const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
const toggleButton = quickAddRow?.querySelector('button[type="button"]');
expect(toggleButton).toBeTruthy();
fireEvent.click(toggleButton!);
// Set quantity to 3 (triggers bulkCreateMutation instead of createMutation)
const quantityContainer = screen.getByText('Quantity').closest('div');
const quantityInput = quantityContainer?.querySelector('input[type="number"]');
expect(quantityInput).toBeTruthy();
fireEvent.change(quantityInput!, { target: { value: '3' } });
// Click the submit button
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton!);
await waitFor(() => {
expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
});
// Should show a warning toast for partial failure (1 created, 2 failed, 3 requested)
expect(mockShowToast).toHaveBeenCalledWith(
expect.stringContaining('1 of 3'),
'warning',
);
});
it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => {
vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
created: [
{ id: 1, material: 'PLA' } as InventorySpool,
{ id: 2, material: 'PLA' } as InventorySpool,
{ id: 3, material: 'PLA' } as InventorySpool,
],
requested_count: 3,
failed_count: 0,
});
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
currencySymbol="$"
spoolmanMode={true}
/>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
});
// Enable Quick Add mode so the quantity field appears
const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
const toggleButton = quickAddRow?.querySelector('button[type="button"]');
expect(toggleButton).toBeTruthy();
fireEvent.click(toggleButton!);
// Set quantity to 3
const quantityContainer = screen.getByText('Quantity').closest('div');
const quantityInput = quantityContainer?.querySelector('input[type="number"]');
expect(quantityInput).toBeTruthy();
fireEvent.change(quantityInput!, { target: { value: '3' } });
// Click the submit button
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton!);
await waitFor(() => {
expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
});
// Should show a success toast listing the count of created spools
expect(mockShowToast).toHaveBeenCalledWith(
expect.stringContaining('3'),
'success',
);
});
it('displays correct catalog name when duplicates exist', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,
@@ -0,0 +1,233 @@
/**
* Tests for the ?spool= deep-link flow in InventoryPage.
*
* Three scenarios:
* 1. Spool is already in the loaded list → modal opens immediately.
* 2. Spool is not in list → targeted API fetch succeeds → modal opens.
* 3. Spool is not found (404) → error toast shown, param removed from URL.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import InventoryPageRouter from '../../pages/InventoryPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
// Minimal spool fixture shared across scenarios
const BASE_SPOOL = {
id: 42,
material: 'PLA',
subtype: 'Basic',
brand: 'Bambu Lab',
color_name: 'Red',
rgba: 'FF0000FF',
label_weight: 1000,
core_weight: 250,
weight_used: 100,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: 220,
nozzle_temp_max: 240,
note: null,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: null,
tray_uuid: null,
data_origin: null,
tag_type: null,
archived_at: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
k_profiles: [],
cost_per_kg: null,
last_scale_weight: null,
last_weighed_at: null,
storage_location: null,
weight_locked: false,
};
const MOCK_SETTINGS = {
auto_archive: false,
save_thumbnails: false,
capture_finish_photo: false,
default_filament_cost: 25.0,
currency: 'USD',
energy_cost_per_kwh: 0.15,
energy_tracking_mode: 'total',
spoolman_enabled: false,
spoolman_url: '',
spoolman_sync_mode: 'auto',
spoolman_disable_weight_sync: false,
spoolman_report_partial_usage: true,
check_updates: false,
check_printer_firmware: false,
include_beta_updates: false,
language: 'en',
notification_language: 'en',
bed_cooled_threshold: 35,
ams_humidity_good: 40,
ams_humidity_fair: 60,
ams_temp_good: 28,
ams_temp_fair: 35,
ams_history_retention_days: 30,
per_printer_mapping_expanded: false,
date_format: 'system',
time_format: 'system',
default_printer_id: null,
virtual_printer_enabled: false,
virtual_printer_access_code: '',
virtual_printer_mode: 'immediate',
dark_style: 'classic',
dark_background: 'neutral',
dark_accent: 'green',
light_style: 'classic',
light_background: 'neutral',
light_accent: 'green',
ftp_retry_enabled: true,
ftp_retry_count: 3,
ftp_retry_delay: 2,
ftp_timeout: 30,
mqtt_enabled: false,
mqtt_broker: '',
mqtt_port: 1883,
mqtt_username: '',
mqtt_password: '',
mqtt_topic_prefix: 'bambuddy',
mqtt_use_tls: false,
external_url: '',
ha_enabled: false,
ha_url: '',
ha_token: '',
ha_url_from_env: false,
ha_token_from_env: false,
ha_env_managed: false,
library_archive_mode: 'ask',
library_disk_warning_gb: 5.0,
camera_view_mode: 'window',
preferred_slicer: 'bambu_studio',
prometheus_enabled: false,
prometheus_token: '',
low_stock_threshold: 20.0,
};
function setupCommonHandlers(spoolList: object[]) {
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json(MOCK_SETTINGS)),
// getSpoolmanSettings calls /api/v1/settings/spoolman (not /api/v1/spoolman/settings)
http.get('/api/v1/settings/spoolman', () =>
HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' })
),
http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolList)),
http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
);
}
describe('InventoryPage - deep-link ?spool= flow', () => {
const originalLocation = window.location.href;
afterEach(() => {
// Restore URL after each test
window.history.replaceState({}, '', originalLocation);
});
describe('scenario 1: spool is already in the loaded list', () => {
beforeEach(() => {
window.history.pushState({}, '', '/?spool=42');
setupCommonHandlers([BASE_SPOOL]);
});
it('removes ?spool= param from URL after handling', async () => {
render(<InventoryPageRouter />);
await waitFor(() => {
expect(window.location.search).not.toContain('spool=42');
});
});
it('opens the edit modal for the linked spool', async () => {
render(<InventoryPageRouter />);
// The SpoolFormModal should open — it renders material name in a heading or field
await waitFor(() => {
// Modal is open when the spool form inputs are visible
expect(screen.getAllByText(/PLA/i).length).toBeGreaterThan(0);
});
});
});
describe('scenario 2: targeted fetch (spool not in initial list)', () => {
beforeEach(() => {
window.history.pushState({}, '', '/?spool=42');
// List is empty; single-spool fetch returns the spool
setupCommonHandlers([]);
server.use(
http.get('/api/v1/inventory/spools/:id', ({ params }) => {
if (Number(params.id) === 42) {
return HttpResponse.json(BASE_SPOOL);
}
return HttpResponse.json({ detail: 'Not found' }, { status: 404 });
})
);
});
it('removes ?spool= param from URL after successful targeted fetch', async () => {
render(<InventoryPageRouter />);
await waitFor(() => {
expect(window.location.search).not.toContain('spool=42');
});
});
});
describe('scenario 3: spool not found (404)', () => {
beforeEach(() => {
window.history.pushState({}, '', '/?spool=9999');
setupCommonHandlers([]);
server.use(
http.get('/api/v1/inventory/spools/:id', () =>
HttpResponse.json({ detail: 'Not found' }, { status: 404 })
)
);
});
it('removes ?spool= param from URL on 404', async () => {
render(<InventoryPageRouter />);
await waitFor(() => {
expect(window.location.search).not.toContain('spool=9999');
});
});
it('shows an error notification when spool is not found', async () => {
render(<InventoryPageRouter />);
// Must render the exact i18n string for deepLinkSpoolNotFound (en: 'Spool not found').
// Using findByText fails the test if the toast is absent or uses the wrong key.
await screen.findByText('Spool not found');
});
});
describe('scenario 4: targeted fetch returns 5xx server error', () => {
beforeEach(() => {
window.history.pushState({}, '', '/?spool=42');
setupCommonHandlers([]);
server.use(
http.get('/api/v1/inventory/spools/:id', () =>
HttpResponse.json({ detail: 'Internal Server Error' }, { status: 500 })
)
);
});
it('shows the deepLinkFetchFailed toast on 5xx', async () => {
render(<InventoryPageRouter />);
// The deep-link query has a custom retry callback (up to 2 retries with
// exponential backoff) that overrides the test QueryClient's retry:false.
// Allow up to 6 s for the retries to exhaust before the toast appears.
await screen.findByText('Could not load spool — try again', {}, { timeout: 6000 });
});
});
});
@@ -0,0 +1,175 @@
/**
* Unit tests for the global search filter used in InventoryPage.
*
* The filter is a pure client-side computation — replicated inline here
* following the same pattern as InventoryPageGrouping.test.ts so no DOM
* render or API mock is needed.
*
* Fields covered: brand, material, color_name, subtype, note,
* slicer_filament_name, storage_location.
*/
import { describe, it, expect } from 'vitest';
import type { InventorySpool } from '../../api/client';
// Replicate the search filter from InventoryPage
function applySearch(spools: InventorySpool[], search: string): InventorySpool[] {
if (!search) return spools;
const q = search.toLowerCase();
return spools.filter((s) =>
s.brand?.toLowerCase().includes(q) ||
s.material.toLowerCase().includes(q) ||
s.color_name?.toLowerCase().includes(q) ||
s.subtype?.toLowerCase().includes(q) ||
s.note?.toLowerCase().includes(q) ||
s.slicer_filament_name?.toLowerCase().includes(q) ||
s.storage_location?.toLowerCase().includes(q)
);
}
function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
return {
material: 'PLA',
subtype: 'Basic',
brand: 'Bambu Lab',
color_name: 'White',
rgba: 'FFFFFFFF',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
weight_locked: false,
slicer_filament: null,
slicer_filament_name: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: null,
added_full: null,
last_used: null,
encode_time: null,
tag_uid: null,
tray_uuid: null,
data_origin: null,
tag_type: null,
archived_at: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
k_profiles: [],
cost_per_kg: null,
last_scale_weight: null,
last_weighed_at: null,
storage_location: null,
...overrides,
};
}
describe('InventoryPage search filter', () => {
describe('storage_location', () => {
it('returns spools whose storage_location matches the query', () => {
const spools = [
makeSpool({ id: 1, storage_location: 'IKEA Regal' }),
makeSpool({ id: 2, storage_location: 'Kiste - PLA' }),
makeSpool({ id: 3, storage_location: 'Lagerregal' }),
];
expect(applySearch(spools, 'IKEA').map((s) => s.id)).toEqual([1]);
expect(applySearch(spools, 'Kiste').map((s) => s.id)).toEqual([2]);
expect(applySearch(spools, 'regal').map((s) => s.id)).toEqual([1, 3]);
});
it('is case-insensitive for storage_location', () => {
const spools = [makeSpool({ id: 1, storage_location: 'IKEA Regal' })];
expect(applySearch(spools, 'ikea regal')).toHaveLength(1);
expect(applySearch(spools, 'IKEA REGAL')).toHaveLength(1);
expect(applySearch(spools, 'ikEa')).toHaveLength(1);
});
it('matches partial storage_location strings', () => {
const spools = [makeSpool({ id: 1, storage_location: 'Kiste - PLA' })];
expect(applySearch(spools, 'Kis')).toHaveLength(1);
expect(applySearch(spools, 'PLA')).toHaveLength(1);
expect(applySearch(spools, '- PLA')).toHaveLength(1);
});
it('does not crash when storage_location is null', () => {
const spools = [makeSpool({ id: 1, storage_location: null })];
expect(() => applySearch(spools, 'regal')).not.toThrow();
expect(applySearch(spools, 'regal')).toHaveLength(0);
});
it('excludes spools whose storage_location does not match', () => {
const spools = [
makeSpool({ id: 1, storage_location: 'IKEA Regal' }),
makeSpool({ id: 2, storage_location: 'Kiste - PLA' }),
];
expect(applySearch(spools, 'IKEA').map((s) => s.id)).toEqual([1]);
});
});
describe('existing fields (regression)', () => {
it('still finds by brand', () => {
const spools = [
makeSpool({ id: 1, brand: 'Bambu Lab' }),
makeSpool({ id: 2, brand: 'Polymaker' }),
];
expect(applySearch(spools, 'polymaker').map((s) => s.id)).toEqual([2]);
});
it('still finds by material', () => {
const spools = [
makeSpool({ id: 1, material: 'PLA' }),
makeSpool({ id: 2, material: 'PETG' }),
];
expect(applySearch(spools, 'petg').map((s) => s.id)).toEqual([2]);
});
it('still finds by color_name', () => {
const spools = [
makeSpool({ id: 1, color_name: 'Jade White' }),
makeSpool({ id: 2, color_name: 'Black' }),
];
expect(applySearch(spools, 'jade').map((s) => s.id)).toEqual([1]);
});
it('still finds by note', () => {
const spools = [
makeSpool({ id: 1, note: 'fast print only' }),
makeSpool({ id: 2, note: null }),
];
expect(applySearch(spools, 'fast').map((s) => s.id)).toEqual([1]);
});
it('returns all spools when search is empty', () => {
const spools = [makeSpool({ id: 1 }), makeSpool({ id: 2 })];
expect(applySearch(spools, '')).toHaveLength(2);
});
it('returns empty array when nothing matches', () => {
const spools = [makeSpool({ id: 1, brand: 'Bambu Lab', material: 'PLA' })];
expect(applySearch(spools, 'xxxxxxxx')).toHaveLength(0);
});
});
describe('cross-field matching', () => {
it('matches a spool if any field contains the query', () => {
const spool = makeSpool({
id: 1,
brand: 'Bambu Lab',
material: 'PLA',
storage_location: 'IKEA Regal',
});
// Each individual field matches
expect(applySearch([spool], 'Bambu')).toHaveLength(1);
expect(applySearch([spool], 'PLA')).toHaveLength(1);
expect(applySearch([spool], 'IKEA')).toHaveLength(1);
});
it('a query matching only storage_location is found even when other fields do not match', () => {
const spools = [
makeSpool({ id: 1, brand: 'Polymaker', material: 'PETG', storage_location: 'IKEA Regal' }),
makeSpool({ id: 2, brand: 'Polymaker', material: 'PETG', storage_location: 'Kiste' }),
];
const result = applySearch(spools, 'IKEA');
expect(result.map((s) => s.id)).toEqual([1]);
});
});
});
@@ -13,7 +13,9 @@ import React from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
import { ToastProvider } from '../../contexts/ToastContext';
import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
import { api as mockedApi, spoolbuddyApi as mockedSpoolbuddyApi } from '../../api/client';
// Mock the API modules
vi.mock('../../api/client', () => ({
@@ -68,13 +70,15 @@ function renderPage() {
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>
<Routes>
<Route element={<OutletWrapper />}>
<Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
</Route>
</Routes>
</MemoryRouter>
<ToastProvider>
<MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>
<Routes>
<Route element={<OutletWrapper />}>
<Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
</Route>
</Routes>
</MemoryRouter>
</ToastProvider>
</QueryClientProvider>
);
}
@@ -134,4 +138,49 @@ describe('SpoolBuddyWriteTagPage', () => {
expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();
mockOutletContext.sbState.deviceOnline = false; // reset
});
it('shows one toast per warning when writeTag returns multiple warnings (B5 / T2)', async () => {
const spool = {
id: 99,
material: 'PLA',
label_weight: 1000,
weight_used: 0,
tag_uid: null,
tray_uuid: null,
archived_at: null,
rgba: 'FF0000FF',
};
vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]);
vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
{ device_id: 'sb-test', hostname: 'sb-test.local' } as never,
]);
vi.mocked(mockedSpoolbuddyApi.writeTag).mockResolvedValueOnce({
status: 'queued',
warnings: ['color_name not set', 'nozzle_temp_min not set'],
} as never);
mockOutletContext.sbState.deviceOnline = true;
renderPage();
// Wait for the spool to appear and click it to select it
await waitFor(() => {
expect(screen.getByText('PLA')).toBeDefined();
});
fireEvent.click(screen.getByText('PLA'));
// Click the Write Tag button
await waitFor(() => {
const writeBtn = screen.getByText('Write Tag');
expect((writeBtn as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(screen.getByText('Write Tag'));
// Both warning messages must appear as separate toasts
await waitFor(() => {
expect(screen.getByText('color_name not set')).toBeDefined();
expect(screen.getByText('nozzle_temp_min not set')).toBeDefined();
});
mockOutletContext.sbState.deviceOnline = false; // reset
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { validateForm, defaultFormData } from '../../components/spool-form/types';
describe('validateForm', () => {
describe('standard mode', () => {
it('requires slicer_filament, material, brand, and subtype', () => {
const result = validateForm(defaultFormData);
expect(result.isValid).toBe(false);
expect(result.errors.slicer_filament).toBeDefined();
expect(result.errors.material).toBeDefined();
expect(result.errors.brand).toBeDefined();
expect(result.errors.subtype).toBeDefined();
});
it('passes when all required fields are filled', () => {
const data = {
...defaultFormData,
slicer_filament: 'Bambu PLA Basic @BBL',
material: 'PLA',
brand: 'Bambu Lab',
subtype: 'Basic',
};
const result = validateForm(data);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
});
describe('quickAdd mode', () => {
it('only requires material', () => {
const result = validateForm(defaultFormData, true);
expect(result.isValid).toBe(false);
expect(result.errors.material).toBeDefined();
expect(result.errors.slicer_filament).toBeUndefined();
expect(result.errors.brand).toBeUndefined();
});
it('passes with only material set', () => {
const data = { ...defaultFormData, material: 'PETG' };
const result = validateForm(data, true);
expect(result.isValid).toBe(true);
});
});
describe('spoolmanMode', () => {
it('only requires material (same as quickAdd)', () => {
const result = validateForm(defaultFormData, false, true);
expect(result.isValid).toBe(false);
expect(result.errors.material).toBeDefined();
expect(result.errors.slicer_filament).toBeUndefined();
expect(result.errors.brand).toBeUndefined();
expect(result.errors.subtype).toBeUndefined();
});
it('passes with only material set', () => {
const data = { ...defaultFormData, material: 'PLA' };
const result = validateForm(data, false, true);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('does not require slicer_filament even when present', () => {
const data = { ...defaultFormData, material: 'ABS' };
const result = validateForm(data, false, true);
expect(result.isValid).toBe(true);
});
it('fails when material is empty string', () => {
const data = { ...defaultFormData, material: '' };
const result = validateForm(data, false, true);
expect(result.isValid).toBe(false);
expect(result.errors.material).toBeDefined();
});
it('quickAdd takes precedence over spoolmanMode', () => {
const data = { ...defaultFormData, material: 'PLA' };
const result = validateForm(data, true, true);
expect(result.isValid).toBe(true);
});
});
});
+56 -2
View File
@@ -2,6 +2,15 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
const API_BASE = '/api/v1';
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
// Auth token storage
// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
// When the token originates from the ?token= URL param (kiosk bootstrap), it is
@@ -100,7 +109,7 @@ async function request<T>(
}
}
throw new Error(message);
throw new ApiError(message, response.status);
}
// Handle empty responses (204 No Content, etc.)
@@ -2112,6 +2121,13 @@ export interface InventorySpool {
last_scale_weight: number | null;
last_weighed_at: string | null;
k_profiles?: SpoolKProfile[];
storage_location?: string | null;
}
export interface SpoolmanBulkCreateResult {
created: InventorySpool[];
requested_count: number;
failed_count: number;
}
export interface SpoolUsageRecord {
@@ -4217,6 +4233,44 @@ export const api = {
getFilamentPresets: () =>
request<SlicerSetting[]>('/cloud/filaments'),
// Spoolman Inventory proxy (unified UI when Spoolman is enabled)
getSpoolmanInventorySpools: (includeArchived = false) =>
request<InventorySpool[]>(`/spoolman/inventory/spools?include_archived=${includeArchived}`),
getSpoolmanInventorySpool: (id: number) =>
request<InventorySpool>(`/spoolman/inventory/spools/${id}`),
createSpoolmanInventorySpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
request<InventorySpool>('/spoolman/inventory/spools', {
method: 'POST',
body: JSON.stringify(data),
}),
bulkCreateSpoolmanInventorySpools: (
data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>,
quantity: number,
) =>
request<SpoolmanBulkCreateResult | InventorySpool[]>('/spoolman/inventory/spools/bulk', {
method: 'POST',
body: JSON.stringify({ spool: data, quantity }),
}),
updateSpoolmanInventorySpool: (
id: number,
data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>,
) =>
request<InventorySpool>(`/spoolman/inventory/spools/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteSpoolmanInventorySpool: (id: number) =>
request<{ status: string }>(`/spoolman/inventory/spools/${id}`, { method: 'DELETE' }),
archiveSpoolmanInventorySpool: (id: number) =>
request<InventorySpool>(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }),
restoreSpoolmanInventorySpool: (id: number) =>
request<InventorySpool>(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }),
syncSpoolmanSpoolWeight: (spoolId: number, weightGrams: number) =>
request<{ status: string; weight_used: number }>(`/spoolman/inventory/spools/${spoolId}/weight`, {
method: 'PATCH',
body: JSON.stringify({ weight_grams: weightGrams }),
}),
// Updates
getVersion: () => request<VersionInfo>('/updates/version'),
checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
@@ -5745,7 +5799,7 @@ export const spoolbuddyApi = {
request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
writeTag: (deviceId: string, spoolId: number) =>
request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
request<{ status: string; warnings?: string[] }>('/spoolbuddy/nfc/write-tag', {
method: 'POST',
body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
}),
+25 -12
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
import { Droplets, Link2, Copy, Check, Settings2, Package, Unlink } from 'lucide-react';
import { isLightColor } from '../utils/colors';
interface FilamentData {
@@ -51,6 +52,7 @@ interface FilamentHoverCardProps {
*/
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
const [copied, setCopied] = useState(false);
@@ -291,20 +293,20 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
)}
</div>
{/* Open in Spoolman button (when already linked) */}
{spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
{/* Open in inventory button (when already linked to a Spoolman spool) */}
{spoolman.linkedSpoolId && (
<>
<a
href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
title={t('spoolman.openInSpoolman')}
title={t('inventory.openInInventory')}
>
<ExternalLink className="w-3.5 h-3.5" />
{t('spoolman.openInSpoolman')}
</a>
<Package className="w-3.5 h-3.5" />
{t('inventory.openInInventory')}
</button>
{spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && (
<button
@@ -361,6 +363,17 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{inventory.assignedSpool.material}
{inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
</p>
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
title={t('inventory.openInInventory')}
>
<Package className="w-3.5 h-3.5" />
{t('inventory.openInInventory')}
</button>
{inventory.onUnassignSpool && (
<button
onClick={(e) => {
+95 -38
View File
@@ -2,8 +2,8 @@ import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
import { api, ApiError } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, SpoolmanBulkCreateResult } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
@@ -18,6 +18,8 @@ import { SpoolUsageHistory } from './SpoolUsageHistory';
type TabId = 'filament' | 'pa-profile';
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
interface SpoolFormModalProps {
isOpen: boolean;
onClose: () => void;
@@ -25,6 +27,10 @@ interface SpoolFormModalProps {
printersWithCalibrations?: PrinterWithCalibrations[];
currencySymbol: string;
onSpoolsCreated?: (spools: InventorySpool[]) => void;
/** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
spoolmanMode?: boolean;
/** Query key to invalidate after mutations (differs for Spoolman vs local). */
spoolsQueryKey?: string[];
}
export function SpoolFormModal({
@@ -34,6 +40,8 @@ export function SpoolFormModal({
printersWithCalibrations = [],
currencySymbol,
onSpoolsCreated,
spoolmanMode = false,
spoolsQueryKey = ['inventory-spools'],
}: SpoolFormModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -78,9 +86,7 @@ export function SpoolFormModal({
: fetchedCalibrations;
// Count selected PA profiles for tab badge
const selectedProfileCount = useMemo(() => {
return selectedProfiles.size;
}, [selectedProfiles]);
const selectedProfileCount = selectedProfiles.size;
// Load recent colors on mount
useEffect(() => {
@@ -276,6 +282,7 @@ export function SpoolFormModal({
slicer_filament: spool.slicer_filament || '',
note: spool.note || '',
cost_per_kg: spool.cost_per_kg ?? null,
storage_location: spool.storage_location || '',
});
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
@@ -325,71 +332,119 @@ export function SpoolFormModal({
setRecentColors(prev => saveRecentColor(color, prev));
};
// Mutations
// Mutations – dispatch to Spoolman proxy or local inventory based on mode
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.createSpool(data as Parameters<typeof api.createSpool>[0]),
spoolmanMode
? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
: api.createSpool(data as Parameters<typeof api.createSpool>[0]),
onSuccess: async (newSpool) => {
// Save K-profiles if any selected
if (selectedProfiles.size > 0 && newSpool?.id) {
if (!spoolmanMode && selectedProfiles.size > 0 && newSpool?.id) {
await saveKProfiles(newSpool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
showToast(t('inventory.spoolCreated'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.saveFailed'), 'error');
}
},
});
const bulkCreateMutation = useMutation({
mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
onSuccess: async (newSpools) => {
if (selectedProfiles.size > 0) {
for (const spool of newSpools) {
await saveKProfiles(spool.id);
const bulkCreateMutation = useMutation<
SpoolmanBulkCreateResult | InventorySpool[],
Error,
{ data: Record<string, unknown>; qty: number }
>({
mutationFn: ({ data, qty }) =>
spoolmanMode
? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
: api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
onSuccess: async (result) => {
// Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
// Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
// the duck-type check ('created' in result) before any property access.
const spoolmanResult = (spoolmanMode && 'created' in result)
? (result as unknown as SpoolmanBulkCreateResult)
: null;
const createdSpools: InventorySpool[] = spoolmanResult
? spoolmanResult.created
: (result as InventorySpool[]);
if (!spoolmanMode && selectedProfiles.size > 0) {
for (const s of createdSpools) {
await saveKProfiles(s.id);
}
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
if (onSpoolsCreated) onSpoolsCreated(newSpools);
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
if (spoolmanResult && spoolmanResult.failed_count > 0) {
showToast(
t('inventory.spoolsPartiallyCreated', {
created: createdSpools.length,
total: spoolmanResult.requested_count,
}),
'warning',
);
} else {
showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
}
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.saveFailed'), 'error');
}
},
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
spoolmanMode
? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
onSuccess: async () => {
// Save K-profiles
if (spool?.id) {
if (!spoolmanMode && spool?.id) {
await saveKProfiles(spool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.spoolUpdated'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.saveFailed'), 'error');
}
},
});
const deleteTagMutation = useMutation({
mutationFn: () =>
api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
mutationFn: () => {
if (spoolmanMode) {
return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
}
return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.tagClearFailed'), 'error');
}
},
});
@@ -422,8 +477,9 @@ export function SpoolFormModal({
// Clear existing K-profiles
try {
await api.saveSpoolKProfiles(spoolId, []);
} catch {
// Ignore
} catch (e) {
console.error('Failed to save K-profiles:', e);
showToast(t('inventory.kProfileSaveFailed'), 'warning');
}
return;
}
@@ -458,6 +514,7 @@ export function SpoolFormModal({
await api.saveSpoolKProfiles(spoolId, profiles);
} catch (e) {
console.error('Failed to save K-profiles:', e);
showToast(t('inventory.kProfileSaveFailed'), 'warning');
}
}
};
@@ -475,10 +532,9 @@ export function SpoolFormModal({
if (!isOpen) return null;
const handleSubmit = () => {
const validation = validateForm(formData, quickAdd);
const validation = validateForm(formData, quickAdd, spoolmanMode);
if (!validation.isValid) {
setErrors(validation.errors);
// Switch to filament tab if there are errors there
if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
setActiveTab('filament');
}
@@ -503,6 +559,7 @@ export function SpoolFormModal({
nozzle_temp_max: null,
note: formData.note || null,
cost_per_kg: formData.cost_per_kg,
storage_location: formData.storage_location || null,
};
// Only send weight_used when creating or when explicitly changed by the user.
@@ -582,7 +639,7 @@ export function SpoolFormModal({
<Palette className="w-4 h-4" />
{t('inventory.filamentInfoTab')}
</button>
{!quickAdd && (
{!quickAdd && !spoolmanMode && (
<button
onClick={() => setActiveTab('pa-profile')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
@@ -656,8 +713,8 @@ export function SpoolFormModal({
/>
</div>
{/* Usage History (only when editing) */}
{isEditing && spool && (
{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
{isEditing && spool && !spoolmanMode && (
<div>
<SpoolUsageHistory spoolId={spool.id} />
</div>
@@ -313,6 +313,19 @@ export function AdditionalSection({
onChange={(e) => updateField('note', e.target.value)}
/>
</div>
{/* Storage Location */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.storageLocation')}</label>
<input
type="text"
maxLength={255}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.storageLocationPlaceholder')}
value={formData.storage_location}
onChange={(e) => updateField('storage_location', e.target.value)}
/>
</div>
</div>
);
}
+9 -2
View File
@@ -23,6 +23,7 @@ export interface SpoolFormData {
slicer_filament: string;
note: string;
cost_per_kg: number | null;
storage_location: string;
}
export const defaultFormData: SpoolFormData = {
@@ -38,6 +39,7 @@ export const defaultFormData: SpoolFormData = {
slicer_filament: '',
note: '',
cost_per_kg: null,
storage_location: '',
};
// Printer with calibrations type
@@ -123,10 +125,15 @@ export interface ValidationResult {
errors: Partial<Record<keyof SpoolFormData, string>>;
}
export function validateForm(formData: SpoolFormData, quickAdd = false): ValidationResult {
export function validateForm(
formData: SpoolFormData,
quickAdd = false,
spoolmanMode = false,
): ValidationResult {
const errors: Partial<Record<keyof SpoolFormData, string>> = {};
if (quickAdd) {
// Quick-add and Spoolman mode only require material
if (quickAdd || spoolmanMode) {
if (!formData.material) {
errors.material = 'Material is required';
}
+20
View File
@@ -3288,6 +3288,9 @@ export default {
measuredWeight: 'Gemessenes Gewicht',
spoolName: 'Spule',
costPerKg: 'Kosten pro kg',
storageLocation: 'Lagerstandort',
storageLocationPlaceholder: 'z.B. Regal A, Schublade 1',
openInInventory: 'Im Inventar öffnen',
measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
slicerFilament: 'Slicer-Filament',
slicerFilamentName: 'Slicer-Preset-Name',
@@ -3321,11 +3324,27 @@ export default {
stock: 'Lager',
configured: 'Konfiguriert',
spoolsCreated: '{{count}} Spulen erstellt',
spoolsPartiallyCreated: '{{created}} von {{total}} Spulen erstellt (einige fehlgeschlagen)',
spoolCreated: 'Spule erstellt',
spoolUpdated: 'Spule aktualisiert',
spoolDeleted: 'Spule gelöscht',
deepLinkSpoolNotFound: 'Spule nicht gefunden',
deepLinkFetchFailed: 'Spule konnte nicht geladen werden — bitte erneut versuchen',
spoolArchived: 'Spule archiviert',
spoolRestored: 'Spule wiederhergestellt',
kProfileSaveFailed: 'K-Profil-Einstellungen konnten nicht gespeichert werden',
syncWeightSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise gelöscht',
syncWeightSpoolmanUnreachable: 'Spoolman ist nicht erreichbar — bitte später erneut versuchen',
syncWeightFailed: 'Gewicht konnte nicht synchronisiert werden',
spoolmanUnreachable: 'Spoolman ist nicht erreichbar — bitte später erneut versuchen',
deleteSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise bereits gelöscht',
deleteFailed: 'Spule konnte nicht gelöscht werden',
archiveSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise bereits gelöscht',
archiveFailed: 'Spule konnte nicht archiviert werden',
restoreSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise bereits gelöscht',
restoreFailed: 'Spule konnte nicht wiederhergestellt werden',
saveFailed: 'Änderungen konnten nicht gespeichert werden',
tagClearFailed: 'Tag konnte nicht gelöscht werden',
deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
archiveConfirm: 'Möchten Sie diese Spule wirklich archivieren?',
advancedSettings: 'Erweiterte Einstellungen',
@@ -5011,6 +5030,7 @@ export default {
creating: 'Wird erstellt...',
spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',
createFailed: 'Spule konnte nicht erstellt werden',
incompleteDataWarning: 'Tag mit unvollständigen Spoolman-Daten geschrieben',
},
quickMenu: {
printerPower: 'Drucker-Strom',
+20
View File
@@ -3291,6 +3291,9 @@ export default {
measuredWeight: 'Measured Weight',
spoolName: 'Spool',
costPerKg: 'Cost per kg',
storageLocation: 'Storage Location',
storageLocationPlaceholder: 'e.g. Shelf A, Drawer 1',
openInInventory: 'Open in Inventory',
measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
slicerFilament: 'Slicer Filament',
slicerFilamentName: 'Slicer Preset Name',
@@ -3324,11 +3327,27 @@ export default {
stock: 'Stock',
configured: 'Configured',
spoolsCreated: '{{count}} spools created',
spoolsPartiallyCreated: '{{created}} of {{total}} spools created (some failed)',
spoolCreated: 'Spool created',
spoolUpdated: 'Spool updated',
spoolDeleted: 'Spool deleted',
deepLinkSpoolNotFound: 'Spool not found',
deepLinkFetchFailed: 'Could not load spool — try again',
spoolArchived: 'Spool archived',
spoolRestored: 'Spool restored',
kProfileSaveFailed: 'K-profile settings could not be saved',
syncWeightSpoolNotFound: 'Spool not found — it may have been deleted',
syncWeightSpoolmanUnreachable: 'Spoolman is unreachable — try again later',
syncWeightFailed: 'Failed to sync weight',
spoolmanUnreachable: 'Spoolman is not reachable — please try again later',
deleteSpoolNotFound: 'Spool not found — it may have already been deleted',
deleteFailed: 'Failed to delete spool',
archiveSpoolNotFound: 'Spool not found — it may have already been deleted',
archiveFailed: 'Failed to archive spool',
restoreSpoolNotFound: 'Spool not found — it may have already been deleted',
restoreFailed: 'Failed to restore spool',
saveFailed: 'Failed to save changes',
tagClearFailed: 'Failed to clear tag',
deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
archiveConfirm: 'Are you sure you want to archive this spool?',
advancedSettings: 'Advanced Settings',
@@ -5019,6 +5038,7 @@ export default {
creating: 'Creating...',
spoolCreated: 'Spool created! Ready to write.',
createFailed: 'Failed to create spool',
incompleteDataWarning: 'Tag written with incomplete Spoolman data',
},
quickMenu: {
printerPower: 'Printer Power',
+20
View File
@@ -3210,6 +3210,9 @@ export default {
measuredWeight: 'Poids mesuré',
spoolName: 'Bobine',
costPerKg: 'Coût par kg',
storageLocation: 'Emplacement de stockage',
storageLocationPlaceholder: 'ex. Étagère A, Tiroir 1',
openInInventory: "Ouvrir dans l'inventaire",
measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
slicerFilament: 'Filament Slicer',
slicerFilamentName: 'Nom du Preset Slicer',
@@ -3243,11 +3246,27 @@ export default {
stock: 'Stock',
configured: 'Configuré',
spoolsCreated: '{{count}} bobines créées',
spoolsPartiallyCreated: '{{created}} sur {{total}} bobines créées (certaines ont échoué)',
spoolCreated: 'Bobine créée',
spoolUpdated: 'Bobine mise à jour',
spoolDeleted: 'Bobine supprimée',
deepLinkSpoolNotFound: 'Bobine introuvable',
deepLinkFetchFailed: 'Impossible de charger la bobine — réessayez',
spoolArchived: 'Bobine archivée',
spoolRestored: 'Bobine restaurée',
kProfileSaveFailed: 'Les paramètres de profil K n\'ont pas pu être enregistrés',
syncWeightSpoolNotFound: 'Bobine introuvable — elle a peut-être été supprimée',
syncWeightSpoolmanUnreachable: 'Spoolman est inaccessible — réessayez plus tard',
syncWeightFailed: 'Échec de la synchronisation du poids',
spoolmanUnreachable: 'Spoolman est inaccessible — veuillez réessayer plus tard',
deleteSpoolNotFound: 'Bobine introuvable — elle a peut-être déjà été supprimée',
deleteFailed: 'Impossible de supprimer la bobine',
archiveSpoolNotFound: 'Bobine introuvable — elle a peut-être déjà été supprimée',
archiveFailed: 'Impossible d\'archiver la bobine',
restoreSpoolNotFound: 'Bobine introuvable — elle a peut-être déjà été supprimée',
restoreFailed: 'Impossible de restaurer la bobine',
saveFailed: 'Impossible d\'enregistrer les modifications',
tagClearFailed: 'Impossible de supprimer le tag',
deleteConfirm: 'Supprimer définitivement cette bobine ?',
archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',
advancedSettings: 'Paramètres Avancés',
@@ -4925,6 +4944,7 @@ export default {
creating: 'Création...',
spoolCreated: 'Bobine créée ! Prêt à écrire.',
createFailed: 'Impossible de créer la bobine',
incompleteDataWarning: 'Tag écrit avec des données Spoolman incomplètes',
},
quickMenu: {
printerPower: 'Alimentation imprimante',
+20
View File
@@ -3209,6 +3209,9 @@ export default {
measuredWeight: 'Peso Misurato',
spoolName: 'Bobina',
costPerKg: 'Costo per kg',
storageLocation: 'Posizione di archiviazione',
storageLocationPlaceholder: 'es. Scaffale A, Cassetto 1',
openInInventory: "Apri nell'inventario",
measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',
slicerFilament: 'Filamento Slicer',
slicerFilamentName: 'Nome Preset Slicer',
@@ -3242,11 +3245,27 @@ export default {
stock: 'Scorta',
configured: 'Configurata',
spoolsCreated: '{{count}} bobine create',
spoolsPartiallyCreated: '{{created}} di {{total}} bobine create (alcune non riuscite)',
spoolCreated: 'Bobina creata',
spoolUpdated: 'Bobina aggiornata',
spoolDeleted: 'Bobina eliminata',
deepLinkSpoolNotFound: 'Bobina non trovata',
deepLinkFetchFailed: 'Impossibile caricare la bobina — riprova',
spoolArchived: 'Bobina archiviata',
spoolRestored: 'Bobina ripristinata',
kProfileSaveFailed: 'Impossibile salvare le impostazioni del profilo K',
syncWeightSpoolNotFound: 'Bobina non trovata — potrebbe essere stata eliminata',
syncWeightSpoolmanUnreachable: 'Spoolman non è raggiungibile — riprovare più tardi',
syncWeightFailed: 'Sincronizzazione del peso non riuscita',
spoolmanUnreachable: 'Spoolman non è raggiungibile — riprovare più tardi',
deleteSpoolNotFound: 'Bobina non trovata — potrebbe essere già stata eliminata',
deleteFailed: 'Impossibile eliminare la bobina',
archiveSpoolNotFound: 'Bobina non trovata — potrebbe essere già stata eliminata',
archiveFailed: 'Impossibile archiviare la bobina',
restoreSpoolNotFound: 'Bobina non trovata — potrebbe essere già stata eliminata',
restoreFailed: 'Impossibile ripristinare la bobina',
saveFailed: 'Impossibile salvare le modifiche',
tagClearFailed: 'Impossibile cancellare il tag',
deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
archiveConfirm: 'Sei sicuro di voler archiviare questa bobina?',
advancedSettings: 'Impostazioni Avanzate',
@@ -4924,6 +4943,7 @@ export default {
creating: 'Creazione...',
spoolCreated: 'Bobina creata! Pronto per la scrittura.',
createFailed: 'Impossibile creare la bobina',
incompleteDataWarning: 'Tag scritto con dati Spoolman incompleti',
},
quickMenu: {
printerPower: 'Alimentazione stampante',
+20
View File
@@ -3248,6 +3248,9 @@ export default {
measuredWeight: '計測重量',
spoolName: 'スプール',
costPerKg: 'kgあたりのコスト',
storageLocation: '保管場所',
storageLocationPlaceholder: '例:棚A、引き出し1',
openInInventory: 'インベントリで開く',
measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
slicerFilament: 'スライサーフィラメント',
slicerFilamentName: 'スライサープリセット名',
@@ -3281,11 +3284,27 @@ export default {
stock: '在庫',
configured: '設定済み',
spoolsCreated: '{{count}}本のスプールを作成しました',
spoolsPartiallyCreated: '{{total}}本中{{created}}本のスプールを作成しました(一部失敗)',
spoolCreated: 'スプールを作成しました',
spoolUpdated: 'スプールを更新しました',
spoolDeleted: 'スプールを削除しました',
deepLinkSpoolNotFound: 'スプールが見つかりません',
deepLinkFetchFailed: 'スプールを読み込めませんでした — もう一度お試しください',
spoolArchived: 'スプールをアーカイブしました',
spoolRestored: 'スプールを復元しました',
kProfileSaveFailed: 'Kプロファイル設定を保存できませんでした',
syncWeightSpoolNotFound: 'スプールが見つかりません — 削除された可能性があります',
syncWeightSpoolmanUnreachable: 'Spoolmanに接続できません — 後でもう一度お試しください',
syncWeightFailed: '重量の同期に失敗しました',
spoolmanUnreachable: 'Spoolmanに接続できません — 後でもう一度お試しください',
deleteSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
deleteFailed: 'スプールを削除できませんでした',
archiveSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
archiveFailed: 'スプールをアーカイブできませんでした',
restoreSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
restoreFailed: 'スプールを復元できませんでした',
saveFailed: '変更を保存できませんでした',
tagClearFailed: 'タグを削除できませんでした',
deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
archiveConfirm: 'このスプールをアーカイブしますか?',
advancedSettings: '詳細設定',
@@ -4963,6 +4982,7 @@ export default {
creating: '作成中...',
spoolCreated: 'スプール作成完了!書込み準備ができました。',
createFailed: 'スプールの作成に失敗しました',
incompleteDataWarning: '不完全なSpoolmanデータでタグを書き込みました',
},
quickMenu: {
printerPower: 'プリンター電源',
+20
View File
@@ -3223,6 +3223,9 @@ export default {
measuredWeight: 'Peso Medido',
spoolName: 'Bobina',
costPerKg: 'Custo por kg',
storageLocation: 'Local de armazenamento',
storageLocationPlaceholder: 'ex. Prateleira A, Gaveta 1',
openInInventory: 'Abrir no inventário',
measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
slicerFilament: 'Filamento do Fatiador',
slicerFilamentName: 'Nome do Predefinido do Fatiador',
@@ -3256,11 +3259,27 @@ export default {
stock: 'Estoque',
configured: 'Configurado',
spoolsCreated: '{{count}} carretéis criados',
spoolsPartiallyCreated: '{{created}} de {{total}} carretéis criados (alguns falharam)',
spoolCreated: 'Carretel criado',
spoolUpdated: 'Carretel atualizado',
spoolDeleted: 'Carretel excluído',
deepLinkSpoolNotFound: 'Carretel não encontrado',
deepLinkFetchFailed: 'Não foi possível carregar o carretel — tente novamente',
spoolArchived: 'Carretel arquivado',
spoolRestored: 'Carretel restaurado',
kProfileSaveFailed: 'Não foi possível salvar as configurações do perfil K',
syncWeightSpoolNotFound: 'Carretel não encontrado — pode ter sido excluído',
syncWeightSpoolmanUnreachable: 'Spoolman está inacessível — tente novamente mais tarde',
syncWeightFailed: 'Falha ao sincronizar o peso',
spoolmanUnreachable: 'Spoolman está inacessível — tente novamente mais tarde',
deleteSpoolNotFound: 'Carretel não encontrado — pode já ter sido excluído',
deleteFailed: 'Falha ao excluir carretel',
archiveSpoolNotFound: 'Carretel não encontrado — pode já ter sido excluído',
archiveFailed: 'Falha ao arquivar carretel',
restoreSpoolNotFound: 'Carretel não encontrado — pode já ter sido excluído',
restoreFailed: 'Falha ao restaurar carretel',
saveFailed: 'Falha ao salvar as alterações',
tagClearFailed: 'Falha ao limpar a tag',
deleteConfirm: 'Tem certeza de que deseja excluir este carretel? Esta ação não pode ser desfeita.',
archiveConfirm: 'Tem certeza de que deseja arquivar este carretel?',
advancedSettings: 'Configurações Avançadas',
@@ -4938,6 +4957,7 @@ export default {
creating: 'Criando...',
spoolCreated: 'Bobina criada! Pronto para gravar.',
createFailed: 'Falha ao criar bobina',
incompleteDataWarning: 'Tag gravada com dados Spoolman incompletos',
},
quickMenu: {
printerPower: 'Energia da impressora',
+20
View File
@@ -3275,6 +3275,9 @@ export default {
measuredWeight: '称量重量',
spoolName: '线轴',
costPerKg: '每公斤成本',
storageLocation: '存放位置',
storageLocationPlaceholder: '例如:货架A,抽屉1',
openInInventory: '在库存中查看',
measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
slicerFilament: '切片耗材',
slicerFilamentName: '切片预设名称',
@@ -3313,11 +3316,27 @@ export default {
stock: '库存',
configured: '已配置',
spoolsCreated: '已创建 {{count}} 个耗材',
spoolsPartiallyCreated: '已创建 {{created}} / {{total}} 个耗材(部分失败)',
spoolCreated: '耗材已创建',
spoolUpdated: '耗材已更新',
spoolDeleted: '耗材已删除',
deepLinkSpoolNotFound: '未找到耗材',
deepLinkFetchFailed: '无法加载耗材 — 请重试',
spoolArchived: '耗材已归档',
spoolRestored: '耗材已恢复',
kProfileSaveFailed: 'K值配置文件设置无法保存',
syncWeightSpoolNotFound: '未找到耗材 — 可能已被删除',
syncWeightSpoolmanUnreachable: 'Spoolman 无法访问 — 请稍后再试',
syncWeightFailed: '重量同步失败',
spoolmanUnreachable: 'Spoolman 无法访问 — 请稍后再试',
deleteSpoolNotFound: '未找到耗材 — 可能已被删除',
deleteFailed: '删除耗材失败',
archiveSpoolNotFound: '未找到耗材 — 可能已被删除',
archiveFailed: '归档耗材失败',
restoreSpoolNotFound: '未找到耗材 — 可能已被删除',
restoreFailed: '恢复耗材失败',
saveFailed: '保存更改失败',
tagClearFailed: '清除标签失败',
deleteConfirm: '确定要删除此耗材吗?此操作无法撤销。',
archiveConfirm: '确定要归档此耗材吗?',
advancedSettings: '高级设置',
@@ -5002,6 +5021,7 @@ export default {
creating: '创建中...',
spoolCreated: '耗材已创建!准备写入。',
createFailed: '创建耗材失败',
incompleteDataWarning: '已使用不完整的Spoolman数据写入标签',
},
quickMenu: {
printerPower: '打印机电源',
+20
View File
@@ -3275,6 +3275,9 @@ export default {
measuredWeight: '稱量重量',
spoolName: '料盤',
costPerKg: '每公斤成本',
storageLocation: '存放位置',
storageLocationPlaceholder: '例如:貨架A,抽屜1',
openInInventory: '在庫存中查看',
measuredWeightError: '稱量重量必須在 {{min}}g 到 {{max}}g 之間。',
slicerFilament: '切片耗材',
slicerFilamentName: '切片預設名稱',
@@ -3313,11 +3316,27 @@ export default {
stock: '庫存',
configured: '已設定',
spoolsCreated: '已建立 {{count}} 個耗材',
spoolsPartiallyCreated: '已建立 {{created}} / {{total}} 個耗材(部分失敗)',
spoolCreated: '耗材已建立',
spoolUpdated: '耗材已更新',
spoolDeleted: '耗材已刪除',
deepLinkSpoolNotFound: '找不到耗材',
deepLinkFetchFailed: '無法載入耗材 — 請重試',
spoolArchived: '耗材已歸檔',
spoolRestored: '耗材已恢復',
kProfileSaveFailed: 'K值設定檔設定無法儲存',
syncWeightSpoolNotFound: '找不到耗材 — 可能已被刪除',
syncWeightSpoolmanUnreachable: 'Spoolman 無法存取 — 請稍後再試',
syncWeightFailed: '重量同步失敗',
spoolmanUnreachable: 'Spoolman 無法存取 — 請稍後再試',
deleteSpoolNotFound: '找不到耗材 — 可能已被刪除',
deleteFailed: '刪除耗材失敗',
archiveSpoolNotFound: '找不到耗材 — 可能已被刪除',
archiveFailed: '歸檔耗材失敗',
restoreSpoolNotFound: '找不到耗材 — 可能已被刪除',
restoreFailed: '恢復耗材失敗',
saveFailed: '儲存變更失敗',
tagClearFailed: '清除標籤失敗',
deleteConfirm: '確定要刪除此耗材嗎?此操作無法復原。',
archiveConfirm: '確定要歸檔此耗材嗎?',
advancedSettings: '進階設定',
@@ -5002,6 +5021,7 @@ export default {
creating: '建立中...',
spoolCreated: '耗材已建立!準備寫入。',
createFailed: '建立耗材失敗',
incompleteDataWarning: '已使用不完整的Spoolman資料寫入標籤',
},
quickMenu: {
printerPower: '印表機電源',
+147 -69
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect, type ReactNode } from 'react';
import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -7,7 +8,7 @@ import {
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
} from 'lucide-react';
import { api, spoolbuddyApi } from '../api/client';
import { api, spoolbuddyApi, ApiError } from '../api/client';
import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
import { Button } from '../components/Button';
import { SpoolFormModal } from '../components/SpoolFormModal';
@@ -48,6 +49,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'brand', label: 'Brand', visible: true },
{ id: 'slicer_filament', label: 'Slicer Filament', visible: false },
{ id: 'location', label: 'Location', visible: true },
{ id: 'storage_location', label: 'Storage Location', visible: false },
{ id: 'label_weight', label: 'Label', visible: true },
{ id: 'net', label: 'Net', visible: true },
{ id: 'gross', label: 'Gross', visible: false },
@@ -145,6 +147,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
brand: (t) => t('inventory.brand'),
slicer_filament: (t) => t('inventory.slicerFilament'),
location: () => 'Location',
storage_location: (t) => t('inventory.storageLocation'),
label_weight: (t) => t('inventory.labelWeight'),
net: (t) => t('inventory.net'),
gross: () => 'Gross',
@@ -217,6 +220,14 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
</span>
);
},
storage_location: ({ spool }) => {
if (!spool.storage_location) return <span className="text-sm text-bambu-gray">-</span>;
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-400">
{spool.storage_location}
</span>
);
},
label_weight: ({ spool }) => (
<span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
),
@@ -369,6 +380,7 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
const label = a.ams_label ? ` (${a.ams_label})` : '';
return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;
},
storage_location: (s) => (s.storage_location || '').toLowerCase(),
label_weight: (s) => s.label_weight,
net: (s) => Math.max(0, s.label_weight - s.weight_used),
gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
@@ -407,74 +419,28 @@ function saveSortState(state: SortState) {
} catch { /* ignore */ }
}
// Wrapper: when Spoolman is enabled, embed its UI; otherwise show internal inventory
// Wrapper: detects Spoolman mode and passes it to the shared inventory UI
export default function InventoryPageRouter() {
const { t } = useTranslation();
const { data: spoolmanSettings } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: api.getSpoolmanSettings,
staleTime: 5 * 60 * 1000,
});
if (spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url) {
const spoolmanUrl = spoolmanSettings.spoolman_url.replace(/\/+$/, '');
// Browsers block HTTP iframes inside HTTPS parents (mixed-content rule,
// independent of CSP). Spoolman must be reachable over the same protocol
// as Bambuddy. Surfacing a targeted error here beats the silent blank
// iframe users otherwise see. See issue #1096.
const bambuddyIsHttps = window.location.protocol === 'https:';
const spoolmanIsHttp = spoolmanUrl.toLowerCase().startsWith('http://');
if (bambuddyIsHttps && spoolmanIsHttp) {
return (
<div className="p-6 max-w-3xl mx-auto">
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-5">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0 space-y-2 text-sm">
<p className="font-semibold text-amber-900 dark:text-amber-100">
{t('inventory.spoolmanMixedContentTitle')}
</p>
<p className="text-amber-800 dark:text-amber-200">
{t('inventory.spoolmanMixedContentBody')}
</p>
<ul className="list-disc pl-5 space-y-1 text-amber-800 dark:text-amber-200">
<li>{t('inventory.spoolmanMixedContentFixReverseProxy')}</li>
<li>{t('inventory.spoolmanMixedContentFixOpenNewTab')}</li>
</ul>
<div className="flex flex-wrap gap-2 pt-2">
<a
href={`${spoolmanUrl}/spool`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-amber-600 hover:bg-amber-700 text-white"
>
{t('inventory.spoolmanOpenInNewTab')}
</a>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<iframe
src={`${spoolmanUrl}/spool`}
className="h-full w-full border-0"
title="Spoolman"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/>
);
}
const spoolmanModeReady = spoolmanSettings !== undefined;
const spoolmanMode =
spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
return <InventoryPage />;
return <InventoryPage spoolmanMode={spoolmanMode} spoolmanModeReady={spoolmanModeReady} />;
}
function InventoryPage() {
function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spoolmanMode?: boolean; spoolmanModeReady?: boolean }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
const deepLinkHandled = useRef(false);
const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
// Filter state
@@ -516,12 +482,83 @@ function InventoryPage() {
const dateFormat: DateFormat = settings?.date_format || 'system';
// Query key and fetch function differ based on data source
const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'];
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
queryKey: spoolsQueryKey,
queryFn: () =>
spoolmanMode ? api.getSpoolmanInventorySpools(true) : api.getSpools(true),
refetchInterval: 30000,
});
// Deep-link: open edit modal for ?spool=<id>
// Prefer the already-loaded spool list (no extra API call); fall back to a
// targeted fetch for the rare case where the full list hasn't arrived yet.
const _rawSpoolParam = searchParams.get('spool');
// Only accept strings of digits representing a positive integer — guards against
// NaN (Number('abc')), 0, negatives, and floats like '1.5' that would produce
// an invalid path parameter and trigger unnecessary 422 responses from the API.
const deepLinkSpoolId =
_rawSpoolParam && /^\d+$/.test(_rawSpoolParam) && Number(_rawSpoolParam) > 0
? Number(_rawSpoolParam)
: null;
const deepLinkInList = spools?.find((s) => s.id === deepLinkSpoolId) ?? null;
const clearDeepLinkParam = useCallback(() => {
deepLinkHandled.current = true;
setSearchParams((prev) => { prev.delete('spool'); return prev; }, { replace: true });
}, [setSearchParams]);
// Targeted fetch — only fires when mode is known and spool isn't in the list yet
const { data: deepLinkSpool, isError: deepLinkFetchFailed, error: deepLinkError } = useQuery({
queryKey: spoolmanMode
? ['spoolman-inventory-spool', deepLinkSpoolId]
: ['inventory-spool', deepLinkSpoolId],
queryFn: () =>
spoolmanMode
? api.getSpoolmanInventorySpool(deepLinkSpoolId!)
: api.getSpool(deepLinkSpoolId!),
enabled: spoolmanModeReady && deepLinkSpoolId !== null && deepLinkInList === null,
staleTime: Infinity,
retry: (failureCount, error) =>
failureCount < 2 && !(error instanceof ApiError && error.status === 404),
});
useEffect(() => {
if (deepLinkHandled.current) return;
// Case 1: spool is already in the fetched list
if (spoolmanModeReady && deepLinkSpoolId && deepLinkInList) {
clearDeepLinkParam();
setFormModal({ spool: deepLinkInList });
return;
}
// Case 2: spool was fetched individually
if (deepLinkSpool) {
clearDeepLinkParam();
setFormModal({ spool: deepLinkSpool });
return;
}
// Case 3: fetch failed
if (deepLinkFetchFailed) {
clearDeepLinkParam();
const is404 = deepLinkError instanceof ApiError && deepLinkError.status === 404;
showToast(t(is404 ? 'inventory.deepLinkSpoolNotFound' : 'inventory.deepLinkFetchFailed'), 'error');
}
}, [
spoolmanModeReady,
deepLinkSpoolId,
deepLinkInList,
deepLinkSpool,
deepLinkFetchFailed,
deepLinkError,
clearDeepLinkParam,
showToast,
t,
]);
const { data: assignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
@@ -534,38 +571,76 @@ function InventoryPage() {
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteSpool(id),
mutationFn: (id: number) =>
spoolmanMode ? api.deleteSpoolmanInventorySpool(id) : api.deleteSpool(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.spoolDeleted'), 'success');
},
onError: (error: Error) => {
if (error instanceof ApiError && error.status === 404) {
showToast(t('inventory.deleteSpoolNotFound'), 'error');
} else if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.deleteFailed'), 'error');
}
},
});
const archiveMutation = useMutation({
mutationFn: (id: number) => api.archiveSpool(id),
mutationFn: (id: number) =>
spoolmanMode ? api.archiveSpoolmanInventorySpool(id) : api.archiveSpool(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.spoolArchived'), 'success');
},
onError: (error: Error) => {
if (error instanceof ApiError && error.status === 404) {
showToast(t('inventory.archiveSpoolNotFound'), 'error');
} else if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.archiveFailed'), 'error');
}
},
});
const restoreMutation = useMutation({
mutationFn: (id: number) => api.restoreSpool(id),
mutationFn: (id: number) =>
spoolmanMode ? api.restoreSpoolmanInventorySpool(id) : api.restoreSpool(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
showToast(t('inventory.spoolRestored'), 'success');
},
onError: (error: Error) => {
if (error instanceof ApiError && error.status === 404) {
showToast(t('inventory.restoreSpoolNotFound'), 'error');
} else if (error instanceof ApiError && error.status === 503) {
showToast(t('inventory.spoolmanUnreachable'), 'error');
} else {
showToast(t('inventory.restoreFailed'), 'error');
}
},
});
const handleSyncWeight = async (spool: InventorySpool) => {
if (spool.last_scale_weight == null) return;
try {
await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
if (spoolmanMode) {
await api.syncSpoolmanSpoolWeight(spool.id, spool.last_scale_weight);
} else {
await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
}
queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');
showToast(`Synced "${spoolName}" to scale weight`, 'success');
} catch {
showToast('Failed to sync weight', 'error');
} catch (e) {
const is404 = e instanceof ApiError && e.status === 404;
const is503 = e instanceof ApiError && e.status === 503;
if (is404) showToast(t('inventory.syncWeightSpoolNotFound'), 'error');
else if (is503) showToast(t('inventory.syncWeightSpoolmanUnreachable'), 'error');
else showToast(t('inventory.syncWeightFailed'), 'error');
}
};
@@ -703,7 +778,8 @@ function InventoryPage() {
s.color_name?.toLowerCase().includes(q) ||
s.subtype?.toLowerCase().includes(q) ||
s.note?.toLowerCase().includes(q) ||
s.slicer_filament_name?.toLowerCase().includes(q)
s.slicer_filament_name?.toLowerCase().includes(q) ||
s.storage_location?.toLowerCase().includes(q)
);
}
@@ -1512,6 +1588,8 @@ function InventoryPage() {
onClose={() => setFormModal(null)}
spool={formModal.spool}
currencySymbol={currencySymbol}
spoolmanMode={spoolmanMode}
spoolsQueryKey={spoolsQueryKey}
/>
)}
@@ -52,6 +52,13 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
);
}
// Renders inventory directly via React instead of embedding Spoolman's own UI in an
// iframe. The iframe approach was dropped because this component lives inside the
// SpoolBuddy shell and already has direct access to the same auth/query context,
// making the iframe an unnecessary dependency on the Spoolman server being reachable
// from the browser. No feature flag guards this: the internal UI is strictly superior
// (works offline, supports local spools) and the raw Spoolman URL remains accessible
// directly from the Settings page for users who want it.
export function SpoolBuddyInventoryPage() {
const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
const { t } = useTranslation();
@@ -66,9 +73,12 @@ export function SpoolBuddyInventoryPage() {
staleTime: 5 * 60 * 1000,
});
const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(false),
queryKey: spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'],
queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpools(false) : api.getSpools(false),
enabled: spoolmanSettings !== undefined,
refetchInterval: 30000,
});
@@ -128,21 +138,6 @@ export function SpoolBuddyInventoryPage() {
});
}, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
// Spoolman iframe mode
const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
if (spoolmanEnabled) {
return (
<div className="h-full flex flex-col">
<iframe
src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
className="flex-1 w-full border-0"
title="Spoolman"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Search + filter pills */}
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useToast } from '../../contexts/ToastContext';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import {
api,
@@ -34,6 +35,7 @@ const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC',
export function SpoolBuddyWriteTagPage() {
const { t } = useTranslation();
const { showToast } = useToast();
const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
const [activeTab, setActiveTab] = useState<Tab>('existing');
@@ -163,7 +165,12 @@ export function SpoolBuddyWriteTagPage() {
setWriteStatus('writing');
setWriteMessage(t('spoolbuddy.writeTag.waiting', 'Waiting for SpoolBuddy...'));
try {
await spoolbuddyApi.writeTag(device.device_id, selectedSpool.id);
const resp = await spoolbuddyApi.writeTag(device.device_id, selectedSpool.id);
if (resp?.warnings?.length) {
for (const w of resp.warnings) {
showToast(w, 'warning');
}
}
} catch {
setWriteStatus('error');
setWriteMessage(t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'));