Add Print Queue UI and slicer post-processing script
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

- Create Print Queue page showing pending/completed/cancelled jobs
- Add needs_weighing filter to spool API for flagged spools
- Add IPrintJob interface and needs_weighing to ISpool model
- Add slicer_post_process.py for Elegoo/Orca Slicer integration
- Add translations for print queue feature
- Bump version to 0.23C.3

The workflow:
1. Slicer post-processing script creates pending print job
2. After print, user confirms (deducts filament) or cancels (flags for weighing)
3. Spools needing weigh-in are shown in Print Queue UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 21:40:22 -06:00
parent 4cd58ebae9
commit 9365e399be
9 changed files with 797 additions and 7 deletions

View File

@@ -136,12 +136,12 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
| 8 | (see Gitea #8) | ✅ DONE | | 8 | (see Gitea #8) | ✅ DONE |
| 9 | Hierarchical Locations (Room/Bin) | Open | | 9 | Hierarchical Locations (Room/Bin) | Open |
| 10 | Database Import/Export | ✅ DONE | | 10 | Database Import/Export | ✅ DONE |
| 11 | Orca Slicer / Elegoo Slicer Integration | Open | | 11 | Theme Colors | ✅ DONE |
| - | Theme Colors | ✅ DONE | | 12 | Clickable Dashboard Cards | ✅ DONE |
| - | Clickable Dashboard Cards | ✅ DONE | | 13 | QR Code Size Setting | ✅ DONE |
| - | QR Code Size Setting | ✅ DONE | | 14 | Parse Temps from Comments | ✅ DONE |
| - | Parse Temps from Comments | ✅ DONE | | 15 | Print Job Tracking / Slicer Integration | ✅ DONE |
| - | Prev/Next Navigation on Edit Pages | ✅ DONE | | 16 | Prev/Next Navigation on Edit Pages | ✅ DONE |
### Issue #1 Complete - Inline Creation Modals ### Issue #1 Complete - Inline Creation Modals
@@ -314,6 +314,38 @@ Added explicit QR code size control:
- `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface - `client/src/pages/printing/printing.tsx` - Added `qrCodeSize` to interface
- `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto) - `client/src/pages/printing/qrCodePrintingDialog.tsx` - Slider for size in mm (0=auto)
### Issue #15 Complete - Print Job Tracking / Slicer Integration
Track pending print jobs from slicer and manage filament deduction after prints complete.
**Workflow:**
1. Slicer post-processing script parses G-code for filament usage
2. Script creates pending print job in Spoolman via API
3. After print completes, user visits Print Queue UI
4. Complete: Deducts filament from spool, records adjustment history
5. Cancel: Flags spool for manual weigh-in (no automatic deduction)
**Backend:**
- `spoolman/database/models.py` - `PrintJob` model, `needs_weighing` field on Spool
- `spoolman/database/print_job.py` - CRUD operations for print jobs
- `spoolman/api/v1/print_job.py` - REST API endpoints
- `POST /print-job` - Create pending job
- `POST /print-job/{id}/complete` - Complete job, deduct filament
- `POST /print-job/{id}/cancel` - Cancel job, flag spool for weighing
- `GET /print-job` - List jobs with status filter
- `migrations/versions/2025_01_15_0400-c3d4e5f6g7h8_add_print_jobs.py`
**Frontend:**
- `client/src/pages/print-queue/index.tsx` - Print Queue UI page
- `client/src/pages/spools/model.tsx` - `IPrintJob` interface, `needs_weighing` on ISpool
**Slicer Script:**
- `scripts/slicer_post_process.py` - Elegoo/Orca Slicer post-processing script
- Parses G-code comments for filament usage (weight in grams)
- Creates pending print job via API
- Configure via env vars: `SPOOLMAN_URL`, `SPOOLMAN_SPOOL_ID`
- Or config file: `~/.config/spoolman/slicer.conf`
--- ---
## Deployment ## Deployment

View File

@@ -450,5 +450,53 @@
"new_location": "New Location", "new_location": "New Location",
"no_location": "No Location", "no_location": "No Location",
"no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!" "no_locations_help": "This page lets you organize your spools in locations, add some spools to get started!"
},
"print-queue": {
"print-queue": "Print Queue"
},
"print_queue": {
"title": "Print Queue",
"pending_jobs": "Pending Jobs",
"needs_weighing": "Spools Needing Weigh-In",
"recent_history": "Recent History",
"no_pending_jobs": "No pending print jobs",
"no_history": "No completed or cancelled jobs yet",
"fields": {
"filename": "Filename",
"spool": "Spool",
"filament_used": "Filament Used",
"created": "Created",
"finished": "Finished",
"status": "Status"
},
"status": {
"pending": "Pending",
"completed": "Completed",
"cancelled": "Cancelled"
},
"stats": {
"pending": "Pending",
"needs_weighing": "Need Weigh-In",
"completed_today": "Completed Today",
"total_jobs": "Total Jobs"
},
"complete": "Complete",
"cancel": "Cancel",
"cancel_job": "Cancel Job",
"weigh_in": "Weigh In",
"clear_flag": "Clear Flag",
"clear_flag_tooltip": "Mark as weighed without updating weight",
"confirm_complete": "Complete Print Job?",
"confirm_complete_description": "This will deduct {{grams}}g from the spool.",
"confirm_cancel": "Cancel Print Job?",
"confirm_cancel_description": "The spool will be flagged for manual weigh-in since filament usage is unknown.",
"confirm_delete": "Delete Print Job?",
"confirm_delete_description": "This will permanently remove this job from history.",
"job_completed": "Print job completed",
"job_complete_error": "Failed to complete print job",
"job_cancelled": "Print job cancelled - spool flagged for weigh-in",
"job_cancel_error": "Failed to cancel print job",
"job_deleted": "Print job deleted",
"weighing_cleared": "Weigh-in flag cleared"
} }
} }

View File

@@ -9,6 +9,7 @@ import {
FileOutlined, FileOutlined,
HighlightOutlined, HighlightOutlined,
HomeOutlined, HomeOutlined,
PrinterOutlined,
QuestionOutlined, QuestionOutlined,
TableOutlined, TableOutlined,
ToolOutlined, ToolOutlined,
@@ -142,6 +143,14 @@ function App() {
icon: <UserOutlined />, icon: <UserOutlined />,
}, },
}, },
{
name: "print-queue",
list: "/print-queue",
meta: {
canDelete: false,
icon: <PrinterOutlined />,
},
},
{ {
name: "locations", name: "locations",
list: "/locations", list: "/locations",
@@ -225,6 +234,7 @@ function App() {
<Route path="/settings/*" element={<LoadablePage name="settings" />} /> <Route path="/settings/*" element={<LoadablePage name="settings" />} />
<Route path="/help" element={<LoadablePage name="help" />} /> <Route path="/help" element={<LoadablePage name="help" />} />
<Route path="/locations" element={<LoadablePage name="locations" />} /> <Route path="/locations" element={<LoadablePage name="locations" />} />
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
<Route path="*" element={<ErrorComponent />} /> <Route path="*" element={<ErrorComponent />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -0,0 +1,456 @@
import {
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
FileOutlined,
ReloadOutlined,
ToolOutlined,
} from "@ant-design/icons";
import { IResourceComponentsProps, useCustomMutation, useInvalidate, useList, useTranslate } from "@refinedev/core";
import { Badge, Button, Card, Col, Empty, Modal, Row, Space, Statistic, Table, Tag, theme, Tooltip } from "antd";
import { Content } from "antd/es/layout/layout";
import Title from "antd/es/typography/Title";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import utc from "dayjs/plugin/utc";
import React, { useMemo } from "react";
import { Link } from "react-router";
import { IPrintJob, ISpool } from "../spools/model";
dayjs.extend(utc);
dayjs.extend(relativeTime);
const { useToken } = theme;
const { confirm } = Modal;
export const PrintQueue: React.FC<IResourceComponentsProps> = () => {
const { token } = useToken();
const t = useTranslate();
const invalidate = useInvalidate();
const { mutate: customMutate } = useCustomMutation();
// Fetch print jobs
const { result: printJobs, query: printJobsQuery } = useList<IPrintJob>({
resource: "print-job",
pagination: { mode: "off" },
sorters: [{ field: "created", order: "desc" }],
});
// Fetch spools that need weighing
const { result: spoolsNeedingWeighing, query: spoolsQuery } = useList<ISpool>({
resource: "spool",
filters: [{ field: "needs_weighing", operator: "eq", value: true }],
pagination: { mode: "off" },
});
// Categorize jobs
const { pendingJobs, completedJobs, cancelledJobs } = useMemo(() => {
const jobs = printJobs?.data || [];
return {
pendingJobs: jobs.filter((j) => j.status === "pending"),
completedJobs: jobs.filter((j) => j.status === "completed").slice(0, 10),
cancelledJobs: jobs.filter((j) => j.status === "cancelled").slice(0, 10),
};
}, [printJobs]);
const handleComplete = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_complete"),
content: t("print_queue.confirm_complete_description", { grams: job.filament_used_g.toFixed(1) }),
icon: <CheckCircleOutlined style={{ color: token.colorSuccess }} />,
okText: t("buttons.confirm"),
okType: "primary",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}/complete`,
method: "post",
values: {},
successNotification: {
message: t("print_queue.job_completed"),
type: "success",
},
errorNotification: {
message: t("print_queue.job_complete_error"),
type: "error",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
},
});
};
const handleCancel = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_cancel"),
content: t("print_queue.confirm_cancel_description"),
icon: <ExclamationCircleOutlined style={{ color: token.colorWarning }} />,
okText: t("print_queue.cancel_job"),
okType: "danger",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}/cancel`,
method: "post",
values: {},
successNotification: {
message: t("print_queue.job_cancelled"),
type: "success",
},
errorNotification: {
message: t("print_queue.job_cancel_error"),
type: "error",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
},
});
};
const handleDelete = (job: IPrintJob) => {
confirm({
title: t("print_queue.confirm_delete"),
content: t("print_queue.confirm_delete_description"),
icon: <DeleteOutlined style={{ color: token.colorError }} />,
okText: t("buttons.delete"),
okType: "danger",
cancelText: t("buttons.cancel"),
onOk: () => {
customMutate({
url: `print-job/${job.id}`,
method: "delete",
values: {},
successNotification: {
message: t("print_queue.job_deleted"),
type: "success",
},
}, {
onSuccess: () => {
invalidate({ resource: "print-job", invalidates: ["list"] });
},
});
},
});
};
const handleClearWeighingFlag = (spool: ISpool) => {
customMutate({
url: `spool/${spool.id}`,
method: "patch",
values: { needs_weighing: false },
successNotification: {
message: t("print_queue.weighing_cleared"),
type: "success",
},
}, {
onSuccess: () => {
invalidate({ resource: "spool", invalidates: ["list", "detail"] });
},
});
};
const getSpoolName = (job: IPrintJob) => {
if (job.spool?.filament) {
const fil = job.spool.filament;
if (fil.vendor?.name) {
return `${fil.vendor.name} - ${fil.name || fil.material}`;
}
return fil.name || fil.material || `Filament #${fil.id}`;
}
return `Spool #${job.spool_id}`;
};
const pendingColumns = [
{
title: t("print_queue.fields.filename"),
dataIndex: "filename",
key: "filename",
render: (filename: string) => (
<Space>
<FileOutlined />
{filename}
</Space>
),
},
{
title: t("print_queue.fields.spool"),
key: "spool",
render: (_: unknown, record: IPrintJob) => (
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
),
},
{
title: t("print_queue.fields.filament_used"),
key: "filament_used",
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
},
{
title: t("print_queue.fields.created"),
dataIndex: "created",
key: "created",
render: (created: string) => (
<Tooltip title={dayjs.utc(created).local().format("YYYY-MM-DD HH:mm:ss")}>
{dayjs.utc(created).fromNow()}
</Tooltip>
),
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: IPrintJob) => (
<Space>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => handleComplete(record)}
>
{t("print_queue.complete")}
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={() => handleCancel(record)}
>
{t("print_queue.cancel")}
</Button>
</Space>
),
},
];
const historyColumns = [
{
title: t("print_queue.fields.filename"),
dataIndex: "filename",
key: "filename",
},
{
title: t("print_queue.fields.spool"),
key: "spool",
render: (_: unknown, record: IPrintJob) => (
<Link to={`/spool/show/${record.spool_id}`}>{getSpoolName(record)}</Link>
),
},
{
title: t("print_queue.fields.filament_used"),
key: "filament_used",
render: (_: unknown, record: IPrintJob) => `${record.filament_used_g.toFixed(1)} g`,
},
{
title: t("print_queue.fields.status"),
dataIndex: "status",
key: "status",
render: (status: string) => (
<Tag color={status === "completed" ? "success" : "error"}>
{status === "completed" ? t("print_queue.status.completed") : t("print_queue.status.cancelled")}
</Tag>
),
},
{
title: t("print_queue.fields.finished"),
dataIndex: "finished",
key: "finished",
render: (finished: string) =>
finished ? (
<Tooltip title={dayjs.utc(finished).local().format("YYYY-MM-DD HH:mm:ss")}>
{dayjs.utc(finished).fromNow()}
</Tooltip>
) : (
"-"
),
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: IPrintJob) => (
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
),
},
];
const weighingColumns = [
{
title: t("spool.fields.id"),
dataIndex: "id",
key: "id",
},
{
title: t("spool.fields.filament_name"),
key: "filament",
render: (_: unknown, record: ISpool) => {
const fil = record.filament;
if (fil.vendor?.name) {
return `${fil.vendor.name} - ${fil.name || fil.material}`;
}
return fil.name || fil.material || `Filament #${fil.id}`;
},
},
{
title: t("spool.fields.location"),
dataIndex: "location",
key: "location",
},
{
title: t("table.actions"),
key: "actions",
render: (_: unknown, record: ISpool) => (
<Space>
<Link to={`/spool/edit/${record.id}`}>
<Button type="primary" icon={<ToolOutlined />}>
{t("print_queue.weigh_in")}
</Button>
</Link>
<Tooltip title={t("print_queue.clear_flag_tooltip")}>
<Button onClick={() => handleClearWeighingFlag(record)}>
{t("print_queue.clear_flag")}
</Button>
</Tooltip>
</Space>
),
},
];
const isLoading = printJobsQuery.isLoading || spoolsQuery.isLoading;
return (
<Content
style={{
padding: "2em 20px",
minHeight: 280,
maxWidth: 1200,
margin: "0 auto",
backgroundColor: token.colorBgContainer,
borderRadius: token.borderRadiusLG,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24 }}>
<Title level={2} style={{ margin: 0 }}>
{t("print_queue.title")}
</Title>
<Button
icon={<ReloadOutlined />}
onClick={() => {
invalidate({ resource: "print-job", invalidates: ["list"] });
invalidate({ resource: "spool", invalidates: ["list"] });
}}
>
{t("buttons.refresh")}
</Button>
</div>
{/* Stats cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.pending")}
value={pendingJobs.length}
valueStyle={{ color: token.colorWarning }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.needs_weighing")}
value={spoolsNeedingWeighing?.data?.length || 0}
valueStyle={{ color: (spoolsNeedingWeighing?.data?.length || 0) > 0 ? token.colorError : undefined }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.completed_today")}
value={completedJobs.filter((j) => dayjs.utc(j.finished).isAfter(dayjs().startOf("day"))).length}
valueStyle={{ color: token.colorSuccess }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title={t("print_queue.stats.total_jobs")}
value={printJobs?.data?.length || 0}
/>
</Card>
</Col>
</Row>
{/* Pending Jobs */}
<Card
title={
<Badge count={pendingJobs.length} offset={[15, 0]} color={token.colorWarning}>
<span>{t("print_queue.pending_jobs")}</span>
</Badge>
}
style={{ marginBottom: 24 }}
>
{pendingJobs.length > 0 ? (
<Table
dataSource={pendingJobs}
columns={pendingColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
) : (
<Empty description={t("print_queue.no_pending_jobs")} />
)}
</Card>
{/* Spools Needing Weighing */}
{(spoolsNeedingWeighing?.data?.length || 0) > 0 && (
<Card
title={
<Badge count={spoolsNeedingWeighing?.data?.length || 0} offset={[15, 0]} color={token.colorError}>
<span>{t("print_queue.needs_weighing")}</span>
</Badge>
}
style={{ marginBottom: 24 }}
>
<Table
dataSource={spoolsNeedingWeighing?.data || []}
columns={weighingColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
</Card>
)}
{/* Recent History */}
<Card title={t("print_queue.recent_history")}>
{completedJobs.length > 0 || cancelledJobs.length > 0 ? (
<Table
dataSource={[...completedJobs, ...cancelledJobs].sort(
(a, b) => dayjs(b.finished).valueOf() - dayjs(a.finished).valueOf()
).slice(0, 10)}
columns={historyColumns}
rowKey="id"
pagination={false}
loading={isLoading}
/>
) : (
<Empty description={t("print_queue.no_history")} />
)}
</Card>
</Content>
);
};
export default PrintQueue;

View File

@@ -23,9 +23,22 @@ export interface ISpool {
lot_nr?: string; lot_nr?: string;
comment?: string; comment?: string;
archived: boolean; archived: boolean;
needs_weighing: boolean;
extra: { [key: string]: string }; 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 // ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } }; export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "spoolman" name = "spoolman"
version = "0.23C.2" version = "0.23C.3"
description = "A web service that keeps track of 3D printing spools." description = "A web service that keeps track of 3D printing spools."
authors = [ authors = [
{ name = "Tony", email = "tony@nastynas.xyz" }, { name = "Tony", email = "tony@nastynas.xyz" },

214
scripts/slicer_post_process.py Executable file
View File

@@ -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 <gcode_file>", 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()

View File

@@ -264,6 +264,10 @@ async def find(
bool, bool,
Query(title="Allow Archived", description="Whether to include archived spools in the search results."), Query(title="Allow Archived", description="Whether to include archived spools in the search results."),
] = False, ] = False,
needs_weighing: Annotated[
Optional[bool],
Query(title="Needs Weighing", description="Filter by spools that need manual weigh-in."),
] = None,
sort: Annotated[ sort: Annotated[
Optional[str], Optional[str],
Query( Query(
@@ -308,6 +312,7 @@ async def find(
location=location, location=location,
lot_nr=lot_nr, lot_nr=lot_nr,
allow_archived=allow_archived, allow_archived=allow_archived,
needs_weighing=needs_weighing,
sort_by=sort_by, sort_by=sort_by,
limit=limit, limit=limit,
offset=offset, offset=offset,

View File

@@ -123,6 +123,7 @@ async def find( # noqa: C901, PLR0912
location: Optional[str] = None, location: Optional[str] = None,
lot_nr: Optional[str] = None, lot_nr: Optional[str] = None,
allow_archived: bool = False, allow_archived: bool = False,
needs_weighing: Optional[bool] = None,
sort_by: Optional[dict[str, SortOrder]] = None, sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
offset: int = 0, 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 total_count = None
if limit is not None: if limit is not None: