Skip to content

feat: Spoolman filament bookings + upload table in the web GUI - #1

Merged
VGottselig merged 15 commits into
masterfrom
feat/spoolman
Jul 30, 2026
Merged

feat: Spoolman filament bookings + upload table in the web GUI#1
VGottselig merged 15 commits into
masterfrom
feat/spoolman

Conversation

@VGottselig

Copy link
Copy Markdown
Owner

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) and fix-persist-token-on-connect (macdylan#36).

Design decisions were settled beforehand in SPOOLMAN-INTEGRATION.md (1–20), the G-code format in GCODE-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:

  • Upload + start → booked immediately.
  • Plain upload → recorded only, but the spool is created so the remaining column has something to show.
  • No consumption block (Luban, .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

Time File Filament G-code g booked g remaining g Print time Cost Action
30 Jul 12:53 ElefantMobile_0.2mm_4h25m.gcode 🟪 DEEPLEE PLA PRO Rosa 46 [46] 954 4h 24m 54s 0.63 € Book · Set · Cancel

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_G turns 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:

Finding Consequence
extra values are JSON-encoded strings whatever the field type ("\"hello\"") the preset mapping is marshalled on write, unmarshalled on read
GET /spool has no filter for extra the mapping match runs client-side over all non-archived spools
remaining_weight is clamped to >= 0, hiding an over-booked spool remaining is computed here from initial_weight − used_weight, so it can go negative
the API chains no prices or net weights the client forms spool.price → filament.price and spool.initial_weight → filament.weight itself

Negative use_weight works (use_weight_safe adds and clamps at 0) — but that clamp is silent, which is exactly why booked_g is tracked locally. PUT /use returns the updated spool, so the frozen remaining weight needs no extra GET.

Robustness

  • Spoolman must never block a print. Booking happens after a successful upload; a failure only logs, marks the row and leaves booked_g alone so a retry sends the same delta. The answer to Orca is untouched.
  • Atomic ledger writes (temp file, fsync, rename). LocalStorage.Save() truncates then writes; a crash mid-write would destroy a consumption ledger.
  • A mutex around read-modify-write. http.Serve gives every request its own goroutine, so an Orca upload appending a row and a "book" click changing one really can overlap.
  • Bookings use their own mutex, not the ledger's — a booking makes HTTP calls that may take seconds, and holding the ledger lock across them would stall the next upload.
  • /book is POST only, so no link and no browser prefetch can change inventory (:8844 is LAN-internal without auth).

Configuration

environment:
- TZ=Europe/Berlin
- SPOOLMAN_URL=http://spoolman:8000   # empty/absent = bookings off, table still works
- SPOOLMAN_LOW_G=100                  # low-stock threshold in grams
networks:
- default
- home_net                            # without it "spoolman" does not resolve

Each has a flag too (-spoolman, -lowg); the ledger path comes from -uploads/UPLOADS_FILE and otherwise defaults next to -knownhosts, landing in the persistent /data volume. time/tzdata is embedded so Europe/Berlin works 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:

Job Slot G-code rounded €/kg
Assembly 0 37.57 g 38 13.59
ElefantMobile 4h25 1 45.93 g 46 13.59
Ivana 2 1.98 g 2 11.99
ElefantMobile 2h14 3 18.84 g 19 13.59

Ivana proves the index parallelism of filament_cost. Also covered: semicolon inside a quoted preset name, both delimiter styles, the key:/key = duplicate, a block beyond the first tail window, missing consumption lines, atomic and concurrent ledger writes, and the Spoolman client against httptest including the double-encoded extra, negative deltas and the silent clamp.

testdata/ holds only the key lines of those files; the multi-MB originals stay out of the repo behind SM2_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.0 while 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.Alloc sawtooths between 0.8 and 3.2 MB.

Deviations from the pre-agreed decisions

  • 20 (cost): no fetch per render but a 30 s cache with a 2 s timeout; a missing price or an unreachable Spoolman yields 0.00 instead of an empty cell.
  • 4 (mapping): no server-side extra filter exists, so the match is client-side.
  • 5 + 18: auto-creation moved from booking to upload — decision 18 wants an unbooked row to show the spool's current level, which requires the spool to exist. A plain upload still consumes nothing.
  • GUI is English throughout, including the ledger's status values (open/booked/failed).

SPOOLMAN-INTEGRATION.md still describes the pre-implementation state; it has not been rewritten to match.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QHt1eWHFiSbJaNjKVhU38N

VGottselig and others added 15 commits July 29, 2026 21:42
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
@VGottselig
VGottselig merged commit bc6842d into master Jul 30, 2026
@VGottselig
VGottselig deleted the feat/spoolman branch July 30, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant