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
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:
44
CLAUDE.md
44
CLAUDE.md
@@ -136,12 +136,12 @@ This is a fork with UX improvements. Issues are tracked on the Gitea instance ab
|
||||
| 8 | (see Gitea #8) | ✅ DONE |
|
||||
| 9 | Hierarchical Locations (Room/Bin) | Open |
|
||||
| 10 | Database Import/Export | ✅ DONE |
|
||||
| 11 | Orca Slicer / Elegoo Slicer Integration | Open |
|
||||
| - | Theme Colors | ✅ DONE |
|
||||
| - | Clickable Dashboard Cards | ✅ DONE |
|
||||
| - | QR Code Size Setting | ✅ DONE |
|
||||
| - | Parse Temps from Comments | ✅ DONE |
|
||||
| - | Prev/Next Navigation on Edit Pages | ✅ DONE |
|
||||
| 11 | Theme Colors | ✅ DONE |
|
||||
| 12 | Clickable Dashboard Cards | ✅ DONE |
|
||||
| 13 | QR Code Size Setting | ✅ DONE |
|
||||
| 14 | Parse Temps from Comments | ✅ DONE |
|
||||
| 15 | Print Job Tracking / Slicer Integration | ✅ DONE |
|
||||
| 16 | Prev/Next Navigation on Edit Pages | ✅ DONE |
|
||||
|
||||
### 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/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
|
||||
|
||||
@@ -450,5 +450,53 @@
|
||||
"new_location": "New Location",
|
||||
"no_location": "No Location",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileOutlined,
|
||||
HighlightOutlined,
|
||||
HomeOutlined,
|
||||
PrinterOutlined,
|
||||
QuestionOutlined,
|
||||
TableOutlined,
|
||||
ToolOutlined,
|
||||
@@ -142,6 +143,14 @@ function App() {
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "print-queue",
|
||||
list: "/print-queue",
|
||||
meta: {
|
||||
canDelete: false,
|
||||
icon: <PrinterOutlined />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locations",
|
||||
list: "/locations",
|
||||
@@ -225,6 +234,7 @@ function App() {
|
||||
<Route path="/settings/*" element={<LoadablePage name="settings" />} />
|
||||
<Route path="/help" element={<LoadablePage name="help" />} />
|
||||
<Route path="/locations" element={<LoadablePage name="locations" />} />
|
||||
<Route path="/print-queue" element={<LoadablePage name="print-queue" />} />
|
||||
<Route path="*" element={<ErrorComponent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
456
client/src/pages/print-queue/index.tsx
Normal file
456
client/src/pages/print-queue/index.tsx
Normal 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;
|
||||
@@ -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<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
214
scripts/slicer_post_process.py
Executable file
214
scripts/slicer_post_process.py
Executable 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user