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 |
|
| 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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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;
|
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 } };
|
||||||
|
|
||||||
|
|||||||
@@ -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
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,
|
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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user