A monitoring-only FastAPI gateway for the Bambu Lab printers in the lab. It
uses bambulabs_api for local
MQTT telemetry and publishes one
AC lab STATUS_SPEC v1.2 surface per
printer. This repo conforms to lab status spec v1.2 on its per-printer
surfaces; the aggregate gateway envelope stays on v1.0 (it fronts printers and
has no primary operation of its own).
The service deliberately exposes no control endpoints. The third-party
package supports commands, but those methods are isolated behind a narrow
monitoring adapter and are not reachable from HTTP. Future control work must go
through lab-skills, claims, preconditions, audited plans, and the
human-approval rules in the lab contract.
It also runs a submission pipeline: remote users upload a print artifact, the gateway validates it against the target machine's profile, and valid jobs wait in a per-machine queue with expected finish times. That whole path is read-and-analysis — it writes to the gateway's own disk and never to a printer. The one printer-touching step, dispatch, is not implemented; see Submission pipeline.
Bambu printers -- local MQTT/TLS --> background monitors --> cached status
|
Lab dashboard ---------------- HTTP GET /printers/{id}/status -+
|
Remote user -- POST /submissions --> validate against ------- + (reads the cache)
the machine profile
|
v
per-machine queue --> GET /printers/{id}/queue
|
x dispatch: not implemented
Dashboard requests only read the cache. They never connect to a printer or request a telemetry refresh. The background monitor starts only the MQTT client; camera and FTP clients are not started. The submission pipeline reads that same cache and writes only to the gateway's own disk.
Requires Python 3.10+ and uv.
uv sync --extra dev
cp printers.example.yaml printers.local.yaml
cp .env.example .envEdit the two local files. To find a Bambu printer's LAN access code, enable LAN mode and use the printer's network settings. The printer and service host must be able to reach each other on the local network.
printers.local.yaml, .env, printer addresses, access codes, and serial
numbers are intentionally gitignored. Each printer entry names an environment
prefix; the service resolves <PREFIX>_HOST, <PREFIX>_ACCESS_CODE, and
<PREFIX>_SERIAL at startup.
uv run bambu-serverDefaults: 127.0.0.1:8012, overridden by BAMBU_SERVER_HOST and
BAMBU_SERVER_PORT. Keep the service on the lab LAN/Tailnet; it has no
application-level authentication because access is expected to be gated by
Tailscale ACLs.
Gateway routes:
| Method | Path | Purpose |
|---|---|---|
| GET | / |
Service identity and configured printer count |
| GET | /health |
Process liveness |
| GET | /printers |
Safe printer inventory (no addresses or credentials) |
| GET | /status |
Aggregate gateway envelope (one component per printer) |
| GET | /ui |
Submission page for people (see below) |
| GET | /whoami |
Whether this request carries an edge-verified identity |
Per-printer STATUS_SPEC routes:
| Method | Path |
|---|---|
| GET | /printers/{id}/ |
| GET | /printers/{id}/health |
| GET | /printers/{id}/status |
| GET | /openapi.json |
Submission pipeline routes:
| Method | Path | Purpose |
|---|---|---|
| GET | /printers/{id}/profile |
The machine a submitter targets and is validated against |
| GET | /printers/{id}/queue |
Running job and waiting submissions, with finish times |
| POST | /submissions |
Upload a .3mf / .gcode artifact; validated inline |
| GET | /submissions |
List jobs (machine, state, limit filters) |
| GET | /submissions/{submission_id} |
One job with its verdict and history |
| POST | /submissions/{submission_id}/approve |
Record sign-off on a queued job |
| POST | /submissions/{submission_id}/cancel |
Withdraw a waiting job from the queue |
| DELETE | /submissions/{submission_id} |
Delete a finished job's record (retention) |
No /control/* routes exist, and no route dispatches a print.
The status envelope uses equipment_kind: other because the authoritative
contract does not yet define a 3d_printer kind. details.device_type carries
3d_printer without extending the closed enum locally. A reachable printer
reporting FAILED maps to error; missing or stale MQTT telemetry maps to
unknown, never to a fabricated hardware fault.
The primary operation of a printer is running a print job. activity is
derived from the printer's observed gcode state alone — never from
equipment_status, which answers the independent question of whether the
printer is healthy:
| observed gcode state | equipment_status |
activity |
|---|---|---|
IDLE, FINISH |
ready |
idle |
PREPARE, RUNNING, PAUSE |
busy |
running |
FAILED |
error |
idle |
| anything else | unknown |
unknown |
| MQTT down / no telemetry / stale | unknown |
unknown |
PAUSE counts as running: the job is in flight and the printer cannot accept
another one, which also satisfies the spec's busy ⇒ running invariant. The
exact sub-state stays visible in components["print_job"] and message.
FAILED is idle because the job has stopped — §2.3 permits any activity
under error.
Beyond the core state and metrics, a data_ready status enriches details with
read-only observations when the printer actually reports them (never as a bare
null or empty sentinel):
print_type— source of the job (cloud/local).nozzle_type,nozzle_diameter— configured nozzle.wifi_signal— reported signal (dBm as a string).print_error_code— the printer's reported error code (0= none). A failed job'slast_error.messageappends the code when present.skipped_objects— object indices skipped in the current job.ams_trays— loaded AMS filament inventory: per-traytray_type,tray_color,tray_weight,tray_diameter,tray_temp, and the spool's ownnozzle_temp_min/nozzle_temp_maxwindow, andremaining_percent(null when unreported).ams_unit_idspreserves units with no loaded filament. Unit/tray IDs are the inventory address;tray_indexis retained as null for compatibility, because the MQTTnfield is not a slot index. Tray/tag UUIDs are intentionally not surfaced (identifiers, not inventory).
AMS inventory is decoded from the MQTT client's cache without the library's
hub conversion: optional ams_exist_bits, n, and spool-tag fields must not
be required to recognize a loaded tray. Unknown inventory is distinct from a
known empty list. The submission page shows each reported tray and remaining
percentage, including AMS-HT units. Percentages are printer estimates; missing
estimates are never replaced with a guessed full spool.
Both status trays and profile trays include tray_color_name and
tray_color_source. Exact Bambu catalog color matches are display labels,
not proof of spool manufacturer. An all-zero color is unknown by default.
An operator can declare a color in a printer's local
profile.ams.color_labels list, using ams_id, tray_id, material,
reported_color (eight hex digits), and name. The declaration applies only
while that slot, material, and reported color match, and its source is
operator_declared. Review it after replacing a spool; identical telemetry
cannot identify a physical replacement. These local labels never influence
material validation or printer commands.
See API reference for field definitions, color source values, authenticated browser URLs, and the submission/dispatch boundary.
The submission page supports ?embed=1, inherits the same-origin dashboard's
light/dark theme, and accepts theme/selection messages only from that parent.
Standalone links should use the authenticated /bambu/ui/ edge path, never
the gateway's unauthenticated port.
These are best-effort: a failure in any one getter is isolated, and the core
activity/state decision never depends on them.
activity_since is the instant the value last changed, observed by the
background poll (every poll_interval_seconds), not the time the status request
was built. It is null whenever the transition itself was never observed — a
service restart mid-print, or telemetry that went stale and recovered — because
the span began before this service could see it and stamping first-observation
time would report a far-too-short duration. Expect activity: running with
activity_since: null for a job that was already underway when the service
started; it gets a timestamp at the next real transition.
Print jobs run far longer than the dashboard's 60 s poll, so the sampling caveat
in §2.3.1 does not apply and no cycles_total metric is published.
Remote users submit a print artifact to the gateway, an agent-style checker
validates it against the specific machine it is destined for, and valid jobs
wait in a per-machine queue with expected finish times. The design contract is
docs/SUBMISSION_PIPELINE_DESIGN.md.
submitted -> validating -> validated -> queued -> approved -> | dispatch
\-> rejected (terminal) | not implemented
\--------\-> cancelled (terminal)
Everything up to and including approval is analysis and bookkeeping. Approval is
a record, not an action: it marks the job approved and sets
verdict.dispatch_ready, and moves nothing. dispatching, running and
finished are declared by the contract but unreachable — no route in this
service can enter them.
GET /printers/{id}/profile publishes what a submitter targets. It merges the
operator-declared profile from printers.local.yaml (bed size, enclosure, safe
temperature envelope, forbidden materials) with what the printer currently
reports (nozzle, loaded AMS trays). Where the two overlap the observed value
wins and *_source says so; the declared value is the fallback for machines
whose live field is blank — a dual-nozzle H2D reports no parsable nozzle type.
| check | fails when |
|---|---|
machine_compatible |
sliced for another printer, or a nozzle diameter/type the machine does not have |
material_allowed |
the filament is on the machine's forbidden list, or the request's declared material contradicts the sliced one |
material_filament_match |
any required material has no matching loaded AMS tray |
nozzle_temp_in_band |
the configured or commanded nozzle temperature is outside the machine's limit or the loaded filament's window |
bed_chamber_temp_in_band |
the bed temperature is out of band, or a heated chamber is requested on a machine without one |
build_fits_plate |
the model's XY footprint exceeds the declared plate |
gcode_sanity |
the toolpath contains a refused command (firmware update, EEPROM write, PID retune, cold-extrude override, …) |
params_present |
filament type, nozzle temperature or bed temperature is missing — or a .3mf carries no sliced plate, so no printer could run it |
One failing check rejects the submission, and rejection is terminal.
A check whose inputs do not exist reports not_applicable, with the reason,
and is never reported as a pass. Unknown AMS inventory is reported as "not
compared"; known empty inventory fails the material check. Declaring limits and
bed_size_mm in the profile is what turns those checks on — there are no
built-in defaults, because a guessed limit is a fabricated machine fact.
The gcode scan is explicitly a heuristic, and the passing detail says so. It is not a proof of safety.
Material matching is not a dispatch mapping: per-filament slot selection, per-slot temperature checks, sufficient quantity, and revalidation at dispatch remain prerequisites for the future control plane. The current approval flag does not establish those guarantees or enable printer execution.
A .gcode is scanned for its slicer config comments (settings, estimated time)
and its G0/G1 motion (the real plate footprint, measured as an extent so
placement cannot change the answer). A .3mf is a zip: a sliced plate file
embeds Metadata/plate_N.gcode, which is scanned the same way, with
project_settings.config and slice_info.config filling in the rest. An
unsliced project file is reported as such rather than guessed at.
Reads are bounded. Above submissions.scan_max_bytes only the head and tail of
an artifact are read — enough for a config block at either end — and the plate
footprint is then withheld rather than computed from a partial scan. XML
carrying a document type declaration is refused outright, since that is the only
place an entity expansion can be declared.
GET /printers/{id}/queue is gateway-computed and side-effect free. The running
job's remaining time comes from the printer's own telemetry; each queued job's
duration is the slicer's estimate embedded in its artifact. Anything the gateway
cannot compute is null and estimates_complete is false — an unknown
remaining time makes every downstream estimate unknown rather than wrong.
The running job is not correlated with a submission. This service never dispatches, so a running print was started by some other route to the printer (Bambu Studio, the handset, the cloud) and the gateway reports only what it observes.
GET /ui serves a submission page: pick a machine (its plate size, nozzle,
chamber and limits are shown so you know what you are targeting), upload a
file, and read the per-check verdict. It also lists that machine's queue with
finish times, and offers Approve / Cancel.
It is one static file with no build step and no external resources — no
CDN, no npm, no bundler — served from the same origin as the API it calls, so
it needs no CORS exemption and works on an isolated lab network. It holds no
state of its own and calls only the public endpoints below, so it can do
nothing the API would refuse. It offers Approve only on a queued job, which
is the same rule the server enforces: never advertise an action that would be
refused.
The page has no sign-in. The name you type is a label, not an identity — see Identity and approval below.
The page is the easy path. Directly:
curl -sS -X POST http://127.0.0.1:8012/submissions \
-F file=@plate.gcode.3mf \
-F target_machine=bambu_x1c_01 \
-F requested_by=alice \
-F material=PLAThe response is the job, with its per-check verdict, at queued or rejected.
Validation runs inline (the file read happens on a worker thread), so the caller
sees the verdict immediately.
Uploads are stored under submissions.directory named from the submission's
UUID — never from the client's filename, which is reduced to a basename and kept
only as display metadata. No response ever contains a stored path. Jobs are
mirrored to one JSON file each, so a restart does not empty a machine's queue.
This service has no login of its own, so who did what depends on how it is reached, and every job records which of the two it got:
- Through the lab's Caddy edge (
/bambu/*— see Behind the dashboard's login below), the edge authenticates the person againstac_authand injectsX-Auth-User. The gateway believes that header only when the request also carriesX-Edge-AuthmatchingBAMBU_EDGE_SHARED_SECRET, which a caller coming straight off the tailnet cannot produce. The signed-in account becomes the recorded actor, overriding anything the client supplied — a signed-in person must not be able to file work under someone else's name — andrequested_by_verified/approved_by_verifiedaretrue. - Reached directly,
requested_by/approved_by/cancelled_byare opaque labels, not identities, exactly as the status surface is unauthenticated. The*_verifiedfields arefalse.
Two properties are deliberate. The gateway fails closed: with no
BAMBU_EDGE_SHARED_SECRET configured it trusts no injected identity at all,
rather than believing a header it cannot check. And the secret is compared in
constant time, because == on a secret leaks it a byte at a time.
GET /whoami reports what the current request carries, which is how the page
words itself honestly — it distinguishes "not signed in" from "this deployment
cannot tell who you are" (identity_available).
Approval is human-in-the-loop by design: nothing auto-approves, and a submission that did not pass validation can never be approved.
The page is served at /ui relative to wherever the service is reached, and it
derives its API base by stripping that trailing /ui from its own URL. One file
therefore serves both the direct deployment and a path prefix behind the lab's
single Caddy edge, with no server-side rewrite and no build-time config — the
same arrangement as the OT-2 operator SPA.
Fronting it that way is the only way it participates in SSO: a session
cookie cannot be shared with this gateway on its own address, because raw
100.x addresses cannot carry a Domain cookie and *.ts.net is on the Public
Suffix List, so browsers drop tailnet-wide cookies. One origin behind the edge
means one login (see ac-organic-lab/docs/AUTH_DESIGN.md).
Deploying it: docs/EDGE_DEPLOY.md.
The route lives in ac-organic-lab/deploy/Caddyfile.single-edge as /bambu/*,
gated by forward_auth and injecting the identity described above; the dashboard
frames /bambu/ui/ under Utils → 3D Printers. Once that route is live the
service's bind can go back to loopback, closing the unauthenticated
/submissions path on the tailnet.
Note the page answers at both /ui and /ui/. Serving only one would make
Starlette redirect between them with a Location that drops the edge prefix,
landing the visitor on the dashboard.
Terminal records (rejected, finished, failed, cancelled) are swept at
startup once older than submissions.retain_terminal_days (30 by default; set
it to null to keep everything). A job that is still in play is never swept,
however old — one stuck in validating is a signal, not litter. The set of
terminal states is derived from the transition table rather than listed twice,
so a state added there cannot be missed here.
DELETE /submissions/{id} removes one finished job's record and artifact
immediately. Only a terminal job can be deleted: withdrawing one that is still
waiting is cancel, which leaves a record of the decision — deleting it would
erase that along with the job.
Sweeping happens at startup rather than on a timer so the store's on-disk and in-memory views stay identical. A record removed underneath a running process lingers in memory until a restart, which is exactly the divergence that made hand-cleanup necessary before this existed.
POST /submissions/{id}/cancel withdraws a waiting job. It is a queue
operation, not an abort: it is legal only from queued and approved, it
reaches no printer, and it is deliberately refused for anything past the queue —
stopping a running print is a control-plane action that needs a claim, and this
surface has none.
Cancelling deletes the stored artifact (a withdrawn job has no further use for
it, and it is the submitter's data) and marks artifact_removed. The job record
stays, with the actor and reason in its history, so the withdrawal remains
auditable. There is no undo — a withdrawn job is resubmitted, not revived. An
approved job that is cancelled has its dispatch_ready retracted.
Add one entry per printer to ac-organic-lab/equipment.yaml after deploying the
gateway. Keep the actual host in the lab's local registry/configuration flow.
- id: bambu_x1c_01
name: Bambu X1 Carbon 01
platform: fabrication
kind: other
adapter: http
protocol: "1.0"
base_url: http://127.0.0.1:8012
status_path: /printers/bambu_x1c_01/statusThis repository does not modify ac-organic-lab; registration is a separate,
reviewed change once deployment details are known.
uv run pytest -q
uv run ruff check .All tests use fake backends and make no hardware or network calls.
deploy/bambu-server.service provides a hardened systemd baseline for a Linux
lab host. Install the repository at /opt/bambu-server, create its uv
environment and local configuration, then install and enable the unit. It binds
loopback by default so a reverse proxy or same-host dashboard is the intended
client. The unit uses the FastAPI app factory so cors_origins from the local
YAML is applied before middleware is constructed.
The unit runs with ProtectSystem=strict, so submissions.directory must stay
inside ReadWritePaths — the shipped units cover the whole install root, which
the default var/submissions sits under. Moving the directory elsewhere means
widening ReadWritePaths to match.
The initial integration targets bambulabs_api 2.6.x (>=2.6.6,<3). It uses
only Printer.mqtt_start(), Printer.mqtt_stop(), telemetry getters, and the
MQTT message callback. The version cap makes a future breaking major upgrade an
explicit review.
python-multipart backs the submission upload form; it is Starlette's multipart
parser and is a runtime dependency only because POST /submissions exists.
Artifact inspection adds no dependency: .3mf containers are read with the
standard library's zipfile, json, and xml.etree.