Compare commits

..

2 Commits

Author SHA1 Message Date
9365e399be 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>
2026-01-15 21:40:22 -06:00
4cd58ebae9 Add Print Job tracking for slicer integration
Backend for tracking print jobs from Elegoo/Orca Slicer:

- PrintJob model: tracks pending/completed/cancelled jobs
- API endpoints: create, list, complete, cancel jobs
- needs_weighing flag on Spool: for cancelled print recalibration
- Database migration for new table and column

Workflow:
1. Slicer post-processing script creates pending job
2. User completes job → auto-deducts filament
3. User cancels job → flags spool for manual weigh-in

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:10:56 -06:00
15 changed files with 1238 additions and 8 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

@@ -0,0 +1,53 @@
"""add_print_jobs.
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2025-01-15 04:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c3d4e5f6g7h8"
down_revision = "b2c3d4e5f6g7"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create print_job table and add needs_weighing to spool."""
# Create print_job table
op.create_table(
"print_job",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("finished", sa.DateTime(), nullable=True),
sa.Column("filename", sa.String(length=256), nullable=False),
sa.Column("filament_used_g", sa.Float(), nullable=False),
sa.Column("filament_used_mm", sa.Float(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_print_job_id"), "print_job", ["id"], unique=False)
op.create_index(op.f("ix_print_job_spool_id"), "print_job", ["spool_id"], unique=False)
# Add needs_weighing column to spool table
op.add_column(
"spool",
sa.Column("needs_weighing", sa.Boolean(), nullable=True, server_default="0"),
)
def downgrade() -> None:
"""Drop print_job table and remove needs_weighing from spool."""
op.drop_column("spool", "needs_weighing")
op.drop_index(op.f("ix_print_job_spool_id"), table_name="print_job")
op.drop_index(op.f("ix_print_job_id"), table_name="print_job")
op.drop_table("print_job")

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

@@ -331,6 +331,10 @@ class Spool(BaseModel):
examples=[""], examples=[""],
) )
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.") archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(
default=False,
description="Whether this spool needs manual weigh-in (e.g., after a cancelled print).",
)
extra: dict[str, str] = Field( extra: dict[str, str] = Field(
description=( description=(
"Extra fields for this spool. All values are JSON-encoded data. " "Extra fields for this spool. All values are JSON-encoded data. "
@@ -384,6 +388,7 @@ class Spool(BaseModel):
lot_nr=item.lot_nr, lot_nr=item.lot_nr,
comment=item.comment, comment=item.comment,
archived=item.archived if item.archived is not None else False, archived=item.archived if item.archived is not None else False,
needs_weighing=item.needs_weighing if item.needs_weighing is not None else False,
extra={field.key: field.value for field in item.extra}, extra={field.key: field.value for field in item.extra},
) )

View File

@@ -0,0 +1,199 @@
"""Print job API endpoints."""
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Spool
from spoolman.database import print_job
from spoolman.database.database import get_db_session
from spoolman.database.models import PrintJob as DBPrintJob
router = APIRouter(
prefix="/print-job",
tags=["print-job"],
)
class PrintJobResponse(BaseModel):
"""Print job response model."""
id: int
spool_id: int
spool: Optional[Spool] = None
created: datetime
finished: Optional[datetime] = None
filename: str
filament_used_g: float
filament_used_mm: Optional[float] = None
status: str
@classmethod
def from_db(cls, db_item: DBPrintJob) -> "PrintJobResponse":
"""Create from database model."""
return cls(
id=db_item.id,
spool_id=db_item.spool_id,
spool=Spool.from_db(db_item.spool) if db_item.spool else None,
created=db_item.created,
finished=db_item.finished,
filename=db_item.filename,
filament_used_g=db_item.filament_used_g,
filament_used_mm=db_item.filament_used_mm,
status=db_item.status,
)
class CreatePrintJobRequest(BaseModel):
"""Request to create a print job."""
spool_id: int = Field(..., description="ID of the spool to use.")
filename: str = Field(..., max_length=256, description="G-code filename.")
filament_used_g: float = Field(..., ge=0, description="Estimated filament usage in grams.")
filament_used_mm: Optional[float] = Field(None, ge=0, description="Estimated filament usage in mm.")
class CompletePrintJobRequest(BaseModel):
"""Request to complete a print job."""
comment: Optional[str] = Field(None, max_length=256, description="Optional comment for adjustment history.")
@router.get(
"",
name="List print jobs",
description="Get a list of print jobs.",
response_model_exclude_none=True,
responses={200: {"model": list[PrintJobResponse]}},
)
async def find(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
status: Annotated[
Optional[str],
Query(description="Filter by status: 'pending', 'completed', 'cancelled'."),
] = None,
spool_id: Annotated[
Optional[int],
Query(description="Filter by spool ID."),
] = None,
limit: Annotated[
Optional[int],
Query(description="Maximum number of items to return."),
] = None,
offset: Annotated[
int,
Query(description="Offset for pagination."),
] = 0,
) -> JSONResponse:
"""Find print jobs."""
db_items, total_count = await print_job.find(
db=db,
status=status,
spool_id=spool_id,
limit=limit,
offset=offset,
)
return JSONResponse(
content=jsonable_encoder(
[PrintJobResponse.from_db(item) for item in db_items],
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
)
@router.get(
"/{job_id}",
name="Get print job",
description="Get a specific print job.",
response_model_exclude_none=True,
responses={404: {"model": Message}},
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> PrintJobResponse:
"""Get a print job by ID."""
db_item = await print_job.get_by_id(db, job_id)
return PrintJobResponse.from_db(db_item)
@router.post(
"",
name="Create print job",
description="Create a new pending print job (called by slicer post-processing script).",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def create(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: CreatePrintJobRequest,
) -> PrintJobResponse:
"""Create a new print job."""
db_item = await print_job.create(
db=db,
spool_id=body.spool_id,
filename=body.filename,
filament_used_g=body.filament_used_g,
filament_used_mm=body.filament_used_mm,
)
return PrintJobResponse.from_db(db_item)
@router.post(
"/{job_id}/complete",
name="Complete print job",
description="Mark a print job as completed - deducts filament from spool.",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def complete(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
body: Optional[CompletePrintJobRequest] = None,
) -> PrintJobResponse:
"""Complete a print job."""
db_item = await print_job.complete(
db=db,
job_id=job_id,
comment=body.comment if body else None,
)
return PrintJobResponse.from_db(db_item)
@router.post(
"/{job_id}/cancel",
name="Cancel print job",
description="Mark a print job as cancelled - flags spool for manual weigh-in.",
response_model_exclude_none=True,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def cancel(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> PrintJobResponse:
"""Cancel a print job."""
db_item = await print_job.cancel(db=db, job_id=job_id)
return PrintJobResponse.from_db(db_item)
@router.delete(
"/{job_id}",
name="Delete print job",
description="Delete a print job.",
response_model=Message,
responses={404: {"model": Message}},
)
async def delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
job_id: int,
) -> Message:
"""Delete a print job."""
await print_job.delete(db=db, job_id=job_id)
return Message(message="Success!")

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
from . import backup as backup_module, export, externaldb, field, filament, models, other, setting, spool, vendor from . import backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -113,3 +113,4 @@ app.include_router(other.router)
app.include_router(externaldb.router) app.include_router(externaldb.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(backup_module.router) app.include_router(backup_module.router)
app.include_router(print_job.router)

View File

@@ -84,6 +84,7 @@ class SpoolParameters(BaseModel):
examples=[""], examples=[""],
) )
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.") archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
needs_weighing: bool = Field(default=False, description="Whether this spool needs manual weigh-in.")
extra: Optional[dict[str, str]] = Field( extra: Optional[dict[str, str]] = Field(
None, None,
description="Extra fields for this spool.", description="Extra fields for this spool.",
@@ -263,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(
@@ -307,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

@@ -80,6 +80,10 @@ class Spool(Base):
lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024)) comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column() archived: Mapped[Optional[bool]] = mapped_column()
needs_weighing: Mapped[Optional[bool]] = mapped_column(
default=False,
comment="Flag indicating spool needs manual weigh-in (e.g., after cancelled print).",
)
extra: Mapped[list["SpoolField"]] = relationship( extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool", back_populates="spool",
cascade="save-update, merge, delete, delete-orphan", cascade="save-update, merge, delete, delete-orphan",
@@ -106,6 +110,26 @@ class SpoolAdjustment(Base):
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.") comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
class PrintJob(Base):
"""Track pending/completed print jobs from slicer."""
__tablename__ = "print_job"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
spool: Mapped["Spool"] = relationship()
created: Mapped[datetime] = mapped_column(comment="When the job was created (slice time).")
finished: Mapped[Optional[datetime]] = mapped_column(comment="When the job was completed/cancelled.")
filename: Mapped[str] = mapped_column(String(256), comment="G-code filename.")
filament_used_g: Mapped[float] = mapped_column(comment="Estimated filament usage in grams.")
filament_used_mm: Mapped[Optional[float]] = mapped_column(comment="Estimated filament usage in mm.")
status: Mapped[str] = mapped_column(
String(16),
default="pending",
comment="Status: 'pending', 'completed', 'cancelled'.",
)
class Setting(Base): class Setting(Base):
__tablename__ = "setting" __tablename__ = "setting"

View File

@@ -0,0 +1,157 @@
"""Database operations for print jobs."""
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from spoolman.database.models import PrintJob, Spool, SpoolAdjustment
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
spool_id: int,
filename: str,
filament_used_g: float,
filament_used_mm: Optional[float] = None,
) -> PrintJob:
"""Create a new pending print job."""
# Verify spool exists
spool = await db.get(Spool, spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {spool_id} not found.")
job = PrintJob(
spool_id=spool_id,
created=datetime.utcnow(),
filename=filename,
filament_used_g=filament_used_g,
filament_used_mm=filament_used_mm,
status="pending",
)
db.add(job)
await db.commit()
await db.refresh(job)
# Load spool relationship
result = await db.execute(
select(PrintJob).where(PrintJob.id == job.id).options(joinedload(PrintJob.spool))
)
return result.scalar_one()
async def get_by_id(db: AsyncSession, job_id: int) -> PrintJob:
"""Get a print job by ID."""
result = await db.execute(
select(PrintJob).where(PrintJob.id == job_id).options(joinedload(PrintJob.spool))
)
job = result.scalar_one_or_none()
if job is None:
raise ItemNotFoundError(f"Print job with ID {job_id} not found.")
return job
async def find(
*,
db: AsyncSession,
status: Optional[str] = None,
spool_id: Optional[int] = None,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[PrintJob], int]:
"""Find print jobs with optional filters."""
query = select(PrintJob).options(joinedload(PrintJob.spool))
if status:
query = query.where(PrintJob.status == status)
if spool_id:
query = query.where(PrintJob.spool_id == spool_id)
# Get total count
count_query = select(PrintJob)
if status:
count_query = count_query.where(PrintJob.status == status)
if spool_id:
count_query = count_query.where(PrintJob.spool_id == spool_id)
count_result = await db.execute(count_query)
total = len(count_result.all())
# Apply ordering and pagination
query = query.order_by(PrintJob.created.desc())
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all()), total
async def complete(db: AsyncSession, job_id: int, comment: Optional[str] = None) -> PrintJob:
"""Complete a print job - deduct filament from spool."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Deduct filament from spool
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.used_weight += job.filament_used_g
if spool.first_used is None:
spool.first_used = datetime.utcnow()
spool.last_used = datetime.utcnow()
# Record adjustment
adjustment = SpoolAdjustment(
spool_id=spool.id,
timestamp=datetime.utcnow(),
adjustment_type="weight",
value=-job.filament_used_g,
comment=comment or f"Print job: {job.filename}",
)
db.add(adjustment)
# Update job status
job.status = "completed"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def cancel(db: AsyncSession, job_id: int) -> PrintJob:
"""Cancel a print job - flag spool for weighing."""
job = await get_by_id(db, job_id)
if job.status != "pending":
raise ValueError(f"Job {job_id} is not pending (status: {job.status}).")
# Flag spool for weighing
spool = await db.get(Spool, job.spool_id)
if spool is None:
raise ItemNotFoundError(f"Spool with ID {job.spool_id} not found.")
spool.needs_weighing = True
# Update job status
job.status = "cancelled"
job.finished = datetime.utcnow()
await db.commit()
# Reload with relationships
return await get_by_id(db, job_id)
async def delete(db: AsyncSession, job_id: int) -> None:
"""Delete a print job."""
job = await get_by_id(db, job_id)
await db.delete(job)
await db.commit()

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: