2 Commits

Author SHA1 Message Date
github-actions[bot]
2af3509010 chore: bump version to 0.1.5 [skip ci] 2026-05-07 05:05:19 +00:00
RNL
dd57a963ac Add H2C dual-nozzle and multi-AMS-type support
Schema (migration 0004):
- PrinterMetrics: nozzle_temp_left, nozzle_target_temp_left,
  nozzle_diameter_left, nozzle_type_left (all nullable)
- Filament: ams_unit_id (nullable int), ams_type (AMS/AMS 2 Pro/AMS HT)
- AMS_INFO_TO_TYPE map and AMS_TYPE_CHOICES on models

Parser (mqtt_client.py):
- Decode bit-packed temps from device.extruder.info[] for left/right nozzle
- Emit per-nozzle fields in get_snapshot(); legacy keys mirror right side
- AMS unit type from info code per unit dict

Collector (bambu_collector.py):
- Write left-nozzle fields to PrinterMetrics
- Set ams_unit_id + ams_type on Filament records
- Fix: poll MQTTClient.connected before pushall (not BambuPrinter._connected)
- Add 5s post-pushall wait in --once mode so response arrives before collect

Views: API and dashboard include left-nozzle series; is_dual_nozzle flag
Templates: dual-nozzle cards + chart; AMS-type badge + filter on filament list
Charts: left nozzle temp chart with conditional render
Forms: fix tray_id max=3 → max=15; add ams_unit_id, ams_type fields
2026-05-07 14:51:31 +10:00
11 changed files with 430 additions and 26 deletions

View File

@@ -55,7 +55,7 @@ class FilamentForm(forms.ModelForm):
'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent', 'filament_type', 'type', 'sub_type', 'brand', 'color', 'color_hex', 'is_transparent',
'diameter', 'initial_weight_grams', 'diameter', 'initial_weight_grams',
'remaining_percent', 'remaining_weight_grams', 'remaining_percent', 'remaining_weight_grams',
'is_loaded_in_ams', 'current_tray_id', 'is_loaded_in_ams', 'current_tray_id', 'ams_unit_id', 'ams_type',
'purchase_date', 'purchase_price', 'supplier', 'notes' 'purchase_date', 'purchase_price', 'supplier', 'notes'
] ]
widgets = { widgets = {
@@ -87,7 +87,15 @@ class FilamentForm(forms.ModelForm):
'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}), 'remaining_weight_grams': forms.NumberInput(attrs={'class': 'form-control', 'readonly': 'readonly'}),
'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}), 'is_transparent': forms.CheckboxInput(attrs={'class': 'form-check-input', 'id': 'id_is_transparent'}),
'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_loaded_in_ams': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'current_tray_id': forms.NumberInput(attrs={'class': 'form-control', 'min': '0', 'max': '3'}), 'current_tray_id': forms.NumberInput(attrs={
'class': 'form-control', 'min': '0', 'max': '15',
'placeholder': '03 for AMS / AMS 2 Pro, 0 for AMS HT',
}),
'ams_unit_id': forms.NumberInput(attrs={
'class': 'form-control', 'min': '0', 'max': '255',
'placeholder': 'AMS unit id (0,1,… or 128 for AMS HT)',
}),
'ams_type': forms.Select(attrs={'class': 'form-select'}),
'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'purchase_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), 'purchase_price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'supplier': forms.TextInput(attrs={'class': 'form-control'}), 'supplier': forms.TextInput(attrs={'class': 'form-control'}),
@@ -106,6 +114,8 @@ class FilamentForm(forms.ModelForm):
self.fields['type'].required = False self.fields['type'].required = False
self.fields['sub_type'].required = False self.fields['sub_type'].required = False
self.fields['brand'].required = False self.fields['brand'].required = False
self.fields['ams_unit_id'].required = False
self.fields['ams_type'].required = False
self._populate_color_choices() self._populate_color_choices()

View File

@@ -111,6 +111,8 @@ class Command(BaseCommand):
try: try:
if run_once: if run_once:
import time as _time
_time.sleep(5)
self._collect_printer_data() self._collect_printer_data()
logger.info("Single collection completed successfully") logger.info("Single collection completed successfully")
else: else:
@@ -122,6 +124,24 @@ class Command(BaseCommand):
logger.exception(f"Fatal error in main loop: {e}") logger.exception(f"Fatal error in main loop: {e}")
raise CommandError(f"Runner failed: {e}") raise CommandError(f"Runner failed: {e}")
def _request_full_status_when_ready(self, timeout: float = 20.0) -> None:
"""Send pushall once the MQTT broker connection is confirmed.
BambuPrinter._connected is set True immediately after connect(blocking=False),
before the broker handshake. Poll MQTTClient.connected (set in _on_connect)
instead, so publish() won't raise "Not connected to broker".
"""
import time as _time
deadline = _time.time() + timeout
while _time.time() < deadline:
mqtt_client = getattr(self.printer_client, "_mqtt", None)
if mqtt_client is not None and getattr(mqtt_client, "connected", False):
self.printer_client._mqtt.request_full_status()
logger.info("Sent MQTT pushall request")
return
_time.sleep(0.5)
logger.warning("MQTT broker connection not confirmed within %.1fs; skipping pushall", timeout)
def _configure_logging(self): def _configure_logging(self):
log_level = logging.DEBUG if self.verbose else logging.INFO log_level = logging.DEBUG if self.verbose else logging.INFO
logger.setLevel(log_level) logger.setLevel(log_level)
@@ -167,6 +187,11 @@ class Command(BaseCommand):
logger.info("Initiating MQTT connection...") logger.info("Initiating MQTT connection...")
self.printer_client.connect(blocking=False) self.printer_client.connect(blocking=False)
logger.info("MQTT connection initiated (non-blocking)") logger.info("MQTT connection initiated (non-blocking)")
# Request full status so AMS + dual-nozzle data arrive on startup.
try:
self._request_full_status_when_ready()
except Exception as e:
logger.warning("pushall request skipped (non-fatal): %s", e)
except Exception as e: except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e): if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
@@ -377,6 +402,8 @@ class Command(BaseCommand):
created_by='Auto Detection', created_by='Auto Detection',
is_loaded_in_ams=True, is_loaded_in_ams=True,
current_tray_id=tray_data.get('tray_id'), current_tray_id=tray_data.get('tray_id'),
ams_unit_id=tray_data.get('ams_unit_id'),
ams_type=tray_data.get('ams_type', '') or '',
last_loaded_date=timezone.now(), last_loaded_date=timezone.now(),
) )
@@ -390,9 +417,13 @@ class Command(BaseCommand):
return filament return filament
def _update_filament_status(self, filament, tray_id, remain_percent): def _update_filament_status(self, filament, tray_id, remain_percent, tray_data=None):
from bambu_run.models import Filament from bambu_run.models import Filament
tray_data = tray_data or {}
ams_unit_id = tray_data.get('ams_unit_id')
ams_type_label = tray_data.get('ams_type', '') or ''
if filament.remaining_percent != remain_percent: if filament.remaining_percent != remain_percent:
filament.remaining_percent = remain_percent filament.remaining_percent = remain_percent
filament.update_remaining_weight() filament.update_remaining_weight()
@@ -400,10 +431,19 @@ class Command(BaseCommand):
if self.verbose: if self.verbose:
logger.debug(f"Updated filament {filament}: {remain_percent}%") logger.debug(f"Updated filament {filament}: {remain_percent}%")
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id: location_changed = (
previous_filament = Filament.objects.filter( not filament.is_loaded_in_ams
or filament.current_tray_id != tray_id
or (ams_unit_id is not None and filament.ams_unit_id != ams_unit_id)
)
if location_changed:
# Unload anything previously occupying THIS exact (unit, tray) slot.
unload_qs = Filament.objects.filter(
is_loaded_in_ams=True, current_tray_id=tray_id is_loaded_in_ams=True, current_tray_id=tray_id
).exclude(id=filament.id).first() ).exclude(id=filament.id)
if ams_unit_id is not None:
unload_qs = unload_qs.filter(ams_unit_id=ams_unit_id)
previous_filament = unload_qs.first()
if previous_filament: if previous_filament:
previous_filament.is_loaded_in_ams = False previous_filament.is_loaded_in_ams = False
@@ -411,14 +451,21 @@ class Command(BaseCommand):
previous_filament.save() previous_filament.save()
logger.info( logger.info(
f"Auto-unloaded {previous_filament} from Tray {tray_id} " f"Auto-unloaded {previous_filament} from Tray {tray_id} "
f"(replaced by {filament.brand} {filament.type} - {filament.color})" f"(unit {ams_unit_id}; replaced by {filament.brand} {filament.type} - {filament.color})"
) )
filament.is_loaded_in_ams = True filament.is_loaded_in_ams = True
filament.current_tray_id = tray_id filament.current_tray_id = tray_id
if ams_unit_id is not None:
filament.ams_unit_id = ams_unit_id
if ams_type_label:
filament.ams_type = ams_type_label
filament.last_loaded_date = timezone.now() filament.last_loaded_date = timezone.now()
if self.verbose: if self.verbose:
logger.debug(f"Updated filament location: Tray {tray_id}") logger.debug(f"Updated filament location: unit={ams_unit_id} tray={tray_id}")
elif ams_type_label and filament.ams_type != ams_type_label:
# Same slot but ams_type was previously unknown — fill it in.
filament.ams_type = ams_type_label
filament.save() filament.save()
@@ -439,10 +486,13 @@ class Command(BaseCommand):
if filament: if filament:
remain_percent = tray_data.get('remain_percent') remain_percent = tray_data.get('remain_percent')
if remain_percent is not None: if remain_percent is not None:
self._update_filament_status(filament, tray_id, remain_percent) self._update_filament_status(filament, tray_id, remain_percent, tray_data)
unit_id = str(int(tray_id) // 4) if tray_id.isdigit() else None # Locate the AMS unit this tray belongs to. Use the unit_id supplied
unit_data = ams_units.get(unit_id, {}) # by the snapshot directly (matches MQTT ams[i].id, including 128 for AMS HT)
# — the legacy `tray_id // 4` math breaks for AMS HT.
unit_id_int = tray_data.get('ams_unit_id')
unit_data = ams_units.get(str(unit_id_int)) if unit_id_int is not None else {}
FilamentSnapshot.objects.create( FilamentSnapshot.objects.create(
printer_metric=printer_metric, printer_metric=printer_metric,
@@ -595,6 +645,10 @@ class Command(BaseCommand):
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")), chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")), nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
nozzle_type=snapshot.get("nozzle_type"), nozzle_type=snapshot.get("nozzle_type"),
nozzle_temp_left=self._to_decimal(snapshot.get("nozzle_temp_left")),
nozzle_target_temp_left=self._to_decimal(snapshot.get("nozzle_target_temp_left")),
nozzle_diameter_left=self._to_decimal(snapshot.get("nozzle_diameter_left")),
nozzle_type_left=snapshot.get("nozzle_type_left"),
gcode_state=snapshot.get("gcode_state"), gcode_state=snapshot.get("gcode_state"),
print_type=snapshot.get("print_type"), print_type=snapshot.get("print_type"),
print_percent=snapshot.get("print_percent"), print_percent=snapshot.get("print_percent"),

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.2.8 on 2026-05-07 04:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bambu_run", "0003_cloud_task"),
]
operations = [
migrations.AddField(
model_name="filament",
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 spool is loaded in (AMS / AMS 2 Pro / AMS HT)",
max_length=32,
),
),
migrations.AddField(
model_name="filament",
name="ams_unit_id",
field=models.PositiveSmallIntegerField(
blank=True,
db_index=True,
help_text="Which physical AMS unit this spool is loaded in (matches MQTT ams[i].id; 128 = AMS HT)",
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_diameter_left",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Left nozzle diameter (mm). H2C only.",
max_digits=3,
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_target_temp_left",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Left extruder target temperature (°C). H2C only.",
max_digits=5,
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_temp_left",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Left extruder current temperature (°C). H2C only.",
max_digits=5,
null=True,
),
),
migrations.AddField(
model_name="printermetrics",
name="nozzle_type_left",
field=models.CharField(
blank=True,
help_text="Left nozzle type (e.g. HS01-0.4). H2C only.",
max_length=50,
null=True,
),
),
migrations.AlterField(
model_name="filament",
name="current_tray_id",
field=models.IntegerField(
blank=True,
help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)",
null=True,
),
),
]

View File

@@ -2,6 +2,33 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
# Bambu AMS model-code → human-readable type label.
# Source: live H2C MQTT probe — `print.ams.ams[i].info` field.
# Add new codes as they are observed (e.g. AMS Lite, future variants).
AMS_INFO_TO_TYPE = {
"1001": "AMS",
"1003": "AMS 2 Pro",
"2104": "AMS HT",
}
AMS_TYPE_CHOICES = [
("AMS", "AMS"),
("AMS 2 Pro", "AMS 2 Pro"),
("AMS HT", "AMS HT"),
]
def ams_type_from_info(info_code) -> str:
"""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
code is unknown, that bit is a reasonable secondary hint for HT identification.
"""
if info_code is None:
return ""
return AMS_INFO_TO_TYPE.get(str(info_code), "")
class Printer(models.Model): class Printer(models.Model):
"""Represents a Bambu Lab 3D printer device""" """Represents a Bambu Lab 3D printer device"""
@@ -58,12 +85,32 @@ class PrinterMetrics(models.Model):
max_digits=5, decimal_places=2, null=True, blank=True max_digits=5, decimal_places=2, null=True, blank=True
) )
# Nozzle info # Nozzle info — single-nozzle / right-side back-compat fields. On dual-nozzle
# printers (H2C) these mirror the right extruder; the left extruder uses the
# `_left` columns below.
nozzle_diameter = models.DecimalField( nozzle_diameter = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True max_digits=3, decimal_places=2, null=True, blank=True
) )
nozzle_type = models.CharField(max_length=50, null=True, blank=True) nozzle_type = models.CharField(max_length=50, null=True, blank=True)
# H2C dual-nozzle: left-side fields (NULL on single-nozzle printers).
nozzle_temp_left = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Left extruder current temperature (°C). H2C only."
)
nozzle_target_temp_left = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
help_text="Left extruder target temperature (°C). H2C only."
)
nozzle_diameter_left = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True,
help_text="Left nozzle diameter (mm). H2C only."
)
nozzle_type_left = models.CharField(
max_length=50, null=True, blank=True,
help_text="Left nozzle type (e.g. HS01-0.4). H2C only."
)
# Print job status # Print job status
gcode_state = models.CharField( gcode_state = models.CharField(
max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc." max_length=50, null=True, blank=True, help_text="FINISH, RUNNING, IDLE, etc."
@@ -365,7 +412,16 @@ class Filament(models.Model):
) )
current_tray_id = models.IntegerField( current_tray_id = models.IntegerField(
null=True, blank=True, null=True, blank=True,
help_text="Which AMS slot (0-3) if loaded" help_text="Tray slot index within its AMS unit (0-3 for AMS/AMS 2 Pro, 0 for AMS HT)"
)
ams_unit_id = models.PositiveSmallIntegerField(
null=True, blank=True, db_index=True,
help_text="Which physical AMS unit this spool is loaded in (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 spool is loaded in (AMS / AMS 2 Pro / AMS HT)"
) )
last_loaded_date = models.DateTimeField( last_loaded_date = models.DateTimeField(
null=True, blank=True, null=True, blank=True,

View File

@@ -335,10 +335,16 @@ class PrinterState:
wifi_signal: str = "" wifi_signal: str = ""
wifi_signal_dbm: int = 0 wifi_signal_dbm: int = 0
# Nozzle info # Nozzle info — single-nozzle / right-side back-compat fields.
nozzle_diameter: float = 0.4 nozzle_diameter: float = 0.4
nozzle_type: str = "" nozzle_type: str = ""
# H2C dual-nozzle: left-side fields (None on single-nozzle printers).
nozzle_temp_left: Optional[float] = None
nozzle_target_temp_left: Optional[float] = None
nozzle_diameter_left: Optional[float] = None
nozzle_type_left: Optional[str] = None
# System status # System status
home_flag: int = 0 home_flag: int = 0
hw_switch_state: int = 0 hw_switch_state: int = 0
@@ -410,6 +416,21 @@ 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
# under `print.device.extruder.info[]` as a 2-element array (index 0 =
# right, index 1 = left). The `temp` field is bit-packed:
# `temp_raw = (target << 16) | current`, both °C as ints.
nozzle_temp_left = None
nozzle_target_temp_left = None
device = print_data.get("device") or {}
extruders = (device.get("extruder") or {}).get("info") or []
if len(extruders) >= 2:
left = extruders[1]
t = left.get("temp")
if isinstance(t, int):
nozzle_target_temp_left = float((t >> 16) & 0xFFFF)
nozzle_temp_left = float(t & 0xFFFF)
return cls( return cls(
timestamp=timestamp, timestamp=timestamp,
sequence_id=str(print_data.get("sequence_id", "")), sequence_id=str(print_data.get("sequence_id", "")),
@@ -438,6 +459,13 @@ class PrinterState:
wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal), wifi_signal_dbm=cls._parse_wifi_signal(wifi_signal),
nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)), nozzle_diameter=float(print_data.get("nozzle_diameter", 0.4)),
nozzle_type=print_data.get("nozzle_type", ""), nozzle_type=print_data.get("nozzle_type", ""),
nozzle_temp_left=nozzle_temp_left,
nozzle_target_temp_left=nozzle_target_temp_left,
# Diameter/type per side: H2C currently uses uniform nozzles, so reuse top-level
# values. If a future probe shows per-side diameter/type variance, plumb it from
# `device.nozzle.info[]` cross-referenced against `device.extruder.info[i].id`.
nozzle_diameter_left=float(print_data.get("nozzle_diameter", 0.4)) if nozzle_temp_left is not None else None,
nozzle_type_left=print_data.get("nozzle_type", "") if nozzle_temp_left is not None else None,
home_flag=int(print_data.get("home_flag", 0)), home_flag=int(print_data.get("home_flag", 0)),
hw_switch_state=int(print_data.get("hw_switch_state", 0)), hw_switch_state=int(print_data.get("hw_switch_state", 0)),
mc_print_stage=str(print_data.get("mc_print_stage", "")), mc_print_stage=str(print_data.get("mc_print_stage", "")),
@@ -473,6 +501,14 @@ class PrinterState:
"chamber_temp": round(self.chamber_temp, 2), "chamber_temp": round(self.chamber_temp, 2),
"nozzle_diameter": self.nozzle_diameter, "nozzle_diameter": self.nozzle_diameter,
"nozzle_type": self.nozzle_type, "nozzle_type": self.nozzle_type,
"nozzle_temp_left": (
round(self.nozzle_temp_left, 2) if self.nozzle_temp_left is not None else None
),
"nozzle_target_temp_left": (
round(self.nozzle_target_temp_left, 2) if self.nozzle_target_temp_left is not None else None
),
"nozzle_diameter_left": self.nozzle_diameter_left,
"nozzle_type_left": self.nozzle_type_left,
"gcode_state": self.gcode_state, "gcode_state": self.gcode_state,
"print_type": self.print_type, "print_type": self.print_type,
"print_percent": self.print_percent, "print_percent": self.print_percent,
@@ -515,8 +551,19 @@ class PrinterState:
snapshot["tray_now"] = self.ams.tray_now snapshot["tray_now"] = self.ams.tray_now
snapshot["ams_version"] = self.ams.version snapshot["ams_version"] = self.ams.version
from .models import ams_type_from_info
filaments = [] filaments = []
for unit in self.ams.units: for unit in self.ams.units:
# `unit_id` is the AMS unit's own id from the MQTT payload — for the
# original AMS / AMS 2 Pro it's a small int (0,1,2,...); for AMS HT
# it has the 0x80 bit set (e.g. 128). Don't compute tray_id // 4 —
# multi-AMS-type setups are not contiguous.
try:
unit_id_int = int(unit.unit_id)
except (TypeError, ValueError):
unit_id_int = None
ams_type_label = ams_type_from_info(unit.info)
for tray in unit.trays: for tray in unit.trays:
if tray.tray_type: if tray.tray_type:
filaments.append({ filaments.append({
@@ -542,6 +589,9 @@ class PrinterState:
"tray_bed_temp": tray.tray_bed_temp, "tray_bed_temp": tray.tray_bed_temp,
"bed_temp_type": tray.bed_temp_type, "bed_temp_type": tray.bed_temp_type,
"cols": tray.cols, "cols": tray.cols,
"ams_unit_id": unit_id_int,
"ams_info": unit.info,
"ams_type": ams_type_label,
}) })
snapshot["filaments"] = filaments snapshot["filaments"] = filaments
@@ -552,6 +602,7 @@ class PrinterState:
"ams_id": unit.ams_id, "ams_id": unit.ams_id,
"chip_id": unit.chip_id, "chip_id": unit.chip_id,
"info": unit.info, "info": unit.info,
"ams_type": ams_type_from_info(unit.info),
"humidity": unit.humidity, "humidity": unit.humidity,
"humidity_raw": unit.humidity_raw, "humidity_raw": unit.humidity_raw,
"temp": unit.temp, "temp": unit.temp,

View File

@@ -1,7 +1,7 @@
// 3D Printer Charts Initialization and Management // 3D Printer Charts Initialization and Management
// Chart.js implementation for printer metrics visualization // Chart.js implementation for printer metrics visualization
let nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart; let nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart;
let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart; let wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart;
function showNoDataMessage(canvasId) { function showNoDataMessage(canvasId) {
@@ -75,6 +75,50 @@ function initPrinterCharts(printerData, apiUrl) {
options: getTemperatureChartOptions(tickColor, gridColor, '°C') options: getTemperatureChartOptions(tickColor, gridColor, '°C')
}); });
// Initialize Left Nozzle Temperature Chart (H2C-class dual-nozzle).
// Mounted only when the canvas exists AND the API returned non-null
// left-side samples — single-nozzle printers leave the column NULL.
const nozzleLeftCanvas = document.getElementById('nozzleTempLeftChart');
const hasLeftData = Array.isArray(printerData.nozzle_temp_left)
&& printerData.nozzle_temp_left.some(v => v !== null && v !== undefined);
if (nozzleLeftCanvas && hasLeftData) {
const nozzleLeftCtx = nozzleLeftCanvas.getContext('2d');
nozzleTempLeftChart = new Chart(nozzleLeftCtx, {
type: 'line',
data: {
labels: printerData.timestamps,
datasets: [
{
label: 'Actual Temp (Left)',
data: printerData.nozzle_temp_left,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 3,
spanGaps: true
},
{
label: 'Target Temp (Left)',
data: printerData.nozzle_target_temp_left,
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.05)',
borderDash: [5, 5],
tension: 0.3,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 3,
spanGaps: true
}
]
},
options: getTemperatureChartOptions(tickColor, gridColor, '°C')
});
} else if (nozzleLeftCanvas) {
showNoDataMessage('nozzleTempLeftChart');
}
// Initialize Bed Temperature Chart // Initialize Bed Temperature Chart
const bedCtx = document.getElementById('bedTempChart').getContext('2d'); const bedCtx = document.getElementById('bedTempChart').getContext('2d');
bedTempChart = new Chart(bedCtx, { bedTempChart = new Chart(bedCtx, {
@@ -702,7 +746,7 @@ function updateChartTheme() {
// Update all charts // Update all charts
const charts = [ const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart, nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
]; ];
@@ -804,7 +848,7 @@ function applyDateSeparatorsToAllPrinterCharts(timestamps, dates) {
const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates); const sepAnnotations = buildDateSeparatorAnnotations(timestamps, dates);
const charts = [ const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart, nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
]; ];

View File

@@ -200,6 +200,13 @@ function updateAllPrinterCharts(data) {
{ data: data.nozzle_target_temp, datasetIndex: 1 } { data: data.nozzle_target_temp, datasetIndex: 1 }
]); ]);
if (typeof nozzleTempLeftChart !== 'undefined' && nozzleTempLeftChart) {
updateChartData(nozzleTempLeftChart, data.timestamps, [
{ data: data.nozzle_temp_left || [], datasetIndex: 0 },
{ data: data.nozzle_target_temp_left || [], datasetIndex: 1 }
]);
}
updateChartData(bedTempChart, data.timestamps, [ updateChartData(bedTempChart, data.timestamps, [
{ data: data.bed_temp, datasetIndex: 0 }, { data: data.bed_temp, datasetIndex: 0 },
{ data: data.bed_target_temp, datasetIndex: 1 } { data: data.bed_target_temp, datasetIndex: 1 }
@@ -269,7 +276,7 @@ function addProjectMarkersToCharts(markers, timestamps) {
console.log('Adding project markers:', markers); console.log('Adding project markers:', markers);
const charts = [ const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart, nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
]; ];
@@ -400,7 +407,7 @@ function resetPrinterControls() {
// Clear annotations and reload with original data // Clear annotations and reload with original data
const charts = [ const charts = [
nozzleTempChart, bedTempChart, printProgressChart, fanSpeedsChart, nozzleTempChart, nozzleTempLeftChart, bedTempChart, printProgressChart, fanSpeedsChart,
wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart wifiSignalChart, amsConditionsChart, layerProgressChart, filamentTimelineChart
]; ];

View File

@@ -70,14 +70,22 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<select name="loaded" class="form-select"> <select name="loaded" class="form-select">
<option value="">All Spools</option> <option value="">All Spools</option>
<option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option> <option value="yes" {% if request.GET.loaded == 'yes' %}selected{% endif %}>Loaded in AMS</option>
<option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option> <option value="no" {% if request.GET.loaded == 'no' %}selected{% endif %}>Not Loaded</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<select name="ams_type" class="form-select">
<option value="">All AMS Types</option>
{% for at in ams_type_choices %}
<option value="{{ at }}" {% if request.GET.ams_type == at %}selected{% endif %}>{{ at }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-secondary">Filter</button> <button type="submit" class="btn btn-secondary">Filter</button>
<a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a> <a href="{% url 'bambu_run:filament_list' %}" class="btn btn-outline-secondary">Reset</a>
</div> </div>
@@ -149,7 +157,11 @@
</td> </td>
<td class="align-middle"> <td class="align-middle">
{% if filament.is_loaded_in_ams %} {% if filament.is_loaded_in_ams %}
<span class="badge bg-success">AMS Tray {{ filament.current_tray_id }}</span> <span class="badge bg-success">
{% if filament.ams_type %}{{ filament.ams_type }}{% else %}AMS{% endif %}
{% if filament.ams_unit_id is not None %}#{{ filament.ams_unit_id }}{% endif %}
· Tray {{ filament.current_tray_id }}
</span>
{% else %} {% else %}
<span class="badge bg-secondary">Storage</span> <span class="badge bg-secondary">Storage</span>
{% endif %} {% endif %}

View File

@@ -22,7 +22,41 @@
<!-- Summary Cards Row --> <!-- Summary Cards Row -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<!-- Nozzle Temperature Card --> {% if stats.is_dual_nozzle %}
<!-- Right Nozzle (dual-nozzle printers, e.g. H2C) -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Right Nozzle</div>
<div class="stat-value">{{ stats.nozzle_temp|floatformat:1 }}&deg;C</div>
<div class="text-muted small">target {{ stats.nozzle_target_temp|floatformat:0 }}&deg;C
{% if stats.nozzle_type %}· {{ stats.nozzle_type }}{% endif %}</div>
</div>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div>
<!-- Left Nozzle -->
<div class="col-12 col-md-6 col-lg-3">
<div class="card infra-card-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Left Nozzle</div>
<div class="stat-value">{{ stats.nozzle_temp_left|floatformat:1 }}&deg;C</div>
<div class="text-muted small">target {{ stats.nozzle_target_temp_left|floatformat:0 }}&deg;C
{% if stats.nozzle_type_left %}· {{ stats.nozzle_type_left }}{% endif %}</div>
</div>
<i class="bi bi-thermometer-high" style="font-size: 2rem; opacity: 0.3;"></i>
</div>
</div>
</div>
</div>
{% else %}
<!-- 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">
<div class="card-body"> <div class="card-body">
@@ -36,6 +70,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- 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">
@@ -266,10 +301,10 @@
<!-- Charts Section --> <!-- Charts Section -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<!-- Nozzle Temperature Chart --> <!-- Nozzle Temperature Chart (right side / single nozzle) -->
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card"> <div class="card">
<div class="card-header">Nozzle Temperature</div> <div class="card-header">{% if stats.is_dual_nozzle %}Right Nozzle Temperature{% else %}Nozzle Temperature{% endif %}</div>
<div class="card-body"> <div class="card-body">
<div class="chart-container"> <div class="chart-container">
<canvas id="nozzleTempChart"></canvas> <canvas id="nozzleTempChart"></canvas>
@@ -278,6 +313,20 @@
</div> </div>
</div> </div>
{% if stats.is_dual_nozzle %}
<!-- Left Nozzle Temperature Chart (H2C-class dual-nozzle) -->
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">Left Nozzle Temperature</div>
<div class="card-body">
<div class="chart-container">
<canvas id="nozzleTempLeftChart"></canvas>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Bed Temperature Chart --> <!-- Bed Temperature Chart -->
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card"> <div class="card">

View File

@@ -76,6 +76,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
float(m.nozzle_target_temp) if m.nozzle_target_temp else None float(m.nozzle_target_temp) if m.nozzle_target_temp else None
for m in metrics for m in metrics
], ],
"nozzle_temp_left": [
float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None
for m in metrics
],
"nozzle_target_temp_left": [
float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None
for m in metrics
],
"bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics], "bed_temp": [float(m.bed_temp) if m.bed_temp else None for m in metrics],
"bed_target_temp": [ "bed_target_temp": [
float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics float(m.bed_target_temp) if m.bed_target_temp else None for m in metrics
@@ -150,6 +158,14 @@ class PrinterDashboardView(LoginRequiredMixin, TemplateView):
stats = { stats = {
"nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0, "nozzle_temp": float(latest_metric.nozzle_temp) if latest_metric.nozzle_temp else 0,
"nozzle_target_temp": float(latest_metric.nozzle_target_temp) if latest_metric.nozzle_target_temp else 0,
"nozzle_diameter": float(latest_metric.nozzle_diameter) if latest_metric.nozzle_diameter else None,
"nozzle_type": latest_metric.nozzle_type or "",
"nozzle_temp_left": float(latest_metric.nozzle_temp_left) if latest_metric.nozzle_temp_left is not None else None,
"nozzle_target_temp_left": float(latest_metric.nozzle_target_temp_left) if latest_metric.nozzle_target_temp_left is not None else None,
"nozzle_diameter_left": float(latest_metric.nozzle_diameter_left) if latest_metric.nozzle_diameter_left is not None else None,
"nozzle_type_left": latest_metric.nozzle_type_left or "",
"is_dual_nozzle": latest_metric.nozzle_temp_left is not None,
"bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0, "bed_temp": float(latest_metric.bed_temp) if latest_metric.bed_temp else 0,
"chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0, "chamber_temp": float(latest_metric.chamber_temp) if latest_metric.chamber_temp else 0,
"print_percent": latest_metric.print_percent or 0, "print_percent": latest_metric.print_percent or 0,
@@ -347,6 +363,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
dates = [] dates = []
nozzle_temp = [] nozzle_temp = []
nozzle_target_temp = [] nozzle_target_temp = []
nozzle_temp_left = []
nozzle_target_temp_left = []
bed_temp = [] bed_temp = []
bed_target_temp = [] bed_target_temp = []
print_percent = [] print_percent = []
@@ -374,6 +392,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
dates.append(ts.strftime('%Y-%m-%d')) dates.append(ts.strftime('%Y-%m-%d'))
nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None) nozzle_temp.append(float(m.nozzle_temp) if m.nozzle_temp else None)
nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None) nozzle_target_temp.append(float(m.nozzle_target_temp) if m.nozzle_target_temp else None)
nozzle_temp_left.append(float(m.nozzle_temp_left) if m.nozzle_temp_left is not None else None)
nozzle_target_temp_left.append(float(m.nozzle_target_temp_left) if m.nozzle_target_temp_left is not None else None)
bed_temp.append(float(m.bed_temp) if m.bed_temp else None) bed_temp.append(float(m.bed_temp) if m.bed_temp else None)
bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None) bed_target_temp.append(float(m.bed_target_temp) if m.bed_target_temp else None)
print_percent.append(m.print_percent if m.print_percent else 0) print_percent.append(m.print_percent if m.print_percent else 0)
@@ -451,6 +471,8 @@ class PrinterDataAPIView(LoginRequiredMixin, View):
"dates": dates, "dates": dates,
"nozzle_temp": nozzle_temp, "nozzle_temp": nozzle_temp,
"nozzle_target_temp": nozzle_target_temp, "nozzle_target_temp": nozzle_target_temp,
"nozzle_temp_left": nozzle_temp_left,
"nozzle_target_temp_left": nozzle_target_temp_left,
"bed_temp": bed_temp, "bed_temp": bed_temp,
"bed_target_temp": bed_target_temp, "bed_target_temp": bed_target_temp,
"print_percent": print_percent, "print_percent": print_percent,
@@ -561,6 +583,10 @@ class FilamentListView(LoginRequiredMixin, ListView):
elif loaded == 'no': elif loaded == 'no':
queryset = queryset.filter(is_loaded_in_ams=False) queryset = queryset.filter(is_loaded_in_ams=False)
ams_type = self.request.GET.get('ams_type')
if ams_type:
queryset = queryset.filter(ams_type=ams_type)
search = self.request.GET.get('search') search = self.request.GET.get('search')
if search: if search:
queryset = queryset.filter( queryset = queryset.filter(
@@ -580,6 +606,11 @@ class FilamentListView(LoginRequiredMixin, ListView):
context['filament_types'] = sorted( context['filament_types'] = sorted(
set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True)) set(Filament.objects.exclude(type__isnull=True).exclude(type='').values_list('type', flat=True))
) )
context['ams_type_choices'] = sorted(
set(
Filament.objects.exclude(ams_type='').values_list('ams_type', flat=True)
)
)
return context return context

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bambu-run" name = "bambu-run"
version = "0.1.4" version = "0.1.5"
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"}