5 Commits

Author SHA1 Message Date
github-actions[bot]
1e35c48be1 chore: bump version to 0.1.11 [skip ci] 2026-08-03 12:43:07 +00:00
RunLit
b16f4b3cac Merge pull request #16 from RunLit/fix/x2d-stale-state-after-reconnect
Force MQTT pushall re-sync after a report gap
2026-08-03 22:42:51 +10:00
RunLit
58400f16cf Merge pull request #15 from RunLit/fix/x2d-right-nozzle-temp
Fix X2D right nozzle temp showing active-nozzle value
2026-08-03 22:42:48 +10:00
RNL
6f281a0cab Force MQTT pushall re-sync after a report gap (fixes #14)
The printer publishes partial deltas most of the time; the accumulator
merges each one onto whatever state it already has. When the printer
powers off, our cloud MQTT connection stays up (it's a persistent
session to Bambu's broker, not a socket to the printer), so nothing
signals the outage. When the printer reconnects and resumes its normal
partial updates, those deltas get merged onto the stale pre-outage
state — fields the delta doesn't mention (e.g. nozzle_temp) stay frozen
at their old values. Sending any command happens to trigger a full
report from the printer, which is why toggling the light "fixes" it.

Track the time of the last message per BambuPrinter instance. When a
new message arrives after a gap longer than
BAMBU_RUN_MQTT_RESYNC_GAP_SECONDS (default 90s), request a pushall
before processing it, forcing a complete state refresh instead of
trusting the partial delta to overwrite stale fields.
2026-08-02 23:14:03 +10:00
RNL
4db0a7d728 Fix X2D right nozzle temp showing active-nozzle value (fixes #13)
Dual-nozzle decoding only read extruder.info[1] for the left nozzle,
leaving the right nozzle to fall back on the legacy top-level
nozzle_temper/nozzle_target_temper fields. On the H2C that field
happens to always mirror the right extruder, but on the X2D it tracks
whichever nozzle is currently active — so during a left-only print the
"Right Nozzle" card showed the left nozzle's temperature.

Decode extruder.info[0] the same bit-packed way as the left side and
prefer it over the legacy field when present.
2026-08-02 22:57:03 +10:00
3 changed files with 59 additions and 6 deletions

View File

@@ -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()

View File

@@ -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
@@ -486,15 +487,29 @@ 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):
@@ -510,8 +525,14 @@ class PrinterState:
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)),
@@ -809,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."""
@@ -914,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)

View File

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