mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
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:
+15
-2
@@ -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.
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
@@ -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)
|
||||
+228
-23
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'プリンター電源',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '打印机电源',
|
||||
|
||||
@@ -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: '印表機電源',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user