mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-08-22 14:54:19 +01:00
The printer chart API was issuing one extra SELECT per row per field that the serializer read but .only() omitted. nozzle_temp_left/nozzle_target_temp_left were added to the serialization loop without being added to _METRICS_API_FIELDS, so a single-day request ran 5,507 queries and took 34s; the UI's default 48h range took 87s. Against a remote Postgres this is pure round-trip latency. - Add the two left-nozzle fields to _METRICS_API_FIELDS. - Always apply both time bounds in PrinterDataAPIView. Missing or partial date params previously left the range open, so a bare API call scanned the whole metrics table. - Give PrinterDashboardView the same treatment the API already had: .only(), sampling to _MAX_CHART_POINTS, and a targeted snapshot fetch. It also evaluated its queryset twice, because .last() on an unevaluated queryset issues its own query plus its own prefetch. - Extract sample_metrics() and fetch_snapshots_by_metric() for reuse. Printer shares the infrastructure_device table with a host project's other devices and had no category field, so Printer.objects.filter(is_active=True) could return a NAS. Add a category field and a category-scoped default manager, keeping all_objects as the unfiltered base manager so related descriptors still resolve every row. Migration 0009 creates the column in standalone deployments and skips the DDL where the host project already owns it. Measured: API 87s -> 0.67s, dashboard 3.4s -> 0.9s. Query counts are now independent of row count, asserted by tests.
1003 lines
43 KiB
Python
1003 lines
43 KiB
Python
from datetime import timedelta, datetime
|
|
from django.views.generic import TemplateView, View, ListView, CreateView, UpdateView, DetailView, DeleteView
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.shortcuts import get_object_or_404
|
|
from django.utils import timezone
|
|
from django.http import Http404, JsonResponse
|
|
from django.urls import reverse_lazy
|
|
from django.contrib import messages
|
|
from django.db.models import Q, Sum
|
|
import json
|
|
import zoneinfo
|
|
|
|
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',
|
|
'layer_num', 'total_layer_num',
|
|
'gcode_state', 'print_type', 'subtask_name',
|
|
'external_spool',
|
|
]
|
|
_MAX_CHART_POINTS = 3000
|
|
# 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)
|
|
|
|
|
|
def resolve_printer_from_request(pk):
|
|
"""Resolve which Printer a dashboard/API view should show.
|
|
|
|
`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"
|
|
|
|
def _get_date_range(self, request):
|
|
"""Return (start_dt, end_dt) for the dashboard query. Override for custom date logic."""
|
|
time_24h_ago = timezone.now() - timedelta(hours=24)
|
|
return time_24h_ago, None # None means "now"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
|
|
all_printers = Printer.objects.filter(is_active=True)
|
|
context["all_printers"] = all_printers
|
|
# Shown even with a single printer — hints that multi-printer support exists.
|
|
context["show_printer_switcher"] = all_printers.exists()
|
|
|
|
try:
|
|
printer_device = resolve_printer_from_request(self.kwargs.get("pk"))
|
|
if not printer_device:
|
|
context["error"] = (
|
|
"No 3D printer device found. Please run bambu_collector first."
|
|
)
|
|
return context
|
|
except Http404:
|
|
raise
|
|
except Exception as e:
|
|
context["error"] = f"Error loading printer device: {str(e)}"
|
|
return context
|
|
|
|
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
|
|
|
# Get date range (overridable by subclasses)
|
|
start_dt, end_dt = self._get_date_range(self.request)
|
|
query = PrinterMetrics.objects.filter(
|
|
device=printer_device, timestamp__gte=start_dt
|
|
)
|
|
if end_dt:
|
|
query = query.filter(timestamp__lte=end_dt)
|
|
|
|
# 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()
|
|
)
|
|
|
|
printer_data_json = {
|
|
"timestamps": [
|
|
m.timestamp.astimezone(tz).strftime("%H:%M") for m in metrics
|
|
],
|
|
"dates": [
|
|
m.timestamp.astimezone(tz).strftime("%Y-%m-%d") for m in metrics
|
|
],
|
|
"nozzle_temp": [
|
|
float(m.nozzle_temp) if m.nozzle_temp else None for m in metrics
|
|
],
|
|
"nozzle_target_temp": [
|
|
float(m.nozzle_target_temp) if m.nozzle_target_temp else None
|
|
for m in metrics
|
|
],
|
|
"nozzle_temp_left": [
|
|
float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None
|
|
for m in metrics
|
|
],
|
|
"nozzle_target_temp_left": [
|
|
float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None
|
|
for m in metrics
|
|
],
|
|
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
|
|
"bed_target_temp": [
|
|
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
|
|
],
|
|
"print_percent": [
|
|
m.print_percent if m.print_percent else 0 for m in metrics
|
|
],
|
|
"print_type": [m.print_type for m in metrics],
|
|
"gcode_state": [m.gcode_state for m in metrics],
|
|
"cooling_fan_speed": [
|
|
m.cooling_fan_speed if m.cooling_fan_speed else 0 for m in metrics
|
|
],
|
|
"heatbreak_fan_speed": [
|
|
m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0 for m in metrics
|
|
],
|
|
"wifi_signal_dbm": [
|
|
m.wifi_signal_dbm if m.wifi_signal_dbm else None for m in metrics
|
|
],
|
|
"ams_humidity_raw": [
|
|
m.ams_humidity_raw if m.ams_humidity_raw else None for m in metrics
|
|
],
|
|
"ams_temp": [
|
|
float(m.ams_temp) if m.ams_temp else None for m in metrics
|
|
],
|
|
"layer_num": [
|
|
m.layer_num if m.layer_num else 0 for m in metrics
|
|
],
|
|
"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
|
|
),
|
|
}
|
|
|
|
stats = {}
|
|
if latest_metric:
|
|
filaments_list = []
|
|
try:
|
|
# `.all()` (not `.select_related()`) so the prefetch cache is used
|
|
filament_snapshots = latest_metric.filament_snapshots.all()
|
|
for snapshot in filament_snapshots:
|
|
filament_dict = {
|
|
'tray_id': snapshot.tray_id,
|
|
'type': snapshot.type or 'Unknown',
|
|
'brand': snapshot.sub_type or 'Unknown',
|
|
'color': snapshot.color or 'FFFFFFFF',
|
|
'remain_percent': snapshot.remain_percent or 0,
|
|
'ams_unit_id': snapshot.ams_unit_id,
|
|
'ams_type': snapshot.ams_type or '',
|
|
}
|
|
if snapshot.filament:
|
|
filament_dict['color_name'] = snapshot.filament.color
|
|
filament_dict['filament_pk'] = snapshot.filament.pk
|
|
filament_dict['is_transparent'] = snapshot.filament.is_transparent
|
|
filaments_list.append(filament_dict)
|
|
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
|
|
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
|
|
ams_units_list = [
|
|
{'ams_unit_id': uid, 'ams_type': label}
|
|
for uid, label in sorted(seen_units.items())
|
|
]
|
|
|
|
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({
|
|
'unit_id': uid,
|
|
'ams_type': label,
|
|
'label': f"{label or 'AMS'} (Unit {uid})",
|
|
'humidity': unit_meta.get('humidity'),
|
|
'temp': unit_meta.get('temp'),
|
|
'filaments': [f for f in filaments_list if f.get('ams_unit_id') == uid],
|
|
})
|
|
|
|
subtask_name = latest_metric.subtask_name or "No active print"
|
|
# Look up active PrintJob for a better display name (cloud design_title)
|
|
job_display_name = subtask_name
|
|
if latest_metric.subtask_name:
|
|
active_job = (
|
|
PrintJob.objects.filter(
|
|
device=printer_device,
|
|
project_name=latest_metric.subtask_name,
|
|
end_time__isnull=True,
|
|
).select_related('cloud_task').first()
|
|
or PrintJob.objects.filter(
|
|
device=printer_device,
|
|
project_name=latest_metric.subtask_name,
|
|
).select_related('cloud_task').order_by('-start_time').first()
|
|
)
|
|
if active_job:
|
|
job_display_name = active_job.display_name
|
|
|
|
stats = {
|
|
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
|
|
"nozzle_target_temp": float(latest_metric.nozzle_target_temp) if latest_metric.nozzle_target_temp else 0,
|
|
"nozzle_diameter": float(latest_metric.nozzle_diameter) if latest_metric.nozzle_diameter else None,
|
|
"nozzle_type": latest_metric.nozzle_type or "",
|
|
"nozzle_temp_left": float(latest_metric.nozzle_temp_left) if latest_metric.nozzle_temp_left is not None else None,
|
|
"nozzle_target_temp_left": float(latest_metric.nozzle_target_temp_left) if latest_metric.nozzle_target_temp_left is not None else None,
|
|
"nozzle_diameter_left": float(latest_metric.nozzle_diameter_left) if latest_metric.nozzle_diameter_left is not None else None,
|
|
"nozzle_type_left": latest_metric.nozzle_type_left or "",
|
|
"is_dual_nozzle": latest_metric.nozzle_temp_left is not None,
|
|
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
|
|
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
|
|
"print_percent": latest_metric.print_percent or 0,
|
|
"gcode_state": latest_metric.gcode_state or "Unknown",
|
|
"print_type": latest_metric.print_type or "idle",
|
|
"subtask_name": subtask_name,
|
|
"job_display_name": job_display_name,
|
|
"chamber_light": latest_metric.chamber_light or "unknown",
|
|
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
|
"ams_humidity": latest_metric.ams_humidity,
|
|
"filaments": filaments_list,
|
|
"ams_units": ams_units_list,
|
|
"ams_groups": ams_groups,
|
|
"hotends": list(
|
|
Hotend.objects.filter(printer=printer_device)
|
|
.order_by('-is_toolhead', 'slot_number', 'serial_number')
|
|
),
|
|
# Nozzle positions with no induction chip (no stable serial number to
|
|
# key a Hotend registry row on, e.g. H2C's fixed left nozzle) — shown
|
|
# read-only from the latest poll, not persisted/historical. Entries with
|
|
# no readable type/diameter at all (i.e. genuinely nothing there) are
|
|
# dropped rather than shown as an empty placeholder.
|
|
"nozzle_positions": [
|
|
h for h in (latest_metric.nozzle_info or [])
|
|
if h.get('is_empty') and (h.get('nozzle_type') or h.get('diameter'))
|
|
],
|
|
"external_spool": latest_metric.external_spool or {},
|
|
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
|
}
|
|
|
|
project_markers = self._calculate_project_markers(metrics, tz, printer_device)
|
|
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["printer_data_json"] = json.dumps(printer_data_json)
|
|
|
|
return context
|
|
|
|
def _calculate_project_markers(self, metrics, timezone_info, device):
|
|
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
|
if not metrics:
|
|
return []
|
|
|
|
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
|
window_start = metrics[0].timestamp
|
|
window_end = metrics[-1].timestamp
|
|
jobs_qs = PrintJob.objects.filter(
|
|
device=device,
|
|
start_time__gte=window_start - timedelta(minutes=5),
|
|
start_time__lte=window_end + timedelta(minutes=5),
|
|
).select_related('cloud_task')
|
|
# Map project_name (= subtask_name) -> best display name
|
|
subtask_to_display = {}
|
|
for job in jobs_qs:
|
|
subtask_to_display[job.project_name] = job.display_name
|
|
|
|
markers = []
|
|
current_job = None
|
|
last_state = None
|
|
|
|
for idx, metric in enumerate(metrics):
|
|
subtask = metric.subtask_name
|
|
gcode_state = metric.gcode_state
|
|
|
|
is_printing = gcode_state not in ['FINISH', 'IDLE', None, '']
|
|
|
|
if subtask and subtask != current_job and is_printing:
|
|
display = subtask_to_display.get(subtask, subtask)
|
|
markers.append({
|
|
'type': 'start',
|
|
'index': idx,
|
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
|
'project_name': display,
|
|
})
|
|
current_job = subtask
|
|
last_state = gcode_state
|
|
|
|
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gcode_state in ['FINISH', 'IDLE']:
|
|
display = subtask_to_display.get(current_job, current_job)
|
|
markers.append({
|
|
'type': 'end',
|
|
'index': idx,
|
|
'timestamp': metric.timestamp.astimezone(timezone_info).isoformat(),
|
|
'project_name': display,
|
|
})
|
|
current_job = None
|
|
|
|
last_state = gcode_state
|
|
|
|
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.
|
|
"""
|
|
filament_data = {}
|
|
total_points = len(metrics)
|
|
|
|
for idx, metric in enumerate(metrics):
|
|
for snapshot in snapshots_by_metric.get(metric.id, []):
|
|
tray_id = snapshot.tray_id
|
|
ams_unit_id = snapshot.ams_unit_id
|
|
ams_type = snapshot.ams_type or ''
|
|
fil_type = snapshot.type or 'Unknown'
|
|
fil_sub_type = snapshot.sub_type or 'Unknown'
|
|
fil_color = snapshot.color or 'FFFFFFFF'
|
|
|
|
unique_key = f"{ams_unit_id}_{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
|
|
|
if unique_key not in filament_data:
|
|
filament_data[unique_key] = {
|
|
'tray_id': tray_id,
|
|
'ams_unit_id': ams_unit_id,
|
|
'ams_type': ams_type,
|
|
'type': fil_type,
|
|
'brand': fil_sub_type,
|
|
'color': fil_color,
|
|
'remain_data': [None] * total_points,
|
|
'start_idx': idx,
|
|
}
|
|
|
|
remain_percent = snapshot.remain_percent or 0
|
|
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
|
|
|
for idx, metric in enumerate(metrics):
|
|
external = metric.external_spool or {}
|
|
if external.get('type'):
|
|
fil_type = external.get('type', 'Unknown')
|
|
fil_color = external.get('color', '161616FF')
|
|
unique_key = f"External_{fil_type}_{fil_color}"
|
|
|
|
if unique_key not in filament_data:
|
|
filament_data[unique_key] = {
|
|
'tray_id': 'External',
|
|
'type': fil_type,
|
|
'brand': 'External',
|
|
'color': fil_color,
|
|
'remain_data': [None] * total_points,
|
|
'start_idx': idx,
|
|
}
|
|
|
|
remain_percent = external.get('remain', 0)
|
|
filament_data[unique_key]['remain_data'][idx] = remain_percent
|
|
|
|
return filament_data
|
|
|
|
|
|
class PrinterDataAPIView(LoginRequiredMixin, View):
|
|
"""API endpoint for dynamic printer chart updates"""
|
|
|
|
def get(self, request, pk=None):
|
|
start_date = request.GET.get("start_date")
|
|
end_date = request.GET.get("end_date")
|
|
start_time = request.GET.get("start_time", "00:00")
|
|
end_time = request.GET.get("end_time", "23:59")
|
|
|
|
try:
|
|
if pk is not None:
|
|
printer_device = Printer.objects.filter(pk=pk, is_active=True).first()
|
|
if not printer_device:
|
|
return JsonResponse({"error": "Printer not found"}, status=404)
|
|
else:
|
|
printer_device = Printer.objects.filter(is_active=True).first()
|
|
if not printer_device:
|
|
return JsonResponse({"error": "No printer device found"}, status=404)
|
|
|
|
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
|
|
|
# Stage A: only() + step calculation
|
|
query = (
|
|
PrinterMetrics.objects
|
|
.filter(device=printer_device)
|
|
.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)
|
|
|
|
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)
|
|
|
|
# Stage B: single DB round-trip, downsample in Python
|
|
metrics_list = sample_metrics(list(query.order_by("timestamp")))
|
|
|
|
total_points = len(metrics_list)
|
|
|
|
# Stage C: targeted snapshot fetch (only sampled IDs)
|
|
snapshots_by_metric = fetch_snapshots_by_metric(metrics_list)
|
|
|
|
# Stage D: single-pass serialization
|
|
timestamps = []
|
|
timestamps_iso = []
|
|
dates = []
|
|
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 = []
|
|
layer_num = []
|
|
total_layer_num = []
|
|
gcode_state = []
|
|
print_type = []
|
|
subtask_name = []
|
|
|
|
project_markers = []
|
|
current_job = None
|
|
last_state = None
|
|
|
|
filament_data = {}
|
|
|
|
for idx, m in enumerate(metrics_list):
|
|
ts = m.timestamp.astimezone(tz)
|
|
timestamps.append(ts.strftime('%H:%M'))
|
|
timestamps_iso.append(ts.isoformat())
|
|
dates.append(ts.strftime('%Y-%m-%d'))
|
|
nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None)
|
|
nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None)
|
|
nozzle_temp_left.append(float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None)
|
|
nozzle_target_temp_left.append(float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None)
|
|
bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
|
|
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
|
|
print_percent.append(m.print_percent if m.print_percent else 0)
|
|
cooling_fan_speed.append(m.cooling_fan_speed if m.cooling_fan_speed else 0)
|
|
heatbreak_fan_speed.append(m.heatbreak_fan_speed if m.heatbreak_fan_speed else 0)
|
|
wifi_signal_dbm.append(m.wifi_signal_dbm if m.wifi_signal_dbm else None)
|
|
ams_humidity_raw.append(m.ams_humidity_raw if m.ams_humidity_raw else None)
|
|
ams_temp.append(float(m.ams_temp) if m.ams_temp else None)
|
|
layer_num.append(m.layer_num if m.layer_num else 0)
|
|
total_layer_num.append(m.total_layer_num if m.total_layer_num else 0)
|
|
gcode_state.append(m.gcode_state)
|
|
print_type.append(m.print_type)
|
|
subtask_name.append(m.subtask_name)
|
|
|
|
# Project marker detection (inline)
|
|
subtask = m.subtask_name
|
|
gs = m.gcode_state
|
|
is_printing = gs not in ['FINISH', 'IDLE', None, '']
|
|
if subtask and subtask != current_job and is_printing:
|
|
project_markers.append({
|
|
'type': 'start',
|
|
'index': idx,
|
|
'timestamp': ts.isoformat(),
|
|
'project_name': subtask,
|
|
})
|
|
current_job = subtask
|
|
last_state = gs
|
|
elif current_job and last_state and last_state not in ['FINISH', 'IDLE'] and gs in ['FINISH', 'IDLE']:
|
|
project_markers.append({
|
|
'type': 'end',
|
|
'index': idx,
|
|
'timestamp': ts.isoformat(),
|
|
'project_name': current_job,
|
|
})
|
|
current_job = None
|
|
last_state = gs
|
|
|
|
# Filament timeline (inline)
|
|
for snap in snapshots_by_metric.get(m.id, []):
|
|
tray_id = snap.tray_id
|
|
fil_type = snap.type or 'Unknown'
|
|
fil_sub_type = snap.sub_type or 'Unknown'
|
|
fil_color = snap.color or 'FFFFFFFF'
|
|
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
|
if unique_key not in filament_data:
|
|
filament_data[unique_key] = {
|
|
'tray_id': tray_id,
|
|
'type': fil_type,
|
|
'brand': fil_sub_type,
|
|
'color': fil_color,
|
|
'remain_data': [None] * total_points,
|
|
'start_idx': idx,
|
|
}
|
|
filament_data[unique_key]['remain_data'][idx] = snap.remain_percent or 0
|
|
|
|
external = m.external_spool or {}
|
|
if external.get('type'):
|
|
fil_type = external.get('type', 'Unknown')
|
|
fil_color = external.get('color', '161616FF')
|
|
unique_key = f"External_{fil_type}_{fil_color}"
|
|
if unique_key not in filament_data:
|
|
filament_data[unique_key] = {
|
|
'tray_id': 'External',
|
|
'type': fil_type,
|
|
'brand': 'External',
|
|
'color': fil_color,
|
|
'remain_data': [None] * total_points,
|
|
'start_idx': idx,
|
|
}
|
|
filament_data[unique_key]['remain_data'][idx] = external.get('remain', 0)
|
|
|
|
data = {
|
|
"timestamps": timestamps,
|
|
"timestamps_iso": timestamps_iso,
|
|
"dates": dates,
|
|
"nozzle_temp": nozzle_temp,
|
|
"nozzle_target_temp": nozzle_target_temp,
|
|
"nozzle_temp_left": nozzle_temp_left,
|
|
"nozzle_target_temp_left": nozzle_target_temp_left,
|
|
"bed_temp": bed_temp,
|
|
"bed_target_temp": bed_target_temp,
|
|
"print_percent": print_percent,
|
|
"cooling_fan_speed": cooling_fan_speed,
|
|
"heatbreak_fan_speed": heatbreak_fan_speed,
|
|
"wifi_signal_dbm": wifi_signal_dbm,
|
|
"ams_humidity_raw": ams_humidity_raw,
|
|
"ams_temp": ams_temp,
|
|
"layer_num": layer_num,
|
|
"total_layer_num": total_layer_num,
|
|
"gcode_state": gcode_state,
|
|
"print_type": print_type,
|
|
"subtask_name": subtask_name,
|
|
"project_markers": project_markers,
|
|
"filament_timeline": filament_data,
|
|
}
|
|
|
|
return JsonResponse(data)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return JsonResponse({"error": str(e)}, status=500)
|
|
|
|
|
|
class FilamentUsageDataAPIView(LoginRequiredMixin, View):
|
|
"""API endpoint for filament usage history with date/time filtering"""
|
|
|
|
def get(self, request, pk):
|
|
start_date = request.GET.get("start_date")
|
|
end_date = request.GET.get("end_date")
|
|
start_time = request.GET.get("start_time", "00:00")
|
|
end_time = request.GET.get("end_time", "23:59")
|
|
|
|
try:
|
|
filament = Filament.objects.get(pk=pk)
|
|
tz = zoneinfo.ZoneInfo(app_settings.TIMEZONE)
|
|
query = filament.usage_snapshots.select_related('printer_metric')
|
|
|
|
if start_date and start_time:
|
|
from datetime import datetime
|
|
start_dt_naive = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M")
|
|
start_dt = start_dt_naive.replace(tzinfo=tz)
|
|
query = query.filter(printer_metric__timestamp__gte=start_dt)
|
|
|
|
if end_date and end_time:
|
|
from datetime import datetime
|
|
end_dt_naive = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M")
|
|
end_dt = end_dt_naive.replace(tzinfo=tz)
|
|
query = query.filter(printer_metric__timestamp__lte=end_dt)
|
|
|
|
fallback_used = False
|
|
if not start_date and not end_date:
|
|
time_24h_ago = timezone.now() - timedelta(hours=24)
|
|
default_query = query.filter(printer_metric__timestamp__gte=time_24h_ago)
|
|
if default_query.exists():
|
|
snapshots = default_query.order_by('printer_metric__timestamp')
|
|
else:
|
|
# Fallback: show 24h window ending at the most recent available snapshot
|
|
last_snapshot = query.order_by('-printer_metric__timestamp').first()
|
|
if last_snapshot:
|
|
last_ts = last_snapshot.printer_metric.timestamp
|
|
fallback_start = last_ts - timedelta(hours=24)
|
|
snapshots = query.filter(
|
|
printer_metric__timestamp__gte=fallback_start,
|
|
printer_metric__timestamp__lte=last_ts
|
|
).order_by('printer_metric__timestamp')
|
|
fallback_used = True
|
|
else:
|
|
snapshots = query.none()
|
|
else:
|
|
snapshots = query.order_by('printer_metric__timestamp')
|
|
|
|
data = {
|
|
"timestamps": [s.printer_metric.timestamp.astimezone(tz).strftime('%Y-%m-%d %H:%M') for s in snapshots],
|
|
"remaining": [s.remain_percent for s in snapshots],
|
|
"fallback_used": fallback_used,
|
|
}
|
|
|
|
return JsonResponse(data)
|
|
|
|
except Filament.DoesNotExist:
|
|
return JsonResponse({"error": "Filament not found"}, status=404)
|
|
except Exception as e:
|
|
import traceback
|
|
traceback.print_exc()
|
|
return JsonResponse({"error": str(e)}, status=500)
|
|
|
|
|
|
# ==================== Filament CRUD Views ====================
|
|
|
|
class FilamentListView(LoginRequiredMixin, ListView):
|
|
model = Filament
|
|
template_name = 'bambu_run/filament_list.html'
|
|
context_object_name = 'filaments'
|
|
paginate_by = 20
|
|
|
|
def get_queryset(self):
|
|
queryset = Filament.objects.all()
|
|
|
|
filament_type = self.request.GET.get('type')
|
|
if filament_type:
|
|
queryset = queryset.filter(type=filament_type)
|
|
|
|
loaded = self.request.GET.get('loaded')
|
|
if loaded == 'yes':
|
|
queryset = queryset.filter(is_loaded_in_ams=True)
|
|
elif loaded == 'no':
|
|
queryset = queryset.filter(is_loaded_in_ams=False)
|
|
|
|
ams_type = self.request.GET.get('ams_type')
|
|
if ams_type:
|
|
queryset = queryset.filter(ams_type=ams_type)
|
|
|
|
search = self.request.GET.get('search')
|
|
if search:
|
|
queryset = queryset.filter(
|
|
Q(brand__icontains=search) |
|
|
Q(color__icontains=search) |
|
|
Q(type__icontains=search)
|
|
)
|
|
|
|
return queryset
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['total_spools'] = Filament.objects.count()
|
|
context['loaded_spools'] = Filament.objects.filter(is_loaded_in_ams=True).count()
|
|
context['low_filaments'] = Filament.objects.filter(remaining_percent__lt=20).count()
|
|
context['filament_types'] = sorted(
|
|
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
|
|
)
|
|
context['ams_type_choices'] = sorted(
|
|
set(
|
|
Filament.objects.exclude(ams_type='').values_list('ams_type', flat=True)
|
|
)
|
|
)
|
|
return context
|
|
|
|
|
|
def _filament_type_map():
|
|
"""Return a JSON-serialisable dict mapping FilamentType pk → {type, sub_type, brand}."""
|
|
return {
|
|
str(ft.pk): {'type': ft.type, 'sub_type': ft.sub_type or '', 'brand': ft.brand}
|
|
for ft in FilamentType.objects.all()
|
|
}
|
|
|
|
|
|
class FilamentCreateView(LoginRequiredMixin, CreateView):
|
|
model = Filament
|
|
form_class = FilamentForm
|
|
template_name = 'bambu_run/filament_form.html'
|
|
success_url = reverse_lazy('bambu_run:filament_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['filament_type_map'] = json.dumps(_filament_type_map())
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
messages.success(self.request, f'Filament spool "{form.instance}" added successfully!')
|
|
return super().form_valid(form)
|
|
|
|
|
|
class FilamentUpdateView(LoginRequiredMixin, UpdateView):
|
|
model = Filament
|
|
form_class = FilamentForm
|
|
template_name = 'bambu_run/filament_form.html'
|
|
success_url = reverse_lazy('bambu_run:filament_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['filament_type_map'] = json.dumps(_filament_type_map())
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
messages.success(self.request, f'Filament spool "{form.instance}" updated successfully!')
|
|
return super().form_valid(form)
|
|
|
|
|
|
class FilamentDeleteView(LoginRequiredMixin, DeleteView):
|
|
model = Filament
|
|
template_name = 'bambu_run/filament_confirm_delete.html'
|
|
success_url = reverse_lazy('bambu_run:filament_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
return context
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
filament = self.get_object()
|
|
messages.success(self.request, f'Filament spool "{filament}" has been deleted.')
|
|
return super().delete(request, *args, **kwargs)
|
|
|
|
|
|
class FilamentDetailView(LoginRequiredMixin, DetailView):
|
|
model = Filament
|
|
template_name = 'bambu_run/filament_detail.html'
|
|
context_object_name = 'filament'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
filament = self.object
|
|
|
|
context['print_usages'] = filament.print_usages.select_related('print_job__cloud_task').order_by('-print_job__start_time')[:20]
|
|
|
|
total_consumed = filament.print_usages.aggregate(
|
|
total=Sum('consumed_percent')
|
|
)['total'] or 0
|
|
context['total_consumed_percent'] = total_consumed
|
|
|
|
return context
|
|
|
|
|
|
# ==================== FilamentColor Views ====================
|
|
|
|
class FilamentColorListView(LoginRequiredMixin, ListView):
|
|
model = FilamentColor
|
|
template_name = 'bambu_run/filament_color_list.html'
|
|
context_object_name = 'colors'
|
|
paginate_by = 50
|
|
|
|
def get_queryset(self):
|
|
return FilamentColor.objects.all().order_by('filament_type', 'filament_sub_type', 'color_name')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['total_colors'] = FilamentColor.objects.count()
|
|
return context
|
|
|
|
|
|
class FilamentColorCreateView(LoginRequiredMixin, CreateView):
|
|
model = FilamentColor
|
|
form_class = FilamentColorForm
|
|
template_name = 'bambu_run/filament_color_form.html'
|
|
success_url = reverse_lazy('bambu_run:filament_color_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
self._update_matching_filaments(self.object)
|
|
return response
|
|
|
|
def _update_matching_filaments(self, filament_color):
|
|
from .utils import match_and_update_filament_color
|
|
updated_count = match_and_update_filament_color(filament_color)
|
|
if updated_count > 0:
|
|
messages.success(
|
|
self.request,
|
|
f"Color '{filament_color.color_name}' created! "
|
|
f"Updated {updated_count} matching filament spool(s)."
|
|
)
|
|
|
|
|
|
class FilamentColorUpdateView(LoginRequiredMixin, UpdateView):
|
|
model = FilamentColor
|
|
form_class = FilamentColorForm
|
|
template_name = 'bambu_run/filament_color_form.html'
|
|
success_url = reverse_lazy('bambu_run:filament_color_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
self._update_matching_filaments(self.object)
|
|
return response
|
|
|
|
def _update_matching_filaments(self, filament_color):
|
|
from .utils import match_and_update_filament_color
|
|
updated_count = match_and_update_filament_color(filament_color)
|
|
if updated_count > 0:
|
|
messages.success(
|
|
self.request,
|
|
f"Color '{filament_color.color_name}' updated! "
|
|
f"Updated {updated_count} matching filament spool(s)."
|
|
)
|
|
|
|
|
|
class FilamentColorDeleteView(LoginRequiredMixin, DeleteView):
|
|
model = FilamentColor
|
|
template_name = 'bambu_run/filament_color_confirm_delete.html'
|
|
success_url = reverse_lazy('bambu_run:filament_color_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
return context
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
messages.success(request, f"Color '{self.get_object().color_name}' deleted successfully!")
|
|
return super().delete(request, *args, **kwargs)
|
|
|
|
|
|
# ==================== FilamentType Views ====================
|
|
|
|
class FilamentTypeListView(LoginRequiredMixin, ListView):
|
|
model = FilamentType
|
|
template_name = 'bambu_run/filament_type_list.html'
|
|
context_object_name = 'types'
|
|
paginate_by = 50
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['total_types'] = FilamentType.objects.count()
|
|
return context
|
|
|
|
|
|
class FilamentTypeCreateView(LoginRequiredMixin, CreateView):
|
|
model = FilamentType
|
|
form_class = FilamentTypeForm
|
|
template_name = 'bambu_run/filament_type_form.html'
|
|
success_url = reverse_lazy('bambu_run:filament_type_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['existing_types'] = list(
|
|
FilamentType.objects.values_list('type', flat=True).distinct().order_by('type')
|
|
)
|
|
context['existing_sub_types'] = list(
|
|
FilamentType.objects.exclude(sub_type__isnull=True).exclude(sub_type='')
|
|
.values_list('sub_type', flat=True).distinct().order_by('sub_type')
|
|
)
|
|
context['existing_brands'] = list(
|
|
FilamentType.objects.values_list('brand', flat=True).distinct().order_by('brand')
|
|
)
|
|
context['preset_types'] = FilamentTypeForm.PRESET_TYPES
|
|
context['preset_sub_types'] = FilamentTypeForm.PRESET_SUB_TYPES
|
|
context['preset_brands'] = FilamentTypeForm.PRESET_BRANDS
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
messages.success(self.request, f'Filament type "{form.instance}" added successfully!')
|
|
return super().form_valid(form)
|
|
|
|
|
|
class FilamentTypeUpdateView(LoginRequiredMixin, UpdateView):
|
|
model = FilamentType
|
|
form_class = FilamentTypeForm
|
|
template_name = 'bambu_run/filament_type_form.html'
|
|
success_url = reverse_lazy('bambu_run:filament_type_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
context['existing_types'] = list(
|
|
FilamentType.objects.values_list('type', flat=True).distinct().order_by('type')
|
|
)
|
|
context['existing_sub_types'] = list(
|
|
FilamentType.objects.exclude(sub_type__isnull=True).exclude(sub_type='')
|
|
.values_list('sub_type', flat=True).distinct().order_by('sub_type')
|
|
)
|
|
context['existing_brands'] = list(
|
|
FilamentType.objects.values_list('brand', flat=True).distinct().order_by('brand')
|
|
)
|
|
context['preset_types'] = FilamentTypeForm.PRESET_TYPES
|
|
context['preset_sub_types'] = FilamentTypeForm.PRESET_SUB_TYPES
|
|
context['preset_brands'] = FilamentTypeForm.PRESET_BRANDS
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
messages.success(self.request, f'Filament type "{form.instance}" updated successfully!')
|
|
return super().form_valid(form)
|
|
|
|
|
|
class FilamentTypeDeleteView(LoginRequiredMixin, DeleteView):
|
|
model = FilamentType
|
|
template_name = 'bambu_run/filament_type_confirm_delete.html'
|
|
success_url = reverse_lazy('bambu_run:filament_type_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['bambu_run_base_template'] = app_settings.BASE_TEMPLATE
|
|
return context
|
|
|
|
def delete(self, request, *args, **kwargs):
|
|
messages.success(request, f"Filament type '{self.get_object()}' deleted successfully!")
|
|
return super().delete(request, *args, **kwargs)
|