|
@@ -244,20 +244,26 @@ root_logger.setLevel(log_level)
|
|
|
|
|
|
|
|
# Trace-ID injection: this filter populates record.trace_id from the
|
|
# Trace-ID injection: this filter populates record.trace_id from the
|
|
|
# per-request ContextVar so the format string above can reference it.
|
|
# per-request ContextVar so the format string above can reference it.
|
|
|
-# Attached to the root logger so EVERY record (application, uvicorn,
|
|
|
|
|
-# third-party) gets the field — without it, the format string would
|
|
|
|
|
-# raise KeyError on records that don't naturally carry a trace_id
|
|
|
|
|
-# attribute. See backend/app/core/trace.py for the ContextVar that
|
|
|
|
|
-# the filter reads.
|
|
|
|
|
|
|
+# Attached to each HANDLER (not the root logger) because Python's
|
|
|
|
|
+# logging semantics only invoke a logger's filters on records that
|
|
|
|
|
+# *originated* at that logger — records propagated up from child
|
|
|
|
|
+# loggers (every named logger in the app) never trigger root's filter.
|
|
|
|
|
+# Putting it on the handlers means every record any handler emits gets
|
|
|
|
|
+# trace_id injected just before the formatter runs, regardless of which
|
|
|
|
|
+# logger created the record. Without this, the formatter raises
|
|
|
|
|
+# KeyError on every child-logger record and the record is silently
|
|
|
|
|
+# dropped — which is exactly the "logs/bambuddy.log only shows logs
|
|
|
|
|
+# partially" bug we hit. See backend/app/core/trace.py for the
|
|
|
|
|
+# ContextVar the filter reads.
|
|
|
from backend.app.core.trace import TraceIDFilter
|
|
from backend.app.core.trace import TraceIDFilter
|
|
|
|
|
|
|
|
_trace_id_filter = TraceIDFilter()
|
|
_trace_id_filter = TraceIDFilter()
|
|
|
-root_logger.addFilter(_trace_id_filter)
|
|
|
|
|
|
|
|
|
|
# Console handler - always enabled
|
|
# Console handler - always enabled
|
|
|
console_handler = logging.StreamHandler()
|
|
console_handler = logging.StreamHandler()
|
|
|
console_handler.setLevel(log_level)
|
|
console_handler.setLevel(log_level)
|
|
|
console_handler.setFormatter(logging.Formatter(log_format))
|
|
console_handler.setFormatter(logging.Formatter(log_format))
|
|
|
|
|
+console_handler.addFilter(_trace_id_filter)
|
|
|
root_logger.addHandler(console_handler)
|
|
root_logger.addHandler(console_handler)
|
|
|
|
|
|
|
|
# File handler - only in production or if explicitly enabled
|
|
# File handler - only in production or if explicitly enabled
|
|
@@ -271,6 +277,7 @@ if app_settings.log_to_file:
|
|
|
)
|
|
)
|
|
|
file_handler.setLevel(log_level)
|
|
file_handler.setLevel(log_level)
|
|
|
file_handler.setFormatter(logging.Formatter(log_format))
|
|
file_handler.setFormatter(logging.Formatter(log_format))
|
|
|
|
|
+ file_handler.addFilter(_trace_id_filter)
|
|
|
root_logger.addHandler(file_handler)
|
|
root_logger.addHandler(file_handler)
|
|
|
logging.info("Logging to file: %s", log_file)
|
|
logging.info("Logging to file: %s", log_file)
|
|
|
|
|
|
|
@@ -342,6 +349,77 @@ _bed_cool_waiters: dict[int, dict] = {}
|
|
|
# as "cancelled" (stopped by user) so the correct notification email is sent.
|
|
# as "cancelled" (stopped by user) so the correct notification email is sent.
|
|
|
_user_stopped_printers: set[int] = set()
|
|
_user_stopped_printers: set[int] = set()
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+# HMS short-code → human-readable failure reason. Used by _dispatch_archive_update
|
|
|
|
|
+# when status="failed" to label the print's failure_reason in archives.
|
|
|
|
|
+#
|
|
|
|
|
+# Earlier code matched on `module` alone (e.g. "any module 0x0C HMS → Layer shift"),
|
|
|
|
|
+# which is wrong on two counts:
|
|
|
|
|
+# 1. Real layer-shift codes live in module 0x03 (see Bambu wiki), not 0x0C.
|
|
|
|
|
+# 2. Module 0x0C is "Motion Controller" — broad category that also covers cameras
|
|
|
|
|
+# and visual markers, AND the H2D firmware emits a 0x0C HMS (0C00_001B, not in
|
|
|
|
|
+# the public wiki) as part of its user-cancel sequence. Matching on the module
|
|
|
|
|
+# alone caused user-cancellations to be archived as "Layer shift" failures.
|
|
|
|
|
+# We now match by full short code only — anything not in this map leaves
|
|
|
|
|
+# failure_reason=None rather than guessing.
|
|
|
|
|
+_HMS_FAILURE_REASONS: dict[str, str] = {
|
|
|
|
|
+ # Layer shift / step loss
|
|
|
|
|
+ "0300_4057": "Layer shift",
|
|
|
|
|
+ "0300_4068": "Layer shift",
|
|
|
|
|
+ "0300_800C": "Layer shift",
|
|
|
|
|
+ # Filament runout (printer-side & per-AMS-slot)
|
|
|
|
|
+ "0300_8004": "Filament runout",
|
|
|
|
|
+ "0700_8011": "Filament runout",
|
|
|
|
|
+ "0701_8011": "Filament runout",
|
|
|
|
|
+ "0702_8011": "Filament runout",
|
|
|
|
|
+ "0703_8011": "Filament runout",
|
|
|
|
|
+ "0704_8011": "Filament runout",
|
|
|
|
|
+ "0705_8011": "Filament runout",
|
|
|
|
|
+ "0706_8011": "Filament runout",
|
|
|
|
|
+ "0707_8011": "Filament runout",
|
|
|
|
|
+ "07FF_8011": "Filament runout",
|
|
|
|
|
+ # Clogged nozzle / extruder
|
|
|
|
|
+ "0300_4006": "Clogged nozzle",
|
|
|
|
|
+ "0300_8016": "Clogged nozzle",
|
|
|
|
|
+ "0300_801C": "Clogged nozzle",
|
|
|
|
|
+ "0700_8003": "Clogged nozzle",
|
|
|
|
|
+ "0700_8007": "Clogged nozzle",
|
|
|
|
|
+ "0700_8013": "Clogged nozzle",
|
|
|
|
|
+ "0701_8003": "Clogged nozzle",
|
|
|
|
|
+ "0701_8007": "Clogged nozzle",
|
|
|
|
|
+ "0701_8013": "Clogged nozzle",
|
|
|
|
|
+ "0702_8003": "Clogged nozzle",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _hms_short_code(attr: int, code: int | str) -> str:
|
|
|
|
|
+ """Build the canonical "MMMM_CCCC" HMS short code from raw attr/code values."""
|
|
|
|
|
+ if isinstance(code, str):
|
|
|
|
|
+ code_int = int(code.replace("0x", ""), 16) if code else 0
|
|
|
|
|
+ else:
|
|
|
|
|
+ code_int = int(code or 0)
|
|
|
|
|
+ attr_int = int(attr or 0)
|
|
|
|
|
+ return f"{(attr_int >> 16) & 0xFFFF:04X}_{code_int & 0xFFFF:04X}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def derive_failure_reason(status: str, hms_errors: list[dict] | None) -> str | None:
|
|
|
|
|
+ """Derive a human-readable failure_reason for an archived print.
|
|
|
|
|
+
|
|
|
|
|
+ Returns "User cancelled" for cancelled/aborted prints; for failed prints,
|
|
|
|
|
+ returns the first matching reason from _HMS_FAILURE_REASONS, or None when
|
|
|
|
|
+ no HMS code matches (don't guess — null is honest).
|
|
|
|
|
+ """
|
|
|
|
|
+ if status in ("aborted", "cancelled"):
|
|
|
|
|
+ return "User cancelled"
|
|
|
|
|
+ if status != "failed":
|
|
|
|
|
+ return None
|
|
|
|
|
+ for err in hms_errors or []:
|
|
|
|
|
+ short_code = _hms_short_code(err.get("attr", 0), err.get("code", 0))
|
|
|
|
|
+ if short_code in _HMS_FAILURE_REASONS:
|
|
|
|
|
+ return _HMS_FAILURE_REASONS[short_code]
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# Track created_by_id for expected prints so the user email can be sent even when
|
|
# Track created_by_id for expected prints so the user email can be sent even when
|
|
|
# the archive itself doesn't have created_by_id set (e.g. library-file-based prints).
|
|
# the archive itself doesn't have created_by_id set (e.g. library-file-based prints).
|
|
|
# {(printer_id, filename): created_by_id}
|
|
# {(printer_id, filename): created_by_id}
|
|
@@ -3052,33 +3130,14 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
service = ArchiveService(db)
|
|
service = ArchiveService(db)
|
|
|
status = data.get("status", "completed")
|
|
status = data.get("status", "completed")
|
|
|
|
|
|
|
|
- # Auto-detect failure reason
|
|
|
|
|
- failure_reason = None
|
|
|
|
|
- if status == "aborted":
|
|
|
|
|
- failure_reason = "User cancelled"
|
|
|
|
|
- logger.info("[ARCHIVE] Print was aborted by user, setting failure_reason='User cancelled'")
|
|
|
|
|
- elif status == "failed":
|
|
|
|
|
- # Try to determine failure reason from HMS errors
|
|
|
|
|
- hms_errors = data.get("hms_errors", [])
|
|
|
|
|
- if hms_errors:
|
|
|
|
|
- logger.info("[ARCHIVE] HMS errors at failure: %s", hms_errors)
|
|
|
|
|
- # Map known HMS error modules to failure reasons
|
|
|
|
|
- # Module 0x07 = Filament, 0x0C = MC (Motion Controller), etc.
|
|
|
|
|
- for err in hms_errors:
|
|
|
|
|
- module = err.get("module", 0)
|
|
|
|
|
- if module == 0x07: # Filament module
|
|
|
|
|
- failure_reason = "Filament runout"
|
|
|
|
|
- break
|
|
|
|
|
- elif module == 0x0C: # Motion controller
|
|
|
|
|
- failure_reason = "Layer shift"
|
|
|
|
|
- break
|
|
|
|
|
- elif module == 0x05: # Nozzle/extruder
|
|
|
|
|
- failure_reason = "Clogged nozzle"
|
|
|
|
|
- break
|
|
|
|
|
- if failure_reason:
|
|
|
|
|
- logger.info("[ARCHIVE] Detected failure_reason from HMS: %s", failure_reason)
|
|
|
|
|
- else:
|
|
|
|
|
- logger.info("[ARCHIVE] No HMS errors available to determine failure reason")
|
|
|
|
|
|
|
+ hms_errors = data.get("hms_errors", []) if status == "failed" else None
|
|
|
|
|
+ if hms_errors:
|
|
|
|
|
+ logger.info("[ARCHIVE] HMS errors at failure: %s", hms_errors)
|
|
|
|
|
+ failure_reason = derive_failure_reason(status, hms_errors)
|
|
|
|
|
+ if failure_reason:
|
|
|
|
|
+ logger.info("[ARCHIVE] failure_reason=%r (status=%s)", failure_reason, status)
|
|
|
|
|
+ elif status == "failed" and hms_errors:
|
|
|
|
|
+ logger.info("[ARCHIVE] HMS errors present but none matched a known failure-reason short code")
|
|
|
|
|
|
|
|
await service.update_archive_status(
|
|
await service.update_archive_status(
|
|
|
archive_id,
|
|
archive_id,
|