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
This commit is contained in:
RNL
2026-05-07 14:51:31 +10:00
parent 6fadccb527
commit dd57a963ac
10 changed files with 429 additions and 25 deletions

View File

@@ -111,6 +111,8 @@ class Command(BaseCommand):
try:
if run_once:
import time as _time
_time.sleep(5)
self._collect_printer_data()
logger.info("Single collection completed successfully")
else:
@@ -122,6 +124,24 @@ class Command(BaseCommand):
logger.exception(f"Fatal error in main loop: {e}")
raise CommandError(f"Runner failed: {e}")
def _request_full_status_when_ready(self, timeout: float = 20.0) -> None:
"""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):
log_level = logging.DEBUG if self.verbose else logging.INFO
logger.setLevel(log_level)
@@ -167,6 +187,11 @@ class Command(BaseCommand):
logger.info("Initiating MQTT connection...")
self.printer_client.connect(blocking=False)
logger.info("MQTT connection initiated (non-blocking)")
# Request full status so AMS + dual-nozzle data arrive on startup.
try:
self._request_full_status_when_ready()
except Exception as e:
logger.warning("pushall request skipped (non-fatal): %s", e)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" in str(e) or "SSL" in str(e):
@@ -377,6 +402,8 @@ class Command(BaseCommand):
created_by='Auto Detection',
is_loaded_in_ams=True,
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(),
)
@@ -390,9 +417,13 @@ class Command(BaseCommand):
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
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:
filament.remaining_percent = remain_percent
filament.update_remaining_weight()
@@ -400,10 +431,19 @@ class Command(BaseCommand):
if self.verbose:
logger.debug(f"Updated filament {filament}: {remain_percent}%")
if not filament.is_loaded_in_ams or filament.current_tray_id != tray_id:
previous_filament = Filament.objects.filter(
location_changed = (
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
).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:
previous_filament.is_loaded_in_ams = False
@@ -411,14 +451,21 @@ class Command(BaseCommand):
previous_filament.save()
logger.info(
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.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()
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()
@@ -439,10 +486,13 @@ class Command(BaseCommand):
if filament:
remain_percent = tray_data.get('remain_percent')
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
unit_data = ams_units.get(unit_id, {})
# Locate the AMS unit this tray belongs to. Use the unit_id supplied
# 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(
printer_metric=printer_metric,
@@ -595,6 +645,10 @@ class Command(BaseCommand):
chamber_temp=self._to_decimal(snapshot.get("chamber_temp")),
nozzle_diameter=self._to_decimal(snapshot.get("nozzle_diameter")),
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"),
print_type=snapshot.get("print_type"),
print_percent=snapshot.get("print_percent"),