feat: Spoolman filament bookings + upload table in the web GUI - #1
Merged
Conversation
Planning input for the feat/spoolman work, so the spec does not have to
re-derive any of it:
- the requirement as stated, plus the 7 decisions taken up front
(network, table granularity, spool mapping, auto-create, unit, columns)
- verified Spoolman API facts, incl. that PUT /spool/{id}/use accepts
negative weights (silently clamped at 0) so corrections and reversals
are deltas, and that Spoolman keeps no booking history -- an own
ledger is required
- verified local facts: the two containers currently sit in different
docker networks, no -output is set (so the G-Code is never on disk),
and where the GUI/upload handler lives
- GCODE-FILAMENT.md: what Snapmaker Orca writes about filament usage,
including the inconsistent array delimiters that will bite the parser
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
Decided: book immediately on a successful start (no booking if the start
command fails); no separate reversal state -- one editable value per row
and every change sends the delta against what was already booked; all
uploads treated alike (no duplicate detection); no reminder for rows left
unbooked; exactly one spool per filament, so no spool picker; check the
remaining weight and warn on upload+start only, never block; aborted
prints are corrected by hand.
Still blocking: which vendor to store when auto-creating a filament --
the G-Code carries the preset vendor ("Snapmaker"), not the real one.
The Orca presets get reviewed first, then a fresh G-Code is checked.
Do not implement until that is settled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
Booked in grams (use_weight), one decimal, Europe/Berlin timestamps. The amount appears twice per row on purpose: the G-Code value read-only, next to the editable amount that is actually booked in Spoolman -- so "book" just means copying the left value into the right one. Everything is kept in the ledger, only the 10 newest rows are rendered. Port 8844 stays without auth (LAN, same as the rest of the stack), but state changes are POST-only. Files without a usage block (Luban, .nc, laser and CNC jobs) get a row with the filament columns empty and booking disabled -- no reconstruction from the E moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
Extra column showing the roll's remaining weight after that row's booking, frozen in the ledger and only recomputed when the row itself is corrected; rows not booked yet show the roll's current level, which is the same value while their booking is 0. Orange below the threshold, red and bold below zero, threshold via SPOOLMAN_LOW_G (default 100 g). Spoolman cannot supply this: remaining_weight is max(initial - used, 0) with ge=0 on the field, so an overdrawn roll is invisible in it. We compute initial - used ourselves -- and the use call returns the updated spool, so no extra GET is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
The G-Code figures are estimates anyway, so round once at parse time and keep ledger, display and bookings integer throughout -- rounding only for display would make an edit to "46" send a pointless +0.1 delta when 45.9 was booked. Remaining weights coming back from Spoolman may still be fractional (bookings made in its own UI), so those are rounded for display only, never written back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
Creating a spool now also sets its price, derived from the G-Code: filament_cost (Orca's price per kg, per slot) times the net weight. If that key is absent, it can only be reconstructed from the two summary lines, and only when exactly one slot was used -- otherwise the price is left empty. Whether the key is in the block at all is unproven, so it joins the pending G-Code cross-check. The cost column no longer shows the G-Code's total: it is computed from Spoolman's own numbers (booked grams x price / net weight), so it follows a corrected value instead of freezing the sliced estimate. Two API facts matter here: Spoolman does NOT fall back from spool price to filament price (models.py:354 and :219 are separate fields), and the currency from GET /setting/currency is double-JSON-encoded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
Four real files from Snapmaker Orca 2.3.4, produced after the preset fix, happen to cover all four slot indices (0, 3, 1, 2) with exactly one slot each -- a ready-made parser test corpus, kept outside the repo under ../gcode-samples/. filament_vendor now reads DEEPLEE, so the vendor is taken straight from the G-Code and the heuristic is dropped. filament_cost is present as a comma-separated per-slot price per kg, and the cost formula reproduces total filament cost exactly in all four jobs -- the Ivana file proves the arrays stay index-parallel for cost too, since it uses slot 2 and prices at 11.99 rather than 13.59. Two parser traps surfaced while measuring: the trailing block is NOT a fixed length (custom start/end G-Code inflates the config dump), so a tail read has to verify it found every required key and widen otherwise; and the same key can appear twice with different syntax -- HEADER_BLOCK at the top writes "key: value" while the config dump writes "key = value". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
YAML like hosts.yaml (yaml.v3 is already a dependency, no new one), rewritten whole on each change -- fine at ~300 B/row. Written to a temp file then renamed so a crash mid-write cannot corrupt it; the existing LocalStorage.Save() uses os.WriteFile and is NOT atomic, which we avoid for a consumption ledger. A sync.Mutex guards read-modify-write because the OctoPrint server runs on http.Serve and handles every request in its own goroutine -- an Orca upload and a browser "book" click can genuinely race. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
A #RRGGBB square from filament_colour sits before the name in the same column -- inline background, no extra column. White (#FFFFFF, "PLA+ Weiß") gets a 1px border so it does not vanish on a light background. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zWUoJhzouLTnkrpZE7rkJ
Snapmaker Orca writes the consumption statistics and the config dump as
comment lines at the very end of the file, so only the tail is read (256
KB, grown to 1 MB and 4 MB while required keys are missing) and the
reader is always rewound for the uploader. Measured on four real 2.3.4
files the block spans the last ~23.6 KB, but its length is not constant:
custom start/end G-code lands in the config dump.
Three traps the four sample files exposed:
- Two syntaxes in one file. The HEADER_BLOCK on top uses "key: value"
and repeats filament_density; only "=" lines count, so the lower and
complete block wins.
- Mixed delimiters. Number arrays split on comma (with a space after
it), string arrays on semicolon -- and filament_settings_id is quoted
and may contain a semicolon itself, hence the quote-aware splitter.
- Index parallelism. Position i is tool T{i} across every filament_*
array, filament_cost included: Ivana uses slot 2 and its 11.99/kg, not
the 13.59 of the other three.
Grams are rounded once, here, so ledger, display and booking stay
integral afterwards. A slot below 0.5 g keeps its row with 0 g -- it was
really printed, it just books nothing.
testdata holds the key lines of the four real files; the multi-MB
originals stay out of the repo behind SM2_GCODE_SAMPLES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N
Spoolman has no booking history -- only used_weight, first_used and last_used. Without our own record neither "80 -> 60 = -20" nor a repeatable cancellation is possible, so every (upload x slot) pair gets a row holding the G-code amount, the booked amount and the spool it went to. Written with yaml.v3 (already a dependency) to match hosts.yaml, so the file stays readable and repairable in an editor. At ~300 B per row a thousand prints is ~300 KB, small enough to rewrite whole on every change. Two deliberate differences to LocalStorage.Save(): - Atomic write: temp file, fsync, rename. os.WriteFile truncates then writes, and a crash in between would destroy a consumption ledger. - A mutex around read-modify-write. The OctoPrint server runs on http.Serve, so every request has its own goroutine: an Orca upload appending a row and a "book" click changing one really can overlap. Get and Recent hand out copies, because the caller renders them while other goroutines may be writing. Everything is kept; only the GUI caps what it renders. Timestamps are Europe/Berlin -- the container sets no TZ, so the zone is named explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N
Verified against the running v0.25.0 instance and its openapi.json. Four findings shaped this client: - extra values are JSON encoded strings, whatever the field type: a text field comes back as "\"hello\"". The preset mapping is marshalled on write and unmarshalled on read. - GET /spool has no filter for extra fields, so the mapping match runs client-side over all non-archived spools. The list is tiny. - remaining_weight is clamped to >= 0 (api/v1/models.py), which hides an over-booked spool. RemainingG computes initial_weight (falling back to filament.weight) minus used_weight itself, so it can go negative. - The API chains no prices: spool.price -> filament.price and spool.initial_weight -> filament.weight are two separate fields each, so the client forms the fallback. Missing price yields 0, never a broken page. Resolution order is preset mapping, then filament name (adopting the spool and remembering the mapping), then create. A created filament carries density, diameter and colour from the G-code so Spoolman and Orca compute alike; price is filament_cost per kg, which at 1000 g net is the price of a full spool. The tare weight stays empty -- the G-code does not know it -- and the comment marks the entry as ours. Bookings are deltas. Negative values work because use_weight_safe adds and clamps at 0; that clamp is silent, which is precisely why booked_g is tracked in the ledger. PUT /use returns the updated spool, so the new remaining weight needs no extra GET. Currency comes from GET /setting/currency and is double JSON encoded. Rendering uses a 30 s cache with a 2 s timeout and keeps a stale list when Spoolman is unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N
The G-code is parsed right after FormFile, before SMFix, from the original upload; the parser rewinds the reader so the uploader still streams the whole file. Upload plus start books immediately, a plain upload only records -- but the spool is created either way, otherwise a first-time preset has no remaining weight to show. remaining_after_g stays empty until the row is booked, so an open row keeps following the spool instead of freezing a number at upload time. Payload gains Uploaded and Started because Upload() collapses everything into one error, while the ledger needs the difference: with a start requested the file goes out via /prepare_print, so it can be on the printer while only start_print or the load check failed. That case gets an open row; a failed upload gets no row at all. Bookings are serialised by their own mutex, deliberately not the ledger's: a booking makes HTTP calls that may take seconds, and holding the ledger lock across them would stall the next Orca upload. A failure marks the row and leaves booked_g alone, so a retry sends the same delta. Nothing here can fail the print or change the answer to Orca. The table shows the amount twice on purpose -- the immutable G-code value and what Spoolman actually holds. "Book" copies the left to the right, "Cancel" sets 0. Inputs reach their row's buttons through the HTML form owner attribute, so no JavaScript is needed for the actions; the 5 s poll skips its swap while the focus sits inside the table, or it would overwrite a correction mid-typing. /book is POST only, so no link and no prefetch can book. Cost follows the booked amount, not the G-code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N
SRC was $(wildcard *.go), which now picks up the new _test.go files --
and go build refuses a file list containing them ("named files must not
be _test.go files"). Filter them out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N
The deploy chain builds sm2uploader-patched into the working tree before copying it to the compose context; at ~16 MB it has no business in git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Books filament consumption to Spoolman straight out of the upload path, and gives the web GUI an upload table where every booking can be corrected by hand.
Fork feature — not intended for upstream. Deliberately kept apart from the clean PR branches
fix-fixed-gcode-filesize(macdylan#35) andfix-persist-token-on-connect(macdylan#36).Design decisions were settled beforehand in
SPOOLMAN-INTEGRATION.md(1–20), the G-code format inGCODE-FILAMENT.md.How it works
Every upload is parsed before SMFix, from the original, reading only the tail of the file. Each used filament slot becomes one ledger row in
/data/uploads.yaml:.nc, laser/CNC) → a row without filament data, not bookable. Nothing is guessed from E moves.Bookings are always deltas against
booked_g, never absolute values. "Book" sets the booked amount to the G-code amount, "Cancel" sets 0, and any correction sends the difference (booked 120, corrected to 50 →use_weight: -70). There is no separate cancelled state: a row corrected back to 0 is indistinguishable from one never booked.A missing filament is created automatically — vendor, filament and spool — carrying density, diameter, colour and price per kg from the G-code so Spoolman and Orca compute alike. The tare weight stays empty because the G-code does not know it.
Table
[46]The amount appears twice on purpose: the immutable G-code value on the left, what Spoolman actually holds on the right. Cost follows the booked value, so a correction moves it. Remaining weight below
SPOOLMAN_LOW_Gturns orange, below zero red and bold.Spoolman API findings
Verified against a running v0.25.0 instance and its
openapi.json. Four of them shaped the client:extravalues are JSON-encoded strings whatever the field type ("\"hello\"")GET /spoolhas no filter forextraremaining_weightis clamped to>= 0, hiding an over-booked spoolinitial_weight − used_weight, so it can go negativespool.price → filament.priceandspool.initial_weight → filament.weightitselfNegative
use_weightworks (use_weight_safeadds and clamps at 0) — but that clamp is silent, which is exactly whybooked_gis tracked locally.PUT /usereturns the updated spool, so the frozen remaining weight needs no extraGET.Robustness
booked_galone so a retry sends the same delta. The answer to Orca is untouched.LocalStorage.Save()truncates then writes; a crash mid-write would destroy a consumption ledger.http.Servegives every request its own goroutine, so an Orca upload appending a row and a "book" click changing one really can overlap./bookis POST only, so no link and no browser prefetch can change inventory (:8844is LAN-internal without auth).Configuration
Each has a flag too (
-spoolman,-lowg); the ledger path comes from-uploads/UPLOADS_FILEand otherwise defaults next to-knownhosts, landing in the persistent/datavolume.time/tzdatais embedded soEurope/Berlinworks on any base image.Tests
First tests in this repo — 49, all green under
-race. Parser coverage comes from four real Snapmaker Orca 2.3.4 files that happen to cover all four slot indices:Ivana proves the index parallelism of
filament_cost. Also covered: semicolon inside a quoted preset name, both delimiter styles, thekey:/key =duplicate, a block beyond the first tail window, missing consumption lines, atomic and concurrent ledger writes, and the Spoolman client againsthttptestincluding the double-encodedextra, negative deltas and the silent clamp.testdata/holds only the key lines of those files; the multi-MB originals stay out of the repo behindSM2_GCODE_SAMPLES.Verified against the live stack
Deployed and exercised end to end: all four spools auto-created with correct density, diameter, colour, price and preset mapping; book → 46 g / remaining 954 / 0.63 €; correct to 20 g → delta −26 / 980 / 0.27 €; cancel → 0 g / 1000. Over-booking on purpose: Spoolman reports
remaining_weight: 0.0while the table shows −6 in red. Low-stock warning fired and did not block.GET /book→ 405. Survives a container restart. No leak: RSS flat at ~21 MB over 300 page loads,MemStats.Allocsawtooths between 0.8 and 3.2 MB.Deviations from the pre-agreed decisions
0.00instead of an empty cell.extrafilter exists, so the match is client-side.open/booked/failed).SPOOLMAN-INTEGRATION.mdstill describes the pre-implementation state; it has not been rewritten to match.🤖 Generated with Claude Code
https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N