4 Commits

16 changed files with 127 additions and 617 deletions

View File

@@ -22,20 +22,19 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
(d / 'INSTALLER').write_text('pip\n'); \
(d / 'RECORD').write_text('')"
# Install standalone dependencies first — cached unless pyproject.toml changes
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[standalone,mcp]"
# Copy full source and install the local package (no-deps: already installed above)
# Copy application code
COPY . .
RUN pip install --no-cache-dir --no-deps .
# Create data directory for SQLite
RUN mkdir -p /app/data
# Collect static files from the locally installed package
# Collect static files
ENV DJANGO_SETTINGS_MODULE=standalone.settings
RUN python standalone/manage.py collectstatic --noinput
RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
# Supervisor config to run both web and collector
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

View File

@@ -81,12 +81,5 @@ class _Settings:
def CLOUD_SYNC_DAYS(self):
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
# Seconds of silence on the MQTT report topic that, once broken by a new
# message, is treated as "the printer was probably offline" and triggers
# a pushall re-sync instead of trusting the partial delta to fill in stale
# fields left over from before the gap.
@property
def MQTT_RESYNC_GAP_SECONDS(self):
return get_setting("BAMBU_RUN_MQTT_RESYNC_GAP_SECONDS", 90)
app_settings = _Settings()

View File

@@ -1,77 +0,0 @@
"""Add Printer.category so printer queries can be scoped away from other devices.
`infrastructure_device` is shared with host projects. In a standalone Bambu-Run
deployment bambu_run owns the table and the column must be created here. In a
host project like RAE the table was created by that project's own app and
already carries a `category` column, so creating it again would fail.
`AddFieldIfMissing` introspects the table and only emits DDL when needed; the
model state is updated either way.
"""
import django.db.models.manager
from django.db import migrations, models
class AddFieldIfMissing(migrations.AddField):
"""AddField that is a no-op at the database level if the column exists."""
def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.model_name)
with schema_editor.connection.cursor() as cursor:
existing = {
column.name
for column in schema_editor.connection.introspection.get_table_description(
cursor, model._meta.db_table
)
}
if self.name in existing:
return
super().database_forwards(app_label, schema_editor, from_state, to_state)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
"""Reverse the model state only, never the column.
In a host project the column belongs to that project's own app — dropping
it on reverse would break the host's device model. Leaving an unused
column behind in a standalone rollback is the harmless side of this trade.
"""
return
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0008_printermetrics_nozzle_info"),
]
operations = [
AddFieldIfMissing(
model_name="printer",
name="category",
field=models.CharField(
default="threed_printer",
help_text=(
"Device category. Always 'threed_printer' for printers — present "
"because host projects may share this table with other device types."
),
max_length=50,
),
),
migrations.AlterModelOptions(
name="printer",
options={
"base_manager_name": "all_objects",
"default_manager_name": "objects",
"ordering": ["name"],
"verbose_name": "Printer",
"verbose_name_plural": "Printers",
},
),
migrations.AlterModelManagers(
name="printer",
managers=[
("all_objects", django.db.models.manager.Manager()),
("objects", django.db.models.manager.Manager()),
],
),
]

View File

@@ -8,8 +8,7 @@ from django.utils import timezone
AMS_INFO_TO_TYPE = {
"1001": "AMS",
"1003": "AMS 2 Pro",
"1104": "AMS HT", # observed on production H2C (last 4 of info code 11001104)
"2104": "AMS HT", # observed in dev capture (last 4 of info code 11002104)
"2104": "AMS HT",
}
AMS_TYPE_CHOICES = [
@@ -33,33 +32,11 @@ def ams_type_from_info(info_code) -> str:
return AMS_INFO_TO_TYPE.get(code[-4:], "") or AMS_INFO_TO_TYPE.get(code, "")
class PrinterManager(models.Manager):
"""Default manager — scopes every query to actual 3D printers.
`Printer` shares the `infrastructure_device` table with a host project's other
device rows (RAE stores its NAS, routers and cameras there too). Without this
scoping, `Printer.objects.filter(is_active=True).first()` can return a NAS.
"""
def get_queryset(self):
return super().get_queryset().filter(category=Printer.CATEGORY_3D_PRINTER)
class Printer(models.Model):
"""Represents a Bambu Lab 3D printer device"""
CATEGORY_3D_PRINTER = "threed_printer"
name = models.CharField(max_length=200, help_text="Friendly device name")
model = models.CharField(max_length=100, help_text="Device model (e.g., X1C, P1S)")
category = models.CharField(
max_length=50,
default=CATEGORY_3D_PRINTER,
help_text=(
"Device category. Always 'threed_printer' for printers — present because "
"host projects may share this table with other device types."
),
)
manufacturer = models.CharField(
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
)
@@ -74,19 +51,11 @@ class Printer(models.Model):
first_seen = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
# `all_objects` is declared first so it serves as the base manager for related
# descriptors (PrinterMetrics.device etc.) — those must never filter, or rows
# attached to a mis-categorised device become unreachable.
all_objects = models.Manager()
objects = PrinterManager()
class Meta:
db_table = "infrastructure_device"
verbose_name = "Printer"
verbose_name_plural = "Printers"
ordering = ["name"]
base_manager_name = "all_objects"
default_manager_name = "objects"
def __str__(self):
return f"{self.name} ({self.model})"

View File

@@ -21,7 +21,6 @@ import os
import platform
import sys
import select
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
@@ -487,29 +486,15 @@ class PrinterState:
wifi_signal = print_data.get("wifi_signal", "")
# Dual-nozzle decoding (H2C, X2D). Dual-nozzle printers report per-extruder
# temperatures under `print.device.extruder.info[]` as a 2-element array
# (index 0 = right, index 1 = left). The `temp` field is bit-packed:
# H2C dual-nozzle decoding. The H2C reports per-extruder temperatures
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
# right, index 1 = left). The `temp` field is bit-packed:
# `temp_raw = (target << 16) | current`, both °C as ints.
#
# The legacy top-level `nozzle_temper`/`nozzle_target_temper` fields track
# whichever nozzle is currently *active*, not specifically the right one —
# on printers like the X2D they report the left nozzle's temp while it's
# printing, even though the dashboard's "Right Nozzle" card reads them.
# Prefer the decoded right-side value from extruder.info[0] when present.
nozzle_temp_left = None
nozzle_target_temp_left = None
nozzle_temp_right = None
nozzle_target_temp_right = None
device = print_data.get("device") or {}
extruders = (device.get("extruder") or {}).get("info") or []
if len(extruders) >= 2:
right = extruders[0]
t = right.get("temp")
if isinstance(t, int):
nozzle_target_temp_right = float((t >> 16) & 0xFFFF)
nozzle_temp_right = float(t & 0xFFFF)
left = extruders[1]
t = left.get("temp")
if isinstance(t, int):
@@ -525,14 +510,8 @@ class PrinterState:
return cls(
timestamp=timestamp,
sequence_id=str(print_data.get("sequence_id", "")),
nozzle_temp=(
nozzle_temp_right if nozzle_temp_right is not None
else float(print_data.get("nozzle_temper", 0.0))
),
nozzle_target_temp=(
nozzle_target_temp_right if nozzle_target_temp_right is not None
else float(print_data.get("nozzle_target_temper", 0.0))
),
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)),
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)),
bed_temp=float(print_data.get("bed_temper", 0.0)),
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
@@ -830,7 +809,6 @@ class BambuPrinter:
self._accumulator = PrinterStateAccumulator()
self._connected = False
self._devices: List[Dict[str, Any]] = []
self._last_message_at: Optional[float] = None
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
"""Get a fresh token using credentials."""
@@ -936,30 +914,6 @@ class BambuPrinter:
"""Internal MQTT message handler"""
if not data:
return
# The printer publishes partial deltas most of the time; the accumulator
# merges them onto whatever it already has. If the printer went offline
# (power cycle) and just reconnected, the first delta after the gap would
# otherwise be merged onto stale pre-outage state, leaving fields like
# nozzle_temp frozen at their last value until some unrelated full report
# happens to refresh them. Detect the gap and force a full pushall so the
# accumulator gets a clean, complete state instead.
now = time.time()
if (
self._last_message_at is not None
and now - self._last_message_at > app_settings.MQTT_RESYNC_GAP_SECONDS
and self._mqtt is not None
):
try:
self._mqtt.request_full_status()
logger.info(
"MQTT report gap of %.0fs detected for %s; requested full status re-sync",
now - self._last_message_at, device_id,
)
except Exception as e:
logger.warning("Full status re-sync request failed (non-fatal): %s", e)
self._last_message_at = now
state = self._accumulator.update(data)
if self._on_update:
self._on_update(state)

View File

@@ -113,14 +113,29 @@
border-color: currentColor;
}
/* Grouped AMS unit panels — Bootstrap row/col handles sizing;
multi-slot units (AMS/AMS 2 Pro) are col-12, single-slot (AMS HT) are col-lg-3. */
/* Grouped AMS unit panels — wide (multi-slot) units stack one per row,
compact (single-slot, e.g. AMS HT) units flow side-by-side and wrap. */
.ams-groups {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.ams-group {
border-radius: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--ams-group-border-color);
}
.ams-group--wide {
flex: 1 1 100%;
}
.ams-group--compact {
flex: 0 1 auto;
min-width: 220px;
}
.ams-badge-bg-ams {
background-color: color-mix(in srgb, var(--ams-badge-ams) 8%, transparent);
border-left: 3px solid var(--ams-badge-ams);

View File

@@ -10,7 +10,7 @@
</div>
<div class="col-md-4 text-end">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">
<a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Color
</a>
{% endif %}
@@ -81,8 +81,8 @@
<td class="align-middle">{{ color.brand }}</td>
<td class="align-middle">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_color_update' color.pk %}" class="btn btn-sm btn-warning{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">Edit</a>
<a href="{% url 'bambu_run:filament_color_delete' color.pk %}" class="btn btn-sm btn-danger{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">Delete</a>
<a href="{% url 'bambu_run:filament_color_update' color.pk %}" class="btn btn-sm btn-warning">Edit</a>
<a href="{% url 'bambu_run:filament_color_delete' color.pk %}" class="btn btn-sm btn-danger">Delete</a>
{% endif %}
</td>
</tr>

View File

@@ -14,7 +14,7 @@
</div>
<div class="col-auto">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">Edit</a>
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning">Edit</a>
{% endif %}
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a>
</div>

View File

@@ -20,7 +20,7 @@
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-outline-info me-2">
<i class="bi bi-palette"></i> Manage Colors
</a>
<a href="{% url 'bambu_run:filament_create' %}" class="btn btn-primary{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">
<a href="{% url 'bambu_run:filament_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Filament
</a>
</div>
@@ -177,7 +177,7 @@
<td class="align-middle">
<a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a>
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">Edit</a>
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning">Edit</a>
{% endif %}
</td>
</tr>

View File

@@ -10,7 +10,7 @@
</div>
<div class="col-md-4 text-end">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">
<a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add New Type
</a>
{% endif %}
@@ -63,8 +63,8 @@
<td class="align-middle">{{ ft.brand }}</td>
<td class="align-middle">
{% if not is_basic_user %}
<a href="{% url 'bambu_run:filament_type_update' ft.pk %}" class="btn btn-sm btn-warning{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">Edit</a>
<a href="{% url 'bambu_run:filament_type_delete' ft.pk %}" class="btn btn-sm btn-danger{% if not can_write %} disabled" aria-disabled="true" tabindex="-1" title="Read-only access{% endif %}">Delete</a>
<a href="{% url 'bambu_run:filament_type_update' ft.pk %}" class="btn btn-sm btn-warning">Edit</a>
<a href="{% url 'bambu_run:filament_type_delete' ft.pk %}" class="btn btn-sm btn-danger">Delete</a>
{% endif %}
</td>
</tr>

View File

@@ -164,6 +164,37 @@
</div>
{% endif %}
<!-- AMS Status Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>AMS Status</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<strong>Temperature:</strong>
{% if stats.ams_temp %}
{{ stats.ams_temp|floatformat:1 }}&deg;C
{% else %}
N/A
{% endif %}
</div>
<div class="col-md-4">
<strong>Humidity:</strong>
{% if stats.ams_humidity %}
{{ stats.ams_humidity }}%
{% else %}
N/A
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filaments Section -->
<div class="row mb-4">
<div class="col-12">
@@ -181,9 +212,9 @@
{% endfor %}
</div>
{% endif %}
<div class="row g-3 ams-groups">
<div class="ams-groups">
{% for group in stats.ams_groups %}
<div class="{% if group.filaments|length > 1 %}col-12{% else %}col-12 col-md-6 col-lg-3{% endif %} ams-group ams-badge-bg-{{ group.ams_type|slugify }}" data-ams-unit-id="{{ group.unit_id }}">
<div class="ams-group ams-badge-bg-{{ group.ams_type|slugify }} {% if group.filaments|length > 1 %}ams-group--wide{% else %}ams-group--compact{% endif %}" data-ams-unit-id="{{ group.unit_id }}">
<div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
<strong class="small">{{ group.label }}</strong>
{% if group.humidity is not None or group.temp is not None %}

View File

@@ -14,13 +14,9 @@ from .conf import app_settings
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
# Every field the chart serializers read must be listed here. A field that is
# accessed but missing triggers a deferred-field load — one extra SELECT per row,
# which turns a single-query page into thousands.
_METRICS_API_FIELDS = [
'id', 'device_id', 'timestamp',
'nozzle_temp', 'nozzle_target_temp',
'nozzle_temp_left', 'nozzle_target_temp_left',
'bed_temp', 'bed_target_temp',
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
@@ -28,16 +24,7 @@ _METRICS_API_FIELDS = [
'gcode_state', 'print_type', 'subtask_name',
'external_spool',
]
# 24h at the collector's 30s cadence is ~2800 readings, and every one of them
# also drags in ~9 FilamentSnapshot rows — that snapshot fetch, not the metrics
# query, is what dominated the dashboard's load time (measured: 2.09s of context
# building and a 410 KB payload at 3000). 1440 caps the series at roughly one
# point per minute over a day, which is finer than any chart can resolve on
# screen, and cuts both the server time and the payload by ~4x.
_MAX_CHART_POINTS = 1440
# Fallback window for requests that don't specify a full date range. Without it a
# bare API call scans the entire metrics table.
_DEFAULT_WINDOW = timedelta(hours=24)
_MAX_CHART_POINTS = 3000
def resolve_printer_from_request(pk):
@@ -45,47 +32,12 @@ def resolve_printer_from_request(pk):
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
`pk` omitted -> first active printer (today's single-printer default behavior).
Both paths go through `Printer.objects`, which is category-scoped, so a
non-printer row sharing `infrastructure_device` (a NAS, a router) can never be
resolved as "the printer" — even when no active printer exists.
"""
if pk is not None:
return get_object_or_404(Printer, pk=pk, is_active=True)
return Printer.objects.filter(is_active=True).first()
def sample_metrics(metrics_list, max_points=None):
"""Evenly thin a metrics list to at most `max_points`, always keeping the last
reading — the stat cards are built from it."""
max_points = max_points or _MAX_CHART_POINTS
total = len(metrics_list)
if total <= max_points:
return metrics_list
step = (total // max_points) + 1
sampled = metrics_list[::step]
if sampled[-1] is not metrics_list[-1]:
sampled.append(metrics_list[-1])
return sampled
def fetch_snapshots_by_metric(metrics_list):
"""Load filament snapshots for exactly the metrics we're serializing.
Beats `prefetch_related` on the unsampled queryset, which pulls a snapshot row
for every metric in the window (~25k rows for 24h) including the ones sampling
just discarded.
"""
if not metrics_list:
return {}
snapshots_by_metric = {}
for snap in FilamentSnapshot.objects.filter(
printer_metric_id__in=[m.id for m in metrics_list]
):
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
return snapshots_by_metric
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
template_name = "bambu_run/printer_dashboard.html"
@@ -120,28 +72,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
# Get date range (overridable by subclasses)
start_dt, end_dt = self._get_date_range(self.request)
query = PrinterMetrics.objects.filter(
metrics = PrinterMetrics.objects.filter(
device=printer_device, timestamp__gte=start_dt
)
if end_dt:
query = query.filter(timestamp__lte=end_dt)
metrics = metrics.filter(timestamp__lte=end_dt)
metrics = metrics.prefetch_related('filament_snapshots').order_by("timestamp")
# Chart series only need the columns the serializer below reads, and only
# as many points as a chart can render. Fetching every column (including
# the large JSON blobs) for every row is what made this page slow.
metrics = sample_metrics(
list(query.only(*_METRICS_API_FIELDS).order_by("timestamp"))
)
snapshots_by_metric = fetch_snapshots_by_metric(metrics)
# The stat cards read far more fields than the charts do, so the latest
# reading is fetched separately as a full instance rather than deferring
# (a deferred field on a sampled row costs an extra query per access).
latest_metric = (
query.prefetch_related('filament_snapshots__filament')
.order_by("-timestamp")
.first()
)
latest_metric = metrics.last()
printer_data_json = {
"timestamps": [
@@ -195,17 +133,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
"total_layer_num": [
m.total_layer_num if m.total_layer_num else 0 for m in metrics
],
"filament_timeline": self._prepare_filament_timeline(
metrics, snapshots_by_metric
),
"filament_timeline": self._prepare_filament_timeline(metrics),
}
stats = {}
if latest_metric:
filaments_list = []
try:
# `.all()` (not `.select_related()`) so the prefetch cache is used
filament_snapshots = latest_metric.filament_snapshots.all()
filament_snapshots = latest_metric.filament_snapshots.select_related('filament').all()
for snapshot in filament_snapshots:
filament_dict = {
'tray_id': snapshot.tray_id,
@@ -224,41 +159,26 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
except Exception:
filaments_list = []
# Build a lookup from unit_id → AMS unit metadata (humidity, temp, info code)
# first so we can enrich blank ams_type values derived from old snapshots.
units_meta = {
u.get('unit_id'): u for u in (latest_metric.ams_units or [])
}
# Distinct AMS units in this snapshot. ams_type stored on FilamentSnapshot
# may be blank for rows written before the multi-AMS deploy — fall back to
# re-deriving from the unit's info code so labels always show correctly.
from .models import ams_type_from_info as _ams_type_from_info
# Distinct AMS units represented in this snapshot, for the unit
# filter/badges in the template. Sort numeric unit ids first
# (AMS / AMS 2 Pro), HT (id 128 / bit 0x80 set) last.
seen_units = {}
for f in filaments_list:
uid = f.get('ams_unit_id')
if uid is not None and uid not in seen_units:
label = f.get('ams_type') or ''
if not label:
unit_meta = units_meta.get(str(uid), {})
label = _ams_type_from_info(unit_meta.get('info', ''))
seen_units[uid] = label
seen_units[uid] = f.get('ams_type') or ''
ams_units_list = [
{'ams_unit_id': uid, 'ams_type': label}
for uid, label in sorted(seen_units.items())
]
# Group trays by physical AMS unit for the panel-style dashboard layout —
# one tinted panel per unit, full-width for multi-slot units (AMS/AMS 2 Pro),
# compact for single-slot units (AMS HT) so several can flow side-by-side.
units_meta = {
u.get('unit_id'): u for u in (latest_metric.ams_units or [])
}
ams_groups = []
ungrouped = [f for f in filaments_list if f.get('ams_unit_id') is None]
if ungrouped:
ams_groups.append({
'unit_id': None,
'ams_type': '',
'label': 'AMS',
'humidity': None,
'temp': None,
'filaments': ungrouped,
})
for uid, label in sorted(seen_units.items()):
unit_meta = units_meta.get(str(uid), {})
ams_groups.append({
@@ -328,18 +248,18 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
}
project_markers = self._calculate_project_markers(metrics, tz, printer_device)
project_markers = self._calculate_project_markers(list(metrics), tz)
printer_data_json["project_markers"] = project_markers
context["printer_device"] = printer_device
context["device_name"] = printer_device.name
context["stats"] = stats
context["metrics_count"] = len(metrics)
context["metrics_count"] = metrics.count()
context["printer_data_json"] = json.dumps(printer_data_json)
return context
def _calculate_project_markers(self, metrics, timezone_info, device):
def _calculate_project_markers(self, metrics, timezone_info):
"""Calculate where print jobs start and end, using cloud design_title when available."""
if not metrics:
return []
@@ -347,6 +267,7 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
window_start = metrics[0].timestamp
window_end = metrics[-1].timestamp
device = metrics[0].device
jobs_qs = PrintJob.objects.filter(
device=device,
start_time__gte=window_start - timedelta(minutes=5),
@@ -392,17 +313,18 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
return markers
def _prepare_filament_timeline(self, metrics, snapshots_by_metric):
"""Prepare filament data organized by unique filament configurations.
Snapshots are passed in pre-grouped by metric id; reading them off each
metric instance instead would issue one query per point.
"""
def _prepare_filament_timeline(self, metrics):
"""Prepare filament data organized by unique filament configurations."""
filament_data = {}
total_points = len(metrics)
for idx, metric in enumerate(metrics):
for snapshot in snapshots_by_metric.get(metric.id, []):
try:
snapshots = metric.filament_snapshots.all()
except Exception:
snapshots = []
for snapshot in snapshots:
tray_id = snapshot.tray_id
ams_unit_id = snapshot.ams_unit_id
ams_type = snapshot.ams_type or ''
@@ -478,25 +400,38 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
.only(*_METRICS_API_FIELDS)
)
# Both bounds are always applied. A missing bound falls back to a 24h
# window rather than being left open — an unbounded range would scan
# every metric ever recorded.
def _parse(date_str, time_str):
return datetime.strptime(
f"{date_str} {time_str}", "%Y-%m-%d %H:%M"
).replace(tzinfo=tz)
if start_date and start_time and end_date and end_time:
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
range_seconds = (end_dt - start_dt).total_seconds()
expected_count = max(1, int(range_seconds / 30))
elif start_date and start_time:
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
query = query.filter(timestamp__gte=start_dt)
expected_count = _MAX_CHART_POINTS
elif end_date and end_time:
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
query = query.filter(timestamp__lte=end_dt)
expected_count = _MAX_CHART_POINTS
else:
expected_count = _MAX_CHART_POINTS
end_dt = _parse(end_date, end_time) if end_date else timezone.now()
start_dt = _parse(start_date, start_time) if start_date else end_dt - _DEFAULT_WINDOW
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
step = max(1, expected_count // _MAX_CHART_POINTS)
# Stage B: single DB round-trip, downsample in Python
metrics_list = sample_metrics(list(query.order_by("timestamp")))
metrics_list = list(query.order_by("timestamp"))
if step > 1:
metrics_list = metrics_list[::step]
total_points = len(metrics_list)
# Stage C: targeted snapshot fetch (only sampled IDs)
snapshots_by_metric = fetch_snapshots_by_metric(metrics_list)
snapshots_by_metric: dict = {}
if metrics_list:
sampled_ids = [m.id for m in metrics_list]
for snap in FilamentSnapshot.objects.filter(printer_metric_id__in=sampled_ids):
snapshots_by_metric.setdefault(snap.printer_metric_id, []).append(snap)
# Stage D: single-pass serialization
timestamps = []

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bambu-run"
version = "0.1.13"
version = "0.1.7"
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
readme = "README.md"
license = {text = "MIT"}

View File

@@ -57,13 +57,8 @@ def test_filament_timeline_keeps_same_tray_id_units_separate(logged_in_client):
type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=50,
)
from bambu_run.views import fetch_snapshots_by_metric
view = PrinterDashboardView()
metrics = list(PrinterMetrics.objects.filter(pk=metric.pk))
timeline = view._prepare_filament_timeline(
metrics, fetch_snapshots_by_metric(metrics)
)
timeline = view._prepare_filament_timeline(PrinterMetrics.objects.filter(pk=metric.pk))
assert len(timeline) == 2
@@ -162,8 +157,8 @@ def test_dashboard_renders_wide_and_compact_panels(logged_in_client):
)
html = resp.content.decode()
assert "col-12 ams-group" in html # wide group: col-12 only
assert "col-lg-3 ams-group" in html # compact group: col-lg-3
assert "ams-group--wide" in html
assert "ams-group--compact" in html
assert "AMS 2 Pro (Unit 0)" in html
assert "AMS HT (Unit 128)" in html

View File

@@ -1,71 +0,0 @@
"""Printer shares the `infrastructure_device` table with non-printer devices
(NAS, routers, ...) in host projects like RAE. Printer queries must never
resolve one of those rows.
"""
import pytest
from django.urls import reverse
from bambu_run.models import Printer
from bambu_run.views import resolve_printer_from_request
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="scoping", password="pw")
client.force_login(user)
return client
@pytest.fixture
def nas():
"""A non-printer device row sharing the table, sorting before any printer."""
return Printer.all_objects.create(
name="A NAS", model="DS920+", category="nas", is_active=True
)
@pytest.mark.django_db
def test_default_manager_excludes_non_printers(nas):
printer = Printer.objects.create(name="Z Printer", model="H2C", is_active=True)
assert list(Printer.objects.all()) == [printer]
assert nas in Printer.all_objects.all()
@pytest.mark.django_db
def test_new_printers_default_to_the_printer_category():
printer = Printer.objects.create(name="Fresh", model="H2C")
assert printer.category == Printer.CATEGORY_3D_PRINTER
assert printer in Printer.objects.all()
@pytest.mark.django_db
def test_resolve_printer_skips_an_active_nas(nas):
"""The exact production failure: NAS sorts first and is active, printer is not."""
printer = Printer.objects.create(name="Z Printer", model="H2C", is_active=False)
assert resolve_printer_from_request(None) is None, "inactive printer must not resolve"
printer.is_active = True
printer.save()
assert resolve_printer_from_request(None) == printer
@pytest.mark.django_db
def test_resolve_printer_by_pk_rejects_a_non_printer(nas):
from django.http import Http404
with pytest.raises(Http404):
resolve_printer_from_request(nas.pk)
@pytest.mark.django_db
def test_dashboard_does_not_fall_back_to_a_nas(logged_in_client, nas):
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.status_code == 200
assert "error" in resp.context
assert resp.context.get("printer_device") is None
assert list(resp.context["all_printers"]) == []

View File

@@ -1,233 +0,0 @@
"""Guards against the query-count and payload regressions that made the printer
pages slow: deferred-field N+1s, unbounded date ranges, and unsampled chart data.
"""
import json
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from bambu_run.models import Printer, PrinterMetrics, FilamentSnapshot
from bambu_run.views import _MAX_CHART_POINTS
@pytest.fixture
def logged_in_client(client, django_user_model):
user = django_user_model.objects.create_user(username="perf", password="pw")
client.force_login(user)
return client
@pytest.fixture
def printer():
return Printer.objects.create(name="Perf Printer", model="H2C", is_active=True)
def _make_metrics(printer, count, *, snapshots_per_metric=2, spacing_seconds=30):
"""Create `count` metrics ending now, each with some filament snapshots."""
now = timezone.now()
metrics = PrinterMetrics.objects.bulk_create(
[
PrinterMetrics(
device=printer,
timestamp=now - timedelta(seconds=spacing_seconds * (count - i)),
nozzle_temp=200 + i % 5,
nozzle_target_temp=220,
nozzle_temp_left=180 + i % 3,
nozzle_target_temp_left=190,
bed_temp=60,
bed_target_temp=60,
print_percent=i % 100,
gcode_state="RUNNING",
print_type="local",
subtask_name="job",
)
for i in range(count)
]
)
FilamentSnapshot.objects.bulk_create(
[
FilamentSnapshot(
printer_metric=m,
tray_id=str(tray),
type="PLA",
sub_type="Bambu",
color="FF0000FF",
remain_percent=80,
)
for m in metrics
for tray in range(snapshots_per_metric)
]
)
return metrics
# --- Root cause 1: deferred-field N+1 in the API -----------------------------
def _count_queries(client, url, params=None):
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
resp = client.get(url, params or {})
assert resp.status_code == 200
return len(ctx)
@pytest.mark.django_db
def test_api_query_count_is_independent_of_row_count(logged_in_client, printer):
"""Every field the serializer reads must be in .only(), or Django emits one
extra SELECT per row per missing field — making query count scale with data."""
today = timezone.localtime().date()
url = reverse("bambu_run:printer_api")
params = {
"start_date": str(today - timedelta(days=1)),
"end_date": str(today),
"start_time": "00:00",
"end_time": "23:59",
}
_make_metrics(printer, 10)
few = _count_queries(logged_in_client, url, params)
_make_metrics(printer, 190)
many = _count_queries(logged_in_client, url, params)
assert few == many, f"query count scales with rows: {few} -> {many}"
@pytest.mark.django_db
def test_api_returns_dual_nozzle_values(logged_in_client, printer):
"""The left-nozzle fields must survive the .only() narrowing."""
_make_metrics(printer, 5)
today = timezone.localtime().date()
resp = logged_in_client.get(
reverse("bambu_run:printer_api"),
{
"start_date": str(today - timedelta(days=1)),
"end_date": str(today),
"start_time": "00:00",
"end_time": "23:59",
},
)
data = resp.json()
assert any(v is not None for v in data["nozzle_temp_left"])
assert any(v is not None for v in data["nozzle_target_temp_left"])
# --- Root cause: unbounded query when date params are missing ----------------
@pytest.mark.django_db
def test_api_without_params_is_time_bounded(logged_in_client, printer):
"""A bare API call must not scan the whole table — it defaults to 24h."""
_make_metrics(printer, 10, spacing_seconds=30) # inside 24h
old = PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now() - timedelta(days=30), nozzle_temp=100
)
resp = logged_in_client.get(reverse("bambu_run:printer_api"))
data = resp.json()
assert len(data["timestamps"]) == 10
assert old.timestamp.isoformat() not in data["timestamps_iso"]
@pytest.mark.django_db
def test_api_with_only_start_date_is_time_bounded(logged_in_client, printer):
"""Partial params must not drop the upper bound and scan forever."""
_make_metrics(printer, 5)
today = timezone.localtime().date()
resp = logged_in_client.get(
reverse("bambu_run:printer_api"), {"start_date": str(today - timedelta(days=1))}
)
assert resp.status_code == 200
assert len(resp.json()["timestamps"]) == 5
@pytest.mark.django_db
def test_api_downsamples_above_max_chart_points(logged_in_client, printer, monkeypatch):
monkeypatch.setattr("bambu_run.views._MAX_CHART_POINTS", 10)
_make_metrics(printer, 40, snapshots_per_metric=1, spacing_seconds=30)
today = timezone.localtime().date()
resp = logged_in_client.get(
reverse("bambu_run:printer_api"),
{
"start_date": str(today - timedelta(days=1)),
"end_date": str(today),
"start_time": "00:00",
"end_time": "23:59",
},
)
assert 0 < len(resp.json()["timestamps"]) <= 10
# --- Root cause 2: the dashboard render -------------------------------------
@pytest.mark.django_db
def test_dashboard_query_count_is_independent_of_row_count(logged_in_client, printer):
url = reverse("bambu_run:printer_dashboard")
_make_metrics(printer, 10)
few = _count_queries(logged_in_client, url)
_make_metrics(printer, 190)
many = _count_queries(logged_in_client, url)
assert few == many, f"query count scales with rows: {few} -> {many}"
@pytest.mark.django_db
def test_dashboard_downsamples_chart_payload(logged_in_client, printer, monkeypatch):
"""The dashboard inlines its JSON into the HTML, so it must sample like the API."""
monkeypatch.setattr("bambu_run.views._MAX_CHART_POINTS", 10)
_make_metrics(printer, 60, snapshots_per_metric=1)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
payload = json.loads(resp.context["printer_data_json"])
assert 0 < len(payload["timestamps"]) <= 10
@pytest.mark.django_db
def test_dashboard_stats_use_the_newest_metric(logged_in_client, printer):
"""Sampling must never drop the latest reading — the stat cards depend on it."""
import zoneinfo
from bambu_run.conf import app_settings
_make_metrics(printer, 20)
newest = PrinterMetrics.objects.create(
device=printer, timestamp=timezone.now(), nozzle_temp=242, gcode_state="RUNNING"
)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
assert resp.context["stats"]["nozzle_temp"] == pytest.approx(242)
assert resp.context["stats"]["timestamp"] == newest.timestamp.astimezone(
zoneinfo.ZoneInfo(app_settings.TIMEZONE)
).strftime("%Y-%m-%d %H:%M:%S")
@pytest.mark.django_db
def test_dashboard_filament_timeline_aligns_with_timestamps(logged_in_client, printer):
"""remain_data must stay index-aligned with timestamps after sampling."""
_make_metrics(printer, 30, snapshots_per_metric=2)
resp = logged_in_client.get(reverse("bambu_run:printer_dashboard"))
payload = json.loads(resp.context["printer_data_json"])
n = len(payload["timestamps"])
assert payload["filament_timeline"]
for series in payload["filament_timeline"].values():
assert len(series["remain_data"]) == n