Sliced files previewed through a vendored copy of PrettyGCode in an
iframe. It drew each move as a screen-space line -- a line has no
thickness in the scene, so it cannot occlude the layer behind it, which
is why prints came out stringy and shimmered where layers crossed. Being
a separate app in a frame, it could be neither themed nor translated, and
carried its own machinery for detecting a proxy refusing the embed.
Now built on libvgcode, the renderer OrcaSlicer draws its own preview
with, vendored from three-slicer (AGPL, same as us). It takes the THREE
namespace as an argument and imports nothing, so it runs on our 0.181
rather than the 0.160 its package pins.
The parser is ours; upstream renders its own kernel's output and ships no
G-code parser at all. Two things it has to get right, both found by
checking a real plate rather than assuming:
- BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It
writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and
"; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading
only the latter showed a 52-layer print as 23,165 layers in one colour,
because with no layer marker recognised every travel Z-hop split a
layer and every segment took the fallback feature.
- It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a
single plate. Ignoring them punched holes through curved walls and tree
supports. Arcs with no X/Y are the helical travel lift and lay down
nothing, so they interpolate as travels.
Four colour modes: filament (default, from the AMS slots the file was
sliced with), feature, layer height, line width. Speed, fan and
temperature are deliberately absent -- upstream derives those from
settings rather than the toolpath, and guesses dressed as measurements
are worse than an honest omission. The parser now carries the data to do
them properly later.
Legend entries are switches. Hiding removes the records before the mesh
is built rather than recolouring them: the shader packs colour into a
single float with no alpha, so there is no transparent to set, and
removal is the useful behaviour anyway -- a hidden support stops
occluding what it covered.
The scene is built once and only the toolpath rebuilds. Doing otherwise
constructed a new WebGLRenderer on every render, because the buildVolume
default is an object literal and so a fresh identity each time; browsers
cap live WebGL contexts and drop the oldest, which blanked the canvas
after a few interactions.
utils/framing.ts goes with the iframe, along with six now-orphaned
strings in all 13 locales. src/lib/vendor is excluded from eslint --
acting on findings in vendored code makes it impossible to re-copy on the
next upstream release.
The image upgraded pip to >=26.1, but PYSEC-2026-196's fix is specifically
26.1.2 (PYSEC-2026-2875/2876 are fixed in 26.1). The old floor could resolve
26.1.0/26.1.1, which are still vulnerable to PYSEC-2026-196. --upgrade already
grabbed the latest in practice; this makes the pin match the advisory exactly.
pkgs.tailscale.com intermittently returns 504, which aborted the whole
image build even though the Tailscale CLI is optional (the code falls
back to self-signed without it). Retry the fetch, and on sustained
failure continue building without the CLI instead of failing.
Two defects, both invisible until you ask the app to stop.
Docker never shut down gracefully at all. CMD ["sh","-c","uvicorn ..."] left
the shell as PID 1 with uvicorn as its child, and dash does not forward
signals, so docker stop SIGTERMed the shell and uvicorn never heard about it.
Measured on the shipped image: the full 10s grace period, exit 137, and no
"Shutting down" line in the log. Every stop, restart and image update was a
hard kill -- no WAL checkpoint, no MQTT disconnect, no virtual-printer
teardown. `exec` makes uvicorn PID 1; the rebuilt image now stops in 1s with
exit 0 and checkpoints the WAL.
Separately, uvicorn's timeout_graceful_shutdown defaults to None -- wait
forever for in-flight requests. An MJPEG camera stream is a response that
never completes (httptools' connection shutdown() only flips keep_alive on an
in-flight cycle, it never closes the transport), so one open camera tile
pinned the process until systemd SIGKILLed at 90s. The ordering makes it
unfixable from inside the app: uvicorn fires the lifespan shutdown -- the code
that tears the streams down -- only after connections drain.
All six launchers now pass --timeout-graceful-shutdown 5: Dockerfile,
deploy/bambuddy.service, the systemd unit and launchd plist from
install/install.sh, the SpoolBuddy installer's unit, and the Windows NSSM
registration. On timeout uvicorn cancels the request tasks; the camera
generators already unwind cleanly on CancelledError.
TimeoutStopSec raised to 30s on the units and stop_grace_period: 30s added to
compose, as backstops rather than the mechanism. On Windows NSSM's default
1500ms AppStopMethodConsole was force-killing uvicorn mid-teardown; raised to
15s, with the WM_CLOSE and thread-message stages skipped (uvicorn is a console
app with neither a window nor a message loop).
Matplotlib (imported lazily by stl_thumbnail.py) tried to create its
font/style cache at $HOME/.config/matplotlib on first STL upload.
HOME=/app per the Dockerfile but /app is root-owned and not writable
by the PUID:PGID the entrypoint drops to, so matplotlib logged
"Permission denied" and fell back to /tmp/matplotlib-* — wiped on
every restart, paying the font-scan cost again on the next STL.
Add ENV MPLCONFIGDIR=/tmp/matplotlib to make the cache directory
writable and persistent across the container's lifetime. /tmp is
writable by any uid, so this works regardless of PUID.
The python:3.13-slim-trixie base image ships pip 26.0.1, which runs its
self-update check after installing wheels — a malicious wheel that included
a module name matching a deferred stdlib import (urllib, ssl, ...) could
hijack the import inside the install step. GitHub code-scanning alert #778
flagged this as medium-severity.
Dockerfile now upgrades pip to >=26.1 immediately before the requirements.txt
install, so the requirements install runs under the patched pip and the
resulting dist-info metadata in the final image is the fixed version.
No requirements.txt change — the floor is enforced at the image-build layer
where the vulnerable copy actually lived.
The embedded GCode viewer's static assets (gcode_viewer/) were never
copied into the production Docker image, so /gcode-viewer/ returned a
bare FastAPI 404 ({"detail":"Not Found"}) and 3D Preview broke for every
Docker user since the viewer landed in 0.2.4b1. The Vite production
build doesn't stage the directory either — the dev server serves it via
a configureServer middleware that's dev-only.
Dockerfile now copies gcode_viewer/ alongside the React build output.
Defence in depth: main.py logs an ERROR at startup when
_gcode_viewer_dir/index.html is missing so future packaging gaps surface
in docker logs and the support bundle instead of as silent runtime 404s.
The existing integration test accepted 404 unconditionally
(assert response.status_code in (200, 404)) so CI never caught the
missing files. Add test_gcode_viewer_index_served_when_assets_present
which skips when the directory is intentionally absent (unit-test envs)
but asserts 200 + non-empty HTML body when the assets do exist on disk —
so a broken COPY fails CI loudly rather than shipping a broken image.
Two related failure modes have been biting Docker users repeatedly,
most recently in #1211:
1. Docker named volumes are created by the daemon as root:root, and
the previous `chmod 777 /app/data` Dockerfile workaround only
covered the named-volume root — so subdirs Bambuddy creates at
runtime (virtual_printer/uploads, virtual_printer/certs, etc.)
inherited wrong ownership when the container ran as 1000:1000.
2. The shipped docker-compose.yml ships
`./virtual_printer:/app/data/virtual_printer` uncommented, and
dockerd creates a missing bind-mount source on the host as root
before the container starts — leaving the host directory
unwritable by uid 1000 inside the container even though the named
volume above it had the chmod-777 workaround.
Symptom either way: [Errno 13] Permission denied:
'/app/data/virtual_printer/uploads', no virtual printer ever starts,
"VP doesn't work" support reports follow.
Replace the chmod-777 hack with a proper entrypoint:
- deploy/docker-entrypoint.sh runs as root, chowns /app/data and
/app/logs (and /app/data/virtual_printer when bind-mounted) to
PUID:PGID, then drops to that uid via gosu before exec'ing the
app. The chown is gated behind a top-level ownership check so
subsequent restarts skip the recursive traversal — no multi-
second startup penalty on multi-GB archive directories.
- A sentinel .bambuddy file in each data path prevents Docker from
re-syncing image directory metadata on every mount (otherwise
empty volumes have their ownership reverted from the image on
each restart, defeating the idempotency).
- When the container is started with an explicit `user:` directive
or `--user` flag the entrypoint detects it isn't root and falls
through to direct exec — preserving compatibility for users who
pin a specific uid.
Compose template changes:
- Remove `user: "${PUID:-1000}:${PGID:-1000}"` (entrypoint owns
privilege drop now).
- Add PUID / PGID env vars with the same defaults.
- Comment out the ./virtual_printer:/app/data/virtual_printer
bind mount by default, with explicit "only needed if you also
run a native install of Bambuddy on the same host and want both
to share the VP CA cert" guidance. The entrypoint chowns the
host-side dir through the bind mount the first time it sees
wrong ownership, so existing uncomented installs continue to
work and #1211 specifically gets fixed.
Add the Tailscale CLI to the production image and document how to
enable Let's Encrypt cert provisioning for virtual printers from a
Docker-deployed Bambuddy.
- Dockerfile installs `tailscale` from the official Debian repo. Only
the CLI is used at runtime; tailscaled itself stays on the host.
The binary is harmless if the socket isn't mounted — the code logs
an actionable hint and falls back to self-signed certs.
- docker-compose.yml adds a commented-out volume mount for
/var/run/tailscale/tailscaled.sock with inline setup instructions.
- tailscale.py's docker-socket hint now also fires when the binary is
present but the daemon socket is unreachable (i.e. the new Docker
pattern), not just when the binary is missing, so users get the
actionable "mount the socket" message instead of opaque CLI stderr.
Enabling the integration on a Docker host:
1. `curl -fsSL https://tailscale.com/install.sh | sh` on host
2. `sudo tailscale up`
3. `sudo tailscale set --operator=<user>` for the container PUID
4. Uncomment the tailscaled.sock mount in docker-compose.yml
5. `docker compose up -d --force-recreate`
6. Flip the Tailscale toggle on the VP card
Picks up ffmpeg 5 → 7 (HEVC/AV1 improvements), OpenSSL 3.0 → 3.3, and
two more years of APT package freshness. Frontend-builder stays on
Bookworm until the Node.js image team publishes Trixie variants.
The SpoolBuddy remote-update flow always pulled `main` on the remote
device when Bambuddy ran under Docker, regardless of which branch the
image was built from. Root cause: the Dockerfile COPYs only backend/
and static/, and .dockerignore excluded .git entirely, so the container
had no git metadata anywhere. detect_current_branch() silently fell
through its file-read path and returned the GIT_BRANCH env-var default
of "main".
The old subprocess-based implementation had the same bug but it was
masked twice: no .git in the image AND no `git` binary in the image,
so git rev-parse raised FileNotFoundError, the bare except swallowed
it, and the fallback kicked in.
Let the one file we actually need (.git/HEAD — ~20 bytes containing
`ref: refs/heads/<branch>`) through the .dockerignore filter and COPY
it into the image at /app/.git/HEAD. detect_current_branch() already
reads exactly that path, so no Python code changes are needed. Bind-
mount development setups are unaffected — the bind mount overlays the
baked-in file with the live repo's .git/HEAD.
Verified with a throwaway alpine build using the same .dockerignore
pattern: .git/HEAD passes through, decoy .git/refs and .git/objects
entries are excluded, and COPY writes the expected content into the
image.
Follow-up to the asyncssh migration. asyncssh.connect() internally
calls getpass.getuser() for ~/.ssh/config host matching, regardless
of the explicit `username=` passed for the remote login. Under an
arbitrary Docker PUID with no /etc/passwd entry, getpass.getuser()
raises "No username set in the environment" (OSError in Python 3.13+,
previously a bare KeyError).
Fix: set LOGNAME=bambuddy, USER=bambuddy, HOME=/app in the Dockerfile.
getpass.getuser() tries env vars before pwd.getpwuid(), so the lookup
never touches the passwd database and works for any PUID the operator
picks — no helper code, no image rebuild for different UIDs.
Also pass config=[] to asyncssh.connect() so it does not try to load
~/.ssh/config (whose default path needs a resolvable home directory).
An earlier draft of this fix added a Python helper that caught the
KeyError and injected LOGNAME at module import. That was both more
code than needed and broken on Python 3.13, which wraps the KeyError
in an OSError the helper didn't catch — so the module import itself
crashed, producing a 500 on /spoolbuddy/devices/{id}/update. Reverted
in favour of the one-line ENV fix.
The daemon's self-update mechanism (git fetch/reset on its own code) was
fragile: .git permission errors, self-modifying code mid-run, hardcoded
main branch. Bambuddy now SSHes into the SpoolBuddy Pi and drives the
update remotely — matching its own branch, with step-by-step progress
via WebSocket. Install script updated with SSH access, sudoers entry,
and --ssh-pubkey flag for pairing.
- Add ports 6000 (file transfer) and 322 (RTSP camera) to Dockerfile
EXPOSE and docker-compose.yml bridge mode port mapping
- Update migration doc with new proxy mode port requirements
- Regenerate proxy-mode-diagram.png with all proxied ports
When running multiple virtual printers with different access codes on
separate bind IPs, FTP connections were always routed to the wrong VP.
Root cause: the iptables REDIRECT rule (990→9990) rewrites the
destination IP to the incoming interface's primary address. With Linux's
weak host model (arp_filter=0), packets for secondary IPs arrive on the
primary interface, and REDIRECT sends them all to the first VP's FTP
server. MQTT was unaffected because port 8883 had no redirect.
Fix: FTP server now binds directly to port 990 (standard implicit FTPS),
eliminating the iptables redirect entirely. Requires CAP_NET_BIND_SERVICE
(already set in the systemd service file and Docker image).
Also removed a global asyncio set_exception_handler() in the MQTT server
that was overwritten by each VP instance, causing spurious "Unhandled
exception in client_connected_cb" errors on startup.
Changes:
- FTP_PORT: 9990 → 990 (ftp_server.py)
- Removed set_exception_handler() from MQTT server
- Updated Dockerfile, docker-compose.yml port mappings
- Deprecated --redirect-990 in install script
- Updated wiki: removed iptables instructions for all platforms
- Added migration guide (docs/migration-vp-ftp-port.md)
- Added unit tests for port constant and no-global-state invariant
Fix P2S camera stream dropping and snapshot capture race (#661)
P2S firmware's TLS renegotiation is rejected by Debian's hardened GnuTLS
defaults, causing ffmpeg RTSP sessions to drop after ~3 seconds. Add
GnuTLS config allowing unsafe renegotiation and legacy ciphers. Also add
ffmpeg fast-start flags, reduce reconnect delay from 1.0s to 0.2s,
remove double rate-limiting on external camera streams, and fix orphan
cleanup killing snapshot capture ffmpeg processes (exit code -9).
Or as a single combined commit:
Fix P2S camera streaming, snapshot race, and energy stats (#661, #695)
Camera: P2S firmware's TLS renegotiation rejected by Debian's hardened
GnuTLS defaults, dropping RTSP sessions after ~3s. Add GnuTLS compat
config, ffmpeg fast-start flags, reduce reconnect delay to 0.2s, remove
external camera double rate-limiting, and register snapshot ffmpeg PIDs
with the orphan tracker to prevent SIGKILL during capture.
The Docker image (python:3.13-slim) didn't include iproute2, so the
`ip` command wasn't available. The code fell back to ioctl-based
enumeration which can only return one IP per interface — aliases like
eth0:1 were completely invisible. Added iproute2 to the Dockerfile.
cap_add: NET_BIND_SERVICE in docker-compose.yml doesn't reliably
propagate to the Python process when combined with the user: directive
(depends on ambient capability support in the container runtime).
Set the file capability directly on the Python binary via setcap in
the Dockerfile, which the kernel honors regardless of runtime config.
Multiple Virtual Printers:
- Each VP gets a dedicated bind IP with independent FTP, MQTT, SSDP, and Bind services
- New VirtualPrinter DB model, CRUD API (/api/virtual-printers), React UI
- VirtualPrinterList, VirtualPrinterCard, VirtualPrinterAddDialog components
- Per-instance TLS certificates (shared CA), 11 printer models, all 4 modes
- Auto-incremented serial suffixes, network interface override per VP
Dual Bind/Detect Ports (#445):
- Listen on both ports 3000 and 3002 for slicer bind/detect handshake
- Different BambuStudio/OrcaSlicer versions use different ports
- Applies to BindServer (server mode) and SlicerProxyManager (proxy mode)
- Updated Dockerfile, docker-compose.yml, firewall rules in wiki
Also:
- Rewrote VP test suite for new multi-instance architecture (75 tests)
- Rewritten "How it works" section with 3-step workflow explanation
- Updated all 5 locales (en, de, ja, fr, it)
- Updated wiki and website for multi-VP + dual ports
- New multi-VP screenshot
Recent BambuStudio/OrcaSlicer updates require a bind/detect handshake on
port 3000 before connecting via MQTT/FTP. Without this, slicers cannot
discover or connect to the virtual printer in any mode.
- Add BindServer for server modes (immediate/review/print_queue)
- Add TCPProxy for raw TCP forwarding (proxy mode)
- Update Dockerfile (EXPOSE 3000) and docker-compose.yml (bridge port)
- Add 10 new tests for BindServer protocol and integration
Enable Bambu Studio on a remote network to print through BamBuddy
acting as a TLS-terminating proxy for both MQTT and FTP connections.
- Add TLSProxy base class and FTPTLSProxy with PASV response rewriting,
EPSV→PASV translation, PROT P/C tracking, and one-shot data proxies
- Add SlicerProxyManager to coordinate per-slicer MQTT + FTP proxy pairs
- Support additional SAN IPs in certificate generation for proxy mode
- Broadcast SSDP on LAN B so slicers discover the proxy as a printer
- Narrow FTP passive port range to 50000-50100 with retry logic
- Expose proxy ports (8883, 9990, 50000-50100) in Dockerfile
- Document passive port range in docker-compose.yml
- Add user directive to docker-compose.yml using PUID/PGID env vars
- Allows container to run as host user, fixing permission issues with
bind-mounted volumes (e.g., ./virtual_printer)
- Add chmod 777 to /app/data and /app/logs in Dockerfile for non-root compatibility
- Usage: PUID=$(id -u) PGID=$(id -g) docker compose up -d
Note: Existing named volumes (bambuddy_logs, bambuddy_data) created by previous
root containers may need to be removed or have permissions fixed manually.
Manually configure AMS slots for third-party or generic filaments:
1. Hover over an AMS slot on the printer card
2. Click the menu button (:material-dots-vertical:) that appears
3. Select **Configure Slot**
4. Choose a filament preset from your Bambu Studio cloud presets
5. Select a matching K profile (pressure advance calibration)
6. Optionally set a custom color using the color picker
7. Click **Configure Slot** to apply
**Color Picker Features:**
- Enter custom hex codes or color names (e.g., "brown", "FF8800")
- Live preview of selected color
- Expandable color picker in Configure AMS Slot modal:
- 8 basic colors shown by default
- 24 additional colors available via expand button
- Tests for ConfigureAmsSlotModal component
- Tests for AMS change callback
- Updated README with AMS slot configuration feature
- Wiki documentation for Configure AMS Slot feature
- Multi plate issue where plate names showed incorrect. #93
- Items from Queue end up as "source" files in archive. #107
- Added env variable support to change network port. #108
Docs -> https://wiki.bambuddy.cool/getting-started/docker/?h=port#custom-port
- Added DEBIAN_FRONTEND=noninteractive to suppress debconf warnings
- Added --root-user-action=ignore to pip install
- Added ffmpeg to Docker image
- Increased chunkSizeWarningLimit to 3000 for Vite build
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Test Summary:
- Build tests: 3 passed (image build, backend imports, static files)
- Backend unit tests: 378 passed (9 docker tests excluded)
- Frontend unit tests: 137 passed
- Integration tests: 9 passed (health, API endpoints, persistence, WebSocket)
Changes made to fix the Docker test suite:
1. Added curl to the production Dockerfile for integration tests
2. Removed deprecated version attribute from docker-compose.test.yml
3. Added --pull flag to all build commands to ensure fresh images
4. Added explicit build step before starting integration container
5. Fixed WebSocket test to accept 200 as a valid response
6. Excluded docker-marked tests from backend unit test runs (-m "not docker")