mirror of
https://github.com/RunLit/Bambu-Run.git
synced 2026-08-22 14:54:19 +01:00
Compare commits
17 Commits
58ebdf518e
...
v0.1.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a083fb94 | ||
|
|
1bbeef7a47 | ||
|
|
40ec163134 | ||
|
|
61cfd3f5a9 | ||
|
|
1e35c48be1 | ||
|
|
b16f4b3cac | ||
|
|
58400f16cf | ||
|
|
6f281a0cab | ||
|
|
4db0a7d728 | ||
|
|
c7ea0dd094 | ||
|
|
6f19560842 | ||
|
|
86619a807c | ||
|
|
1231dfafc9 | ||
|
|
2c7d6b8dba | ||
|
|
0a1da46d1f | ||
|
|
4c01b3d909 | ||
|
|
146d5af7aa |
@@ -22,19 +22,20 @@ RUN pip install --no-cache-dir bambu-lab-cloud-api --no-deps && \
|
|||||||
(d / 'INSTALLER').write_text('pip\n'); \
|
(d / 'INSTALLER').write_text('pip\n'); \
|
||||||
(d / 'RECORD').write_text('')"
|
(d / 'RECORD').write_text('')"
|
||||||
|
|
||||||
# Install project and remaining dependencies (pip sees opencv-python already satisfied)
|
# Install standalone dependencies first — cached unless pyproject.toml changes
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
RUN pip install --no-cache-dir ".[standalone,mcp]"
|
||||||
|
|
||||||
# Copy application code
|
# Copy full source and install the local package (no-deps: already installed above)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pip install --no-cache-dir --no-deps .
|
||||||
|
|
||||||
# Create data directory for SQLite
|
# Create data directory for SQLite
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Collect static files
|
# Collect static files from the locally installed package
|
||||||
ENV DJANGO_SETTINGS_MODULE=standalone.settings
|
ENV DJANGO_SETTINGS_MODULE=standalone.settings
|
||||||
RUN python standalone/manage.py collectstatic --noinput 2>/dev/null || true
|
RUN python standalone/manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Supervisor config to run both web and collector
|
# Supervisor config to run both web and collector
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask
|
from .models import Printer, PrinterMetrics, Filament, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, BambuCloudTask, Hotend, HotendSnapshot
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Printer)
|
@admin.register(Printer)
|
||||||
@@ -107,6 +107,21 @@ class FilamentUsageAdmin(admin.ModelAdmin):
|
|||||||
readonly_fields = ('consumed_percent', 'consumed_grams')
|
readonly_fields = ('consumed_percent', 'consumed_grams')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Hotend)
|
||||||
|
class HotendAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('printer', 'serial_number', 'nozzle_type', 'is_toolhead', 'slot_number', 'used_time_seconds', 'wear_percent', 'last_seen_at')
|
||||||
|
list_filter = ('printer', 'is_toolhead', 'nozzle_type')
|
||||||
|
search_fields = ('serial_number',)
|
||||||
|
readonly_fields = ('last_seen_at', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(HotendSnapshot)
|
||||||
|
class HotendSnapshotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'timestamp')
|
||||||
|
list_filter = ('hotend',)
|
||||||
|
readonly_fields = ('printer_metric', 'hotend', 'raw_id', 'used_time_seconds', 'wear_percent', 'stat', 'timestamp')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BambuCloudTask)
|
@admin.register(BambuCloudTask)
|
||||||
class BambuCloudTaskAdmin(admin.ModelAdmin):
|
class BambuCloudTaskAdmin(admin.ModelAdmin):
|
||||||
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
|
list_display = ('task_id', 'design_title', 'plate_title', 'device_serial', 'cloud_status', 'weight_grams', 'cloud_start_time', 'synced_at')
|
||||||
|
|||||||
@@ -81,5 +81,12 @@ class _Settings:
|
|||||||
def CLOUD_SYNC_DAYS(self):
|
def CLOUD_SYNC_DAYS(self):
|
||||||
return get_setting("BAMBU_RUN_CLOUD_SYNC_DAYS", 30)
|
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()
|
app_settings = _Settings()
|
||||||
|
|||||||
@@ -554,6 +554,8 @@ class Command(BaseCommand):
|
|||||||
printer_metric=printer_metric,
|
printer_metric=printer_metric,
|
||||||
filament=filament,
|
filament=filament,
|
||||||
tray_id=tray_id,
|
tray_id=tray_id,
|
||||||
|
ams_unit_id=unit_id_int,
|
||||||
|
ams_type=tray_data.get('ams_type', '') or '',
|
||||||
slot_name=tray_data.get('slot'),
|
slot_name=tray_data.get('slot'),
|
||||||
type=tray_data.get('type'),
|
type=tray_data.get('type'),
|
||||||
sub_type=tray_data.get('sub_type'),
|
sub_type=tray_data.get('sub_type'),
|
||||||
@@ -569,6 +571,38 @@ class Command(BaseCommand):
|
|||||||
match_method=match_method
|
match_method=match_method
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _update_hotends(self, printer, printer_metric, hotends_data):
|
||||||
|
from bambu_run.models import Hotend, HotendSnapshot
|
||||||
|
|
||||||
|
for h in hotends_data:
|
||||||
|
if h.get("is_empty"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
hotend, _ = Hotend.objects.update_or_create(
|
||||||
|
printer=printer,
|
||||||
|
serial_number=h.get("serial_number"),
|
||||||
|
defaults={
|
||||||
|
"raw_id": h.get("raw_id", 0),
|
||||||
|
"nozzle_type": h.get("nozzle_type", ""),
|
||||||
|
"diameter": self._to_decimal(h.get("diameter")),
|
||||||
|
"slot_number": h.get("slot_number"),
|
||||||
|
"is_toolhead": bool(h.get("is_toolhead")),
|
||||||
|
"last_filament_profile_id": h.get("fila_id", ""),
|
||||||
|
"last_color": h.get("color") or "",
|
||||||
|
"used_time_seconds": h.get("used_time_seconds", 0),
|
||||||
|
"wear_percent": h.get("wear_percent", 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
HotendSnapshot.objects.create(
|
||||||
|
printer_metric=printer_metric,
|
||||||
|
hotend=hotend,
|
||||||
|
raw_id=h.get("raw_id", 0),
|
||||||
|
used_time_seconds=h.get("used_time_seconds", 0),
|
||||||
|
wear_percent=h.get("wear_percent", 0),
|
||||||
|
stat=h.get("stat"),
|
||||||
|
)
|
||||||
|
|
||||||
def _track_print_job(self, session, metric, snapshot):
|
def _track_print_job(self, session, metric, snapshot):
|
||||||
from bambu_run.models import PrintJob
|
from bambu_run.models import PrintJob
|
||||||
|
|
||||||
@@ -641,31 +675,40 @@ class Command(BaseCommand):
|
|||||||
elif not session.trays_used:
|
elif not session.trays_used:
|
||||||
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
|
logger.warning(f"No trays tracked for job {job.project_name}, skipping filament usage")
|
||||||
else:
|
else:
|
||||||
|
# A bare tray_id (from `tray_now`) doesn't identify which physical AMS
|
||||||
|
# unit was active when multiple units share the same slot numbering —
|
||||||
|
# so create one usage row per (unit, tray) that had a tracked filament
|
||||||
|
# loaded at job start, rather than guessing a single "correct" unit.
|
||||||
|
created_usages = []
|
||||||
for tray_id in session.trays_used:
|
for tray_id in session.trays_used:
|
||||||
start_snap = start_metric.filament_snapshots.filter(
|
start_snaps = start_metric.filament_snapshots.filter(
|
||||||
tray_id=tray_id, filament__isnull=False
|
tray_id=tray_id, filament__isnull=False
|
||||||
).first()
|
|
||||||
if not start_snap:
|
|
||||||
continue
|
|
||||||
|
|
||||||
end_snap = metric.filament_snapshots.filter(
|
|
||||||
filament=start_snap.filament, tray_id=tray_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
usage = FilamentUsage.objects.create(
|
|
||||||
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(session.trays_used) == 1),
|
|
||||||
)
|
)
|
||||||
usage.calculate_consumed()
|
for start_snap in start_snaps:
|
||||||
|
end_snap = metric.filament_snapshots.filter(
|
||||||
|
filament=start_snap.filament,
|
||||||
|
tray_id=tray_id,
|
||||||
|
ams_unit_id=start_snap.ams_unit_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
usage = FilamentUsage.objects.create(
|
||||||
|
print_job=job,
|
||||||
|
filament=start_snap.filament,
|
||||||
|
tray_id=tray_id,
|
||||||
|
ams_unit_id=start_snap.ams_unit_id,
|
||||||
|
starting_percent=start_snap.remain_percent or 100,
|
||||||
|
ending_percent=end_snap.remain_percent if end_snap else None,
|
||||||
|
)
|
||||||
|
usage.calculate_consumed()
|
||||||
|
created_usages.append(usage)
|
||||||
|
|
||||||
|
for usage in created_usages:
|
||||||
|
usage.is_primary = len(created_usages) == 1
|
||||||
usage.save()
|
usage.save()
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Filament usage for {start_snap.filament} (tray {tray_id}): "
|
f"Filament usage for {usage.filament} (unit {usage.ams_unit_id}, tray {usage.tray_id}): "
|
||||||
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
|
f"{usage.starting_percent}% -> {usage.ending_percent}%, consumed {usage.consumed_percent}%"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -746,12 +789,17 @@ class Command(BaseCommand):
|
|||||||
external_spool=snapshot.get("external_spool", {}),
|
external_spool=snapshot.get("external_spool", {}),
|
||||||
lights_report=snapshot.get("lights_report", []),
|
lights_report=snapshot.get("lights_report", []),
|
||||||
vortek_raw=snapshot.get("vortek_raw", {}),
|
vortek_raw=snapshot.get("vortek_raw", {}),
|
||||||
|
nozzle_info=snapshot.get("hotends", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
filaments_data = snapshot.get('filaments', [])
|
filaments_data = snapshot.get('filaments', [])
|
||||||
if filaments_data:
|
if filaments_data:
|
||||||
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
self._create_filament_snapshots(metric, filaments_data, snapshot)
|
||||||
|
|
||||||
|
hotends_data = snapshot.get('hotends', [])
|
||||||
|
if hotends_data:
|
||||||
|
self._update_hotends(session.printer, metric, hotends_data)
|
||||||
|
|
||||||
self._track_print_job(session, metric, snapshot)
|
self._track_print_job(session, metric, snapshot)
|
||||||
|
|
||||||
session.success_count += 1
|
session.success_count += 1
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-06-20 12:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bambu_run", "0005_printermetrics_vortek_raw"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="filamentsnapshot",
|
||||||
|
options={
|
||||||
|
"ordering": ["printer_metric", "ams_unit_id", "tray_id"],
|
||||||
|
"verbose_name": "Filament Snapshot",
|
||||||
|
"verbose_name_plural": "Filament Snapshots",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="filamentsnapshot",
|
||||||
|
name="ams_type",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("AMS", "AMS"),
|
||||||
|
("AMS 2 Pro", "AMS 2 Pro"),
|
||||||
|
("AMS HT", "AMS HT"),
|
||||||
|
],
|
||||||
|
default="",
|
||||||
|
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="filamentsnapshot",
|
||||||
|
name="ams_unit_id",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="filamentusage",
|
||||||
|
name="ams_unit_id",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="filamentsnapshot",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["printer_metric", "ams_unit_id", "tray_id"],
|
||||||
|
name="infrastruct_printer_2ad168_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
172
bambu_run/migrations/0007_hotend_hotendsnapshot.py
Normal file
172
bambu_run/migrations/0007_hotend_hotendsnapshot.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-06-20 14:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bambu_run", "0006_alter_filamentsnapshot_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Hotend",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("serial_number", models.CharField(db_index=True, max_length=100)),
|
||||||
|
(
|
||||||
|
"nozzle_type",
|
||||||
|
models.CharField(blank=True, default="", max_length=50),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"diameter",
|
||||||
|
models.DecimalField(
|
||||||
|
blank=True, decimal_places=2, max_digits=3, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"raw_id",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
help_text="Last-seen MQTT device.nozzle.info[].id"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"slot_number",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel).",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_toolhead",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0).",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_filament_profile_id",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_color",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)",
|
||||||
|
max_length=6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("used_time_seconds", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"wear_percent",
|
||||||
|
models.DecimalField(
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent",
|
||||||
|
max_digits=5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("last_seen_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"printer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="hotends",
|
||||||
|
to="bambu_run.printer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Hotend",
|
||||||
|
"verbose_name_plural": "Hotends",
|
||||||
|
"db_table": "infrastructure_hotend",
|
||||||
|
"ordering": ["printer", "-is_toolhead", "slot_number", "serial_number"],
|
||||||
|
"unique_together": {("printer", "serial_number")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="HotendSnapshot",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("raw_id", models.PositiveSmallIntegerField()),
|
||||||
|
("used_time_seconds", models.PositiveIntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"wear_percent",
|
||||||
|
models.DecimalField(decimal_places=2, default=0, max_digits=5),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"stat",
|
||||||
|
models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Raw MQTT status code for this hotend",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timestamp",
|
||||||
|
models.DateTimeField(
|
||||||
|
db_index=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hotend",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="snapshots",
|
||||||
|
to="bambu_run.hotend",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"printer_metric",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="hotend_snapshots",
|
||||||
|
to="bambu_run.printermetrics",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Hotend Snapshot",
|
||||||
|
"verbose_name_plural": "Hotend Snapshots",
|
||||||
|
"db_table": "infrastructure_hotend_snapshot",
|
||||||
|
"ordering": ["printer_metric", "hotend"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["printer_metric", "hotend"],
|
||||||
|
name="infrastruct_printer_b528aa_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["hotend", "-timestamp"],
|
||||||
|
name="infrastruct_hotend__691f7e_idx",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
22
bambu_run/migrations/0008_printermetrics_nozzle_info.py
Normal file
22
bambu_run/migrations/0008_printermetrics_nozzle_info.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-06-20 14:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bambu_run", "0007_hotend_hotendsnapshot"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="printermetrics",
|
||||||
|
name="nozzle_info",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
help_text="Parsed per-poll nozzle/hotend info list",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
77
bambu_run/migrations/0009_printer_category.py
Normal file
77
bambu_run/migrations/0009_printer_category.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -8,7 +8,8 @@ from django.utils import timezone
|
|||||||
AMS_INFO_TO_TYPE = {
|
AMS_INFO_TO_TYPE = {
|
||||||
"1001": "AMS",
|
"1001": "AMS",
|
||||||
"1003": "AMS 2 Pro",
|
"1003": "AMS 2 Pro",
|
||||||
"2104": "AMS HT",
|
"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)
|
||||||
}
|
}
|
||||||
|
|
||||||
AMS_TYPE_CHOICES = [
|
AMS_TYPE_CHOICES = [
|
||||||
@@ -21,19 +22,44 @@ AMS_TYPE_CHOICES = [
|
|||||||
def ams_type_from_info(info_code) -> str:
|
def ams_type_from_info(info_code) -> str:
|
||||||
"""Resolve an AMS unit's `info` model code to a human label.
|
"""Resolve an AMS unit's `info` model code to a human label.
|
||||||
|
|
||||||
The HT unit reports its `id` with the 0x80 bit set (e.g. 128) — when the info
|
Real MQTT `info` codes are 8 characters (e.g. "10001003") with the type encoded
|
||||||
code is unknown, that bit is a reasonable secondary hint for HT identification.
|
in the last 4 digits — confirmed against a live H2C with AMS 2 Pro / AMS / AMS HT.
|
||||||
|
Fall back to an exact match for the bare 4-digit form in case other firmware
|
||||||
|
reports it short.
|
||||||
"""
|
"""
|
||||||
if info_code is None:
|
if not info_code:
|
||||||
return ""
|
return ""
|
||||||
return AMS_INFO_TO_TYPE.get(str(info_code), "")
|
code = str(info_code)
|
||||||
|
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):
|
class Printer(models.Model):
|
||||||
"""Represents a Bambu Lab 3D printer device"""
|
"""Represents a Bambu Lab 3D printer device"""
|
||||||
|
|
||||||
|
CATEGORY_3D_PRINTER = "threed_printer"
|
||||||
|
|
||||||
name = models.CharField(max_length=200, help_text="Friendly device name")
|
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)")
|
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(
|
manufacturer = models.CharField(
|
||||||
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
|
max_length=100, default="Bambu Lab", help_text="e.g., Bambu Lab"
|
||||||
)
|
)
|
||||||
@@ -48,11 +74,19 @@ class Printer(models.Model):
|
|||||||
first_seen = models.DateTimeField(auto_now_add=True)
|
first_seen = models.DateTimeField(auto_now_add=True)
|
||||||
last_updated = models.DateTimeField(auto_now=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:
|
class Meta:
|
||||||
db_table = "infrastructure_device"
|
db_table = "infrastructure_device"
|
||||||
verbose_name = "Printer"
|
verbose_name = "Printer"
|
||||||
verbose_name_plural = "Printers"
|
verbose_name_plural = "Printers"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
base_manager_name = "all_objects"
|
||||||
|
default_manager_name = "objects"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.model})"
|
return f"{self.name} ({self.model})"
|
||||||
@@ -239,6 +273,15 @@ class PrinterMetrics(models.Model):
|
|||||||
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
|
default=dict, blank=True, help_text="Raw print.device MQTT payload (Vortek rack groundwork)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parsed device.nozzle.info[] from this poll, one dict per entry (mirrors
|
||||||
|
# HotendInfo.to_dict()). Includes induction-chip hotends *and* non-inductive
|
||||||
|
# nozzle positions (e.g. H2C's fixed left nozzle) that have no stable serial
|
||||||
|
# number to key a Hotend registry row on — kept here so the dashboard can show
|
||||||
|
# their readable type/diameter without claiming an identity/history we don't have.
|
||||||
|
nozzle_info = models.JSONField(
|
||||||
|
default=list, blank=True, help_text="Parsed per-poll nozzle/hotend info list"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "infrastructure_printer_metrics"
|
db_table = "infrastructure_printer_metrics"
|
||||||
verbose_name = "Printer Metric"
|
verbose_name = "Printer Metric"
|
||||||
@@ -498,6 +541,15 @@ class FilamentSnapshot(models.Model):
|
|||||||
max_length=20, null=True, blank=True,
|
max_length=20, null=True, blank=True,
|
||||||
help_text="Slot identifier like A00-W1"
|
help_text="Slot identifier like A00-W1"
|
||||||
)
|
)
|
||||||
|
ams_unit_id = models.PositiveSmallIntegerField(
|
||||||
|
null=True, blank=True, db_index=True,
|
||||||
|
help_text="Which physical AMS unit this tray belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||||
|
)
|
||||||
|
ams_type = models.CharField(
|
||||||
|
max_length=32, blank=True, default="",
|
||||||
|
choices=AMS_TYPE_CHOICES,
|
||||||
|
help_text="Type of the AMS unit this tray belongs to (AMS / AMS 2 Pro / AMS HT)"
|
||||||
|
)
|
||||||
|
|
||||||
type = models.CharField(max_length=50, null=True, blank=True)
|
type = models.CharField(max_length=50, null=True, blank=True)
|
||||||
sub_type = models.CharField(
|
sub_type = models.CharField(
|
||||||
@@ -545,9 +597,10 @@ class FilamentSnapshot(models.Model):
|
|||||||
db_table = "infrastructure_filament_snapshot"
|
db_table = "infrastructure_filament_snapshot"
|
||||||
verbose_name = "Filament Snapshot"
|
verbose_name = "Filament Snapshot"
|
||||||
verbose_name_plural = "Filament Snapshots"
|
verbose_name_plural = "Filament Snapshots"
|
||||||
ordering = ['printer_metric', 'tray_id']
|
ordering = ['printer_metric', 'ams_unit_id', 'tray_id']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['printer_metric', 'tray_id']),
|
models.Index(fields=['printer_metric', 'tray_id']),
|
||||||
|
models.Index(fields=['printer_metric', 'ams_unit_id', 'tray_id']),
|
||||||
models.Index(fields=['filament']),
|
models.Index(fields=['filament']),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -686,6 +739,10 @@ class FilamentUsage(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
tray_id = models.IntegerField(help_text="Which AMS slot was used")
|
tray_id = models.IntegerField(help_text="Which AMS slot was used")
|
||||||
|
ams_unit_id = models.PositiveSmallIntegerField(
|
||||||
|
null=True, blank=True, db_index=True,
|
||||||
|
help_text="Which physical AMS unit the slot belongs to (matches MQTT ams[i].id; 128 = AMS HT)"
|
||||||
|
)
|
||||||
|
|
||||||
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
|
starting_percent = models.IntegerField(help_text="Filament remaining % at job start")
|
||||||
ending_percent = models.IntegerField(
|
ending_percent = models.IntegerField(
|
||||||
@@ -723,3 +780,108 @@ class FilamentUsage(models.Model):
|
|||||||
self.consumed_grams = int(
|
self.consumed_grams = int(
|
||||||
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
|
self.filament.initial_weight_grams * (self.consumed_percent / 100.0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Hotend(models.Model):
|
||||||
|
"""Registry of individual Vortek hotends, keyed by serial number.
|
||||||
|
|
||||||
|
A Vortek rack holds up to 6 swappable hotends (bays, MQTT `id` 16-21) plus
|
||||||
|
1 mounted on the toolhead at a time (MQTT `id` 0). `raw_id` reflects whichever
|
||||||
|
address was last seen on the wire for this hotend; `slot_number` is only set
|
||||||
|
when that address falls in the 16-21 rack-bay range — confirmed by watching
|
||||||
|
a "Read All" MQTT capture reassign a toolhead-mounted hotend's id from 0 to
|
||||||
|
its true bay id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
printer = models.ForeignKey(
|
||||||
|
'Printer', on_delete=models.CASCADE, related_name='hotends'
|
||||||
|
)
|
||||||
|
serial_number = models.CharField(max_length=100, db_index=True)
|
||||||
|
|
||||||
|
nozzle_type = models.CharField(max_length=50, blank=True, default="")
|
||||||
|
diameter = models.DecimalField(
|
||||||
|
max_digits=3, decimal_places=2, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_id = models.PositiveSmallIntegerField(
|
||||||
|
help_text="Last-seen MQTT device.nozzle.info[].id"
|
||||||
|
)
|
||||||
|
slot_number = models.PositiveSmallIntegerField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="Rack bay 1-6, derived from raw_id 16-21. Null if currently unknown (e.g. mounted on toolhead and id reports as the 0 sentinel)."
|
||||||
|
)
|
||||||
|
is_toolhead = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if currently mounted on the toolhead under normal polling (raw_id == 0)."
|
||||||
|
)
|
||||||
|
|
||||||
|
last_filament_profile_id = models.CharField(
|
||||||
|
max_length=20, blank=True, default="",
|
||||||
|
help_text="Bambu material profile id of the filament last loaded (MQTT fila_id, e.g. 'GFA01')"
|
||||||
|
)
|
||||||
|
last_color = models.CharField(
|
||||||
|
max_length=6, blank=True, default="",
|
||||||
|
help_text="6-char hex of the filament last loaded (MQTT color_m, alpha stripped)"
|
||||||
|
)
|
||||||
|
|
||||||
|
used_time_seconds = models.PositiveIntegerField(default=0)
|
||||||
|
wear_percent = models.DecimalField(
|
||||||
|
max_digits=5, decimal_places=2, default=0,
|
||||||
|
help_text="MQTT wear (0-128 scale) converted to a 0-100 percent"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_seen_at = models.DateTimeField(auto_now=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_hotend"
|
||||||
|
verbose_name = "Hotend"
|
||||||
|
verbose_name_plural = "Hotends"
|
||||||
|
ordering = ['printer', '-is_toolhead', 'slot_number', 'serial_number']
|
||||||
|
unique_together = [['printer', 'serial_number']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
location = "Toolhead" if self.is_toolhead else (
|
||||||
|
f"Slot {self.slot_number}" if self.slot_number else "Rack"
|
||||||
|
)
|
||||||
|
return f"{self.serial_number} ({location})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def used_time_display(self) -> str:
|
||||||
|
hours, remainder = divmod(self.used_time_seconds, 3600)
|
||||||
|
minutes = remainder // 60
|
||||||
|
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
class HotendSnapshot(models.Model):
|
||||||
|
"""Point-in-time reading of a Hotend, one row per collector poll."""
|
||||||
|
|
||||||
|
printer_metric = models.ForeignKey(
|
||||||
|
'PrinterMetrics', on_delete=models.CASCADE,
|
||||||
|
related_name='hotend_snapshots'
|
||||||
|
)
|
||||||
|
hotend = models.ForeignKey(
|
||||||
|
'Hotend', on_delete=models.CASCADE,
|
||||||
|
related_name='snapshots'
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_id = models.PositiveSmallIntegerField()
|
||||||
|
used_time_seconds = models.PositiveIntegerField(default=0)
|
||||||
|
wear_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
|
||||||
|
stat = models.IntegerField(
|
||||||
|
null=True, blank=True, help_text="Raw MQTT status code for this hotend"
|
||||||
|
)
|
||||||
|
timestamp = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "infrastructure_hotend_snapshot"
|
||||||
|
verbose_name = "Hotend Snapshot"
|
||||||
|
verbose_name_plural = "Hotend Snapshots"
|
||||||
|
ordering = ['printer_metric', 'hotend']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['printer_metric', 'hotend']),
|
||||||
|
models.Index(fields=['hotend', '-timestamp']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.hotend.serial_number} @ {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
import select
|
import select
|
||||||
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -296,6 +297,73 @@ class AMSState:
|
|||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HotendInfo:
|
||||||
|
"""A single hotend reported in `device.nozzle.info[]` (Vortek rack).
|
||||||
|
|
||||||
|
`raw_id` semantics (confirmed by watching a live "Read All" MQTT capture):
|
||||||
|
0 = currently mounted on the (swappable) toolhead — the sentinel hides the
|
||||||
|
true bay address until "Read All" resolves it; 1 = the fixed left nozzle on
|
||||||
|
dual-nozzle printers (no RFID chip, always reports sn="N/A"); 16-21 = rack
|
||||||
|
bay address, slot_number = raw_id - 15 (1-6).
|
||||||
|
"""
|
||||||
|
raw_id: int = 0
|
||||||
|
serial_number: str = ""
|
||||||
|
nozzle_type: str = ""
|
||||||
|
diameter: float = 0.4
|
||||||
|
fila_id: str = ""
|
||||||
|
color: Optional[str] = None
|
||||||
|
used_time_seconds: int = 0
|
||||||
|
wear_percent: float = 0.0
|
||||||
|
stat: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "HotendInfo":
|
||||||
|
from .utils import strip_color_padding
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
raw_id=int(data.get("id", 0)),
|
||||||
|
serial_number=data.get("sn", ""),
|
||||||
|
nozzle_type=data.get("type", ""),
|
||||||
|
diameter=float(data.get("diameter", 0.4)),
|
||||||
|
fila_id=data.get("fila_id", ""),
|
||||||
|
color=strip_color_padding(data.get("color_m")),
|
||||||
|
used_time_seconds=int(data.get("p_t", 0)),
|
||||||
|
wear_percent=round(float(data.get("wear", 0.0)) / 128.0 * 100, 2),
|
||||||
|
stat=int(data.get("stat", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_toolhead(self) -> bool:
|
||||||
|
return self.raw_id == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return self.serial_number in ("", "N/A")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slot_number(self) -> Optional[int]:
|
||||||
|
if 16 <= self.raw_id <= 21:
|
||||||
|
return self.raw_id - 15
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"raw_id": self.raw_id,
|
||||||
|
"serial_number": self.serial_number,
|
||||||
|
"nozzle_type": self.nozzle_type,
|
||||||
|
"diameter": self.diameter,
|
||||||
|
"fila_id": self.fila_id,
|
||||||
|
"color": self.color,
|
||||||
|
"used_time_seconds": self.used_time_seconds,
|
||||||
|
"wear_percent": self.wear_percent,
|
||||||
|
"stat": self.stat,
|
||||||
|
"is_toolhead": self.is_toolhead,
|
||||||
|
"is_empty": self.is_empty,
|
||||||
|
"slot_number": self.slot_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PrinterState:
|
class PrinterState:
|
||||||
"""Complete printer state parsed from MQTT data"""
|
"""Complete printer state parsed from MQTT data"""
|
||||||
@@ -388,6 +456,9 @@ class PrinterState:
|
|||||||
# External spool (virtual tray)
|
# External spool (virtual tray)
|
||||||
vt_tray: Optional[Dict[str, Any]] = None
|
vt_tray: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Vortek hotend rack (device.nozzle.info[])
|
||||||
|
hotends: List[HotendInfo] = field(default_factory=list)
|
||||||
|
|
||||||
# Raw data for any additional fields
|
# Raw data for any additional fields
|
||||||
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
_raw_data: Dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
@@ -416,26 +487,52 @@ class PrinterState:
|
|||||||
|
|
||||||
wifi_signal = print_data.get("wifi_signal", "")
|
wifi_signal = print_data.get("wifi_signal", "")
|
||||||
|
|
||||||
# H2C dual-nozzle decoding. The H2C reports per-extruder temperatures
|
# Dual-nozzle decoding (H2C, X2D). Dual-nozzle printers report per-extruder
|
||||||
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
|
# temperatures under `print.device.extruder.info[]` as a 2-element array
|
||||||
# right, index 1 = left). The `temp` field is bit-packed:
|
# (index 0 = right, index 1 = left). The `temp` field is bit-packed:
|
||||||
# `temp_raw = (target << 16) | current`, both °C as ints.
|
# `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_temp_left = None
|
||||||
nozzle_target_temp_left = None
|
nozzle_target_temp_left = None
|
||||||
|
nozzle_temp_right = None
|
||||||
|
nozzle_target_temp_right = None
|
||||||
device = print_data.get("device") or {}
|
device = print_data.get("device") or {}
|
||||||
extruders = (device.get("extruder") or {}).get("info") or []
|
extruders = (device.get("extruder") or {}).get("info") or []
|
||||||
if len(extruders) >= 2:
|
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]
|
left = extruders[1]
|
||||||
t = left.get("temp")
|
t = left.get("temp")
|
||||||
if isinstance(t, int):
|
if isinstance(t, int):
|
||||||
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
|
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
|
||||||
nozzle_temp_left = float(t & 0xFFFF)
|
nozzle_temp_left = float(t & 0xFFFF)
|
||||||
|
|
||||||
|
# Vortek hotend rack: device.nozzle.info[] — one entry per hotend.
|
||||||
|
hotends = [
|
||||||
|
HotendInfo.from_dict(h)
|
||||||
|
for h in (device.get("nozzle") or {}).get("info") or []
|
||||||
|
]
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
sequence_id=str(print_data.get("sequence_id", "")),
|
sequence_id=str(print_data.get("sequence_id", "")),
|
||||||
nozzle_temp=float(print_data.get("nozzle_temper", 0.0)),
|
nozzle_temp=(
|
||||||
nozzle_target_temp=float(print_data.get("nozzle_target_temper", 0.0)),
|
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))
|
||||||
|
),
|
||||||
bed_temp=float(print_data.get("bed_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)),
|
bed_target_temp=float(print_data.get("bed_target_temper", 0.0)),
|
||||||
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
|
chamber_temp=float(print_data.get("chamber_temper", 0.0)),
|
||||||
@@ -487,6 +584,7 @@ class PrinterState:
|
|||||||
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
gcode_file_prepare_percent=str(print_data.get("gcode_file_prepare_percent", "")),
|
||||||
lifecycle=print_data.get("lifecycle", ""),
|
lifecycle=print_data.get("lifecycle", ""),
|
||||||
vt_tray=print_data.get("vt_tray"),
|
vt_tray=print_data.get("vt_tray"),
|
||||||
|
hotends=hotends,
|
||||||
_raw_data=data,
|
_raw_data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -533,6 +631,7 @@ class PrinterState:
|
|||||||
# hotends + 1 fixed left nozzle) isn't fully modeled yet — stash everything
|
# 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.
|
# here so no data is lost once the real Vortek MQTT schema is confirmed.
|
||||||
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
|
"vortek_raw": self._raw_data.get("print", {}).get("device", {}),
|
||||||
|
"hotends": [h.to_dict() for h in self.hotends],
|
||||||
"hms": self.hms,
|
"hms": self.hms,
|
||||||
"stg_cur": self.stg_cur,
|
"stg_cur": self.stg_cur,
|
||||||
"lights_report": self.lights_report,
|
"lights_report": self.lights_report,
|
||||||
@@ -731,6 +830,7 @@ class BambuPrinter:
|
|||||||
self._accumulator = PrinterStateAccumulator()
|
self._accumulator = PrinterStateAccumulator()
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._devices: List[Dict[str, Any]] = []
|
self._devices: List[Dict[str, Any]] = []
|
||||||
|
self._last_message_at: Optional[float] = None
|
||||||
|
|
||||||
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
def _get_fresh_token(self, verification_code_timeout: int = 300) -> str:
|
||||||
"""Get a fresh token using credentials."""
|
"""Get a fresh token using credentials."""
|
||||||
@@ -836,6 +936,30 @@ class BambuPrinter:
|
|||||||
"""Internal MQTT message handler"""
|
"""Internal MQTT message handler"""
|
||||||
if not data:
|
if not data:
|
||||||
return
|
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)
|
state = self._accumulator.update(data)
|
||||||
if self._on_update:
|
if self._on_update:
|
||||||
self._on_update(state)
|
self._on_update(state)
|
||||||
|
|||||||
@@ -64,3 +64,78 @@
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AMS unit type colors — CSS variables so RAE/standalone can override per theme */
|
||||||
|
:root {
|
||||||
|
--ams-badge-ams: #6c757d;
|
||||||
|
--ams-badge-ams-2-pro: #0d6efd;
|
||||||
|
--ams-badge-ams-ht: #fd7e14;
|
||||||
|
--ams-group-border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-coreui-theme="dark"] {
|
||||||
|
--ams-group-border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-ams {
|
||||||
|
background-color: var(--ams-badge-ams);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-ams-2-pro {
|
||||||
|
background-color: var(--ams-badge-ams-2-pro);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-ams-ht {
|
||||||
|
background-color: var(--ams-badge-ams-ht);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-filter-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-filter-pill {
|
||||||
|
border-radius: 50rem;
|
||||||
|
padding: 0.25rem 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid var(--ams-group-border-color);
|
||||||
|
background-color: transparent;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-filter-pill.active {
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
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. */
|
||||||
|
.ams-group {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--ams-group-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-bg-ams {
|
||||||
|
background-color: color-mix(in srgb, var(--ams-badge-ams) 8%, transparent);
|
||||||
|
border-left: 3px solid var(--ams-badge-ams);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-bg-ams-2-pro {
|
||||||
|
background-color: color-mix(in srgb, var(--ams-badge-ams-2-pro) 8%, transparent);
|
||||||
|
border-left: 3px solid var(--ams-badge-ams-2-pro);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-bg-ams-ht {
|
||||||
|
background-color: color-mix(in srgb, var(--ams-badge-ams-ht) 8%, transparent);
|
||||||
|
border-left: 3px solid var(--ams-badge-ams-ht);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ams-badge-bg- {
|
||||||
|
border-left: 3px solid var(--ams-group-border-color);
|
||||||
|
}
|
||||||
|
|||||||
@@ -625,8 +625,23 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
|
|||||||
data: filamentTimeline[key]
|
data: filamentTimeline[key]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort by tray_id (numeric first, External last), then by start_idx (chronological)
|
// Distinct (non-null/undefined) AMS units present in this timeline — used to decide
|
||||||
|
// whether labels need an AMS unit prefix (avoid noise for the common single-AMS case).
|
||||||
|
const distinctUnits = new Set(
|
||||||
|
filamentEntries
|
||||||
|
.map(e => e.data.ams_unit_id)
|
||||||
|
.filter(uid => uid !== null && uid !== undefined)
|
||||||
|
);
|
||||||
|
const showUnitPrefix = distinctUnits.size > 1;
|
||||||
|
|
||||||
|
// Sort by AMS unit, then tray_id (numeric first, External last), then by start_idx
|
||||||
filamentEntries.sort((a, b) => {
|
filamentEntries.sort((a, b) => {
|
||||||
|
const unitA = a.data.ams_unit_id ?? -1;
|
||||||
|
const unitB = b.data.ams_unit_id ?? -1;
|
||||||
|
if (unitA !== unitB) {
|
||||||
|
return unitA - unitB;
|
||||||
|
}
|
||||||
|
|
||||||
const trayA = a.data.tray_id;
|
const trayA = a.data.tray_id;
|
||||||
const trayB = b.data.tray_id;
|
const trayB = b.data.tray_id;
|
||||||
|
|
||||||
@@ -659,6 +674,10 @@ function createFilamentDatasets(filamentTimeline, timestamps) {
|
|||||||
displayLabel = `Tray ${filament.tray_id} (${filament.type})`;
|
displayLabel = `Tray ${filament.tray_id} (${filament.type})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showUnitPrefix && filament.ams_type) {
|
||||||
|
displayLabel = `${filament.ams_type} · ${displayLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Add brand if it's different from type (avoid redundancy)
|
// Add brand if it's different from type (avoid redundancy)
|
||||||
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
|
if (filament.brand && filament.brand !== filament.type && filament.brand !== 'External') {
|
||||||
displayLabel += ` - ${filament.brand}`;
|
displayLabel += ` - ${filament.brand}`;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-end">
|
<div class="col-md-4 text-end">
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<a href="{% url 'bambu_run:filament_color_create' %}" class="btn btn-primary">
|
<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 %}">
|
||||||
<i class="bi bi-plus-circle"></i> Add New Color
|
<i class="bi bi-plus-circle"></i> Add New Color
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
<td class="align-middle">{{ color.brand }}</td>
|
<td class="align-middle">{{ color.brand }}</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<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_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">Delete</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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-warning">Edit</a>
|
<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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a>
|
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-secondary">Back to List</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-outline-info me-2">
|
<a href="{% url 'bambu_run:filament_color_list' %}" class="btn btn-outline-info me-2">
|
||||||
<i class="bi bi-palette"></i> Manage Colors
|
<i class="bi bi-palette"></i> Manage Colors
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'bambu_run:filament_create' %}" class="btn btn-primary">
|
<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 %}">
|
||||||
<i class="bi bi-plus-circle"></i> Add Filament
|
<i class="bi bi-plus-circle"></i> Add Filament
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
<a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a>
|
<a href="{% url 'bambu_run:filament_detail' filament.pk %}" class="btn btn-sm btn-info">View</a>
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<a href="{% url 'bambu_run:filament_update' filament.pk %}" class="btn btn-sm btn-warning">Edit</a>
|
<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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-end">
|
<div class="col-md-4 text-end">
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<a href="{% url 'bambu_run:filament_type_create' %}" class="btn btn-primary">
|
<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 %}">
|
||||||
<i class="bi bi-plus-circle"></i> Add New Type
|
<i class="bi bi-plus-circle"></i> Add New Type
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
<td class="align-middle">{{ ft.brand }}</td>
|
<td class="align-middle">{{ ft.brand }}</td>
|
||||||
<td class="align-middle">
|
<td class="align-middle">
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<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_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">Delete</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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -37,32 +37,38 @@
|
|||||||
<!-- Summary Cards Row -->
|
<!-- Summary Cards Row -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
{% if stats.is_dual_nozzle %}
|
{% if stats.is_dual_nozzle %}
|
||||||
<!-- Right Nozzle (dual-nozzle printers, e.g. H2C) -->
|
<!-- Left Nozzle (dual-nozzle printers, e.g. H2C) -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-warning">
|
<div class="card infra-card-warning h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-label">Right Nozzle</div>
|
<div class="stat-label d-flex align-items-center gap-1">
|
||||||
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}°C</div>
|
<svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-left"></use></svg>
|
||||||
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°C
|
Left Nozzle
|
||||||
{% if stats.nozzle_type %}· {{ stats.nozzle_type }}{% endif %}</div>
|
</div>
|
||||||
|
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}°C</div>
|
||||||
|
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°C
|
||||||
|
{% if stats.nozzle_type_left %}· Nozzle {{ stats.nozzle_type_left }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Left Nozzle -->
|
<!-- Right Nozzle -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-warning">
|
<div class="card infra-card-warning h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-label">Left Nozzle</div>
|
<div class="stat-label d-flex align-items-center gap-1">
|
||||||
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}°C</div>
|
Right Nozzle
|
||||||
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}°C
|
<svg class="icon" style="width: 1.25rem; height: 1.25rem;"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-arrow-thick-right"></use></svg>
|
||||||
{% if stats.nozzle_type_left %}· {{ stats.nozzle_type_left }}{% endif %}</div>
|
</div>
|
||||||
|
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}°C</div>
|
||||||
|
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}°C
|
||||||
|
{% if stats.nozzle_type %}· Nozzle {{ stats.nozzle_type }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +78,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<!-- Nozzle Temperature Card (single-nozzle printers) -->
|
<!-- Nozzle Temperature Card (single-nozzle printers) -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-warning">
|
<div class="card infra-card-warning h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -88,7 +94,7 @@
|
|||||||
|
|
||||||
<!-- Bed Temperature Card -->
|
<!-- Bed Temperature Card -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-danger">
|
<div class="card infra-card-danger h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -103,7 +109,7 @@
|
|||||||
|
|
||||||
<!-- Print Progress Card -->
|
<!-- Print Progress Card -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card infra-card-info">
|
<div class="card infra-card-info h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -118,7 +124,7 @@
|
|||||||
|
|
||||||
<!-- Chamber Light Card -->
|
<!-- Chamber Light Card -->
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
|
<div class="card h-100 {% if stats.chamber_light == 'on' %}infra-card-success{% else %}infra-card-secondary{% endif %}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -158,37 +164,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 }}°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 -->
|
<!-- Filaments Section -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -198,34 +173,59 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if stats.filaments %}
|
{% if stats.filaments %}
|
||||||
<div class="row g-3">
|
{% if stats.ams_units|length > 1 %}
|
||||||
{% for filament in stats.filaments %}
|
<div class="ams-filter-pills mb-3" id="amsFilterPills">
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<button type="button" class="btn ams-filter-pill active" data-ams-filter="all">All</button>
|
||||||
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}>
|
{% for unit in stats.ams_units %}
|
||||||
<div class="card-body">
|
<button type="button" class="btn ams-filter-pill ams-badge-{{ unit.ams_type|slugify }}" data-ams-filter="{{ unit.ams_unit_id }}">{{ unit.ams_type|default:"AMS" }}</button>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
{% endfor %}
|
||||||
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
|
</div>
|
||||||
{% if filament.filament_pk %}
|
{% endif %}
|
||||||
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
|
<div class="row g-3 ams-groups">
|
||||||
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
|
{% for group in stats.ams_groups %}
|
||||||
</a>
|
<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 }}">
|
||||||
{% endif %}
|
<div class="ams-group-header d-flex justify-content-between align-items-center mb-2">
|
||||||
</div>
|
<strong class="small">{{ group.label }}</strong>
|
||||||
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
|
{% if group.humidity is not None or group.temp is not None %}
|
||||||
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
|
<span class="small text-body-secondary">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
{% if group.humidity is not None %}{{ group.humidity }}%RH{% endif %}
|
||||||
<span class="small">Remaining</span>
|
{% if group.temp is not None %}· {{ group.temp }}°C{% endif %}
|
||||||
<span class="badge filament-badge">{{ filament.remain_percent }}%</span>
|
</span>
|
||||||
</div>
|
{% endif %}
|
||||||
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
|
</div>
|
||||||
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
<div class="row g-3">
|
||||||
|
{% for filament in group.filaments %}
|
||||||
|
<div class="col-12 {% if group.filaments|length > 1 %}col-md-6 col-lg-3{% endif %}">
|
||||||
|
<div class="card filament-card" data-filament-color="{{ filament.color|slice:':6' }}"{% if filament.is_transparent %} data-filament-transparent="true"{% endif %}>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">Tray {{ filament.tray_id }}</h6>
|
||||||
|
{% if filament.filament_pk %}
|
||||||
|
<a href="{% url 'bambu_run:filament_detail' filament.filament_pk %}" class="text-decoration-none" title="View in inventory">
|
||||||
|
<svg class="icon icon-sm text-body-secondary"><use href="{% static 'bambu_run/vendors/coreui-icons-free.svg' %}#cil-external-link"></use></svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 small"><strong>{{ filament.type }}</strong> - {{ filament.brand }}</p>
|
||||||
|
{% if filament.color_name %}<p class="mb-1 small text-body-secondary">{{ filament.color_name }}</p>{% endif %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small">Remaining</span>
|
||||||
|
<span class="badge filament-badge">{{ filament.remain_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
|
||||||
|
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ filament.remain_percent }}%;" aria-valuenow="{{ filament.remain_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if stats.external_spool.type %}
|
{% if stats.external_spool.type %}
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
<div class="col-12 col-md-6 col-lg-3">
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
|
<div class="card filament-card" data-filament-color="{{ stats.external_spool.color|slice:':6' }}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -241,8 +241,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-body-secondary">No filament data available</p>
|
<p class="text-body-secondary">No filament data available</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -251,6 +251,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hotends Section (Vortek rack + any plain/non-inductive nozzles) -->
|
||||||
|
{% if stats.hotends or stats.nozzle_positions %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Hotends</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for hotend in stats.hotends %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card filament-card" data-filament-color="{{ hotend.last_color|default:'888888' }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
{% if hotend.is_toolhead %}Toolhead{% elif hotend.slot_number %}Slot {{ hotend.slot_number }}{% else %}Rack{% endif %}
|
||||||
|
</h6>
|
||||||
|
{% if hotend.is_toolhead %}<span class="badge filament-badge">Toolhead</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 small text-body-secondary">SN {{ hotend.serial_number }}</p>
|
||||||
|
<p class="mb-1 small"><strong>{{ hotend.nozzle_type }}</strong>{% if hotend.diameter %} · {{ hotend.diameter }}mm{% endif %}</p>
|
||||||
|
{% if hotend.last_filament_profile_id %}<p class="mb-1 small text-body-secondary">Last: {{ hotend.last_filament_profile_id }}</p>{% endif %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="small">Used time</span>
|
||||||
|
<span class="small">{{ hotend.used_time_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small">Wear</span>
|
||||||
|
<span class="badge filament-badge">{{ hotend.wear_percent|floatformat:0 }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 10px; background-color: rgba(0,0,0,0.1);">
|
||||||
|
<div class="progress-bar filament-progress" role="progressbar" style="width: {{ hotend.wear_percent }}%;" aria-valuenow="{{ hotend.wear_percent }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for nozzle in stats.nozzle_positions %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0">{% if nozzle.is_toolhead %}Toolhead{% else %}Fixed Nozzle{% endif %}</h6>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 small"><strong>{{ nozzle.nozzle_type }}</strong>{% if nozzle.diameter %} · {{ nozzle.diameter }}mm{% endif %}</p>
|
||||||
|
<p class="mb-0 small text-body-secondary">No induction chip data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Date/Time Filter Controls -->
|
<!-- Date/Time Filter Controls -->
|
||||||
{% if not is_basic_user %}
|
{% if not is_basic_user %}
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
@@ -467,4 +525,27 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const pillsContainer = document.getElementById('amsFilterPills');
|
||||||
|
if (!pillsContainer) return;
|
||||||
|
const items = document.querySelectorAll('.ams-groups .ams-group');
|
||||||
|
|
||||||
|
pillsContainer.addEventListener('click', function(e) {
|
||||||
|
const pill = e.target.closest('.ams-filter-pill');
|
||||||
|
if (!pill) return;
|
||||||
|
|
||||||
|
pillsContainer.querySelectorAll('.ams-filter-pill').forEach(function(p) {
|
||||||
|
p.classList.remove('active');
|
||||||
|
});
|
||||||
|
pill.classList.add('active');
|
||||||
|
|
||||||
|
const filter = pill.dataset.amsFilter;
|
||||||
|
items.forEach(function(item) {
|
||||||
|
const show = filter === 'all' || item.dataset.amsUnitId === filter;
|
||||||
|
item.classList.toggle('d-none', !show);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ import json
|
|||||||
import zoneinfo
|
import zoneinfo
|
||||||
|
|
||||||
from .conf import app_settings
|
from .conf import app_settings
|
||||||
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage
|
from .models import Printer, PrinterMetrics, Filament, FilamentColor, FilamentType, FilamentSnapshot, PrintJob, FilamentUsage, Hotend
|
||||||
from .forms import FilamentForm, FilamentColorForm, FilamentTypeForm
|
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 = [
|
_METRICS_API_FIELDS = [
|
||||||
'id', 'device_id', 'timestamp',
|
'id', 'device_id', 'timestamp',
|
||||||
'nozzle_temp', 'nozzle_target_temp',
|
'nozzle_temp', 'nozzle_target_temp',
|
||||||
|
'nozzle_temp_left', 'nozzle_target_temp_left',
|
||||||
'bed_temp', 'bed_target_temp',
|
'bed_temp', 'bed_target_temp',
|
||||||
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
|
'print_percent', 'cooling_fan_speed', 'heatbreak_fan_speed',
|
||||||
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
|
'wifi_signal_dbm', 'ams_humidity_raw', 'ams_temp',
|
||||||
@@ -24,7 +28,16 @@ _METRICS_API_FIELDS = [
|
|||||||
'gcode_state', 'print_type', 'subtask_name',
|
'gcode_state', 'print_type', 'subtask_name',
|
||||||
'external_spool',
|
'external_spool',
|
||||||
]
|
]
|
||||||
_MAX_CHART_POINTS = 3000
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
def resolve_printer_from_request(pk):
|
def resolve_printer_from_request(pk):
|
||||||
@@ -32,12 +45,47 @@ def resolve_printer_from_request(pk):
|
|||||||
|
|
||||||
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
|
`pk` given (URL kwarg) -> that exact printer, 404 if missing/inactive.
|
||||||
`pk` omitted -> first active printer (today's single-printer default behavior).
|
`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:
|
if pk is not None:
|
||||||
return get_object_or_404(Printer, pk=pk, is_active=True)
|
return get_object_or_404(Printer, pk=pk, is_active=True)
|
||||||
return Printer.objects.filter(is_active=True).first()
|
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):
|
class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "bambu_run/printer_dashboard.html"
|
template_name = "bambu_run/printer_dashboard.html"
|
||||||
|
|
||||||
@@ -72,14 +120,28 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
# Get date range (overridable by subclasses)
|
# Get date range (overridable by subclasses)
|
||||||
start_dt, end_dt = self._get_date_range(self.request)
|
start_dt, end_dt = self._get_date_range(self.request)
|
||||||
metrics = PrinterMetrics.objects.filter(
|
query = PrinterMetrics.objects.filter(
|
||||||
device=printer_device, timestamp__gte=start_dt
|
device=printer_device, timestamp__gte=start_dt
|
||||||
)
|
)
|
||||||
if end_dt:
|
if end_dt:
|
||||||
metrics = metrics.filter(timestamp__lte=end_dt)
|
query = query.filter(timestamp__lte=end_dt)
|
||||||
metrics = metrics.prefetch_related('filament_snapshots').order_by("timestamp")
|
|
||||||
|
|
||||||
latest_metric = metrics.last()
|
# 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 = {
|
printer_data_json = {
|
||||||
"timestamps": [
|
"timestamps": [
|
||||||
@@ -133,14 +195,17 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
"total_layer_num": [
|
"total_layer_num": [
|
||||||
m.total_layer_num if m.total_layer_num else 0 for m in metrics
|
m.total_layer_num if m.total_layer_num else 0 for m in metrics
|
||||||
],
|
],
|
||||||
"filament_timeline": self._prepare_filament_timeline(metrics),
|
"filament_timeline": self._prepare_filament_timeline(
|
||||||
|
metrics, snapshots_by_metric
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = {}
|
stats = {}
|
||||||
if latest_metric:
|
if latest_metric:
|
||||||
filaments_list = []
|
filaments_list = []
|
||||||
try:
|
try:
|
||||||
filament_snapshots = latest_metric.filament_snapshots.select_related('filament').all()
|
# `.all()` (not `.select_related()`) so the prefetch cache is used
|
||||||
|
filament_snapshots = latest_metric.filament_snapshots.all()
|
||||||
for snapshot in filament_snapshots:
|
for snapshot in filament_snapshots:
|
||||||
filament_dict = {
|
filament_dict = {
|
||||||
'tray_id': snapshot.tray_id,
|
'tray_id': snapshot.tray_id,
|
||||||
@@ -148,6 +213,8 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
'brand': snapshot.sub_type or 'Unknown',
|
'brand': snapshot.sub_type or 'Unknown',
|
||||||
'color': snapshot.color or 'FFFFFFFF',
|
'color': snapshot.color or 'FFFFFFFF',
|
||||||
'remain_percent': snapshot.remain_percent or 0,
|
'remain_percent': snapshot.remain_percent or 0,
|
||||||
|
'ams_unit_id': snapshot.ams_unit_id,
|
||||||
|
'ams_type': snapshot.ams_type or '',
|
||||||
}
|
}
|
||||||
if snapshot.filament:
|
if snapshot.filament:
|
||||||
filament_dict['color_name'] = snapshot.filament.color
|
filament_dict['color_name'] = snapshot.filament.color
|
||||||
@@ -157,6 +224,52 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
except Exception:
|
except Exception:
|
||||||
filaments_list = []
|
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"
|
subtask_name = latest_metric.subtask_name or "No active print"
|
||||||
# Look up active PrintJob for a better display name (cloud design_title)
|
# Look up active PrintJob for a better display name (cloud design_title)
|
||||||
job_display_name = subtask_name
|
job_display_name = subtask_name
|
||||||
@@ -196,22 +309,37 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
"ams_temp": float(latest_metric.ams_temp) if latest_metric.ams_temp else None,
|
||||||
"ams_humidity": latest_metric.ams_humidity,
|
"ams_humidity": latest_metric.ams_humidity,
|
||||||
"filaments": filaments_list,
|
"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 {},
|
"external_spool": latest_metric.external_spool or {},
|
||||||
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": latest_metric.timestamp.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
|
|
||||||
project_markers = self._calculate_project_markers(list(metrics), tz)
|
project_markers = self._calculate_project_markers(metrics, tz, printer_device)
|
||||||
printer_data_json["project_markers"] = project_markers
|
printer_data_json["project_markers"] = project_markers
|
||||||
|
|
||||||
context["printer_device"] = printer_device
|
context["printer_device"] = printer_device
|
||||||
context["device_name"] = printer_device.name
|
context["device_name"] = printer_device.name
|
||||||
context["stats"] = stats
|
context["stats"] = stats
|
||||||
context["metrics_count"] = metrics.count()
|
context["metrics_count"] = len(metrics)
|
||||||
context["printer_data_json"] = json.dumps(printer_data_json)
|
context["printer_data_json"] = json.dumps(printer_data_json)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def _calculate_project_markers(self, metrics, timezone_info):
|
def _calculate_project_markers(self, metrics, timezone_info, device):
|
||||||
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
"""Calculate where print jobs start and end, using cloud design_title when available."""
|
||||||
if not metrics:
|
if not metrics:
|
||||||
return []
|
return []
|
||||||
@@ -219,7 +347,6 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
# Build a lookup: subtask_name -> display_name from PrintJobs in this time window
|
||||||
window_start = metrics[0].timestamp
|
window_start = metrics[0].timestamp
|
||||||
window_end = metrics[-1].timestamp
|
window_end = metrics[-1].timestamp
|
||||||
device = metrics[0].device
|
|
||||||
jobs_qs = PrintJob.objects.filter(
|
jobs_qs = PrintJob.objects.filter(
|
||||||
device=device,
|
device=device,
|
||||||
start_time__gte=window_start - timedelta(minutes=5),
|
start_time__gte=window_start - timedelta(minutes=5),
|
||||||
@@ -265,28 +392,31 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
return markers
|
return markers
|
||||||
|
|
||||||
def _prepare_filament_timeline(self, metrics):
|
def _prepare_filament_timeline(self, metrics, snapshots_by_metric):
|
||||||
"""Prepare filament data organized by unique filament configurations."""
|
"""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 = {}
|
filament_data = {}
|
||||||
total_points = len(metrics)
|
total_points = len(metrics)
|
||||||
|
|
||||||
for idx, metric in enumerate(metrics):
|
for idx, metric in enumerate(metrics):
|
||||||
try:
|
for snapshot in snapshots_by_metric.get(metric.id, []):
|
||||||
snapshots = metric.filament_snapshots.all()
|
|
||||||
except Exception:
|
|
||||||
snapshots = []
|
|
||||||
|
|
||||||
for snapshot in snapshots:
|
|
||||||
tray_id = snapshot.tray_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_type = snapshot.type or 'Unknown'
|
||||||
fil_sub_type = snapshot.sub_type or 'Unknown'
|
fil_sub_type = snapshot.sub_type or 'Unknown'
|
||||||
fil_color = snapshot.color or 'FFFFFFFF'
|
fil_color = snapshot.color or 'FFFFFFFF'
|
||||||
|
|
||||||
unique_key = f"{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
unique_key = f"{ams_unit_id}_{tray_id}_{fil_type}_{fil_sub_type}_{fil_color}"
|
||||||
|
|
||||||
if unique_key not in filament_data:
|
if unique_key not in filament_data:
|
||||||
filament_data[unique_key] = {
|
filament_data[unique_key] = {
|
||||||
'tray_id': tray_id,
|
'tray_id': tray_id,
|
||||||
|
'ams_unit_id': ams_unit_id,
|
||||||
|
'ams_type': ams_type,
|
||||||
'type': fil_type,
|
'type': fil_type,
|
||||||
'brand': fil_sub_type,
|
'brand': fil_sub_type,
|
||||||
'color': fil_color,
|
'color': fil_color,
|
||||||
@@ -348,38 +478,25 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
|
|||||||
.only(*_METRICS_API_FIELDS)
|
.only(*_METRICS_API_FIELDS)
|
||||||
)
|
)
|
||||||
|
|
||||||
if start_date and start_time and end_date and end_time:
|
# Both bounds are always applied. A missing bound falls back to a 24h
|
||||||
start_dt = datetime.strptime(f"{start_date} {start_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
# window rather than being left open — an unbounded range would scan
|
||||||
end_dt = datetime.strptime(f"{end_date} {end_time}", "%Y-%m-%d %H:%M").replace(tzinfo=tz)
|
# every metric ever recorded.
|
||||||
query = query.filter(timestamp__gte=start_dt, timestamp__lte=end_dt)
|
def _parse(date_str, time_str):
|
||||||
range_seconds = (end_dt - start_dt).total_seconds()
|
return datetime.strptime(
|
||||||
expected_count = max(1, int(range_seconds / 30))
|
f"{date_str} {time_str}", "%Y-%m-%d %H:%M"
|
||||||
elif start_date and start_time:
|
).replace(tzinfo=tz)
|
||||||
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
|
|
||||||
|
|
||||||
step = max(1, 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)
|
||||||
|
|
||||||
# Stage B: single DB round-trip, downsample in Python
|
# Stage B: single DB round-trip, downsample in Python
|
||||||
metrics_list = list(query.order_by("timestamp"))
|
metrics_list = sample_metrics(list(query.order_by("timestamp")))
|
||||||
if step > 1:
|
|
||||||
metrics_list = metrics_list[::step]
|
|
||||||
|
|
||||||
total_points = len(metrics_list)
|
total_points = len(metrics_list)
|
||||||
|
|
||||||
# Stage C: targeted snapshot fetch (only sampled IDs)
|
# Stage C: targeted snapshot fetch (only sampled IDs)
|
||||||
snapshots_by_metric: dict = {}
|
snapshots_by_metric = fetch_snapshots_by_metric(metrics_list)
|
||||||
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
|
# Stage D: single-pass serialization
|
||||||
timestamps = []
|
timestamps = []
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bambu-run"
|
name = "bambu-run"
|
||||||
version = "0.1.7"
|
version = "0.1.13"
|
||||||
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
description = "Django reusable app for Bambu Lab 3D printer monitoring and filament inventory management"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
|
|||||||
25
tests/test_ams_type_from_info.py
Normal file
25
tests/test_ams_type_from_info.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from bambu_run.models import ams_type_from_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"info_code,expected",
|
||||||
|
[
|
||||||
|
# Real-world 8-char info codes captured from a live H2C with
|
||||||
|
# AMS 2 Pro (unit 0), AMS (unit 1), AMS HT (unit 128).
|
||||||
|
("10001003", "AMS 2 Pro"),
|
||||||
|
("10001001", "AMS"),
|
||||||
|
("11002104", "AMS HT"),
|
||||||
|
# Bare 4-digit codes (original assumption) still resolve.
|
||||||
|
("1001", "AMS"),
|
||||||
|
("1003", "AMS 2 Pro"),
|
||||||
|
("2104", "AMS HT"),
|
||||||
|
# Unknown/missing codes resolve to empty string, not an error.
|
||||||
|
("99999999", ""),
|
||||||
|
("", ""),
|
||||||
|
(None, ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ams_type_from_info(info_code, expected):
|
||||||
|
assert ams_type_from_info(info_code) == expected
|
||||||
185
tests/test_filament_context.py
Normal file
185
tests/test_filament_context.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import pytest
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bambu_run.models import Printer, PrinterMetrics, FilamentSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logged_in_client(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="tester", password="pw")
|
||||||
|
client.force_login(user)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_filaments_carry_ams_unit_info(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
|
||||||
|
type="PLA", remain_percent=80,
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
|
||||||
|
type="PA-CF", remain_percent=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
filaments = resp.context["stats"]["filaments"]
|
||||||
|
assert len(filaments) == 2
|
||||||
|
units = {(f["ams_unit_id"], f["ams_type"]) for f in filaments}
|
||||||
|
assert units == {(0, "AMS"), (128, "AMS HT")}
|
||||||
|
|
||||||
|
ams_units = resp.context["stats"]["ams_units"]
|
||||||
|
assert ams_units == [
|
||||||
|
{"ams_unit_id": 0, "ams_type": "AMS"},
|
||||||
|
{"ams_unit_id": 128, "ams_type": "AMS HT"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_filament_timeline_keeps_same_tray_id_units_separate(logged_in_client):
|
||||||
|
from bambu_run.views import PrinterDashboardView
|
||||||
|
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
|
||||||
|
type="PLA", sub_type="PLA Basic", color="FF0000", remain_percent=80,
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(timeline) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_renders_unit_pills_and_badges_with_multiple_units(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
|
||||||
|
type="PLA", color="FF0000FF", remain_percent=80,
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
|
||||||
|
type="PA-CF", color="00FF00FF", remain_percent=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert "ams-filter-pills" in html
|
||||||
|
assert "ams-badge-ams" in html
|
||||||
|
assert "ams-badge-ams-ht" in html
|
||||||
|
assert 'data-ams-unit-id="0"' in html
|
||||||
|
assert 'data-ams-unit-id="128"' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_groups_filaments_by_ams_unit(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
metric = PrinterMetrics.objects.create(
|
||||||
|
device=printer, timestamp=timezone.now(),
|
||||||
|
ams_units=[
|
||||||
|
{"unit_id": "0", "ams_type": "AMS 2 Pro", "humidity": 5, "temp": 22.5},
|
||||||
|
{"unit_id": "128", "ams_type": "AMS HT", "humidity": 8, "temp": 60.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS 2 Pro",
|
||||||
|
type="ABS", remain_percent=80,
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=1, ams_unit_id=0, ams_type="AMS 2 Pro",
|
||||||
|
type="ABS", remain_percent=60,
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
|
||||||
|
type="PA-CF", remain_percent=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
groups = resp.context["stats"]["ams_groups"]
|
||||||
|
assert len(groups) == 2
|
||||||
|
|
||||||
|
ams2pro_group, ht_group = groups
|
||||||
|
assert ams2pro_group["unit_id"] == 0
|
||||||
|
assert ams2pro_group["label"] == "AMS 2 Pro (Unit 0)"
|
||||||
|
assert ams2pro_group["humidity"] == 5
|
||||||
|
assert ams2pro_group["temp"] == 22.5
|
||||||
|
assert len(ams2pro_group["filaments"]) == 2
|
||||||
|
|
||||||
|
assert ht_group["unit_id"] == 128
|
||||||
|
assert ht_group["label"] == "AMS HT (Unit 128)"
|
||||||
|
assert ht_group["humidity"] == 8
|
||||||
|
assert len(ht_group["filaments"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_renders_wide_and_compact_panels(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
metric = PrinterMetrics.objects.create(
|
||||||
|
device=printer, timestamp=timezone.now(),
|
||||||
|
ams_units=[
|
||||||
|
{"unit_id": "0", "ams_type": "AMS 2 Pro", "humidity": 5, "temp": 22.5},
|
||||||
|
{"unit_id": "128", "ams_type": "AMS HT", "humidity": 8, "temp": 60.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for tray_id in range(4):
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=tray_id, ams_unit_id=0, ams_type="AMS 2 Pro",
|
||||||
|
type="ABS", remain_percent=80,
|
||||||
|
)
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=128, ams_type="AMS HT",
|
||||||
|
type="PA-CF", remain_percent=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
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 2 Pro (Unit 0)" in html
|
||||||
|
assert "AMS HT (Unit 128)" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_hides_unit_pills_with_single_unit(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
metric = PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
|
||||||
|
FilamentSnapshot.objects.create(
|
||||||
|
printer_metric=metric, tray_id=0, ams_unit_id=0, ams_type="AMS",
|
||||||
|
type="PLA", color="FF0000FF", remain_percent=80,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "ams-filter-pills" not in resp.content.decode()
|
||||||
121
tests/test_hotend_collection.py
Normal file
121
tests/test_hotend_collection.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
|
||||||
|
from bambu_run.models import Hotend, HotendSnapshot, PrinterMetrics
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
"""Stub in place of BambuPrinter — returns canned snapshots, no real MQTT."""
|
||||||
|
|
||||||
|
def __init__(self, snapshots):
|
||||||
|
self._snapshots = snapshots
|
||||||
|
self._index = 0
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def get_snapshot(self):
|
||||||
|
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
|
||||||
|
self._index += 1
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(device_id, name, snapshots):
|
||||||
|
printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"})
|
||||||
|
return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer)
|
||||||
|
|
||||||
|
|
||||||
|
def hotends_snapshot(used_time=11472, wear=100.0):
|
||||||
|
return {
|
||||||
|
"gcode_state": "IDLE",
|
||||||
|
"hotends": [
|
||||||
|
{
|
||||||
|
"raw_id": 21, "serial_number": "20D06A5B2918952", "nozzle_type": "HS01",
|
||||||
|
"diameter": 0.4, "fila_id": "GFA01", "color": "FFFFFF",
|
||||||
|
"used_time_seconds": used_time, "wear_percent": wear, "stat": 0,
|
||||||
|
"is_toolhead": False, "is_empty": False, "slot_number": 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01",
|
||||||
|
"diameter": 0.4, "fila_id": "", "color": None,
|
||||||
|
"used_time_seconds": 0, "wear_percent": 0.0, "stat": 0,
|
||||||
|
"is_toolhead": False, "is_empty": True, "slot_number": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"raw_id": 0, "serial_number": "20D06A5C0426280", "nozzle_type": "HS01",
|
||||||
|
"diameter": 0.4, "fila_id": "GFA00", "color": "FEC600",
|
||||||
|
"used_time_seconds": 93490, "wear_percent": 100.0, "stat": 0,
|
||||||
|
"is_toolhead": True, "is_empty": False, "slot_number": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_first_poll_creates_one_hotend_per_non_empty_entry():
|
||||||
|
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.verbose = False
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
|
||||||
|
hotends = Hotend.objects.filter(printer=session.printer)
|
||||||
|
assert hotends.count() == 2 # empty bay (sn="N/A") skipped
|
||||||
|
|
||||||
|
rack = hotends.get(serial_number="20D06A5B2918952")
|
||||||
|
assert rack.raw_id == 21
|
||||||
|
assert rack.slot_number == 6
|
||||||
|
assert rack.is_toolhead is False
|
||||||
|
assert rack.used_time_seconds == 11472
|
||||||
|
assert rack.wear_percent == 100.0
|
||||||
|
assert rack.nozzle_type == "HS01"
|
||||||
|
assert rack.last_filament_profile_id == "GFA01"
|
||||||
|
assert rack.last_color == "FFFFFF"
|
||||||
|
|
||||||
|
toolhead = hotends.get(serial_number="20D06A5C0426280")
|
||||||
|
assert toolhead.is_toolhead is True
|
||||||
|
assert toolhead.slot_number is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_first_poll_creates_one_snapshot_per_non_empty_hotend():
|
||||||
|
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.verbose = False
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
|
||||||
|
metric = PrinterMetrics.objects.get(device=session.printer)
|
||||||
|
assert HotendSnapshot.objects.filter(printer_metric=metric).count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_collector_persists_raw_nozzle_info_including_non_inductive_entries():
|
||||||
|
session = make_session("SERIAL-A", "Printer A", [hotends_snapshot()])
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.verbose = False
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
|
||||||
|
metric = PrinterMetrics.objects.get(device=session.printer)
|
||||||
|
assert len(metric.nozzle_info) == 3 # all entries, including the empty/non-inductive one
|
||||||
|
serials = {h["serial_number"] for h in metric.nozzle_info}
|
||||||
|
assert serials == {"20D06A5B2918952", "N/A", "20D06A5C0426280"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_second_poll_updates_existing_hotend_instead_of_duplicating():
|
||||||
|
session = make_session(
|
||||||
|
"SERIAL-A", "Printer A",
|
||||||
|
[hotends_snapshot(used_time=11472, wear=100.0), hotends_snapshot(used_time=11500, wear=100.0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.verbose = False
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
|
||||||
|
hotends = Hotend.objects.filter(printer=session.printer, serial_number="20D06A5B2918952")
|
||||||
|
assert hotends.count() == 1
|
||||||
|
assert hotends.first().used_time_seconds == 11500
|
||||||
|
|
||||||
|
snapshots = HotendSnapshot.objects.filter(hotend=hotends.first())
|
||||||
|
assert snapshots.count() == 2
|
||||||
128
tests/test_hotend_dashboard.py
Normal file
128
tests/test_hotend_dashboard.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bambu_run.models import Printer, PrinterMetrics, Hotend
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logged_in_client(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="tester", password="pw")
|
||||||
|
client.force_login(user)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_context_includes_hotends_toolhead_first(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
|
||||||
|
|
||||||
|
Hotend.objects.create(
|
||||||
|
printer=printer, serial_number="RACK-SN", raw_id=16, slot_number=1,
|
||||||
|
is_toolhead=False, nozzle_type="HS01", used_time_seconds=3600, wear_percent=50,
|
||||||
|
)
|
||||||
|
Hotend.objects.create(
|
||||||
|
printer=printer, serial_number="TOOLHEAD-SN", raw_id=0, slot_number=None,
|
||||||
|
is_toolhead=True, nozzle_type="HS01", used_time_seconds=7200, wear_percent=80,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
hotends = resp.context["stats"]["hotends"]
|
||||||
|
assert len(hotends) == 2
|
||||||
|
assert hotends[0].serial_number == "TOOLHEAD-SN"
|
||||||
|
assert hotends[1].serial_number == "RACK-SN"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_context_includes_non_inductive_nozzle_positions(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
PrinterMetrics.objects.create(
|
||||||
|
device=printer, timestamp=timezone.now(),
|
||||||
|
nozzle_info=[
|
||||||
|
{
|
||||||
|
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
|
||||||
|
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
positions = resp.context["stats"]["nozzle_positions"]
|
||||||
|
assert len(positions) == 1
|
||||||
|
assert positions[0]["nozzle_type"] == "HS01"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_omits_nozzle_positions_with_no_readable_data(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
PrinterMetrics.objects.create(
|
||||||
|
device=printer, timestamp=timezone.now(),
|
||||||
|
nozzle_info=[
|
||||||
|
{
|
||||||
|
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "", "diameter": 0,
|
||||||
|
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
|
||||||
|
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.context["stats"]["nozzle_positions"] == []
|
||||||
|
assert "<h5>Hotends</h5>" not in resp.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_renders_nozzle_position_without_serial_or_wear(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
PrinterMetrics.objects.create(
|
||||||
|
device=printer, timestamp=timezone.now(),
|
||||||
|
nozzle_info=[
|
||||||
|
{
|
||||||
|
"raw_id": 1, "serial_number": "N/A", "nozzle_type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "", "color": None, "used_time_seconds": 0, "wear_percent": 0.0,
|
||||||
|
"stat": 0, "is_toolhead": False, "is_empty": True, "slot_number": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert "Hotends" in html
|
||||||
|
assert "HS01" in html
|
||||||
|
assert "SN: N/A" not in html
|
||||||
|
assert "SN N/A" not in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dashboard_renders_hotends_card(logged_in_client):
|
||||||
|
printer = Printer.objects.create(name="Printer A", model="H2C", is_active=True)
|
||||||
|
PrinterMetrics.objects.create(device=printer, timestamp=timezone.now())
|
||||||
|
|
||||||
|
Hotend.objects.create(
|
||||||
|
printer=printer, serial_number="RACK-SN", raw_id=18, slot_number=3,
|
||||||
|
is_toolhead=False, nozzle_type="HS01", diameter=0.4,
|
||||||
|
used_time_seconds=3661, wear_percent=50, last_filament_profile_id="GFA01",
|
||||||
|
last_color="DE4343",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = logged_in_client.get(
|
||||||
|
reverse("bambu_run:printer_dashboard", kwargs={"pk": printer.pk})
|
||||||
|
)
|
||||||
|
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert "Hotends" in html
|
||||||
|
assert "RACK-SN" in html
|
||||||
|
assert "Slot 3" in html
|
||||||
100
tests/test_hotend_parsing.py
Normal file
100
tests/test_hotend_parsing.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from bambu_run.mqtt_client import PrinterState
|
||||||
|
|
||||||
|
|
||||||
|
def real_nozzle_payload():
|
||||||
|
"""Real captured device.nozzle payload from a live H2C with a Vortek rack
|
||||||
|
(1x AMS, 1x AMS 2 Pro, 1x AMS HT physically connected — unrelated here).
|
||||||
|
SN/used-time cross-checked against the user's Bambu Studio Hotends Info table."""
|
||||||
|
return {
|
||||||
|
"exist": 3997699,
|
||||||
|
"src_id": 17,
|
||||||
|
"tar_id": 17,
|
||||||
|
"state": 0,
|
||||||
|
"info": [
|
||||||
|
{"id": 21, "sn": "20D06A5B2918952", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "GFA01", "color_m": "FFFFFFFF", "p_t": 11472, "wear": 128.0, "stat": 0, "tm": 350},
|
||||||
|
{"id": 1, "sn": "N/A", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "", "color_m": "00000000", "p_t": 0, "wear": 0.0, "stat": 0, "tm": 0},
|
||||||
|
{"id": 16, "sn": "20D06A5B2919219", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "GFA01", "color_m": "A3D8E1FF", "p_t": 105386, "wear": 128.0, "stat": 0, "tm": 350},
|
||||||
|
{"id": 20, "sn": "20D06A590610257", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "GFG01", "color_m": "00000000", "p_t": 81506, "wear": 128.0, "stat": 0, "tm": 350},
|
||||||
|
{"id": 18, "sn": "20D06A591506263", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "GFA01", "color_m": "DE4343FF", "p_t": 30962, "wear": 128.0, "stat": 0, "tm": 350},
|
||||||
|
{"id": 0, "sn": "20D06A5C0426280", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "GFA00", "color_m": "FEC600FF", "p_t": 93490, "wear": 128.0, "stat": 0, "tm": 350},
|
||||||
|
{"id": 19, "sn": "20D06A5C0207881", "type": "HS01", "diameter": 0.4,
|
||||||
|
"fila_id": "GFA01", "color_m": "DE4343FF", "p_t": 1430, "wear": 128.0, "stat": 0, "tm": 350},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_data(nozzle_payload):
|
||||||
|
return {"print": {"gcode_state": "IDLE", "device": {"nozzle": nozzle_payload}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_includes_one_hotend_per_nozzle_info_entry():
|
||||||
|
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
|
||||||
|
snapshot = state.get_snapshot()
|
||||||
|
|
||||||
|
assert len(snapshot["hotends"]) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_hotend_fields_extracted_correctly():
|
||||||
|
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
|
||||||
|
snapshot = state.get_snapshot()
|
||||||
|
|
||||||
|
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
|
||||||
|
h = by_sn["20D06A5B2919219"]
|
||||||
|
|
||||||
|
assert h["raw_id"] == 16
|
||||||
|
assert h["nozzle_type"] == "HS01"
|
||||||
|
assert h["diameter"] == 0.4
|
||||||
|
assert h["fila_id"] == "GFA01"
|
||||||
|
assert h["color"] == "A3D8E1" # alpha stripped
|
||||||
|
assert h["used_time_seconds"] == 105386
|
||||||
|
assert h["wear_percent"] == 100.0 # 128/128*100
|
||||||
|
assert h["is_empty"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_zero_is_toolhead_and_resolves_slot_number():
|
||||||
|
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
|
||||||
|
snapshot = state.get_snapshot()
|
||||||
|
|
||||||
|
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
|
||||||
|
toolhead = by_sn["20D06A5C0426280"]
|
||||||
|
|
||||||
|
assert toolhead["raw_id"] == 0
|
||||||
|
assert toolhead["is_toolhead"] is True
|
||||||
|
assert toolhead["slot_number"] is None # true bay address hidden while id==0 sentinel
|
||||||
|
|
||||||
|
|
||||||
|
def test_rack_bay_ids_resolve_to_slot_numbers_one_through_six():
|
||||||
|
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
|
||||||
|
snapshot = state.get_snapshot()
|
||||||
|
|
||||||
|
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
|
||||||
|
|
||||||
|
assert by_sn["20D06A5B2919219"]["slot_number"] == 1 # raw_id 16
|
||||||
|
assert by_sn["20D06A591506263"]["slot_number"] == 3 # raw_id 18
|
||||||
|
assert by_sn["20D06A5C0207881"]["slot_number"] == 4 # raw_id 19
|
||||||
|
assert by_sn["20D06A590610257"]["slot_number"] == 5 # raw_id 20
|
||||||
|
assert by_sn["20D06A5B2918952"]["slot_number"] == 6 # raw_id 21
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_bay_with_na_serial_is_flagged_empty():
|
||||||
|
state = PrinterState.from_mqtt_data(make_data(real_nozzle_payload()))
|
||||||
|
snapshot = state.get_snapshot()
|
||||||
|
|
||||||
|
by_sn = {h["serial_number"]: h for h in snapshot["hotends"]}
|
||||||
|
empty = by_sn["N/A"]
|
||||||
|
|
||||||
|
assert empty["is_empty"] is True
|
||||||
|
assert empty["is_toolhead"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_hotends_empty_list_when_no_nozzle_payload():
|
||||||
|
state = PrinterState.from_mqtt_data({"print": {"gcode_state": "IDLE"}})
|
||||||
|
snapshot = state.get_snapshot()
|
||||||
|
|
||||||
|
assert snapshot["hotends"] == []
|
||||||
104
tests/test_multi_ams_collection.py
Normal file
104
tests/test_multi_ams_collection.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from bambu_run.management.commands.bambu_collector import Command, DeviceSession, resolve_printer_device
|
||||||
|
from bambu_run.models import Filament, FilamentSnapshot, FilamentUsage, PrinterMetrics
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
"""Stub in place of BambuPrinter — returns canned snapshots, no real MQTT."""
|
||||||
|
|
||||||
|
def __init__(self, snapshots):
|
||||||
|
self._snapshots = snapshots
|
||||||
|
self._index = 0
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
def get_snapshot(self):
|
||||||
|
snap = self._snapshots[min(self._index, len(self._snapshots) - 1)]
|
||||||
|
self._index += 1
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(device_id, name, snapshots):
|
||||||
|
printer = resolve_printer_device(device_id, {"name": name, "dev_product_name": "H2C"})
|
||||||
|
return DeviceSession(device_id=device_id, client=FakeClient(snapshots), printer=printer)
|
||||||
|
|
||||||
|
|
||||||
|
def two_unit_tray0_snapshot():
|
||||||
|
"""Two AMS units (AMS unit_id=0, AMS HT unit_id=128) both report tray_id=0,
|
||||||
|
with different filament types loaded — these must not collide."""
|
||||||
|
return {
|
||||||
|
"gcode_state": "IDLE",
|
||||||
|
"ams_units": [
|
||||||
|
{"unit_id": "0", "ams_type": "AMS", "humidity": 30, "temp": 25.0},
|
||||||
|
{"unit_id": "128", "ams_type": "AMS HT", "humidity": 20, "temp": 60.0},
|
||||||
|
],
|
||||||
|
"filaments": [
|
||||||
|
{
|
||||||
|
"tray_id": 0, "type": "PLA", "sub_type": "PLA Basic", "color": "FF0000FF",
|
||||||
|
"tray_uuid": "UUID-UNIT0-TRAY0",
|
||||||
|
"remain_percent": 80, "ams_unit_id": 0, "ams_type": "AMS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tray_id": 0, "type": "PA-CF", "sub_type": "PA6-CF", "color": "00FF00FF",
|
||||||
|
"tray_uuid": "UUID-UNIT128-TRAY0",
|
||||||
|
"remain_percent": 50, "ams_unit_id": 128, "ams_type": "AMS HT",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_two_ams_units_with_same_tray_id_create_distinct_snapshots():
|
||||||
|
session = make_session("SERIAL-A", "Printer A", [two_unit_tray0_snapshot()])
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.verbose = False
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
|
||||||
|
metric = PrinterMetrics.objects.get(device=session.printer)
|
||||||
|
snapshots = FilamentSnapshot.objects.filter(printer_metric=metric).order_by("ams_unit_id")
|
||||||
|
|
||||||
|
assert snapshots.count() == 2
|
||||||
|
|
||||||
|
ams_snap, ht_snap = snapshots
|
||||||
|
assert ams_snap.tray_id == 0
|
||||||
|
assert ams_snap.ams_unit_id == 0
|
||||||
|
assert ams_snap.ams_type == "AMS"
|
||||||
|
assert ams_snap.type == "PLA"
|
||||||
|
|
||||||
|
assert ht_snap.tray_id == 0
|
||||||
|
assert ht_snap.ams_unit_id == 128
|
||||||
|
assert ht_snap.ams_type == "AMS HT"
|
||||||
|
assert ht_snap.type == "PA-CF"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_filament_usage_matches_correct_unit_when_tray_ids_collide():
|
||||||
|
start_snapshot = two_unit_tray0_snapshot()
|
||||||
|
start_snapshot.update({"gcode_state": "RUNNING", "subtask_name": "job_1", "print_percent": 1, "tray_now": "0"})
|
||||||
|
|
||||||
|
end_snapshot = two_unit_tray0_snapshot()
|
||||||
|
end_snapshot["filaments"][0]["remain_percent"] = 70 # AMS unit 0 consumed
|
||||||
|
end_snapshot["filaments"][1]["remain_percent"] = 50 # AMS HT unit 128 untouched
|
||||||
|
end_snapshot.update({"gcode_state": "FINISH", "subtask_name": "job_1", "print_percent": 100})
|
||||||
|
|
||||||
|
session = make_session("SERIAL-A", "Printer A", [start_snapshot, end_snapshot])
|
||||||
|
|
||||||
|
cmd = Command()
|
||||||
|
cmd.verbose = False
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
cmd._collect_printer_data(session)
|
||||||
|
|
||||||
|
usages = FilamentUsage.objects.filter(print_job__device=session.printer).order_by("ams_unit_id")
|
||||||
|
# Both units reported tray_id=0 with a tracked filament loaded throughout the
|
||||||
|
# job — usage is recorded per physical unit, not collapsed into one ambiguous row.
|
||||||
|
assert usages.count() == 2
|
||||||
|
|
||||||
|
ams_usage, ht_usage = usages
|
||||||
|
assert ams_usage.ams_unit_id == 0
|
||||||
|
assert ams_usage.starting_percent == 80
|
||||||
|
assert ams_usage.ending_percent == 70
|
||||||
|
|
||||||
|
assert ht_usage.ams_unit_id == 128
|
||||||
|
assert ht_usage.starting_percent == 50
|
||||||
|
assert ht_usage.ending_percent == 50
|
||||||
71
tests/test_printer_device_scoping.py
Normal file
71
tests/test_printer_device_scoping.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""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"]) == []
|
||||||
233
tests/test_printer_query_performance.py
Normal file
233
tests/test_printer_query_performance.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user