A Go emulator for a Bambu Lab 3D printer running in LAN-only Developer Mode. It exposes the same network surface a real printer does — an MQTT broker for status/commands, an FTPS server for file uploads, and a UDP discovery responder — so it can be added to Bambu Studio, Bambu Handy, or third-party tools like Bambuddy using just an IP address, serial number, and access code.
It does not slice, print, or execute anything real. Print jobs are simulated (temperatures ramp, progress advances, filament is "consumed") purely for believable status reporting.
services:
fakebambu-p2s:
image: jc21/fakebambu:latest
network_mode: host
command: ["-model", "p2s"]go build -o fakebambu .
./fakebambu -model p1s
On startup it generates (or uses the values you pass) a serial number and
LAN access code and prints them to the console, along with every IPv4
address the host can be reached on (or just the one address, if you passed
-interface):
==============================================================
fakebambu - Bambu Lab printer emulator
==============================================================
Model: P1S (p1s)
Device name: fakebambu-p1s
Serial number: 01S188063640885 (generated)
Access code: HZUG1EHB (generated)
--------------------------------------------------------------
Listening on all interfaces (0.0.0.0); reachable at:
- 192.168.0.194
MQTT(S): port 8883 (user "bblp", password = access code)
FTPS: port 990 (implicit TLS, same credentials)
Discovery: UDP port 2021 (SSDP-style, best-effort)
Preview: http://<ip>:8990/thumbnail (poster of the active print)
==============================================================
In Bambu Studio: Add printer → enter this IP, the serial number, and the access code. Select the matching model from the printer type dropdown.
| Flag | Default | Description |
|---|---|---|
-model |
(required) | p1p, p1s, p2s, x1c, x1e, x2d, a1, a1m, a2l, h2d, h2c, h2s |
-serial |
generated | Serial number to present |
-access-code |
generated | LAN access code |
-name |
fakebambu-<model> |
Device name advertised over discovery |
-interface |
0.0.0.0 |
Interface name (e.g. eth1) or IP address to bind every listener to; 0.0.0.0 binds all interfaces |
-mqtt-port |
8883 |
MQTT(S) broker port |
-ftp-port |
990 |
FTPS (implicit TLS) port |
-ftp-passive-port-start |
50100 |
First port of the 100-port FTP passive-data range |
-discovery-port |
2021 |
UDP discovery port |
-no-discovery |
off | Disable the discovery broadcaster/responder |
-data-dir |
./data |
Where uploaded print files are written |
-print-queue |
(none) | Path to a YAML/JSON playlist of prints to emulate on a loop |
-preview-port |
8990 |
Port for the HTTP poster/thumbnail preview server |
-log-level |
info |
debug, info, warn, error |
Each listener binds to -interface (default 0.0.0.0, every interface).
Point each instance at a different NIC and they can share every other flag
verbatim — identical port numbers included — since a socket bound to one
interface's address doesn't collide with another bound to a different one:
./fakebambu -model p1s -interface eth1 -mqtt-port 8883 -ftp-port 990
./fakebambu -model x1c -interface eth2 -mqtt-port 8883 -ftp-port 990
-interface accepts either an interface name (eth1) or a literal IP
address (192.168.1.50) — whichever's more convenient.
One thing that does not follow the interface split: FTP passive-mode data
connections. ftpserverlib always binds its passive listener to 0.0.0.0
regardless of the control connection's address, so two instances sharing a
passive port would collide even on different interfaces. Give each instance
a distinct -ftp-passive-port-start (100 ports apart is enough — the
default range is 100 wide) to avoid that:
./fakebambu -model p1s -interface eth1 -ftp-passive-port-start 50100
./fakebambu -model x1c -interface eth2 -ftp-passive-port-start 50200
Pass -print-queue examples/print-queue.yaml (or your own file) and the
emulator behaves like a printer that's constantly busy: it works through the
list one job at a time and loops back to the first when it reaches the end,
forever. Real time drives everything — mc_percent, layer_num, and
temperatures all advance relative to how far into each job's duration /
layers the current moment is, exactly as they do during a normal print
(IDLE → RUNNING → FINISH, with a short idle pause between jobs).
prints:
- name: "3DBenchy"
file: "benchy.gcode.3mf"
duration: 45m
layers: 220
thumbnail: "posters/benchy.png"
material: PLA
- name: "Articulated Dragon"
file: "dragon.gcode.3mf"
duration: 6h30m
layers: 1800
thumbnail: "posters/dragon.png"
material: PETGSee examples/print-queue.yaml for the full
field reference. JSON works too — just use a .json extension with the same
field names. thumbnail paths resolve relative to the playlist file's own
directory, so you can keep posters alongside it.
Commands sent over MQTT (pause/resume/stop, or a manual print start from Bambu Studio) still work normally on top of the queue: pausing or resuming affects whichever job is currently running, and once the printer goes back to idle the queue driver picks up with the next job in the list.
Just put the file wherever thumbnail: in the playlist points — e.g. for
examples/print-queue.yaml's
thumbnail: "posters/benchy.png", that's examples/posters/benchy.png
(paths resolve relative to the playlist file's own directory). Nothing else
to configure.
When that job starts, the emulator packages the image into a minimal
.gcode.3mf and writes it to the virtual SD card
(<data-dir>/uploads/<job name>.gcode.3mf) — the same filename convention
and embedded-thumbnail path (Metadata/plate_1.png) real Bambu Studio uses
when it FTPs a sliced project to a printer. Bambuddy fetches print covers
by FTP-downloading exactly that file (tried at /, /cache/, /model/,
/data/, keyed off the print's subtask_name) and reading the thumbnail
back out of it — this was confirmed directly against Bambuddy's own source
(backend/app/services/bambu_ftp.py, backend/app/api/routes/printers.py,
backend/app/services/archive.py). So a queue job with a thumbnail set
just works in Bambuddy's Archives/cover UI with no extra steps; a plain
curl/browser view is also available at:
GET http://<printer-ip>:8990/thumbnail # fakebambu-only convenience, see caveats
GET http://<printer-ip>:8990/status # current print-state snapshot as JSON
This only happens for -print-queue jobs, since those are the only prints
with no real file behind them. A print actually sent from Bambu Studio
already FTPs its own real, fully-thumbnailed 3MF — nothing to do there.
Also relevant: the MQTT report's home_flag sets bit 8 (SD card present)
and bit 11 (store_to_sdcard, i.e. "Store sent files on external storage"),
and includes a storage: {free, total} block — both read by Bambuddy's
connection diagnostic and by Bambu Studio's own pre-flight before it will
attempt an FTP send at all. fakebambu always reports these as enabled,
since (unlike a real printer) every upload here is unconditionally
persisted to disk.
- MQTT broker (
internal/mqtt) — an embedded broker (not just a client) ondevice/{serial}/report/device/{serial}/request, exactly like the mosquitto instance running on a real printer. Requires usernamebblpand password equal to the access code, matching Bambu Studio's LAN login. It pushes a full status report once a second (temperatures,gcode_state, print progress, AMS) and answerspushall,get_version,led_ctrl,project_file/print/pause/resume/stop, andprint_speed. Any other command is logged and safely ignored rather than erroring. - FTPS server (
internal/ftps) — implicit-TLS FTP on port 990, samebblp/access-code credentials, backing onto a real directory (<data-dir>/uploads) so uploaded.gcode.3mffiles from Bambu Studio's "send to printer" flow succeed and are saved for inspection. - Discovery (
internal/discovery) — UDP SSDP-style broadcaster/responder on port 2021, sending periodic "alive" announcements and replying toM-SEARCHprobes. - Simulated state (
internal/state) — idle temperatures with small jitter, AMS units/trays with filament type/color/remaining %, and a simulated print cycle (IDLE → RUNNING → FINISH) with layer count, percent complete, and temperature ramps, all driven by real elapsed time against a configurable duration/layer count (3 minutes / 100 layers by default for an ad-hoc MQTT-triggered print; whatever a-print-queuejob specifies otherwise). - Print queue / autoplay (
internal/queue,internal/autoplay) — optional looping playlist of simulated print jobs, see "Simulating a run of prints" above. - Synthetic 3MF / SD card (
internal/threemf) — packages a queue job's poster image into a minimal.gcode.3mf(valid zip/OPC package, embeddedMetadata/plate_1.png+_small.png) and writes it to the FTPS upload directory under the print's name, so FTP-based cover-fetching tools (see Bambuddy above) find a real file, not just a report field. - Preview server (
internal/preview) — plain HTTP endpoint serving the active job's poster/thumbnail image and a JSON status snapshot. - TLS (
internal/certs) — a fresh self-signed certificate is generated each run; Bambu Studio/Handy don't validate the LAN certificate chain, so this matches real printers (which also use self-signed certs).
Bambu's LAN protocol is not publicly documented; everything here is reconstructed from community reverse-engineering (Home Assistant's Bambu Lab integration, various write-ups, and captured traffic descriptions), not an official spec. In particular:
- The MQTT JSON schema (
internal/state/state.goSnapshot(),internal/mqtt/commands.go) covers the fields most tooling reads (temperatures,gcode_state,mc_percent, AMS trays, lights) but is not guaranteed byte-for-byte identical to a real printer's report. - Serial number prefixes (
internal/profile/profile.go) and per-modelModelCodevalues are plausible-looking placeholders, not verified real allocations. They don't affect connectivity — Bambu Studio's manual "Add printer by IP" flow has you pick the model explicitly. - The discovery packet format (
internal/discovery/ssdp.go) is a best effort; if Bambu Studio's auto-discovery doesn't pick up the emulator, use manual IP entry — that path only depends on MQTT (to verify the connection) and works reliably. - P2S/X2D/H2C/A2L exist as real models; their
internal/profile/profile.goentries (dual-nozzle flag, AMS count, ethernet-implies-nothing-here, rod type) were cross-checked against Bambuddy's own model database, which is more reliable than guessing. Bed size, max temps, and firmware version on those four are still placeholders in the same spirit as every other profile — Bambuddy's file doesn't cover physical dimensions, and those numbers don't affect connectivity anyway. - The
/thumbnailand/statuspreview endpoints (internal/preview) are a fakebambu-only convenience, not a reconstruction of any real Bambu camera/thumbnail protocol — real printers most likely expose this via the local camera stream (port 6000-ish) rather than plain HTTP, but that protocol isn't public either. If you need Bambu Studio itself to render the poster, this won't do that; it's meant for scripts/dashboards you control that are testing against the emulator. Bambuddy specifically does not need this endpoint — it gets covers via the synthetic-3MF/FTP path described above, which was verified against its actual source rather than guessed.
If you have real captured traffic from a printer, matching the JSON schema and discovery packet more precisely against it is straightforward — the relevant code is centralized in the three files listed above.