From e7bc3291b6ee07a23eab4fec5cf135affb2d4b9c Mon Sep 17 00:00:00 2001
From: RNL
Date: Thu, 18 Jun 2026 22:50:39 +1000
Subject: [PATCH] Initial implementation of multi-printer support.
---
.../management/commands/bambu_collector.py | 352 ++++++++++--------
.../0005_printermetrics_vortek_raw.py | 22 ++
bambu_run/models.py | 8 +
bambu_run/mqtt_client.py | 4 +
.../bambu_run/printer_dashboard.html | 19 +-
bambu_run/urls.py | 2 +
bambu_run/views.py | 35 +-
pyproject.toml | 4 +
tests/__init__.py | 0
tests/settings.py | 40 ++
tests/test_multi_device_collection.py | 90 +++++
tests/test_printer_routing.py | 69 ++++
tests/test_resolve_printer_device.py | 78 ++++
tests/test_vortek_groundwork.py | 43 +++
tests/urls.py | 5 +
15 files changed, 617 insertions(+), 154 deletions(-)
create mode 100644 bambu_run/migrations/0005_printermetrics_vortek_raw.py
create mode 100644 tests/__init__.py
create mode 100644 tests/settings.py
create mode 100644 tests/test_multi_device_collection.py
create mode 100644 tests/test_printer_routing.py
create mode 100644 tests/test_resolve_printer_device.py
create mode 100644 tests/test_vortek_groundwork.py
create mode 100644 tests/urls.py
diff --git a/bambu_run/management/commands/bambu_collector.py b/bambu_run/management/commands/bambu_collector.py
index 1b7f462..c12029c 100644
--- a/bambu_run/management/commands/bambu_collector.py
+++ b/bambu_run/management/commands/bambu_collector.py
@@ -13,8 +13,9 @@ import logging
import os
import ssl
import time
+from dataclasses import dataclass, field
from decimal import Decimal
-from typing import Optional
+from typing import Any, Dict, Optional
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
@@ -26,6 +27,56 @@ from bambu_run.models import Printer, PrinterMetrics
logger = logging.getLogger("bambu_run.collector")
+def resolve_printer_device(device_id: str, device_info: Optional[dict] = None) -> Printer:
+ """Find-or-create the Printer row for a Bambu cloud device, keyed by serial number.
+
+ `device_info` is one entry from BambuClient.get_devices() (keys: name,
+ dev_product_name, dev_id, ...). Falls back to generic defaults when unavailable
+ (e.g. local-only connections that never call get_devices()).
+ """
+ device_info = device_info or {}
+ name = device_info.get("name") or "Bambu Lab Printer"
+ model = device_info.get("dev_product_name") or "Bambu Lab"
+
+ printer = Printer.objects.filter(serial_number=device_id).first()
+
+ if printer is None:
+ # Upgrade path: a pre-multi-printer deployment has exactly one Printer row
+ # with no serial number yet. Backfill it instead of creating a duplicate.
+ # If there's more than one such row, we can't tell which one this device
+ # used to be, so don't guess — create a fresh row instead.
+ legacy_candidates = list(Printer.objects.filter(serial_number__isnull=True)[:2])
+ if len(legacy_candidates) == 1:
+ printer = legacy_candidates[0]
+ printer.serial_number = device_id
+
+ if printer is None:
+ printer = Printer(serial_number=device_id)
+
+ printer.name = name
+ printer.model = model
+ printer.manufacturer = "Bambu Lab"
+ printer.is_active = True
+ printer.save()
+ return printer
+
+
+@dataclass
+class DeviceSession:
+ """Per-printer mutable state for one bound device in a multi-printer collector run."""
+
+ device_id: str
+ client: Any # BambuPrinter
+ printer: Printer
+ current_print_job: Optional[Any] = None
+ last_gcode_state: Optional[str] = None
+ last_subtask_name: Optional[str] = None
+ trays_used: set = field(default_factory=set)
+ error_count: int = 0
+ success_count: int = 0
+ mqtt_connect_errors: int = 0
+
+
class Command(BaseCommand):
"""
MQTT Poll -> PrinterMetrics -> FilamentSnapshot -> Auto-Match -> Update Filament
@@ -51,18 +102,11 @@ class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.printer_client = None
- self.printer_device = None
+ self.sessions: Dict[str, DeviceSession] = {}
+ self._token: Optional[str] = None
self.verbose = False
self.disable_ssl_verify = False
- self.error_count = 0
- self.success_count = 0
- self.mqtt_connect_errors = 0
self.start_time = None
- self.current_print_job = None
- self.last_gcode_state = None
- self.last_subtask_name = None
- self.trays_used = set()
def handle(self, *args, **options):
self.verbose = options["verbose"]
@@ -100,12 +144,13 @@ class Command(BaseCommand):
self._configure_logging()
try:
- self._initialize_printer()
+ self._initialize_printers()
except Exception as e:
raise CommandError(f"Initialization failed: {e}")
self.start_time = timezone.now()
- logger.info(f"Bambu Run data collector started for printer: {self.printer_device.name}")
+ printer_names = ", ".join(s.printer.name for s in self.sessions.values())
+ logger.info(f"Bambu Run data collector started for {len(self.sessions)} printer(s): {printer_names}")
logger.info(f"Collection interval: {interval} seconds")
logger.info(f"Mode: {'Single run' if run_once else 'Continuous'}")
@@ -113,7 +158,8 @@ class Command(BaseCommand):
if run_once:
import time as _time
_time.sleep(5)
- self._collect_printer_data()
+ for session in self.sessions.values():
+ self._collect_printer_data(session)
logger.info("Single collection completed successfully")
else:
self._run_continuous_loop(interval)
@@ -124,7 +170,7 @@ class Command(BaseCommand):
logger.exception(f"Fatal error in main loop: {e}")
raise CommandError(f"Runner failed: {e}")
- def _request_full_status_when_ready(self, timeout: float = 20.0) -> None:
+ def _request_full_status_when_ready(self, client, timeout: float = 20.0) -> None:
"""Send pushall once the MQTT broker connection is confirmed.
BambuPrinter._connected is set True immediately after connect(blocking=False),
@@ -134,9 +180,9 @@ class Command(BaseCommand):
import time as _time
deadline = _time.time() + timeout
while _time.time() < deadline:
- mqtt_client = getattr(self.printer_client, "_mqtt", None)
+ mqtt_client = getattr(client, "_mqtt", None)
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
- self.printer_client._mqtt.request_full_status()
+ client._mqtt.request_full_status()
logger.info("Sent MQTT pushall request")
return
_time.sleep(0.5)
@@ -155,7 +201,9 @@ class Command(BaseCommand):
handler.setFormatter(formatter)
logger.addHandler(handler)
- def _initialize_printer(self):
+ def _initialize_printers(self):
+ """Authenticate once, discover every device bound to the account, and open
+ one BambuPrinter (own MQTT thread) per device — all in this single process."""
from bambu_run.mqtt_client import BambuPrinter
bambu_username = os.environ.get("BAMBU_USERNAME")
@@ -169,30 +217,12 @@ class Command(BaseCommand):
"environment variables must be set"
)
- logger.info("Connecting to Bambu Lab printer...")
+ logger.info("Authenticating with Bambu Lab cloud...")
try:
- if bambu_token:
- logger.info("Using saved BAMBU_TOKEN for authentication")
- self.printer_client = BambuPrinter(
- token=bambu_token, device_id=bambu_device_id
- )
- else:
- logger.info("Authenticating with username/password")
- self.printer_client = BambuPrinter(
- username=bambu_username,
- password=bambu_password,
- device_id=bambu_device_id,
- )
-
- logger.info("Initiating MQTT connection...")
- self.printer_client.connect(blocking=False)
- logger.info("MQTT connection initiated (non-blocking)")
- # Request full status so AMS + dual-nozzle data arrive on startup.
- try:
- self._request_full_status_when_ready()
- except Exception as e:
- logger.warning("pushall request skipped (non-fatal): %s", e)
-
+ auth = BambuPrinter(
+ username=bambu_username, password=bambu_password, token=bambu_token,
+ )
+ self._token = auth._ensure_token()
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
error_msg = (
@@ -203,56 +233,62 @@ class Command(BaseCommand):
"3. pip install --upgrade certifi\n"
)
raise CommandError(error_msg)
- raise CommandError(f"Failed to initialize printer client: {e}")
+ raise CommandError(f"Failed to authenticate: {e}")
- self.printer_device = self._ensure_printer_device_exists()
- logger.info(f"Initialized for printer device: {self.printer_device}")
-
- def _ensure_printer_device_exists(self) -> Printer:
- try:
- snapshot = self.printer_client.get_snapshot()
-
- if snapshot:
- device, created = Printer.objects.update_or_create(
- model="Bambu Lab",
- defaults={
- "name": "Bambu Lab Printer",
- "manufacturer": "Bambu Lab",
- "is_active": True,
- },
- )
- action = "Created" if created else "Updated"
- logger.info(f"{action} printer device record: {device}")
- return device
- else:
- logger.warning("Snapshot returned None - MQTT not connected yet")
- device = Printer.objects.filter(is_active=True).first()
- if device:
- logger.info(f"Using existing device record: {device}")
- return device
- else:
- device = Printer.objects.create(
- name="Bambu Lab Printer",
- model="Bambu Lab",
- manufacturer="Bambu Lab",
- is_active=True,
- )
- logger.info(f"Created placeholder device: {device}")
- return device
-
- except Exception as e:
- logger.error(f"Error during device initialization: {e}")
+ device_infos = self._discover_devices(bambu_device_id)
+ for device_id, device_info in device_infos.items():
try:
- device = Printer.objects.filter(is_active=True).first()
- if device:
- logger.warning(f"Using existing device record from DB: {device}")
- return device
- else:
- raise CommandError(
- "No printer device found in database and initialization failed."
- )
- except Printer.DoesNotExist:
- raise CommandError("Failed to create or retrieve printer device.")
+ self._add_session(device_id, device_info)
+ except Exception as e:
+ logger.error(f"Failed to initialize printer {device_id}: {e}")
+
+ if not self.sessions:
+ raise CommandError("No printer sessions could be initialized")
+
+ def _discover_devices(self, explicit_device_id: Optional[str]) -> Dict[str, dict]:
+ """Return {device_id: device_info} for every printer to monitor.
+
+ device_info comes from BambuClient.get_devices() (name, dev_product_name,
+ etc.) — empty dict when explicitly pinned to one device via BAMBU_DEVICE_ID
+ and the cloud listing can't be reached.
+ """
+ from bambu_run.mqtt_client import BambuClient
+
+ try:
+ cloud = BambuClient(token=self._token)
+ devices = cloud.get_devices()
+ except Exception as e:
+ if explicit_device_id:
+ logger.warning(f"Could not list account devices ({e}); using BAMBU_DEVICE_ID only")
+ return {explicit_device_id: {}}
+ raise
+
+ device_infos = {d.get("dev_id"): d for d in devices if d.get("dev_id")}
+
+ if explicit_device_id:
+ return {explicit_device_id: device_infos.get(explicit_device_id, {})}
+
+ if not device_infos:
+ raise CommandError("No devices found on this account")
+
+ return device_infos
+
+ def _add_session(self, device_id: str, device_info: dict) -> "DeviceSession":
+ from bambu_run.mqtt_client import BambuPrinter
+
+ logger.info(f"Connecting to printer {device_id} ({device_info.get('name', 'unknown')})...")
+ client = BambuPrinter(token=self._token, device_id=device_id)
+ client.connect(blocking=False)
+ try:
+ self._request_full_status_when_ready(client)
+ except Exception as e:
+ logger.warning("pushall request skipped (non-fatal): %s", e)
+
+ printer = resolve_printer_device(device_id, device_info)
+ session = DeviceSession(device_id=device_id, client=client, printer=printer)
+ self.sessions[device_id] = session
+ logger.info(f"Initialized session for printer: {printer}")
+ return session
def _run_continuous_loop(self, interval: int):
iteration = 0
@@ -263,7 +299,8 @@ class Command(BaseCommand):
if self.verbose:
logger.debug(f"=== Iteration {iteration} ===")
- self._collect_printer_data()
+ for session in list(self.sessions.values()):
+ self._collect_printer_data(session)
elapsed = time.time() - loop_start
sleep_time = max(0, interval - elapsed)
@@ -273,9 +310,28 @@ class Command(BaseCommand):
if iteration % 100 == 0:
self._print_statistics()
+ self._refresh_devices()
time.sleep(sleep_time)
+ def _refresh_devices(self):
+ """Pick up printers added to the account without restarting the process."""
+ if os.environ.get("BAMBU_DEVICE_ID"):
+ return # pinned to a single explicit device — nothing to discover
+ try:
+ device_infos = self._discover_devices(None)
+ except Exception as e:
+ logger.warning(f"Device refresh skipped (non-fatal): {e}")
+ return
+
+ for device_id, device_info in device_infos.items():
+ if device_id not in self.sessions:
+ logger.info(f"New printer detected on account: {device_id}")
+ try:
+ self._add_session(device_id, device_info)
+ except Exception as e:
+ logger.error(f"Failed to initialize newly-detected printer {device_id}: {e}")
+
def _convert_mqtt_color(self, mqtt_color):
if not mqtt_color:
return None
@@ -513,19 +569,19 @@ class Command(BaseCommand):
match_method=match_method
)
- def _track_print_job(self, metric, snapshot):
- from bambu_run.models import PrintJob, FilamentUsage
+ def _track_print_job(self, session, metric, snapshot):
+ from bambu_run.models import PrintJob
gcode_state = snapshot.get('gcode_state')
subtask_name = snapshot.get('subtask_name')
- if self._is_print_starting(gcode_state, subtask_name):
- if self.current_print_job:
- self._finalize_print_job(metric, snapshot)
+ if self._is_print_starting(session, gcode_state, subtask_name):
+ if session.current_print_job:
+ self._finalize_print_job(session, metric, snapshot)
raw_task_id = snapshot.get('task_id')
- self.current_print_job = PrintJob.objects.create(
- device=self.printer_device,
+ session.current_print_job = PrintJob.objects.create(
+ device=session.printer,
project_name=subtask_name,
gcode_file=snapshot.get('gcode_file'),
start_time=metric.timestamp,
@@ -534,57 +590,58 @@ class Command(BaseCommand):
completion_percent=snapshot.get('print_percent', 0),
cloud_task_id_raw=int(raw_task_id) if raw_task_id else None,
)
- self.trays_used = set()
- logger.info(f"Print job started: {subtask_name}")
+ session.trays_used = set()
+ logger.info(f"[{session.device_id}] Print job started: {subtask_name}")
- if self.current_print_job:
+ if session.current_print_job:
tray_now = snapshot.get('tray_now', '')
if tray_now not in (None, '', '255'):
try:
tray_id = int(tray_now)
if 0 <= tray_id <= 15:
- self.trays_used.add(tray_id)
+ session.trays_used.add(tray_id)
except (ValueError, TypeError):
pass
- if self._is_print_ending(gcode_state) and self.current_print_job:
- self._finalize_print_job(metric, snapshot)
+ if self._is_print_ending(session, gcode_state) and session.current_print_job:
+ self._finalize_print_job(session, metric, snapshot)
- self.last_gcode_state = gcode_state
- self.last_subtask_name = subtask_name
+ session.last_gcode_state = gcode_state
+ session.last_subtask_name = subtask_name
- def _is_print_starting(self, gcode_state, subtask_name):
+ def _is_print_starting(self, session, gcode_state, subtask_name):
is_printing = gcode_state not in ['FINISH', 'IDLE', 'FAILED', None, '']
- has_new_job = subtask_name and subtask_name != self.last_subtask_name
+ has_new_job = subtask_name and subtask_name != session.last_subtask_name
return is_printing and has_new_job
- def _is_print_ending(self, gcode_state):
+ def _is_print_ending(self, session, gcode_state):
ending_states = ['FINISH', 'FAILED']
- return gcode_state in ending_states and self.last_gcode_state not in ending_states
+ return gcode_state in ending_states and session.last_gcode_state not in ending_states
- def _finalize_print_job(self, metric, snapshot):
+ def _finalize_print_job(self, session, metric, snapshot):
from bambu_run.models import FilamentUsage
- self.current_print_job.end_time = metric.timestamp
- self.current_print_job.end_metric = metric
- self.current_print_job.final_status = snapshot.get('gcode_state')
- self.current_print_job.completion_percent = snapshot.get('print_percent', 0)
- self.current_print_job.calculate_duration()
- self.current_print_job.save()
+ job = session.current_print_job
+ job.end_time = metric.timestamp
+ job.end_metric = metric
+ job.final_status = snapshot.get('gcode_state')
+ job.completion_percent = snapshot.get('print_percent', 0)
+ job.calculate_duration()
+ job.save()
try:
from bambu_run.bambu_cloud import fetch_and_upsert_task
- fetch_and_upsert_task(self.printer_client._client, self.current_print_job)
+ fetch_and_upsert_task(session.client._client, job)
except Exception as e:
logger.warning(f"Cloud task sync skipped (non-fatal): {e}")
- start_metric = self.current_print_job.start_metric
+ start_metric = job.start_metric
if not start_metric:
- logger.warning(f"No start_metric for job {self.current_print_job.id}, skipping filament usage")
- elif not self.trays_used:
- logger.warning(f"No trays tracked for job {self.current_print_job.project_name}, skipping filament usage")
+ logger.warning(f"No start_metric for job {job.id}, skipping filament usage")
+ elif not session.trays_used:
+ logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
else:
- for tray_id in self.trays_used:
+ for tray_id in session.trays_used:
start_snap = start_metric.filament_snapshots.filter(
tray_id=tray_id, filament__isnull=False
).first()
@@ -596,12 +653,12 @@ class Command(BaseCommand):
).first()
usage = FilamentUsage.objects.create(
- print_job=self.current_print_job,
+ print_job=job,
filament=start_snap.filament,
tray_id=tray_id,
starting_percent=start_snap.remain_percent or 100,
ending_percent=end_snap.remain_percent if end_snap else None,
- is_primary=(len(self.trays_used) == 1),
+ is_primary=(len(session.trays_used) == 1),
)
usage.calculate_consumed()
usage.save()
@@ -613,30 +670,30 @@ class Command(BaseCommand):
)
logger.info(
- f"Print job finished: {self.current_print_job.project_name} "
- f"({self.current_print_job.final_status}) - Duration: {self.current_print_job.duration_minutes} min, "
- f"Trays used: {sorted(self.trays_used) if self.trays_used else 'none tracked'}"
+ f"[{session.device_id}] Print job finished: {job.project_name} "
+ f"({job.final_status}) - Duration: {job.duration_minutes} min, "
+ f"Trays used: {sorted(session.trays_used) if session.trays_used else 'none tracked'}"
)
- self.current_print_job = None
- self.trays_used = set()
+ session.current_print_job = None
+ session.trays_used = set()
- def _collect_printer_data(self):
+ def _collect_printer_data(self, session: "DeviceSession"):
try:
- snapshot = self.printer_client.get_snapshot()
+ snapshot = session.client.get_snapshot()
if snapshot is None:
- self.mqtt_connect_errors += 1
- if self.mqtt_connect_errors <= 5 or self.verbose:
+ session.mqtt_connect_errors += 1
+ if session.mqtt_connect_errors <= 5 or self.verbose:
logger.warning(
- f"MQTT not connected yet or no data available "
- f"(attempt {self.mqtt_connect_errors})"
+ f"[{session.device_id}] MQTT not connected yet or no data available "
+ f"(attempt {session.mqtt_connect_errors})"
)
return
with transaction.atomic():
metric = PrinterMetrics.objects.create(
- device=self.printer_device,
+ device=session.printer,
timestamp=timezone.now(),
nozzle_temp=self._to_decimal(snapshot.get("nozzle_temp")),
nozzle_target_temp=self._to_decimal(snapshot.get("nozzle_target_temp")),
@@ -688,27 +745,28 @@ class Command(BaseCommand):
ams_units=snapshot.get("ams_units", []),
external_spool=snapshot.get("external_spool", {}),
lights_report=snapshot.get("lights_report", []),
+ vortek_raw=snapshot.get("vortek_raw", {}),
)
filaments_data = snapshot.get('filaments', [])
if filaments_data:
self._create_filament_snapshots(metric, filaments_data, snapshot)
- self._track_print_job(metric, snapshot)
+ self._track_print_job(session, metric, snapshot)
- self.success_count += 1
+ session.success_count += 1
if self.verbose:
logger.debug(
- f"Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
+ f"[{session.device_id}] Printer Metrics: Nozzle={snapshot.get('nozzle_temp')}C, "
f"Bed={snapshot.get('bed_temp')}C, "
f"Progress={snapshot.get('print_percent')}%, "
f"State={snapshot.get('gcode_state')}"
)
except Exception as e:
- self.error_count += 1
- logger.error(f"Error collecting printer data (total errors: {self.error_count}): {e}")
+ session.error_count += 1
+ logger.error(f"[{session.device_id}] Error collecting printer data (total errors: {session.error_count}): {e}")
if self.verbose:
logger.exception("Detailed traceback:")
@@ -723,16 +781,20 @@ class Command(BaseCommand):
def _print_statistics(self):
if self.start_time:
runtime = timezone.now() - self.start_time
- total_collections = self.success_count + self.error_count
+ success_count = sum(s.success_count for s in self.sessions.values())
+ error_count = sum(s.error_count for s in self.sessions.values())
+ mqtt_connect_errors = sum(s.mqtt_connect_errors for s in self.sessions.values())
+ total_collections = success_count + error_count
success_rate = (
- (self.success_count / total_collections * 100)
+ (success_count / total_collections * 100)
if total_collections > 0
else 0
)
logger.info("=== Statistics ===")
logger.info(f"Runtime: {runtime}")
- logger.info(f"Successful collections: {self.success_count}")
- logger.info(f"Failed collections: {self.error_count}")
- logger.info(f"MQTT connection warnings: {self.mqtt_connect_errors}")
+ logger.info(f"Printers tracked: {len(self.sessions)}")
+ logger.info(f"Successful collections: {success_count}")
+ logger.info(f"Failed collections: {error_count}")
+ logger.info(f"MQTT connection warnings: {mqtt_connect_errors}")
logger.info(f"Success rate: {success_rate:.1f}%")
diff --git a/bambu_run/migrations/0005_printermetrics_vortek_raw.py b/bambu_run/migrations/0005_printermetrics_vortek_raw.py
new file mode 100644
index 0000000..c0b88fb
--- /dev/null
+++ b/bambu_run/migrations/0005_printermetrics_vortek_raw.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.8 on 2026-06-18 12:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bambu_run", "0004_h2c_dual_nozzle_and_ams_fields"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="printermetrics",
+ name="vortek_raw",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Raw print.device MQTT payload (Vortek rack groundwork)",
+ ),
+ ),
+ ]
diff --git a/bambu_run/models.py b/bambu_run/models.py
index cfa3ee1..91da214 100644
--- a/bambu_run/models.py
+++ b/bambu_run/models.py
@@ -231,6 +231,14 @@ class PrinterMetrics(models.Model):
default=list, help_text="Light status report [{node, mode}]"
)
+ # Groundwork for H2C's Vortek nozzle-changer rack (6 swappable hotends + 1 fixed
+ # left nozzle) — the full MQTT schema for per-slot state isn't confirmed yet, so
+ # the raw `print.device` payload is captured here unfiltered to avoid losing data
+ # ahead of proper per-slot modeling.
+ vortek_raw = models.JSONField(
+ default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
+ )
+
class Meta:
db_table = "infrastructure_printer_metrics"
verbose_name = "Printer Metric"
diff --git a/bambu_run/mqtt_client.py b/bambu_run/mqtt_client.py
index b451c98..0ddc815 100644
--- a/bambu_run/mqtt_client.py
+++ b/bambu_run/mqtt_client.py
@@ -529,6 +529,10 @@ class PrinterState:
"wifi_signal_dbm": self.wifi_signal_dbm,
"print_error": self.print_error,
"has_errors": self.print_error != 0,
+ # Full `print.device` payload, unfiltered. H2C's Vortek rack (6 swappable
+ # hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
+ # here so no data is lost once the real Vortek MQTT schema is confirmed.
+ "vortek_raw": self._raw_data.get("print", {}).get("device", {}),
"hms": self.hms,
"stg_cur": self.stg_cur,
"lights_report": self.lights_report,
diff --git a/bambu_run/templates/bambu_run/printer_dashboard.html b/bambu_run/templates/bambu_run/printer_dashboard.html
index 3b0ecc7..0c88400 100644
--- a/bambu_run/templates/bambu_run/printer_dashboard.html
+++ b/bambu_run/templates/bambu_run/printer_dashboard.html
@@ -14,6 +14,19 @@
Real-time monitoring for {{ device_name }}
+ {% if show_printer_switcher %}
+
+
+
+ {% endif %}
{% if error %}
@@ -423,12 +436,12 @@
-{% if not is_basic_user %}
-
+{% if not is_basic_user and printer_device %}
+