+
+
+ {t("print_queue.title")}
+
+ }
+ onClick={() => {
+ invalidate({ resource: "print-job", invalidates: ["list"] });
+ invalidate({ resource: "spool", invalidates: ["list"] });
+ }}
+ >
+ {t("buttons.refresh")}
+
+
+
+ {/* Stats cards */}
+
+
+
+
+
+
+
+
+ 0 ? token.colorError : undefined }}
+ />
+
+
+
+
+ dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
+ valueStyle={{ color: token.colorSuccess }}
+ />
+
+
+
+
+
+
+
+
+
+ {/* Pending Jobs */}
+
+ {t("print_queue.pending_jobs")}
+
+ }
+ style={{ marginBottom: 24 }}
+ >
+ {pendingJobs.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Spools Needing Weighing */}
+ {(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
+
+ {t("print_queue.needs_weighing")}
+
+ }
+ style={{ marginBottom: 24 }}
+ >
+
+
+ )}
+
+ {/* Recent History */}
+
+ {completedJobs.length > 0 || cancelledJobs.length > 0 ? (
+ dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
+ ).slice(0, 10)}
+ columns={historyColumns}
+ rowKey="id"
+ pagination={false}
+ loading={isLoading}
+ />
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default PrintQueue;
diff --git a/client/src/pages/spools/model.tsx b/client/src/pages/spools/model.tsx
index 2ecf46f..dbaba11 100644
--- a/client/src/pages/spools/model.tsx
+++ b/client/src/pages/spools/model.tsx
@@ -23,9 +23,22 @@ export interface ISpool {
lot_nr?: string;
comment?: string;
archived: boolean;
+ needs_weighing: boolean;
extra: { [key: string]: string };
}
+export interface IPrintJob {
+ id: number;
+ spool_id: number;
+ spool?: ISpool;
+ created: string;
+ finished?: string;
+ filename: string;
+ filament_used_g: number;
+ filament_used_mm?: number;
+ status: "pending" | "completed" | "cancelled";
+}
+
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit & { extra?: { [key: string]: unknown } };
diff --git a/pyproject.toml b/pyproject.toml
index 79b91a2..d66ac7e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "spoolman"
-version = "0.23C.2"
+version = "0.23C.3"
description = "A web service that keeps track of 3D printing spools."
authors = [
{ name = "Tony", email = "tony@nastynas.xyz" },
diff --git a/scripts/slicer_post_process.py b/scripts/slicer_post_process.py
new file mode 100755
index 0000000..e604d95
--- /dev/null
+++ b/scripts/slicer_post_process.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""
+Elegoo Slicer / Orca Slicer Post-Processing Script for Spoolman Integration.
+
+This script parses G-code files to extract filament usage information and creates
+a pending print job in Spoolman. After the print completes, use the Spoolman
+Print Queue UI to confirm the print (deducts filament) or cancel it (flags for weighing).
+
+Usage:
+ 1. In Elegoo Slicer, go to Printer Settings > Post-processing Scripts
+ 2. Add the path to this script: /path/to/slicer_post_process.py
+ 3. Configure the environment variables below or create a config file
+
+Configuration:
+ Set these environment variables:
+ - SPOOLMAN_URL: Your Spoolman instance URL (e.g., http://192.168.0.11:8000)
+ - SPOOLMAN_SPOOL_ID: Default spool ID to use (or set per-filament in slicer)
+
+ Or create a file at ~/.config/spoolman/slicer.conf:
+ [spoolman]
+ url = http://192.168.0.11:8000
+ spool_id = 1
+
+The script will:
+ 1. Read the G-code file passed as argument
+ 2. Parse Elegoo/Orca slicer comments for filament usage
+ 3. Create a pending print job in Spoolman via API
+ 4. The job appears in the Print Queue for confirmation after printing
+"""
+
+import configparser
+import os
+import re
+import sys
+import urllib.request
+import json
+from pathlib import Path
+
+
+def get_config() -> tuple[str, int]:
+ """Get Spoolman URL and default spool ID from environment or config file."""
+ url = os.environ.get("SPOOLMAN_URL")
+ spool_id = os.environ.get("SPOOLMAN_SPOOL_ID")
+
+ if not url:
+ config_path = Path.home() / ".config" / "spoolman" / "slicer.conf"
+ if config_path.exists():
+ config = configparser.ConfigParser()
+ config.read(config_path)
+ url = config.get("spoolman", "url", fallback=None)
+ spool_id = config.get("spoolman", "spool_id", fallback=None)
+
+ if not url:
+ print("ERROR: SPOOLMAN_URL not set. Set environment variable or create config file.", file=sys.stderr)
+ sys.exit(1)
+
+ if not spool_id:
+ print("ERROR: SPOOLMAN_SPOOL_ID not set. Set environment variable or create config file.", file=sys.stderr)
+ sys.exit(1)
+
+ return url.rstrip("/"), int(spool_id)
+
+
+def parse_gcode(filepath: str) -> dict:
+ """
+ Parse G-code file for filament usage information.
+
+ Looks for Elegoo/Orca Slicer comments like:
+ ; total filament used [g] = 12.34
+ ; filament used [mm] = 4567.89
+ ; filament used [g] = 12.34
+
+ Also checks for PrusaSlicer format:
+ ; filament used [mm] = 4567.89
+ ; filament used [g] = 12.34
+ """
+ result = {
+ "filename": Path(filepath).name,
+ "filament_used_g": None,
+ "filament_used_mm": None,
+ }
+
+ # Patterns for different slicers
+ patterns = {
+ # Elegoo/Orca format
+ "total_filament_g": re.compile(r";\s*total filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE),
+ "filament_g": re.compile(r";\s*filament used \[g\]\s*=\s*([\d.]+)", re.IGNORECASE),
+ "filament_mm": re.compile(r";\s*filament used \[mm\]\s*=\s*([\d.]+)", re.IGNORECASE),
+ # Alternative formats
+ "filament_weight": re.compile(r";\s*(?:total )?filament (?:weight|cost)\s*=\s*([\d.]+)\s*g", re.IGNORECASE),
+ "filament_length": re.compile(r";\s*(?:total )?filament\s*=\s*([\d.]+)\s*mm", re.IGNORECASE),
+ }
+
+ try:
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
+ # Only read first 500 lines and last 500 lines for efficiency
+ lines = f.readlines()
+ header = lines[:500]
+ footer = lines[-500:] if len(lines) > 500 else []
+ check_lines = header + footer
+
+ for line in check_lines:
+ if not line.startswith(";"):
+ continue
+
+ # Check each pattern
+ for key, pattern in patterns.items():
+ match = pattern.search(line)
+ if match:
+ value = float(match.group(1))
+ if "g" in key or "weight" in key:
+ if result["filament_used_g"] is None:
+ result["filament_used_g"] = value
+ elif "mm" in key or "length" in key:
+ if result["filament_used_mm"] is None:
+ result["filament_used_mm"] = value
+
+ except Exception as e:
+ print(f"Warning: Error parsing G-code: {e}", file=sys.stderr)
+
+ return result
+
+
+def create_print_job(url: str, spool_id: int, gcode_info: dict) -> bool:
+ """Create a pending print job in Spoolman."""
+ if gcode_info["filament_used_g"] is None:
+ print("Warning: Could not find filament weight in G-code. Skipping job creation.", file=sys.stderr)
+ return False
+
+ api_url = f"{url}/api/v1/print-job"
+
+ payload = {
+ "spool_id": spool_id,
+ "filename": gcode_info["filename"],
+ "filament_used_g": gcode_info["filament_used_g"],
+ }
+
+ if gcode_info["filament_used_mm"] is not None:
+ payload["filament_used_mm"] = gcode_info["filament_used_mm"]
+
+ try:
+ data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(
+ api_url,
+ data=data,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+
+ with urllib.request.urlopen(req, timeout=10) as response:
+ if response.status in (200, 201):
+ result = json.loads(response.read().decode("utf-8"))
+ print(f"Created print job #{result['id']}: {gcode_info['filename']} ({gcode_info['filament_used_g']:.1f}g)")
+ return True
+ else:
+ print(f"Error: API returned status {response.status}", file=sys.stderr)
+ return False
+
+ except urllib.error.HTTPError as e:
+ error_body = e.read().decode("utf-8") if e.fp else ""
+ print(f"Error: API request failed: {e.code} {e.reason} - {error_body}", file=sys.stderr)
+ return False
+ except urllib.error.URLError as e:
+ print(f"Error: Could not connect to Spoolman: {e.reason}", file=sys.stderr)
+ return False
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return False
+
+
+def main():
+ """Main entry point."""
+ if len(sys.argv) < 2:
+ print("Usage: slicer_post_process.py ", file=sys.stderr)
+ print(__doc__)
+ sys.exit(1)
+
+ gcode_path = sys.argv[1]
+
+ if not os.path.exists(gcode_path):
+ print(f"Error: G-code file not found: {gcode_path}", file=sys.stderr)
+ sys.exit(1)
+
+ # Get configuration
+ url, spool_id = get_config()
+
+ # Parse G-code
+ print(f"Parsing G-code: {gcode_path}")
+ gcode_info = parse_gcode(gcode_path)
+
+ if gcode_info["filament_used_g"]:
+ print(f" Filament: {gcode_info['filament_used_g']:.2f}g", end="")
+ if gcode_info["filament_used_mm"]:
+ print(f" ({gcode_info['filament_used_mm']:.1f}mm)")
+ else:
+ print()
+ else:
+ print(" Warning: Could not parse filament usage from G-code")
+
+ # Create print job
+ success = create_print_job(url, spool_id, gcode_info)
+
+ if success:
+ print(f"Print job created in Spoolman ({url})")
+ print("After printing, use the Print Queue to confirm or cancel the job.")
+ else:
+ print("Failed to create print job. The print will proceed without tracking.", file=sys.stderr)
+
+ # Always exit 0 so the slicer continues with the print
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py
index 77428f6..aa91283 100644
--- a/spoolman/api/v1/spool.py
+++ b/spoolman/api/v1/spool.py
@@ -264,6 +264,10 @@ async def find(
bool,
Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
] = False,
+ needs_weighing: Annotated[
+ Optional[bool],
+ Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."),
+ ] = None,
sort: Annotated[
Optional[str],
Query(
@@ -308,6 +312,7 @@ async def find(
location=location,
lot_nr=lot_nr,
allow_archived=allow_archived,
+ needs_weighing=needs_weighing,
sort_by=sort_by,
limit=limit,
offset=offset,
diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py
index 677f7a4..744c9d5 100644
--- a/spoolman/database/spool.py
+++ b/spoolman/database/spool.py
@@ -123,6 +123,7 @@ async def find( # noqa: C901, PLR0912
location: Optional[str] = None,
lot_nr: Optional[str] = None,
allow_archived: bool = False,
+ needs_weighing: Optional[bool] = None,
sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None,
offset: int = 0,
@@ -158,6 +159,17 @@ async def find( # noqa: C901, PLR0912
),
)
+ if needs_weighing is not None:
+ if needs_weighing:
+ stmt = stmt.where(models.Spool.needs_weighing.is_(True))
+ else:
+ stmt = stmt.where(
+ sqlalchemy.or_(
+ models.Spool.needs_weighing.is_(False),
+ models.Spool.needs_weighing.is_(None),
+ ),
+ )
+
total_count = None
if limit is not None: