↓ Skip to main content

Building a Public Bambu Lab P2S Live Dashboard

·29 mins

I wanted a simple page on my website that answered one question:

What is my Bambu Lab P2S doing right now?

Not another printer-control application, and not a replacement for Bambu Handy. I wanted a public, read-only dashboard showing:

  • the P2S’s built-in camera;
  • current print name and percentage;
  • current and total layers;
  • estimated time remaining;
  • nozzle and bed temperatures;
  • fan speeds and Wi-Fi signal;
  • AMS filament, colours and remaining amounts where available;
  • the currently active filament;
  • AMS and AMS HT temperature/humidity levels; and
  • active filament-drying status, target temperature and time remaining.

The finished version is running here:

https://mattcharlton.co.uk/pages/3d/

This guide describes the working, reproducible path rather than every dead end encountered while building it.

It assumes you’re reasonably comfortable with Linux and a shell, but each component is deliberately small.

Important: this uses local interfaces exposed by the printer, including MQTT data that Bambu Lab does not present as a stable public API. Firmware changes may therefore break parts of this setup.

Do not expose the printer itself, its MQTT port, its camera credentials or its LAN access code directly to the internet.

What we’re building
#

The final architecture looks like this:

                         YOUR LAN

                 ┌────────────────────┐
                 │   Bambu Lab P2S    │
                 │                    │
                 │ MQTT :8883         │
                 │ RTSPS :322         │
                 └──────┬──────┬──────┘
                        │      │
                    MQTT│      │RTSPS
                        │      │
              ┌─────────▼──┐ ┌─▼─────────┐
              │ Python API │ │  go2rtc   │
              │ :8090      │ │  :1984    │
              └──────┬─────┘ └────┬──────┘
                     │             │ HLS
                     └──────┬──────┘
                            │
                       ┌────▼─────┐
                       │  nginx   │
                       │  :8080   │
                       └────┬─────┘
                            │
                     Tailscale Funnel
                            │
                            ▼
                  https://*.ts.net
                            │
                            ▼
                 Your existing website
                 HTML + JavaScript + HLS

The important security boundary is nginx.

The printer remains on the LAN.

Only a small sanitised JSON API and the HLS camera stream are made public.

My implementation runs in a small Debian LXC on Proxmox, but a Debian/Ubuntu VM or small Linux machine should work just as well.

The bit I wasn’t expecting: local access without going LAN-only
#

Before building any of this, there was a fairly fundamental question:

Can I get at the P2S locally without sacrificing Bambu Cloud and Bambu Handy?

I didn’t want to put the printer into full LAN Only mode. I use Bambu Handy, and the intention here was to add a public status page — not change the way I normally use the printer.

This turned out to be more interesting than I expected.

The P2S has two separate settings:

  • LAN Only
  • LAN Only Liveview

They sound related enough that it’s easy to assume the second is relevant only when the printer itself is running in LAN Only mode.

It isn’t.

My working configuration is:

LAN Only:          OFF
LAN Only Liveview: ON

With that configuration the printer remains connected to Bambu Cloud and Bambu Handy continues to work normally, but the local camera stream is also available on the LAN. That was the key discovery that made the rest of this project worthwhile. I could keep using the P2S exactly as I already did while independently consuming its local camera and telemetry.

With LAN Only Liveview enabled, the built-in camera was available over RTSPS at:

rtsps://bblp:LAN_ACCESS_CODE@PRINTER_IP:322/streaming/live/1

The stream turned out to be H.264, 1920×1080 at 30fps. So there was no need to add another camera, capture the Bambu application or give up any of the normal cloud functionality.

The next question was whether I could do the same thing with the printer’s status information.


1. Configure the P2S
#

On my P2S I have:

LAN Only:          OFF
LAN Only Liveview: ON

That combination is important for my use case.

The printer remains connected to Bambu Cloud, so Bambu Handy continues to work normally, while the local camera stream is also available.

You’ll need three things:

Printer IP:       192.168.x.x
Printer serial:   YOUR_PRINTER_SERIAL
LAN access code:  YOUR_LAN_ACCESS_CODE

The LAN access code is a secret.

Don’t put it in your public website, don’t commit it to Git and don’t paste it into anything you’re going to publish.

For the examples below I’ll use:

192.168.1.50
YOUR_PRINTER_SERIAL
YOUR_LAN_ACCESS_CODE

Replace those with your own values.


2. Create the Linux host
#

My backend is running in a Debian 13 LXC on Proxmox.

It really doesn’t need much:

1 vCPU
512 MB RAM
512 MB swap
Static/reserved LAN address
Start at boot

Install the basic packages:

apt update
apt install -y nginx python3 python3-venv curl ca-certificates

If you’re using an ordinary VM or physical Linux machine, you can skip the next section.

Proxmox LXC: give Tailscale /dev/net/tun
#

This caught me out.

If you’re using an unprivileged Proxmox LXC, Tailscale needs access to the TUN device.

On the Proxmox host, edit the configuration for your container.

For example, if the container ID is 203:

nano /etc/pve/lxc/203.conf

Add:

lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file

Restart the container.

Inside the container:

ls -l /dev/net/tun

You should see a character device with major/minor:

10,200

On an unprivileged LXC it may appear owned by nobody:nogroup. That’s fine.


3. Stream the P2S camera with go2rtc
#

The P2S exposes its built-in camera locally over RTSPS.

For the P2S, the stream URL is:

rtsps://bblp:YOUR_LAN_ACCESS_CODE@192.168.1.50:322/streaming/live/1

The camera on my P2S provides:

H.264 High
1920x1080
30 fps

The local stream I observed was video-only; no audio track was present, so this setup does not expose room audio.

Browsers don’t particularly want to consume that RTSPS URL directly, so I use go2rtc to turn it into something browser-friendly.

Install the appropriate go2rtc binary for your architecture as:

/usr/local/bin/go2rtc

Then:

chmod +x /usr/local/bin/go2rtc
mkdir -p /etc/go2rtc

Create:

/etc/go2rtc/go2rtc.yaml

with:

streams:
  p2s:
    - rtsps://bblp:YOUR_LAN_ACCESS_CODE@192.168.1.50:322/streaming/live/1

api:
  listen: ":1984"

webrtc:
  listen: ":8555"

That file contains your printer’s LAN access code, so protect it:

chmod 600 /etc/go2rtc/go2rtc.yaml

Now create:

/etc/systemd/system/go2rtc.service
[Unit]
Description=go2rtc - Bambu P2S Camera
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/go2rtc -config /etc/go2rtc/go2rtc.yaml
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable it:

systemctl daemon-reload
systemctl enable --now go2rtc

Check it:

systemctl status go2rtc

And try requesting an HLS playlist:

curl -I 'http://127.0.0.1:1984/api/stream.m3u8?src=p2s'

Don’t expose the entire go2rtc API
#

This is important.

Don’t just expose port 1984 to the internet.

Some go2rtc API endpoints can reveal information about configured streams, including the upstream RTSPS URL.

And that URL contains your access code.

We’ll put nginx in front of it later and expose only the HLS paths that the browser needs.


4. Get printer telemetry using MQTT
#

The other half of the project is the printer state.

The P2S publishes a surprisingly useful amount of information over MQTT.

The connection details are:

Host:      PRINTER_IP
Port:      8883
Username:  bblp
Password:  LAN_ACCESS_CODE

The printer publishes reports to:

device/YOUR_PRINTER_SERIAL/report

and receives requests on:

device/YOUR_PRINTER_SERIAL/request

There is one particularly important behaviour to understand:

MQTT reports are incremental
#

The printer doesn’t necessarily send its entire state in every MQTT message.

It may send something like:

{
  "print": {
    "mc_percent": 92
  }
}

and then later:

{
  "print": {
    "nozzle_temper": 255
  }
}

If you simply replace your cached state with every message, fields will apparently disappear at random.

Instead, the service needs to recursively merge each new update into the state it already knows about.

That turned out to be one of the most important parts of making the dashboard reliable.


5. Create the Python status service
#

Create a directory:

mkdir -p /opt/bambu-status

Create a virtual environment:

python3 -m venv /opt/bambu-status/venv

Install Flask and Paho MQTT:

/opt/bambu-status/venv/bin/pip install flask paho-mqtt

Store the secrets separately
#

Create:

/etc/bambu-status.env

containing:

BAMBU_HOST=192.168.1.50
BAMBU_SERIAL=YOUR_PRINTER_SERIAL
BAMBU_ACCESS_CODE=YOUR_LAN_ACCESS_CODE

Protect it:

chmod 600 /etc/bambu-status.env

This keeps the credentials out of the actual application code.


6. The Python API
#

Create:

/opt/bambu-status/status.py

with the following:

import json
import os
import ssl
import threading
import time
from datetime import datetime, timezone

import paho.mqtt.client as mqtt
from flask import Flask, jsonify


HOST = os.environ["BAMBU_HOST"]
SERIAL = os.environ["BAMBU_SERIAL"]
PASSWORD = os.environ["BAMBU_ACCESS_CODE"]

PORT = 8883
USERNAME = "bblp"

REPORT_TOPIC = f"device/{SERIAL}/report"
REQUEST_TOPIC = f"device/{SERIAL}/request"


app = Flask(__name__)

lock = threading.Lock()

printer_state = {}
last_update = None
mqtt_connected = False


def deep_merge(target, update):
    """
    Recursively merge incremental MQTT updates into the state
    we've already received.
    """

    for key, value in update.items():
        if (
            key in target
            and isinstance(target[key], dict)
            and isinstance(value, dict)
        ):
            deep_merge(target[key], value)
        else:
            target[key] = value


def first_value(data, *keys, default=None):
    for key in keys:
        value = data.get(key)

        if value is not None:
            return value

    return default


def safe_int(value):
    try:
        return int(value) if value is not None else None
    except (TypeError, ValueError):
        return None


def safe_float(value):
    try:
        return float(value) if value is not None else None
    except (TypeError, ValueError):
        return None


def fan_percent(value):
    """
    Bambu fan values observed here use a 0-15 scale.
    Convert that to something nicer for the website.
    """

    try:
        if value is None:
            return None

        return round((float(value) / 15.0) * 100)

    except (TypeError, ValueError):
        return None


def colour(value):
    if not value:
        return None

    value = str(value).strip().lstrip("#")

    if len(value) < 6:
        return None

    return f"#{value[:6].upper()}"


def parse_ams(print_data):
    ams = print_data.get("ams") or {}

    result = {
        "units": [],
    }

    for unit in ams.get("ams") or []:

        unit_id = str(unit.get("id", ""))

        dry = unit.get("dry_setting") or {}

        dry_duration = safe_int(
            dry.get("dry_duration")
        )

        dry_target = safe_float(
            dry.get("dry_temperature")
        )

        dry_time = safe_int(
            unit.get("dry_time")
        )

        # This matched the behaviour observed on the P2S while
        # the AMS / AMS HT were actively drying.
        drying_active = (
            (dry_duration or 0) > 0
            and (dry_target or 0) > 0
            and (dry_time or 0) > 0
        )

        if unit_id == "128":
            name = "AMS HT"

        else:
            try:
                name = f"AMS {int(unit_id) + 1}"
            except ValueError:
                name = f"AMS {unit_id}"

        parsed = {
            "id": unit.get("id"),

            "name": name,

            "temperature": safe_float(
                unit.get("temp")
            ),

            "humidity": safe_int(
                unit.get("humidity")
            ),

            "humidity_raw": safe_int(
                unit.get("humidity_raw")
            ),

            "drying": {
                "active": drying_active,

                "filament": (
                    dry.get("dry_filament")
                    or None
                ),

                "target_temperature": (
                    dry_target
                    if (dry_target or -1) >= 0
                    else None
                ),

                "duration_hours": (
                    dry_duration
                    if (dry_duration or -1) >= 0
                    else None
                ),

                # Watching this value during a drying cycle showed
                # it counting down approximately once per minute.
                "remaining_minutes": dry_time,
            },

            "slots": [],
        }

        for tray in unit.get("tray") or []:

            parsed["slots"].append({
                "id": tray.get("id"),

                "type": first_value(
                    tray,
                    "tray_type",
                    "type"
                ),

                "sub_brand": first_value(
                    tray,
                    "tray_sub_brands",
                    "sub_brand"
                ),

                "colour": colour(
                    first_value(
                        tray,
                        "tray_color",
                        "color"
                    )
                ),

                "remaining": safe_int(
                    first_value(
                        tray,
                        "remain",
                        "remaining"
                    )
                ),

                "diameter": safe_float(
                    first_value(
                        tray,
                        "tray_diameter",
                        "diameter"
                    )
                ),

                "weight": safe_int(
                    first_value(
                        tray,
                        "tray_weight",
                        "weight"
                    )
                ),

                "state": safe_int(
                    tray.get("state")
                ),
                # Observed state 27 on the tray actively
                # feeding filament during a print.
                "in_use": safe_int(
                    tray.get("state")
                ) == 27,
            })

        result["units"].append(parsed)

    return result


def build_status():

    with lock:
        data = dict(printer_state)
        updated = last_update
        connected = mqtt_connected

    p = data.get("print") or {}

    return {
        "online": connected,

        "last_update": updated,

        "print": {
            "state": first_value(
                p,
                "gcode_state",
                "state"
            ),

            "name": p.get(
                "subtask_name"
            ),

            "file": first_value(
                p,
                "gcode_file",
                "file"
            ),

            "progress": safe_int(
                first_value(
                    p,
                    "mc_percent",
                    "percent"
                )
            ),

            "layer": safe_int(
                p.get("layer_num")
            ),

            "total_layers": safe_int(
                p.get("total_layer_num")
            ),

            "remaining_minutes": safe_int(
                first_value(
                    p,
                    "mc_remaining_time",
                    "remain_time"
                )
            ),

            "speed_percent": safe_int(
                p.get("spd_mag")
            ),
        },

        "temperatures": {
            "nozzle": {
                "actual": safe_float(
                    first_value(
                        p,
                        "nozzle_temper",
                        "nozzle_temp"
                    )
                ),

                "target": safe_float(
                    first_value(
                        p,
                        "nozzle_target_temper",
                        "nozzle_target_temp"
                    )
                ),
            },

            "bed": {
                "actual": safe_float(
                    first_value(
                        p,
                        "bed_temper",
                        "bed_temp"
                    )
                ),

                "target": safe_float(
                    first_value(
                        p,
                        "bed_target_temper",
                        "bed_target_temp"
                    )
                ),
            },
        },

        "fans": {
            "cooling": fan_percent(
                first_value(
                    p,
                    "cooling_fan_speed",
                    "fan_gear"
                )
            ),

            "aux": fan_percent(
                p.get("big_fan1_speed")
            ),

            "chamber": fan_percent(
                p.get("big_fan2_speed")
            ),
        },

        "toolhead": {
            "nozzle_diameter": first_value(
                p,
                "nozzle_diameter",
                "nozzle_diameter_type"
            ),
        },

        "network": {
            "wifi_signal": p.get(
                "wifi_signal"
            ),
        },

        "ams": parse_ams(p),
    }


def on_connect(
    client,
    userdata,
    flags,
    reason_code,
    properties
):

    global mqtt_connected

    if reason_code == 0:

        with lock:
            mqtt_connected = True

        client.subscribe(
            REPORT_TOPIC
        )

        # Ask the printer for a complete initial state.
        client.publish(
            REQUEST_TOPIC,
            json.dumps({
                "pushing": {
                    "sequence_id": "0",
                    "command": "pushall"
                }
            }),
        )


def on_disconnect(
    client,
    userdata,
    disconnect_flags,
    reason_code,
    properties
):

    global mqtt_connected

    with lock:
        mqtt_connected = False


def on_message(
    client,
    userdata,
    msg
):

    global last_update

    try:
        payload = json.loads(
            msg.payload.decode("utf-8")
        )

    except (
        UnicodeDecodeError,
        json.JSONDecodeError
    ):
        return

    update = payload.get("print")

    if not isinstance(update, dict):
        return

    with lock:

        printer_state.setdefault(
            "print",
            {}
        )

        deep_merge(
            printer_state["print"],
            update
        )

        last_update = (
            datetime
            .now(timezone.utc)
            .isoformat()
        )


def mqtt_worker():

    global mqtt_connected

    client = mqtt.Client(
        callback_api_version=(
            mqtt.CallbackAPIVersion.VERSION2
        ),

        client_id="bambu-status",

        protocol=mqtt.MQTTv311,
    )

    client.username_pw_set(
        USERNAME,
        PASSWORD
    )

    # The printer's device certificate did not verify against
    # the normal system CA chain in this setup.
    #
    # The connection is TLS encrypted, but certificate
    # verification is disabled here.
    #
    # See the security note in the article.
    client.tls_set(
        cert_reqs=ssl.CERT_NONE,
        tls_version=ssl.PROTOCOL_TLS_CLIENT,
    )

    client.tls_insecure_set(True)

    client.on_connect = on_connect
    client.on_disconnect = on_disconnect
    client.on_message = on_message

    client.reconnect_delay_set(
        min_delay=1,
        max_delay=30
    )

    # Retry forever.
    #
    # This matters after a container reboot because
    # systemd's "network online" state doesn't guarantee
    # the printer's LAN address is actually reachable yet.
    while True:

        try:

            client.connect(
                HOST,
                PORT,
                keepalive=60
            )

            client.loop_forever()

        except Exception as exc:

            with lock:
                mqtt_connected = False

            print(
                f"MQTT connection failed: {exc}",
                flush=True
            )

            time.sleep(5)


@app.route("/status")
def status():

    return jsonify(
        build_status()
    )


@app.route("/health")
def health():

    with lock:

        return jsonify({
            "ok": mqtt_connected,

            "mqtt_connected":
                mqtt_connected,

            "last_update":
                last_update,
        })


if __name__ == "__main__":

    threading.Thread(
        target=mqtt_worker,
        daemon=True
    ).start()

    app.run(
        host="127.0.0.1",
        port=8090,
        debug=False,
        use_reloader=False,
    )

Before starting it, it’s worth checking for a syntax error:

/opt/bambu-status/venv/bin/python \
    -m py_compile \
    /opt/bambu-status/status.py

7. Run the status API with systemd
#

Create:

/etc/systemd/system/bambu-status.service
[Unit]
Description=Bambu P2S Status API
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
EnvironmentFile=/etc/bambu-status.env
ExecStart=/opt/bambu-status/venv/bin/python /opt/bambu-status/status.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Then:

systemctl daemon-reload
systemctl enable --now bambu-status

Check it:

systemctl status bambu-status

And test the API locally:

curl -s http://127.0.0.1:8090/status \
    | python3 -m json.tool

You should get a relatively small, sanitised JSON object rather than the enormous raw MQTT payload from the printer.

You can also check:

curl -s http://127.0.0.1:8090/health \
    | python3 -m json.tool

8. A note about MQTT TLS
#

There’s one security compromise in my current implementation that’s worth being explicit about.

The Python service uses:

client.tls_set(
    cert_reqs=ssl.CERT_NONE,
    tls_version=ssl.PROTOCOL_TLS_CLIENT
)

client.tls_insecure_set(True)

The connection is therefore TLS encrypted, but the printer’s certificate is not being verified.

The reason is that the certificate presented by my P2S didn’t validate normally against the system CA chain in this setup.

For a service communicating with a printer on my trusted LAN, that’s a compromise I’m prepared to make.

It is not, however, equivalent to fully verified TLS.

A more hardened version could pin the expected printer certificate or investigate an appropriate partial-chain verification strategy.

Most importantly:

port 8883 is never exposed to the internet.


9. Put nginx in front of everything
#

Now we want one small, controlled interface that Tailscale can expose.

Create:

/etc/nginx/sites-available/bambu-stream
server {
    listen 8080;
    server_name _;

    # Don't provide a useful root page.
    location = / {
        return 404;
    }

    # HLS master playlist from go2rtc.
    location = /camera/stream.m3u8 {
        proxy_pass http://127.0.0.1:1984/api/stream.m3u8?src=p2s;

        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_read_timeout 60s;

        add_header Cache-Control "no-store" always;
    }

    # HLS media playlists and segments.
    location /camera/hls/ {
        rewrite ^/camera/(.*)$ /api/$1 break;

        proxy_pass http://127.0.0.1:1984;

        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_read_timeout 60s;

        add_header Cache-Control "no-store" always;
    }

    # Sanitised telemetry only.
    location = /status {
        proxy_pass http://127.0.0.1:8090/status;

        proxy_http_version 1.1;

        add_header Access-Control-Allow-Origin "*" always;
        add_header Cache-Control "no-store" always;
    }

    location = /health {
        proxy_pass http://127.0.0.1:8090/health;

        proxy_http_version 1.1;

        add_header Access-Control-Allow-Origin "*" always;
        add_header Cache-Control "no-store" always;
    }
}

Enable it:

ln -s \
    /etc/nginx/sites-available/bambu-stream \
    /etc/nginx/sites-enabled/bambu-stream

Remove Debian’s default site:

rm -f /etc/nginx/sites-enabled/default

Test the configuration:

nginx -t

Then:

systemctl reload nginx

Test the status endpoint through nginx:

curl -s http://127.0.0.1:8080/status \
    | python3 -m json.tool

And the camera:

curl -I \
    http://127.0.0.1:8080/camera/stream.m3u8

If:

curl -I http://127.0.0.1:8080/

returns a 404, that’s deliberate.


10. Install Tailscale
#

Install Tailscale using the current Linux instructions for your distribution.

Once installed:

tailscale up

Then:

tailscale status

You should also have a real Tailscale interface:

ip addr show tailscale0

If you’re using an unprivileged Proxmox LXC and tailscale0 doesn’t exist, go back and check /dev/net/tun.

I originally had Tailscale running in userspace networking mode because the container didn’t have TUN access.

It worked well enough to be confusing.

Giving the container /dev/net/tun and running Tailscale normally was the proper fix.


11. Publish nginx with Tailscale Funnel
#

There’s an important distinction here:

Tailscale Serve makes a service available to devices on your tailnet.

Tailscale Funnel makes it available to the public internet.

For this project we want Funnel because visitors to the website aren’t expected to be members of my tailnet.

nginx is listening on:

127.0.0.1 / port 8080

Publish it with:

tailscale funnel --bg 8080

Then:

tailscale funnel status

You’ll get a public hostname similar to:

https://bambu-stream.example-tailnet.ts.net

Tailscale handles the public HTTPS endpoint and certificate.

There is:

  • no router port forwarding;
  • no public connection directly to the P2S;
  • no public connection directly to MQTT;
  • no public connection directly to go2rtc.

The path is:

Internet
   ↓
Tailscale Funnel
   ↓
nginx :8080
   ↓
only the routes we've deliberately configured

Test it properly
#

Don’t only test it from another machine that’s connected to the same tailnet.

Try:

curl \
    https://bambu-stream.example-tailnet.ts.net/status

from a genuinely external connection or temporarily disconnect Tailscale on the client.

Public DNS for a newly created Funnel hostname can also take a little while to become visible everywhere.

If everything appears correct but the hostname initially doesn’t resolve, give it a little time before dismantling the configuration.


12. The public API
#

At this point I deliberately expose only:

/status
/health
/camera/stream.m3u8
/camera/hls/...

The root:

/

returns:

404

That’s intentional.

A typical /status response looks conceptually like:

{
  "online": true,

  "print": {
    "state": "RUNNING",
    "name": "AMS HT riser",
    "progress": 92,
    "layer": 360,
    "total_layers": 470,
    "remaining_minutes": 30,
    "speed_percent": 100
  },

  "temperatures": {
    "nozzle": {
      "actual": 255,
      "target": 255
    },

    "bed": {
      "actual": 70,
      "target": 70
    }
  },

  "fans": {
    "cooling": 47,
    "aux": 47,
    "chamber": 40
  },

  "network": {
    "wifi_signal": "-52dBm"
  },

  "ams": {
    "units": [
      {
        "id": "1",
        "name": "AMS 2",
        "temperature": 54.7,
        "humidity": 4,
        "slots": [
          {
            "id": "0",
            "type": "PETG",
            "state": 11,
            "in_use": false
          },
          {
            "id": "1",
            "type": "PETG",
            "state": 27,
            "in_use": true
          }
        ]
      }
    ]
  }
}

The browser never needs the MQTT credentials.

It only receives the sanitised information we’re happy to publish.


13. The interesting bit: AMS telemetry
#

The AMS data turned out to be one of the more interesting parts of this project.

Normal AMS units appeared with IDs such as:

0
1

while my AMS HT appeared as:

128

I therefore map those to friendly names:

0   → AMS 1
1   → AMS 2
128 → AMS HT

Humidity
#

The normal:

humidity

field behaved like a level, not a percentage.

So my UI deliberately displays:

Humidity level 4

rather than:

4%

There is also:

humidity_raw

which looked interesting, but initially I hadn’t established what it actually meant.

So at first I exposed it in the API without pretending it was a percentage in the UI.

Sometimes the best reverse engineering is knowing when not to make up a unit.

Rather than keep guessing, I compared it against the hardware. With all three units online, I read the humidity shown on each AMS’s own display and checked it against the telemetry at effectively the same moment:

AMSPhysical displayhumidity_rawBambu humidity
AMS 128% RH283
AMS 211% RH114
AMS HT8% RH85

All three humidity_raw values matched the displayed relative humidity exactly.

So, for this P2S/AMS setup, humidity_raw can reasonably be exposed as relative humidity percentage.

That leaves two genuinely different fields:

  • humidity_raw → the measured % RH
  • humidity → Bambu’s 1–5 humidity/dryness level

The comparison also shows that a higher level means a drier AMS: 28% was level 3, 11% was level 4 and 8% was level 5.

What it doesn’t tell me is where Bambu draws the boundaries between levels. Three observations aren’t enough to work out the thresholds, so I’m not going to pretend I know them.

As with the rest of the AMS data, this is observed behaviour rather than a documented, stable Bambu protocol.

The AMS HT gave me an independent sanity check on the temperature telemetry too: its display showed 66°C during drying while the API was reporting about 66.1°C.

The dashboard now shows both values together when humidity_raw is available:

Humidity: 8% RH · Level 5

and falls back to just the level if it isn’t.


Finding the filament actually in use
#

There was another slightly deceptive field in the AMS data:

tray_now

Initially I assumed this identified the active tray across all connected AMS units.

That appeared to work until I actually printed from my second AMS.

While printing from slot B of AMS 2, tray_now reported:

1

Unfortunately, slot B of AMS 1 is also tray ID 1.

My first attempt treated the tray number as a global index and consequently highlighted AMS 1, slot B on the dashboard even though the printer was quite happily pulling PETG from AMS 2, slot B.

Looking at the individual tray objects during the print revealed a much more useful difference.

The idle trays were reporting:

{
  "state": 11
}

while the tray actually feeding filament was reporting:

{
  "state": 27
}

During this particular print the relevant telemetry looked conceptually like this:

AMS 1
  A  state 11
  B  state 11
  C  state 11
  D  state 11

AMS 2
  A  state 11
  B  state 27  ← actually feeding the printer
  C  state 11
  D  state 11

I haven’t found official documentation defining these state values, so I’m deliberately treating this as observed behaviour rather than a guaranteed Bambu protocol specification.

On my P2S, however, state 27 identified the tray currently feeding filament during this test.

Rather than make the browser understand another undocumented Bambu value, I translate it into something meaningful in the backend:

"state": safe_int(
    tray.get("state")
),

# Observed state 27 on the tray actively
# feeding filament during a print.
"in_use": safe_int(
    tray.get("state")
) == 27,

The public API can therefore return something much more useful:

{
  "id": "1",
  "type": "PETG",
  "state": 27,
  "in_use": true
}

and the frontend becomes wonderfully boring:

const active = slot.in_use === true;

That produces the result I actually wanted: the correct slot in the correct AMS is marked In use.

This is also a good example of why I kept some of the raw MQTT values around while building the API.

A field with an obvious-looking name isn’t necessarily telling you quite what you think it is.

tray_now looked like the answer.

Watching the actual tray state while the printer was running gave me a better one.

14. Detecting AMS drying
#

During an active drying cycle I observed data resembling:

{
  "dry_setting": {
    "dry_duration": 12,
    "dry_filament": "PETG",
    "dry_temperature": 55
  },

  "dry_time": 378,

  "temp": "54.7"
}

At the same time my AMS HT reported PETG drying with a target temperature of 65°C.

The useful fields therefore became:

Filament:            PETG
Target temperature:  55°C
Current temperature: 54.7°C
Configured duration: 12 hours
Remaining time:      378

The particularly interesting one was:

dry_time

Watching it over several MQTT messages showed:

381
378
375
...

at the appropriate elapsed intervals.

That strongly indicated that it represents remaining drying time in minutes.

So the public API exposes it as:

"remaining_minutes": 378

rather than making the frontend understand another mysterious Bambu field.


15. The AMS HT oddity
#

The AMS HT produced another amusing edge case.

It could report:

Drying PETG
65°C target
472 minutes remaining

while its normal tray metadata didn’t contain a filament name.

If the frontend relied exclusively on the tray metadata, the result was essentially:

🔥 Drying PETG

Empty

Which was technically derived from the data but obviously ridiculous.

So in the frontend I use:

let type = slot.sub_brand || slot.type;

if (!type && unit.drying?.active) {
    type = unit.drying.filament;
}

That way an actively drying AMS HT can display:

PETG

even when its normal RFID/tray metadata is missing.


16. A frontend for any website
#

My site uses Hugo and Blowfish, but none of this actually requires Hugo.

The browser only needs:

/status

and:

/camera/stream.m3u8

So here’s a deliberately generic example that can be adapted to:

  • Hugo;
  • WordPress;
  • a plain HTML site;
  • another static site generator;
  • a custom application.

Safari can play HLS natively.

Other common browsers can use hls.js.

Example HTML
#

<div class="printer-dashboard">

  <video
    id="printer-camera"
    autoplay
    muted
    playsinline
    controls>
  </video>

  <div class="printer-heading">

    <div>
      <span id="printer-state">
        Connecting…
      </span>

      <h2 id="printer-name">
        Bambu Lab P2S
      </h2>
    </div>

    <strong id="printer-progress">
      —
    </strong>

  </div>

  <progress
    id="progress-bar"
    max="100"
    value="0">
  </progress>

  <div class="stats">

    <div>
      <span>Layer</span>
      <strong id="layer">—</strong>
    </div>

    <div>
      <span>Remaining</span>
      <strong id="remaining">—</strong>
    </div>

    <div>
      <span>Nozzle</span>
      <strong id="nozzle">—</strong>
    </div>

    <div>
      <span>Bed</span>
      <strong id="bed">—</strong>
    </div>

    <div>
      <span>Speed</span>
      <strong id="speed">—</strong>
    </div>

  </div>

  <h2>Filament</h2>

  <div id="ams">
    Loading AMS information…
  </div>

  <small id="updated"></small>

</div>

<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>

JavaScript
#

Change:

https://bambu-stream.example-tailnet.ts.net

to your own Funnel hostname.

<script>

const API =
  "https://bambu-stream.example-tailnet.ts.net";


function text(id, value) {

  document
    .getElementById(id)
    .textContent =
      value === null ||
      value === undefined
        ? "—"
        : value;
}


function formatMinutes(minutes) {

  if (
    minutes === null ||
    minutes === undefined
  ) {
    return "—";
  }

  if (minutes < 60) {
    return `${minutes} min`;
  }

  const hours =
    Math.floor(minutes / 60);

  const remaining =
    minutes % 60;

  return `${hours}h ${remaining}m`;
}


function temperature(pair) {

  if (!pair) {
    return "—";
  }

  const actual =
    pair.actual ?? "—";

  const target =
    pair.target ?? "—";

  return `${actual}° / ${target}°`;
}


function renderAMS(ams) {

  const root =
    document.getElementById("ams");

  root.innerHTML = "";

  for (
    const unit of
    (ams?.units || [])
  ) {

    const card =
      document.createElement("section");

    card.className =
      "ams-card" +
      (
        unit.drying?.active
          ? " drying"
          : ""
      );


    const heading =
      document.createElement("h3");

    heading.textContent =
      unit.name ||
      `AMS ${unit.id}`;

    card.appendChild(heading);


    const environment =
      document.createElement("p");

    // humidity_raw is % RH; humidity is Bambu's 1–5 level.
    // Show both when the RH reading is available.
    const humidity =
      unit.humidity_raw != null
        ? `Humidity: ${unit.humidity_raw}% RH` +
          (unit.humidity != null
            ? ` · Level ${unit.humidity}`
            : "")
        : `Humidity level ` +
          `${unit.humidity ?? "—"}`;

    environment.textContent =
      `${unit.temperature ?? "—"}°C · ` +
      humidity;

    card.appendChild(
      environment
    );


    if (unit.drying?.active) {

      const drying =
        document.createElement("div");

      drying.className =
        "drying-status";

      drying.textContent =
        `🔥 Drying ` +
        `${unit.drying.filament || "filament"} — ` +
        `${unit.temperature ?? "—"}° / ` +
        `${unit.drying.target_temperature ?? "—"}° · ` +
        `${formatMinutes(
          unit.drying.remaining_minutes
        )} remaining`;

      card.appendChild(
        drying
      );
    }


    for (
      const slot of
      (unit.slots || [])
    ) {

      const row =
        document.createElement("div");

      row.className =
        "filament";


      let type =
        slot.sub_brand ||
        slot.type;


      /*
       * AMS HT can report its drying
       * filament even when its ordinary
       * tray metadata is absent.
       */
      if (
        !type &&
        unit.drying?.active
      ) {
        type =
          unit.drying.filament;
      }


      row.innerHTML = `
        <span
          class="swatch"
          style="
            background:
            ${slot.colour || "#888"}
          ">
        </span>

        <span>
          ${type || "Empty"}
        </span>

        <span>
          ${
            slot.remaining >= 0
              ? slot.remaining + "%"
              : ""
          }
        </span>
      `;

      card.appendChild(row);
    }


    root.appendChild(card);
  }
}


async function updateStatus() {

  try {

    const response =
      await fetch(
        `${API}/status`,
        {
          cache: "no-store"
        }
      );


    if (!response.ok) {

      throw new Error(
        `HTTP ${response.status}`
      );
    }


    const data =
      await response.json();

    const p =
      data.print || {};


    text(
      "printer-state",
      data.online
        ? (p.state || "Online")
        : "Offline"
    );


    text(
      "printer-name",
      p.name ||
      "Bambu Lab P2S"
    );


    text(
      "printer-progress",
      p.progress === null
        ? "—"
        : `${p.progress}%`
    );


    document
      .getElementById(
        "progress-bar"
      )
      .value =
        p.progress || 0;


    text(
      "layer",

      p.layer != null &&
      p.total_layers != null

        ? `${p.layer} / ${p.total_layers}`

        : "—"
    );


    text(
      "remaining",
      formatMinutes(
        p.remaining_minutes
      )
    );


    text(
      "nozzle",
      temperature(
        data.temperatures?.nozzle
      )
    );


    text(
      "bed",
      temperature(
        data.temperatures?.bed
      )
    );


    text(
      "speed",

      p.speed_percent == null
        ? "—"
        : `${p.speed_percent}%`
    );


    renderAMS(
      data.ams
    );


    text(
      "updated",
      "Updated just now"
    );

  }

  catch (error) {

    text(
      "printer-state",
      "Unavailable"
    );

    console.error(error);
  }
}


const video =
  document.getElementById(
    "printer-camera"
  );


const stream =
  `${API}/camera/stream.m3u8`;


/*
 * Safari supports HLS natively.
 */
if (
  video.canPlayType(
    "application/vnd.apple.mpegurl"
  )
) {

  video.src =
    stream;

}

/*
 * Other browsers can use hls.js.
 */
else if (
  Hls.isSupported()
) {

  const hls =
    new Hls();

  hls.loadSource(
    stream
  );

  hls.attachMedia(
    video
  );
}


updateStatus();


setInterval(
  updateStatus,
  5000
);

</script>

Example CSS
#

This is deliberately simple rather than attempting to reproduce the exact styling of my website.

<style>

.printer-dashboard {
  max-width: 900px;
  margin: 0 auto;
  font-family:
    system-ui,
    sans-serif;
}


#printer-camera {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: #111;
  border-radius: 12px;
}


.printer-heading {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 1.5rem;
}


#printer-progress {
  font-size: 2.5rem;
}


#progress-bar {
  width: 100%;
}


.stats {
  display: grid;

  grid-template-columns:
    repeat(
      5,
      minmax(0, 1fr)
    );

  gap: 1rem;

  margin: 1.5rem 0;
}


.stats > div,
.ams-card {
  border:
    1px solid #7775;

  border-radius:
    10px;

  padding:
    1rem;
}


.stats span {
  display: block;

  opacity:
    0.65;

  margin-bottom:
    0.5rem;
}


.ams-card {
  margin-bottom:
    1rem;
}


.ams-card.drying {
  border-color:
    #d8782c;
}


.drying-status {
  padding:
    0.8rem;

  margin:
    0.8rem 0;

  border-left:
    3px solid #e9832f;

  background:
    #8882;
}


.filament {
  display: grid;

  grid-template-columns:
    18px 1fr auto;

  gap:
    0.75rem;

  align-items:
    center;

  padding:
    0.35rem 0;
}


.swatch {
  width:
    14px;

  height:
    14px;

  border:
    1px solid #aaa;

  border-radius:
    50%;
}


#updated {
  display:
    block;

  text-align:
    right;

  opacity:
    0.6;
}


@media (
  max-width: 700px
) {

  .stats {
    grid-template-columns:
      repeat(
        2,
        minmax(0, 1fr)
      );
  }

}

</style>

That’s enough to produce a functional dashboard.

Make it pretty afterwards.


17. CORS
#

You’ll notice nginx adds:

add_header Access-Control-Allow-Origin "*" always;

to the JSON endpoints.

That’s because my main website and the Tailscale Funnel endpoint have different origins.

For example:

Website:
https://example.com

API:
https://bambu-stream.example-tailnet.ts.net

Without the appropriate CORS response header, the browser won’t let JavaScript on the website read the API response.

Because /status is deliberately public anyway, allowing any origin is appropriate for my use case.

If that’s not what you want, restrict it to your site’s origin instead.


18. Failure modes worth knowing about
#

A few things are worth documenting because they aren’t obvious.

Tailscale works but there is no tailscale0
#

If you’re in an unprivileged Proxmox LXC, check:

ls -l /dev/net/tun

and verify the two LXC configuration lines from earlier.

Don’t paper over it indefinitely with userspace networking if you actually want a normal Tailscale interface.


The API starts after reboot but MQTT doesn’t work
#

This one was sneaky.

The Flask process started successfully, but the MQTT thread attempted to connect before the printer’s LAN address was reachable.

The result was effectively:

Network is unreachable

The web service remained alive, making the problem look stranger than it actually was.

That’s why the Python example contains an outer retry loop.

network-online.target does not necessarily mean:

Every device I want to talk to on my LAN is definitely reachable right now.


Status values randomly disappear
#

Remember:

Bambu MQTT reports are incremental.

Don’t do this:

printer_state = new_message

Merge updates into your cached state.

Otherwise your lovely dashboard will periodically develop amnesia.


nginx displays its default welcome page
#

Remove the default site:

rm -f /etc/nginx/sites-enabled/default
systemctl reload nginx

The .ts.net hostname doesn’t resolve immediately
#

When I first enabled Funnel, public DNS took considerably longer to appear than I expected.

Before changing everything, check using a public resolver or a genuinely external connection.

Sometimes the correct troubleshooting procedure is, irritatingly:

wait a bit.


Camera works locally but not publicly
#

Test each boundary separately.

Directly against go2rtc
#

curl -I \
  'http://127.0.0.1:1984/api/stream.m3u8?src=p2s'

Through nginx
#

curl -I \
  http://127.0.0.1:8080/camera/stream.m3u8

Through Funnel
#

curl -I \
  https://YOUR-FUNNEL-NAME.ts.net/camera/stream.m3u8

That quickly tells you whether the problem is:

P2S → go2rtc
go2rtc → nginx
or
nginx → Funnel

rather than changing three systems at once.


19. Security checklist
#

Before calling the project finished, I checked that:

  • the P2S has no public port forwarding;
  • MQTT port 8883 remains LAN-only;
  • RTSPS port 322 remains LAN-only;
  • the LAN access code never reaches browser-side JavaScript;
  • /etc/bambu-status.env is mode 600;
  • /etc/go2rtc/go2rtc.yaml is mode 600;
  • go2rtc’s general API isn’t publicly proxied;
  • nginx exposes only the endpoints the dashboard requires;
  • /status contains only information I’m happy to publish;
  • the nginx root returns 404;
  • the website contains only the public Funnel URL; and
  • I understand that anything deliberately exposed through Funnel is actually public.

That last one is worth emphasising.

Tailscale Funnel isn’t security through obscurity for your tailnet.

Its purpose is specifically to expose a service to the public internet.

If print filenames, filament choices or anything else in /status is sensitive to you, remove it before exposing the endpoint.


20. Useful diagnostic commands
#

These are the commands I’d keep around.

Services
#

systemctl status go2rtc
systemctl status bambu-status
systemctl status nginx
systemctl status tailscaled

Tailscale
#

tailscale status
tailscale funnel status

Local API
#

curl -s \
  http://127.0.0.1:8090/health \
  | python3 -m json.tool
curl -s \
  http://127.0.0.1:8080/status \
  | python3 -m json.tool

Camera
#

curl -I \
  http://127.0.0.1:8080/camera/stream.m3u8

Public API
#

From another machine:

curl -s \
  https://YOUR-FUNNEL-NAME.ts.net/status \
  | python3 -m json.tool

21. What I’d improve later
#

There are a few things I may revisit.

Proper MQTT certificate verification
#

The biggest technical debt is the use of encrypted but unverified MQTT TLS.

Certificate pinning would be preferable.

Gunicorn
#

The Python service currently uses Flask’s built-in server.

That’s acceptable for this tiny service because it:

  • listens only on 127.0.0.1;
  • sits behind nginx; and
  • handles a tiny amount of traffic.

Using Gunicorn would nevertheless be a more conventional production setup.

Rate limiting
#

If the dashboard became popular, nginx rate limiting would be sensible.

The camera stream is obviously far more bandwidth-heavy than the JSON endpoint.

Firmware changes
#

The MQTT fields used here are not something I’d assume will remain unchanged forever.

A future Bambu firmware update may require adjusting the parser.

That’s another reason to keep the translation layer in the Python service rather than making the public website understand raw Bambu MQTT messages.


22. Why have the Python API at all?
#

It might be tempting to connect the frontend directly to MQTT somehow.

I deliberately didn’t.

The Python service gives me a clean boundary:

Bambu's internal representation
              ↓
        Python parser
              ↓
     My public representation
              ↓
           Website

If Bambu changes:

mc_remaining_time

to something else, I change one backend.

The website can continue asking for:

{
  "remaining_minutes": 30
}

It also means I can decide exactly which printer information is public.

That’s much nicer than throwing the printer’s entire raw MQTT state onto the internet and hoping for the best.


23. The final result
#

The end result is intentionally boring from a visitor’s point of view.

They open an ordinary page on my website and see:

Live camera

● Printing
AMS HT riser                         92%

████████████████████████████████

Layer        Remaining       Nozzle
360 / 470    30 min          255° / 255°

Bed          Speed
70° / 70°    100%

Filament

AMS 1
PLA Basic...

AMS 2
🔥 Drying PETG
54.7° / 55° · 6h 15m remaining

AMS HT
🔥 Drying PETG
66.6° / 65° · 7h 52m remaining

Underneath that fairly simple page is:

P2S
 │
 ├── RTSPS
 │      ↓
 │    go2rtc
 │      ↓
 │     HLS
 │
 └── MQTT
        ↓
     Python
        ↓
       JSON

        ↓

      nginx
        ↓
 Tailscale Funnel
        ↓
    public HTTPS
        ↓
     website

The P2S remains a normal cloud-connected Bambu printer.

Bambu Handy still works.

Nothing on the printer is directly exposed to the internet.

The browser gets a browser-friendly camera stream and a small read-only representation of the printer’s current state.

Which is exactly what I wanted.


24. If you’re copying this setup
#

Before publishing your own version:

  1. Replace every example IP address.
  2. Replace the example printer serial.
  3. Replace the example Funnel hostname.
  4. Never publish your LAN access code.
  5. Decide which /status fields you’re comfortable making public.
  6. Test from outside your LAN and tailnet.
  7. Test what the page does while the printer is idle.
  8. Test it with AMS drying stopped as well as running.
  9. Expect undocumented printer fields to change eventually.
  10. Treat the state == 27 active-filament mapping as observed behaviour and verify it against your own printer/firmware.
  11. Don’t expose go2rtc’s configuration API just because it’s convenient.

And, perhaps most importantly:

Build and test it one layer at a time.

Get MQTT working locally.

Then get the API working.

Then get the camera working.

Then put nginx in front of them.

Then add Tailscale.

Then build the website.

Debugging:

printer + MQTT + RTSPS + go2rtc + Python +
nginx + Tailscale + DNS + JavaScript

simultaneously is considerably less entertaining than it sounds.

Ask me how I know.


References
#

Tailscale Funnel:

https://tailscale.com/docs/features/tailscale-funnel

Tailscale Funnel CLI:

https://tailscale.com/docs/reference/tailscale-cli/funnel

go2rtc:

https://github.com/AlexxIT/go2rtc

My finished dashboard:

https://mattcharlton.co.uk/pages/3d/


comments powered by Disqus