Skip to main content
Glama
billford

mcp-bambu

by billford
README.md
# mcp-bambu

Local-network tooling for a small fleet of Bambu Lab 3D printers: an MCP server
so Claude Code can see and drive the machines, an always-on monitor that turns
the MQTT telemetry stream into alerts and a durable event log, and a media sync.
No cloud dependency — everything talks to the printers directly over the LAN.

It exists because the printers are poor witnesses to their own failures. A P1S
will report `FINISH` on a print that produced a six-hour bird's nest, its own
logs are encrypted, and the only usable record of what actually happened is the
live MQTT stream — which nothing keeps unless you keep it.

| entry point | run as | what it does |
|---|---|---|
| `bambu_mcp.server` | MCP (Claude Code) | status, files, print control, filament, maintenance, diagnosis |
| `bambu_mcp.monitor` | launchd, always on | MQTT watcher; alerts, event log, filament billing, maintenance clock |
| `bambu_mcp.sync` | launchd, hourly | pulls timelapses to the NAS |
| `bambu_mcp.export` | by hand | render + lint + record a model (see below) |

Three entry points, one config, one set of credentials.

## Requirements

- Python 3.11+
- A Bambu Lab printer reachable on the LAN, and its **access code**
  (printer screen → Settings → Network/WLAN)
- Optional: an [ntfy](https://ntfy.sh) server for push notifications
- Optional, for `bambu_mcp.export`: OpenSCAD and `print-lint`

## Install

```sh
git clone <this repo> && cd mcp-bambu
python3 -m venv .venv && . .venv/bin/activate
pip install -e .
```

The editable install is not optional — see the first gotcha below.

## Configure

```sh
cp config.example.toml config.toml     # printers, paths, ntfy
cp creds.example.toml  creds.toml      # access codes
chmod 600 creds.toml
```

Both real files are gitignored. `config.toml` holds printers, paths and ntfy;
`creds.toml` holds access codes and the ntfy password. Adding a printer is one
`[[printers]]` block plus its access code, and it joins all three entry points
automatically.

`serial` must be exact — it *is* the MQTT topic (`device/<serial>/report`).
Both it and the IP are on the same screen as the access code.

Alert routing lives in `alert_rules.toml`, re-read on every event so edits need
no restart. A missing or corrupt rules file **fails open** — everything alerts —
because on this fleet quiet has meant broken. Maintenance rules deliberately
fail *quiet*: a missed reminder is an inconvenience, a missed fault is not.

## Run

```sh
python -m bambu_mcp.monitor          # foreground; run under launchd in anger
python -m bambu_mcp.server           # registered as an MCP server in Claude Code
```

## Checks

```sh
pip install -e ".[dev]"
pytest tests/                        # 105 tests, no hardware needed
pylint bambu_mcp tests               # 10.00/10
bandit -c pyproject.toml -r .        # 0 issues
```

Both linters are configured in `pyproject.toml`, and every disabled check
carries the reason it is disabled. Nothing is silenced to raise a score: the
subprocess warnings are accepted because the calls pass argument *lists* and
never touch a shell, and the two `# nosec B310` markers point at the scheme
guard in `Notifier._request()` that bandit cannot see from the call site.

## The dashboard

A static page, published to Cloudflare Pages. There is no server and no API:
`publish.py` reads what the monitor already wrote and exports
`site/data/*.json`; `site/index.html` fetches them.

```sh
python -m bambu_mcp.publish     # export only
./run_and_publish.sh            # export, then wrangler pages deploy
```

**Freshness is the whole problem with a static page.** It is a snapshot, and a
stale one is indistinguishable from a calm fleet - which is this repo's oldest
lesson. So there are two guards, deliberately overlapping:

* `run_and_publish.sh` turns a failed export or a failed deploy into an ntfy
  alert. Neither step can report its own death, and Cloudflare will happily
  keep serving the last good build forever.
* Every payload is stamped, and **the page computes its own age in the
  browser**. A JSON file cannot know how long it has been sitting there; only
  the viewer's clock can tell a fresh deploy from a fossil. So the page says so
  even if the publisher never runs again.

Cadence is a parameter: fast while anything is printing, slow when the fleet is
idle. `any_active` in the payload exists for a wrapper to decide that. See
`com.billfordx.bambu-publish.plist.example` - an example, not an installed job.

**What is published.** No IPs, serials, access codes, hostnames or filesystem
paths - the payload is built to carry none of them, and a test asserts it.
Job names DO appear, because they are what a dashboard is for; set
`redact_jobs = true` under `[publish]` to leave them out. Generated payloads are
gitignored, so they reach Cloudflare and never the repo.

## Findings that cost real time — do not re-derive them

1. **The printers' own `.log` files are encrypted.** Only Bambu support can
   read them. Shipping them anywhere is pointless; the live MQTT report stream
   is the only usable local fault source.
2. **MQTT (8883), FTPS (990) and the camera (6000) all work in CLOUD mode** on
   the P1S. LAN Only is *not* required for monitoring — but the H2D will not
   display an access code outside LAN Only, which is the real reason to convert
   it. Note the cost: LAN Only ends Bambu Handy, which is cloud-only.
3. **Reads work; starting a print may not.** A `project_file` command was
   refused by a cloud-mode P1S with `HMS_0500_0500_0001_0007` — *"MQTT Command
   verification failed"* — while `system/ledctrl` succeeded over the same
   connection seconds later. Monitoring is unaffected.
4. **Undocumented HMS codes are in a table on your own disk.** The public wiki
   404s on some of them. Bambu Studio caches the real per-model table at
   `~/Library/Application Support/BambuStudio/hms/hms_en_<model>.json`
   (`01P` = P1S). Search it for the code with the underscores stripped.
5. **One MQTT client per printer.** Several short-lived connections opened a few
   seconds apart made both machines return *empty reports* — no error, no
   refusal, no log line. A second collector does not fail loudly, it silently
   returns nothing. Read this project's event log instead of opening a second
   connection.

## The export wrapper

```sh
python -m bambu_mcp.export ufo.scad -o ufo_body.stl -D part=body --layer-height 0.2
```

Renders, lints and records job → source + parameters in one step. Use it
instead of calling `openscad` directly — the manifest entry is what makes a
later failure diagnosable, since the printer only ever knows a filename.

## Diagnosis

On `FAILED` or `PAUSE` the monitor classifies the failure and pushes a second
notification saying *why*. The machine's own fault reporting outranks anything
inferred: **if the printer raised an HMS code, that is the answer and the
geometry is not touched.** Only when no fault was reported does it re-lint the
source and locate the problem at the height the print stopped.

`bambu_repair` will then sweep the recorded parameters, render each candidate
with OpenSCAD and score it with `print-lint`, so a proposal is only ever
returned because it *measurably* beat the original. If nothing improves, that
is the answer — "this needs a shape change, not a number."

Both run on a background thread. A dozen OpenSCAD renders on paho's callback
thread would stall telemetry for minutes and trip the staleness alert.

## Gotchas

- **`mcp` is pinned `<2`.** 2.x removed the low-level `Server` decorator API
  this is written against.
- **The package must be `pip install -e .`'d into its venv.** It is registered
  as `python -m bambu_mcp.server`, and `-m` resolves from the *current working
  directory* — so it starts fine from the project dir and fails everywhere
  else, which is where Claude Code actually launches it. Worse, `claude mcp
  list` inherits the shell's cwd, so running it from the project directory
  reports a cheerful "Connected" for a server the session cannot start.
- **The P1S never decrements filament remaining.** `remain` is -1 on every
  slot, tagged or not. Hence `filament.py`, which bills each finished job from
  the slicer's own figures. The H2D *does* report it, so the same field carries
  two different confidence levels depending on the machine.
- **AMS trays are keyed by unit *and* tray, never tray alone.** Both AMS units
  number their trays 0-3, so flattening on tray id gave the H2D's eight trays
  four ledger keys — the units overwrote each other on every sync, zeroing
  `used_g` and reporting spool swaps that never happened. Keys are `A1..A4` /
  `B1..B4`, lettered because Bambu's own fault strings say "AMS A". Old numeric
  ledgers migrate on read.
- **`chamber_temper` is not a sensor on the P1S.** It stayed pinned at exactly
  5 through a 180 °C swing while nozzle and bed tracked normally. The H2D omits
  the key entirely. One machine lies, the other is silent — do not default it
  to zero and plot it.
- **`subtask_name` is empty at FINISH,** and is often the *slicer profile*
  rather than the model (`0.16mm layer, 2 walls, 10% infill`). The job name
  only appears during SLICING/PREPARE, so the monitor caches the last one it
  saw — without that, no finished print could ever be charged.
- **SD card filenames contain spaces and commas.** Parse FTP listings with
  `split(maxsplit=8)` and percent-encode URLs, or files silently vanish.
- **Timelapse is a slicer setting and does not persist across projects.** With
  it off there is no independent check on a completion at all, which matters
  given the P1S will report `FINISH` on a failure. The monitor fires
  `finish_unverified` when a print ends with no recent timelapse.

## Tested on

A Bambu Lab P1S and an H2D, on macOS, with launchd running the monitor and the
sync. Nothing here is macOS-specific except the launchd plists and the Bambu
Studio cache paths; the rest is plain Python over MQTT and FTPS.

This is personal tooling for one small fleet, published because the findings
above were expensive to arrive at and are hard to find written down anywhere
else. It is not a product and carries no support promise.

## License

MIT - see [LICENSE](LICENSE).