|
@@ -8,6 +8,7 @@ import time
|
|
|
from contextlib import asynccontextmanager
|
|
from contextlib import asynccontextmanager
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from datetime import datetime, timedelta, timezone
|
|
|
from logging.handlers import RotatingFileHandler
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
|
+from pathlib import Path
|
|
|
from urllib.parse import urlparse
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi import FastAPI
|
|
@@ -45,6 +46,7 @@ from backend.app.api.routes import (
|
|
|
notification_templates,
|
|
notification_templates,
|
|
|
notifications,
|
|
notifications,
|
|
|
obico,
|
|
obico,
|
|
|
|
|
+ orca_cloud,
|
|
|
pending_uploads,
|
|
pending_uploads,
|
|
|
print_log,
|
|
print_log,
|
|
|
print_queue,
|
|
print_queue,
|
|
@@ -70,6 +72,7 @@ from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
|
|
|
from backend.app.api.routes.support import init_debug_logging
|
|
from backend.app.api.routes.support import init_debug_logging
|
|
|
from backend.app.core.config import APP_VERSION, settings as app_settings
|
|
from backend.app.core.config import APP_VERSION, settings as app_settings
|
|
|
from backend.app.core.database import async_session, engine, init_db
|
|
from backend.app.core.database import async_session, engine, init_db
|
|
|
|
|
+from backend.app.core.tasks import spawn_background_task
|
|
|
from backend.app.core.websocket import ws_manager
|
|
from backend.app.core.websocket import ws_manager
|
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
|
from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
|
|
from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
|
|
@@ -346,6 +349,13 @@ _expected_prints: dict[tuple[int, str], int] = {}
|
|
|
# Used by usage tracker to map 3MF slots to physical AMS trays
|
|
# Used by usage tracker to map 3MF slots to physical AMS trays
|
|
|
_print_ams_mappings: dict[int, list[int]] = {}
|
|
_print_ams_mappings: dict[int, list[int]] = {}
|
|
|
|
|
|
|
|
|
|
+# Track plate_id for prints from multi-plate 3MFs: {archive_id: plate_id}
|
|
|
|
|
+# Used by usage tracker to scope 3MF parsing to the dispatched plate (#1697).
|
|
|
|
|
+# Populated by direct-Print and queue dispatch paths; queue prints also have a
|
|
|
|
|
+# redundant queue-item lookup in on_print_start so this dict isn't load-bearing
|
|
|
|
|
+# for the queue path. Cleared on print completion or TTL eviction.
|
|
|
|
|
+_print_plate_ids: dict[int, int] = {}
|
|
|
|
|
+
|
|
|
# Track progress milestones for notifications: {printer_id: last_milestone_notified}
|
|
# Track progress milestones for notifications: {printer_id: last_milestone_notified}
|
|
|
# Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
|
|
# Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
|
|
|
_last_progress_milestone: dict[int, int] = {}
|
|
_last_progress_milestone: dict[int, int] = {}
|
|
@@ -564,6 +574,7 @@ def register_expected_print(
|
|
|
archive_id: int,
|
|
archive_id: int,
|
|
|
ams_mapping: list[int] | None = None,
|
|
ams_mapping: list[int] | None = None,
|
|
|
created_by_id: int | None = None,
|
|
created_by_id: int | None = None,
|
|
|
|
|
+ plate_id: int | None = None,
|
|
|
):
|
|
):
|
|
|
"""Register an expected print from reprint/scheduled so we don't create duplicate archives."""
|
|
"""Register an expected print from reprint/scheduled so we don't create duplicate archives."""
|
|
|
# Store with multiple filename variations to catch different naming patterns
|
|
# Store with multiple filename variations to catch different naming patterns
|
|
@@ -576,6 +587,11 @@ def register_expected_print(
|
|
|
# Store AMS mapping for usage tracking at print completion
|
|
# Store AMS mapping for usage tracking at print completion
|
|
|
if ams_mapping is not None:
|
|
if ams_mapping is not None:
|
|
|
_print_ams_mappings[archive_id] = ams_mapping
|
|
_print_ams_mappings[archive_id] = ams_mapping
|
|
|
|
|
+ # Store plate_id for usage tracking when this is a single-plate dispatch from
|
|
|
|
|
+ # a multi-plate 3MF — without this, the direct-Print path attributes the whole
|
|
|
|
|
+ # file's filament total to the spool instead of just the printed plate (#1697).
|
|
|
|
|
+ if plate_id is not None:
|
|
|
|
|
+ _print_plate_ids[archive_id] = plate_id
|
|
|
# Store created_by_id so the user start email can be sent even when the archive
|
|
# Store created_by_id so the user start email can be sent even when the archive
|
|
|
# itself has no created_by_id (e.g. library-file-based queue prints)
|
|
# itself has no created_by_id (e.g. library-file-based queue prints)
|
|
|
if created_by_id is not None:
|
|
if created_by_id is not None:
|
|
@@ -592,7 +608,7 @@ def register_expected_print(
|
|
|
_expected_print_registered_at[(printer_id, base)] = _registered_at
|
|
_expected_print_registered_at[(printer_id, base)] = _registered_at
|
|
|
_expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
|
|
_expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
|
|
|
logging.getLogger(__name__).info(
|
|
logging.getLogger(__name__).info(
|
|
|
- f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
|
|
|
|
|
|
|
+ f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}, plate_id={plate_id}"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -636,6 +652,19 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
|
|
|
return stored_ams_mapping
|
|
return stored_ams_mapping
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _get_start_plate_id(archive_id: int | None) -> int | None:
|
|
|
|
|
+ """Resolve plate_id for print start without consuming stored direct-Print state.
|
|
|
|
|
+
|
|
|
|
|
+ Direct-Print of a single plate from a multi-plate 3MF registers plate_id in
|
|
|
|
|
+ ``_print_plate_ids`` at dispatch time; this lets the spoolman / usage tracker
|
|
|
|
|
+ read it back at print-start without popping (the entry is popped on print
|
|
|
|
|
+ completion or TTL eviction, mirroring ``_print_ams_mappings``).
|
|
|
|
|
+ """
|
|
|
|
|
+ if archive_id is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return _print_plate_ids.get(archive_id)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
|
|
def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
|
|
|
"""Best-effort filament metadata from the MQTT print-start snapshot.
|
|
"""Best-effort filament metadata from the MQTT print-start snapshot.
|
|
|
|
|
|
|
@@ -819,9 +848,27 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
|
|
|
# WebSocket dedup / broadcast logic below, and the connected edge is
|
|
# WebSocket dedup / broadcast logic below, and the connected edge is
|
|
|
# marked True BEFORE the await so concurrent status updates inside
|
|
# marked True BEFORE the await so concurrent status updates inside
|
|
|
# the same connection don't re-trigger reconciliation.
|
|
# the same connection don't re-trigger reconciliation.
|
|
|
- if state.connected and not _printer_reconciled_since_connect.get(printer_id, False):
|
|
|
|
|
|
|
+ #
|
|
|
|
|
+ # Wait for a real push_status before reconciling (#1679): MQTT
|
|
|
|
|
+ # `_on_connect` broadcasts `state` IMMEDIATELY after the broker accepts
|
|
|
|
|
+ # the connection, BEFORE `_request_push_all` round-trips. At that
|
|
|
|
|
+ # instant the `PrinterState` is still on construction defaults — most
|
|
|
|
|
+ # importantly `state.state == "unknown"` and `state.subtask_name == ""`.
|
|
|
|
|
+ # If reconcile spawns here, every in-flight archive falls through to
|
|
|
|
|
+ # the empty-subtask_name trigger and gets synthesised `aborted`, which
|
|
|
|
|
+ # creates a duplicate archive on the real PRINT COMPLETE and
|
|
|
|
|
+ # double-counts filament. Gating on `state.state ∉ ("", "unknown")`
|
|
|
|
|
+ # keeps the #1542 mechanism intact: once the first real push_status
|
|
|
|
|
+ # updates `state.state` (RUNNING / IDLE / FINISH / …), this handler
|
|
|
|
|
+ # fires again with the flag still False — reconcile then runs against
|
|
|
|
|
+ # actual evidence.
|
|
|
|
|
+ state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")
|
|
|
|
|
+ if state.connected and state_known and not _printer_reconciled_since_connect.get(printer_id, False):
|
|
|
_printer_reconciled_since_connect[printer_id] = True
|
|
_printer_reconciled_since_connect[printer_id] = True
|
|
|
- asyncio.create_task(reconcile_stale_active_prints(printer_id))
|
|
|
|
|
|
|
+ spawn_background_task(
|
|
|
|
|
+ reconcile_stale_active_prints(printer_id),
|
|
|
|
|
+ name=f"reconcile-stale-prints-{printer_id}",
|
|
|
|
|
+ )
|
|
|
elif not state.connected and _printer_reconciled_since_connect.get(printer_id, False):
|
|
elif not state.connected and _printer_reconciled_since_connect.get(printer_id, False):
|
|
|
# Re-arm so the next reconnect triggers reconciliation again.
|
|
# Re-arm so the next reconnect triggers reconciliation again.
|
|
|
_printer_reconciled_since_connect[printer_id] = False
|
|
_printer_reconciled_since_connect[printer_id] = False
|
|
@@ -2194,14 +2241,21 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
# before expected-print promotion, so it may have ams_mapping=None when
|
|
# before expected-print promotion, so it may have ams_mapping=None when
|
|
|
# the MQTT request topic subscription failed (common on P1S/A1).
|
|
# the MQTT request topic subscription failed (common on P1S/A1).
|
|
|
_stored_map = _print_ams_mappings.get(expected_archive_id)
|
|
_stored_map = _print_ams_mappings.get(expected_archive_id)
|
|
|
- if _stored_map:
|
|
|
|
|
|
|
+ _stored_plate_id = _print_plate_ids.get(expected_archive_id)
|
|
|
|
|
+ if _stored_map or _stored_plate_id is not None:
|
|
|
try:
|
|
try:
|
|
|
from backend.app.services.usage_tracker import _active_sessions
|
|
from backend.app.services.usage_tracker import _active_sessions
|
|
|
|
|
|
|
|
_ut_session = _active_sessions.get(printer_id)
|
|
_ut_session = _active_sessions.get(printer_id)
|
|
|
- if _ut_session and not _ut_session.ams_mapping:
|
|
|
|
|
|
|
+ if _ut_session and _stored_map and not _ut_session.ams_mapping:
|
|
|
_ut_session.ams_mapping = _stored_map
|
|
_ut_session.ams_mapping = _stored_map
|
|
|
logger.info("[CALLBACK] Injected ams_mapping into usage tracker session: %s", _stored_map)
|
|
logger.info("[CALLBACK] Injected ams_mapping into usage tracker session: %s", _stored_map)
|
|
|
|
|
+ # plate_id injection covers direct-Print of plate N of a multi-plate
|
|
|
|
|
+ # 3MF — queue prints already capture it via the on_print_start queue
|
|
|
|
|
+ # lookup, but direct-Print never goes through the queue (#1697).
|
|
|
|
|
+ if _ut_session and _stored_plate_id is not None and _ut_session.plate_id is None:
|
|
|
|
|
+ _ut_session.plate_id = _stored_plate_id
|
|
|
|
|
+ logger.info("[CALLBACK] Injected plate_id into usage tracker session: %s", _stored_plate_id)
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
@@ -2244,6 +2298,7 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
db,
|
|
db,
|
|
|
printer_manager,
|
|
printer_manager,
|
|
|
ams_mapping=_get_start_ams_mapping(data, archive.id),
|
|
ams_mapping=_get_start_ams_mapping(data, archive.id),
|
|
|
|
|
+ plate_id=_get_start_plate_id(archive.id),
|
|
|
)
|
|
)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
|
|
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
|
|
@@ -2777,6 +2832,7 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
db,
|
|
db,
|
|
|
printer_manager,
|
|
printer_manager,
|
|
|
ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),
|
|
ams_mapping=_get_start_ams_mapping(data, fallback_archive.id),
|
|
|
|
|
+ plate_id=_get_start_plate_id(fallback_archive.id),
|
|
|
)
|
|
)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
|
|
logger.debug("[SPOOLMAN] Could not store tracking for fallback archive: %s", e)
|
|
@@ -2878,6 +2934,7 @@ async def on_print_start(printer_id: int, data: dict):
|
|
|
db,
|
|
db,
|
|
|
printer_manager,
|
|
printer_manager,
|
|
|
ams_mapping=_get_start_ams_mapping(data, archive.id),
|
|
ams_mapping=_get_start_ams_mapping(data, archive.id),
|
|
|
|
|
+ plate_id=_get_start_plate_id(archive.id),
|
|
|
)
|
|
)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
|
|
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
|
|
@@ -3145,6 +3202,158 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
|
|
|
logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
|
|
logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# Defaults for the finish-photo-from-timelapse polling loop (#1397). These are
|
|
|
|
|
+# module-level so tests can monkeypatch them down to ~0 without timing out.
|
|
|
|
|
+_FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS: float = 3.0
|
|
|
|
|
+_FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS: float = 60.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _capture_finish_photo_from_timelapse(
|
|
|
|
|
+ archive_id: int,
|
|
|
|
|
+ archive_dir: Path,
|
|
|
|
|
+) -> str | None:
|
|
|
|
|
+ """Wait for the per-print timelapse to land on the archive and extract its
|
|
|
|
|
+ last frame as the finish photo (#1397).
|
|
|
|
|
+
|
|
|
|
|
+ Bambu firmware stops timelapse recording after the toolhead parks but
|
|
|
|
|
+ before the bed-drop end-gcode runs, so the last frame frames the finished
|
|
|
|
|
+ print correctly. A live camera grab at gcode_state=FINISH captures the
|
|
|
|
|
+ bed already lowered.
|
|
|
|
|
+
|
|
|
|
|
+ ``_scan_for_timelapse_with_retries`` runs in parallel and writes
|
|
|
|
|
+ ``archive.timelapse_path`` when the file lands. This function polls for
|
|
|
|
|
+ that field. Returns the saved photo filename on success, or None if the
|
|
|
|
|
+ timelapse never arrives within the timeout / extraction fails / no
|
|
|
|
|
+ timelapse path was set — in which case the caller falls back to the
|
|
|
|
|
+ existing live-camera capture chain.
|
|
|
|
|
+ """
|
|
|
|
|
+ import uuid
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
|
|
+ from backend.app.services.camera import extract_video_last_frame
|
|
|
|
|
+
|
|
|
|
|
+ logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+ deadline = asyncio.get_event_loop().time() + _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS
|
|
|
|
|
+ poll_interval = _FINISH_PHOTO_TIMELAPSE_POLL_INTERVAL_SECONDS
|
|
|
|
|
+
|
|
|
|
|
+ while True:
|
|
|
|
|
+ async with async_session() as db:
|
|
|
|
|
+ result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
|
|
|
|
+ archive = result.scalar_one_or_none()
|
|
|
|
|
+ timelapse_relpath = archive.timelapse_path if archive else None
|
|
|
|
|
+
|
|
|
|
|
+ if timelapse_relpath:
|
|
|
|
|
+ video_path = app_settings.base_dir / timelapse_relpath
|
|
|
|
|
+ if video_path.exists() and video_path.stat().st_size > 0:
|
|
|
|
|
+ photos_dir = archive_dir / "photos"
|
|
|
|
|
+ photos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
+ filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
|
|
|
+ output_path = photos_dir / filename
|
|
|
|
|
+ if await extract_video_last_frame(video_path, output_path):
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[PHOTO-BG] Extracted finish photo from timelapse %s for archive %s",
|
|
|
|
|
+ video_path.name,
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return filename
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "[PHOTO-BG] Timelapse %s landed but last-frame extraction failed for archive %s; falling back",
|
|
|
|
|
+ video_path.name,
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ if asyncio.get_event_loop().time() >= deadline:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[PHOTO-BG] Timelapse for archive %s didn't land within %.0fs; falling back to live camera",
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ _FINISH_PHOTO_TIMELAPSE_POLL_TIMEOUT_SECONDS,
|
|
|
|
|
+ )
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ await asyncio.sleep(poll_interval)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _cleanup_forced_timelapse(archive_id: int, printer_id: int) -> None:
|
|
|
|
|
+ """Delete the timelapse Bambuddy forced on for #1397's finish-photo path.
|
|
|
|
|
+
|
|
|
|
|
+ Called from the finish-photo background task after the extractor has had
|
|
|
|
|
+ its turn (regardless of whether extraction succeeded — the user never
|
|
|
|
|
+ asked for a video and we shouldn't leave one behind even if ffmpeg
|
|
|
|
|
+ failed). Cleanup is best-effort and never raises: a printer that's
|
|
|
|
|
+ offline at cleanup time means a single orphaned file on the SD card,
|
|
|
|
|
+ not a broken Bambuddy flow.
|
|
|
|
|
+
|
|
|
|
|
+ Cleans both:
|
|
|
|
|
+ - the locally-attached file (clears archive.timelapse_path)
|
|
|
|
|
+ - the printer-side file via FTP DELE
|
|
|
|
|
+ """
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
|
|
+ from backend.app.models.printer import Printer
|
|
|
|
|
+ from backend.app.services.bambu_ftp import delete_file_async
|
|
|
|
|
+
|
|
|
|
|
+ logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+ local_relpath: str | None = None
|
|
|
|
|
+ printer = None
|
|
|
|
|
+
|
|
|
|
|
+ async with async_session() as db:
|
|
|
|
|
+ archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
|
|
|
|
+ archive = archive_result.scalar_one_or_none()
|
|
|
|
|
+ if not archive or not archive.bambuddy_forced_timelapse:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ local_relpath = archive.timelapse_path
|
|
|
|
|
+ if local_relpath:
|
|
|
|
|
+ local_abspath = app_settings.base_dir / local_relpath
|
|
|
|
|
+ try:
|
|
|
|
|
+ if local_abspath.exists():
|
|
|
|
|
+ local_abspath.unlink()
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[FORCED-TIMELAPSE] Deleted local timelapse %s for archive %s",
|
|
|
|
|
+ local_relpath,
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ except OSError as e:
|
|
|
|
|
+ logger.warning("[FORCED-TIMELAPSE] Could not delete local timelapse %s: %s", local_relpath, e)
|
|
|
|
|
+ archive.timelapse_path = None
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ printer_result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
|
|
|
+ printer = printer_result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if printer is None or not local_relpath:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # _scan_for_timelapse_with_retries used the original filename when it
|
|
|
|
|
+ # attached, so the basename of timelapse_path matches the printer-side
|
|
|
|
|
+ # filename. Try the directories the scanner walks (#1397).
|
|
|
|
|
+ filename = Path(local_relpath).name
|
|
|
|
|
+ for remote_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
|
|
|
|
|
+ remote_path = f"{remote_dir}/{filename}"
|
|
|
|
|
+ try:
|
|
|
|
|
+ ok = await delete_file_async(
|
|
|
|
|
+ printer.ip_address,
|
|
|
|
|
+ printer.access_code,
|
|
|
|
|
+ remote_path,
|
|
|
|
|
+ printer_model=printer.model,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.debug("[FORCED-TIMELAPSE] FTP delete attempt failed for %s: %s", remote_path, e)
|
|
|
|
|
+ continue
|
|
|
|
|
+ if ok:
|
|
|
|
|
+ logger.info("[FORCED-TIMELAPSE] Deleted printer-side timelapse %s", remote_path)
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "[FORCED-TIMELAPSE] Could not delete printer-side timelapse %s for archive %s (file may already be gone)",
|
|
|
|
|
+ filename,
|
|
|
|
|
+ archive_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def on_print_running_observed(printer_id: int, data: dict):
|
|
async def on_print_running_observed(printer_id: int, data: dict):
|
|
|
"""Restart-recovery: capture a fresh timelapse baseline for a print that
|
|
"""Restart-recovery: capture a fresh timelapse baseline for a print that
|
|
|
started before Bambuddy came up.
|
|
started before Bambuddy came up.
|
|
@@ -3213,11 +3422,28 @@ def _is_active_archive_stale(archive, state) -> tuple[bool, str]:
|
|
|
|
|
|
|
|
Conservative on purpose: PAUSE / PREPARE / SLICING and any RUNNING state
|
|
Conservative on purpose: PAUSE / PREPARE / SLICING and any RUNNING state
|
|
|
with matching subtask_id+subtask_name is left alone. The cost of a false
|
|
with matching subtask_id+subtask_name is left alone. The cost of a false
|
|
|
- positive is a single misreported "aborted" status that the next real
|
|
|
|
|
- PRINT COMPLETE would have overwritten with the correct status anyway.
|
|
|
|
|
- The cost of a false negative is the ghost-print loop in #1542.
|
|
|
|
|
|
|
+ positive is a duplicate archive on the next real PRINT COMPLETE — the
|
|
|
|
|
+ reactive handler uses ``_active_prints`` for lookup, which the reconcile
|
|
|
|
|
+ clears on synthesis, so the real completion creates a fresh row instead
|
|
|
|
|
+ of overwriting the synthesised one (#1679). The cost of a false negative
|
|
|
|
|
+ is the ghost-print loop in #1542.
|
|
|
|
|
+
|
|
|
|
|
+ Pre-push guard (#1679): when ``state.state`` is empty or ``"unknown"``,
|
|
|
|
|
+ MQTT has connected but the first ``push_status`` response hasn't been
|
|
|
|
|
+ applied yet — ``PrinterState`` is sitting on its construction defaults.
|
|
|
|
|
+ The reconcile caller in ``on_printer_status_change`` is already gated
|
|
|
|
|
+ on a real ``state.state``, so in normal operation this branch is
|
|
|
|
|
+ unreachable; it's kept as belt-and-braces for future callers and for
|
|
|
|
|
+ the narrow window where a partial state update could arrive
|
|
|
|
|
+ (``state.state`` set but ``subtask_name`` not yet populated). Returning
|
|
|
|
|
+ ``not stale`` on degenerate input is strictly conservative: a real
|
|
|
|
|
+ stale archive will still be caught by the next push_status arriving
|
|
|
|
|
+ with terminal state.
|
|
|
"""
|
|
"""
|
|
|
current_state = (state.state or "").upper()
|
|
current_state = (state.state or "").upper()
|
|
|
|
|
+ if current_state in ("", "UNKNOWN"):
|
|
|
|
|
+ # No real push_status yet — PrinterState defaults are not evidence.
|
|
|
|
|
+ return False, ""
|
|
|
if current_state in ("IDLE", "FINISH", "FAILED"):
|
|
if current_state in ("IDLE", "FINISH", "FAILED"):
|
|
|
return True, f"printer state {current_state}"
|
|
return True, f"printer state {current_state}"
|
|
|
# Below here the printer is in a running / pre-running state (RUNNING /
|
|
# Below here the printer is in a running / pre-running state (RUNNING /
|
|
@@ -3685,7 +3911,10 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e)
|
|
logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e)
|
|
|
|
|
|
|
|
- asyncio.create_task(cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]))
|
|
|
|
|
|
|
+ spawn_background_task(
|
|
|
|
|
+ cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]),
|
|
|
|
|
+ name=f"cooldown-poweroff-{printer_id}",
|
|
|
|
|
+ )
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
|
|
logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
|
|
|
|
|
|
|
@@ -3730,6 +3959,12 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
if not stored_ams_mapping and archive_id:
|
|
if not stored_ams_mapping and archive_id:
|
|
|
stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
|
|
stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
|
|
|
|
|
|
|
|
|
|
+ # Always drain the plate_id register on completion — the session already
|
|
|
|
|
+ # consumed it at print-start injection; leaving it would leak into the next
|
|
|
|
|
+ # print on the same archive_id (rare but possible with reprints) (#1697).
|
|
|
|
|
+ if archive_id:
|
|
|
|
|
+ _print_plate_ids.pop(archive_id, None)
|
|
|
|
|
+
|
|
|
# Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
|
|
# Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
|
|
|
try:
|
|
try:
|
|
|
async with async_session() as db:
|
|
async with async_session() as db:
|
|
@@ -3870,8 +4105,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
|
|
logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
|
|
|
|
|
|
|
|
- task = asyncio.create_task(_notify_no_archive())
|
|
|
|
|
- task.add_done_callback(lambda _t: None)
|
|
|
|
|
|
|
+ spawn_background_task(_notify_no_archive(), name="notify-no-archive")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
log_timing("Archive lookup")
|
|
log_timing("Archive lookup")
|
|
@@ -4118,54 +4352,72 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
archive_dir = app_settings.archive_dir / str(archive.id)
|
|
archive_dir = app_settings.archive_dir / str(archive.id)
|
|
|
photo_filename = None
|
|
photo_filename = None
|
|
|
|
|
|
|
|
- # Check for external camera first
|
|
|
|
|
- if printer.external_camera_enabled and printer.external_camera_url:
|
|
|
|
|
- logger.info("[PHOTO-BG] Using external camera")
|
|
|
|
|
- from backend.app.services.external_camera import capture_frame
|
|
|
|
|
|
|
+ # Prefer the timelapse last-frame source when a timelapse was
|
|
|
|
|
+ # recording — it captures the moment after the toolhead parks
|
|
|
|
|
+ # but before the bed drops, which the live-camera grab below
|
|
|
|
|
+ # would miss (#1397). Skipped for external cameras (those have
|
|
|
|
|
+ # their own framing and don't see a Bambu timelapse).
|
|
|
|
|
+ prefer_timelapse_source = bool(data.get("timelapse_was_active")) and not (
|
|
|
|
|
+ printer.external_camera_enabled and printer.external_camera_url
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- frame_data = await capture_frame(
|
|
|
|
|
- printer.external_camera_url,
|
|
|
|
|
- printer.external_camera_type or "mjpeg",
|
|
|
|
|
- snapshot_url=printer.external_camera_snapshot_url,
|
|
|
|
|
|
|
+ if prefer_timelapse_source:
|
|
|
|
|
+ photo_filename = await _capture_finish_photo_from_timelapse(
|
|
|
|
|
+ archive_id=archive_id,
|
|
|
|
|
+ archive_dir=archive_dir,
|
|
|
)
|
|
)
|
|
|
- if frame_data:
|
|
|
|
|
- photos_dir = archive_dir / "photos"
|
|
|
|
|
- photos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
- photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
|
|
|
- photo_path = photos_dir / photo_filename
|
|
|
|
|
- await asyncio.to_thread(photo_path.write_bytes, frame_data)
|
|
|
|
|
- logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
|
|
|
|
|
- else:
|
|
|
|
|
- # Check if camera stream is active - use buffered frame to avoid freeze
|
|
|
|
|
- # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
|
|
|
|
|
- active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
|
|
|
|
|
- active_chamber_for_printer = [
|
|
|
|
|
- k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
|
|
|
|
|
- ]
|
|
|
|
|
- buffered_frame = get_buffered_frame(printer_id)
|
|
|
|
|
-
|
|
|
|
|
- if (active_for_printer or active_chamber_for_printer) and buffered_frame:
|
|
|
|
|
- # Use frame from active stream
|
|
|
|
|
- logger.info("[PHOTO-BG] Using buffered frame from active stream")
|
|
|
|
|
- photos_dir = archive_dir / "photos"
|
|
|
|
|
- photos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
- photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
|
|
|
- photo_path = photos_dir / photo_filename
|
|
|
|
|
- await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
|
|
|
|
|
- logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
|
|
|
|
|
- else:
|
|
|
|
|
- # No active stream - capture new frame
|
|
|
|
|
- from backend.app.services.camera import capture_finish_photo
|
|
|
|
|
-
|
|
|
|
|
- photo_filename = await capture_finish_photo(
|
|
|
|
|
- printer_id=printer_id,
|
|
|
|
|
- ip_address=printer.ip_address,
|
|
|
|
|
- access_code=printer.access_code,
|
|
|
|
|
- model=printer.model,
|
|
|
|
|
- archive_dir=archive_dir,
|
|
|
|
|
|
|
+
|
|
|
|
|
+ # Fallback chain: external camera → buffered live frame →
|
|
|
|
|
+ # fresh RTSP capture. Only runs if the timelapse path above
|
|
|
|
|
+ # didn't already produce a photo.
|
|
|
|
|
+ if not photo_filename:
|
|
|
|
|
+ if printer.external_camera_enabled and printer.external_camera_url:
|
|
|
|
|
+ logger.info("[PHOTO-BG] Using external camera")
|
|
|
|
|
+ from backend.app.services.external_camera import capture_frame
|
|
|
|
|
+
|
|
|
|
|
+ frame_data = await capture_frame(
|
|
|
|
|
+ printer.external_camera_url,
|
|
|
|
|
+ printer.external_camera_type or "mjpeg",
|
|
|
|
|
+ snapshot_url=printer.external_camera_snapshot_url,
|
|
|
)
|
|
)
|
|
|
|
|
+ if frame_data:
|
|
|
|
|
+ photos_dir = archive_dir / "photos"
|
|
|
|
|
+ photos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
+ photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
|
|
|
+ photo_path = photos_dir / photo_filename
|
|
|
|
|
+ await asyncio.to_thread(photo_path.write_bytes, frame_data)
|
|
|
|
|
+ logger.info("[PHOTO-BG] Saved external camera frame: %s", photo_filename)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Check if camera stream is active - use buffered frame to avoid freeze
|
|
|
|
|
+ # Check both RTSP streams (_active_streams) and chamber image streams (_active_chamber_streams)
|
|
|
|
|
+ active_for_printer = [k for k in _active_streams if k.startswith(f"{printer_id}-")]
|
|
|
|
|
+ active_chamber_for_printer = [
|
|
|
|
|
+ k for k in _active_chamber_streams if k.startswith(f"{printer_id}-")
|
|
|
|
|
+ ]
|
|
|
|
|
+ buffered_frame = get_buffered_frame(printer_id)
|
|
|
|
|
+
|
|
|
|
|
+ if (active_for_printer or active_chamber_for_printer) and buffered_frame:
|
|
|
|
|
+ # Use frame from active stream
|
|
|
|
|
+ logger.info("[PHOTO-BG] Using buffered frame from active stream")
|
|
|
|
|
+ photos_dir = archive_dir / "photos"
|
|
|
|
|
+ photos_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
+ photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
|
|
|
|
+ photo_path = photos_dir / photo_filename
|
|
|
|
|
+ await asyncio.to_thread(photo_path.write_bytes, buffered_frame)
|
|
|
|
|
+ logger.info("[PHOTO-BG] Saved buffered frame: %s", photo_filename)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # No active stream - capture new frame
|
|
|
|
|
+ from backend.app.services.camera import capture_finish_photo
|
|
|
|
|
+
|
|
|
|
|
+ photo_filename = await capture_finish_photo(
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ ip_address=printer.ip_address,
|
|
|
|
|
+ access_code=printer.access_code,
|
|
|
|
|
+ model=printer.model,
|
|
|
|
|
+ archive_dir=archive_dir,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
if photo_filename:
|
|
if photo_filename:
|
|
|
photos = archive.photos or []
|
|
photos = archive.photos or []
|
|
@@ -4173,15 +4425,27 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
archive.photos = photos
|
|
archive.photos = photos
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
logger.info("[PHOTO-BG] Saved: %s", photo_filename)
|
|
logger.info("[PHOTO-BG] Saved: %s", photo_filename)
|
|
|
|
|
+
|
|
|
|
|
+ # When Bambuddy forced timelapse on for this print, delete
|
|
|
|
|
+ # the timelapse afterward (#1397). The user didn't ask for
|
|
|
|
|
+ # a video to keep — only the finish photo. Runs even when
|
|
|
|
|
+ # photo extraction failed, so we don't leave debris.
|
|
|
|
|
+ if archive.bambuddy_forced_timelapse:
|
|
|
|
|
+ await _cleanup_forced_timelapse(
|
|
|
|
|
+ archive_id=archive_id,
|
|
|
|
|
+ printer_id=printer_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if photo_filename:
|
|
|
return photo_filename
|
|
return photo_filename
|
|
|
return None
|
|
return None
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("[PHOTO-BG] Failed: %s", e)
|
|
logger.warning("[PHOTO-BG] Failed: %s", e)
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
- asyncio.create_task(_background_energy_calculation())
|
|
|
|
|
|
|
+ spawn_background_task(_background_energy_calculation(), name="background-energy-calc")
|
|
|
# Photo capture task - result will be used by notifications
|
|
# Photo capture task - result will be used by notifications
|
|
|
- photo_task = asyncio.create_task(_background_finish_photo())
|
|
|
|
|
|
|
+ photo_task = spawn_background_task(_background_finish_photo(), name="background-finish-photo")
|
|
|
log_timing("Background tasks scheduled (energy, photo)")
|
|
log_timing("Background tasks scheduled (energy, photo)")
|
|
|
|
|
|
|
|
# Also run smart plug, notifications, and maintenance as background tasks
|
|
# Also run smart plug, notifications, and maintenance as background tasks
|
|
@@ -4360,18 +4624,27 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("[MAINT-BG] Failed: %s", e)
|
|
logger.warning("[MAINT-BG] Failed: %s", e)
|
|
|
|
|
|
|
|
- asyncio.create_task(_background_smart_plug())
|
|
|
|
|
- asyncio.create_task(_background_maintenance_check())
|
|
|
|
|
|
|
+ spawn_background_task(_background_smart_plug(), name="background-smart-plug")
|
|
|
|
|
+ spawn_background_task(_background_maintenance_check(), name="background-maintenance-check")
|
|
|
|
|
+
|
|
|
|
|
+ # Notification task waits for photo capture to complete first (with timeout).
|
|
|
|
|
+ # When a timelapse was recording, photo sourcing polls the per-print
|
|
|
|
|
+ # timelapse for up to 60s (#1397) — extend the budget so the notification
|
|
|
|
|
+ # carries the correct bed-up photo instead of falling through to the
|
|
|
|
|
+ # live-cam grab. Adds ~30s of notification latency at worst on slow links.
|
|
|
|
|
+ photo_wait_timeout = 75 if data.get("timelapse_was_active") else 45
|
|
|
|
|
|
|
|
- # Notification task waits for photo capture to complete first (with timeout)
|
|
|
|
|
async def _photo_then_notify():
|
|
async def _photo_then_notify():
|
|
|
"""Wait for photo capture, then send notification with photo URL."""
|
|
"""Wait for photo capture, then send notification with photo URL."""
|
|
|
finish_photo = None
|
|
finish_photo = None
|
|
|
try:
|
|
try:
|
|
|
- finish_photo = await asyncio.wait_for(photo_task, timeout=45)
|
|
|
|
|
|
|
+ finish_photo = await asyncio.wait_for(photo_task, timeout=photo_wait_timeout)
|
|
|
logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo)
|
|
logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo)
|
|
|
except TimeoutError:
|
|
except TimeoutError:
|
|
|
- logger.warning("[PHOTO-NOTIFY] Photo capture timed out after 45s, sending notification without photo")
|
|
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "[PHOTO-NOTIFY] Photo capture timed out after %ss, sending notification without photo",
|
|
|
|
|
+ photo_wait_timeout,
|
|
|
|
|
+ )
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("[PHOTO-NOTIFY] Photo task failed: %s", e)
|
|
logger.warning("[PHOTO-NOTIFY] Photo task failed: %s", e)
|
|
|
try:
|
|
try:
|
|
@@ -4379,7 +4652,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.error("[PHOTO-NOTIFY] Notification sending failed: %s", e, exc_info=True)
|
|
logger.error("[PHOTO-NOTIFY] Notification sending failed: %s", e, exc_info=True)
|
|
|
|
|
|
|
|
- asyncio.create_task(_photo_then_notify())
|
|
|
|
|
|
|
+ spawn_background_task(_photo_then_notify(), name="photo-then-notify")
|
|
|
|
|
|
|
|
# Stitch external camera layer timelapse if session was active
|
|
# Stitch external camera layer timelapse if session was active
|
|
|
print_status = data.get("status", "completed")
|
|
print_status = data.get("status", "completed")
|
|
@@ -4418,7 +4691,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass # Best-effort timelapse session cancellation on error
|
|
pass # Best-effort timelapse session cancellation on error
|
|
|
|
|
|
|
|
- asyncio.create_task(_background_layer_timelapse())
|
|
|
|
|
|
|
+ spawn_background_task(_background_layer_timelapse(), name="background-layer-timelapse")
|
|
|
|
|
|
|
|
log_timing("All background tasks scheduled")
|
|
log_timing("All background tasks scheduled")
|
|
|
|
|
|
|
@@ -4428,7 +4701,10 @@ async def on_print_complete(printer_id: int, data: dict):
|
|
|
# Schedule timelapse scan as background task with retries
|
|
# Schedule timelapse scan as background task with retries
|
|
|
# The printer needs time to encode the video after print completion
|
|
# The printer needs time to encode the video after print completion
|
|
|
baseline = _timelapse_baselines.pop(printer_id, None)
|
|
baseline = _timelapse_baselines.pop(printer_id, None)
|
|
|
- asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
|
|
|
|
|
|
|
+ spawn_background_task(
|
|
|
|
|
+ _scan_for_timelapse_with_retries(archive_id, baseline),
|
|
|
|
|
+ name=f"scan-timelapse-{archive_id}",
|
|
|
|
|
+ )
|
|
|
log_timing("Timelapse scan scheduled")
|
|
log_timing("Timelapse scan scheduled")
|
|
|
|
|
|
|
|
logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
|
|
logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
|
|
@@ -4444,6 +4720,34 @@ _ams_alarm_cooldown: dict[str, datetime] = {}
|
|
|
AMS_ALARM_COOLDOWN_MINUTES = 60 # Don't send same alarm more than once per hour
|
|
AMS_ALARM_COOLDOWN_MINUTES = 60 # Don't send same alarm more than once per hour
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _ams_has_filament(ams_data: dict) -> bool:
|
|
|
|
|
+ """True if this AMS unit has at least one tray slot holding filament.
|
|
|
|
|
+
|
|
|
|
|
+ Bambu firmware reports loaded slots via `tray_exist_bits`, a per-AMS hex
|
|
|
|
|
+ bitmap (one bit per tray slot — bit set = spool present). Empty AMS units
|
|
|
|
|
+ still report sensor readings, but those readings are ambient and not
|
|
|
|
|
+ actionable: no filament to dry, no humidity to push down. #1619 — gate
|
|
|
|
|
+ humidity/temperature alarms on this check so empty units don't generate
|
|
|
|
|
+ hourly noise. Sensor history still records regardless so the UI charts
|
|
|
|
|
+ stay continuous.
|
|
|
|
|
+
|
|
|
|
|
+ Fallback path inspects the `tray` array's `tray_type` fields for setups
|
|
|
|
|
+ where `tray_exist_bits` is missing (some early-connection pushall shapes).
|
|
|
|
|
+ """
|
|
|
|
|
+ bits = ams_data.get("tray_exist_bits")
|
|
|
|
|
+ if isinstance(bits, str) and bits.strip():
|
|
|
|
|
+ try:
|
|
|
|
|
+ return int(bits, 16) > 0
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ trays = ams_data.get("tray")
|
|
|
|
|
+ if isinstance(trays, list):
|
|
|
|
|
+ return any(
|
|
|
|
|
+ isinstance(t, dict) and isinstance(t.get("tray_type"), str) and t["tray_type"].strip() for t in trays
|
|
|
|
|
+ )
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def record_ams_history():
|
|
async def record_ams_history():
|
|
|
"""Background task to record AMS humidity and temperature data."""
|
|
"""Background task to record AMS humidity and temperature data."""
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
@@ -4541,6 +4845,16 @@ async def record_ams_history():
|
|
|
else:
|
|
else:
|
|
|
ams_label = f"AMS-{chr(65 + ams_id)}"
|
|
ams_label = f"AMS-{chr(65 + ams_id)}"
|
|
|
|
|
|
|
|
|
|
+ # Skip alarm dispatch for empty AMS units — humidity /
|
|
|
|
|
+ # temperature readings are ambient with no filament to
|
|
|
|
|
+ # protect, and the hourly notification just becomes
|
|
|
|
|
+ # noise. Sensor history was already recorded above so
|
|
|
|
|
+ # the UI charts stay continuous (#1619). Per-AMS check
|
|
|
|
|
+ # so a multi-AMS setup with one loaded + one empty
|
|
|
|
|
+ # still alarms on the loaded unit.
|
|
|
|
|
+ if not _ams_has_filament(ams_data):
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
# Check humidity alarm (only if above threshold)
|
|
# Check humidity alarm (only if above threshold)
|
|
|
if humidity is not None and humidity > humidity_threshold:
|
|
if humidity is not None and humidity > humidity_threshold:
|
|
|
cooldown_key = f"{printer.id}:{ams_id}:humidity"
|
|
cooldown_key = f"{printer.id}:{ams_id}:humidity"
|
|
@@ -4854,12 +5168,14 @@ def _evict_stale_expected_prints() -> None:
|
|
|
_expected_print_creators.pop(key, None)
|
|
_expected_print_creators.pop(key, None)
|
|
|
_expected_print_registered_at.pop(key, None)
|
|
_expected_print_registered_at.pop(key, None)
|
|
|
|
|
|
|
|
- # Also clean up _print_ams_mappings for archive_ids that have no remaining
|
|
|
|
|
- # live keys in _expected_prints (i.e. all variants were just evicted).
|
|
|
|
|
|
|
+ # Also clean up _print_ams_mappings and _print_plate_ids for archive_ids
|
|
|
|
|
+ # that have no remaining live keys in _expected_prints (all variants
|
|
|
|
|
+ # were just evicted).
|
|
|
live_archive_ids = set(_expected_prints.values())
|
|
live_archive_ids = set(_expected_prints.values())
|
|
|
for archive_id in evicted_archive_ids:
|
|
for archive_id in evicted_archive_ids:
|
|
|
if archive_id not in live_archive_ids:
|
|
if archive_id not in live_archive_ids:
|
|
|
_print_ams_mappings.pop(archive_id, None)
|
|
_print_ams_mappings.pop(archive_id, None)
|
|
|
|
|
+ _print_plate_ids.pop(archive_id, None)
|
|
|
|
|
|
|
|
logging.getLogger(__name__).info(
|
|
logging.getLogger(__name__).info(
|
|
|
"Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
|
|
"Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
|
|
@@ -5211,7 +5527,7 @@ async def lifespan(app: FastAPI):
|
|
|
logging.warning("Failed to auto-connect to Spoolman: %s", e)
|
|
logging.warning("Failed to auto-connect to Spoolman: %s", e)
|
|
|
|
|
|
|
|
# Start the print scheduler
|
|
# Start the print scheduler
|
|
|
- asyncio.create_task(print_scheduler.run())
|
|
|
|
|
|
|
+ spawn_background_task(print_scheduler.run(), name="print-scheduler")
|
|
|
|
|
|
|
|
# Start background dispatch worker for send/start operations
|
|
# Start background dispatch worker for send/start operations
|
|
|
await background_dispatch.start()
|
|
await background_dispatch.start()
|
|
@@ -5739,6 +6055,7 @@ app.include_router(inventory.router, prefix=app_settings.api_prefix)
|
|
|
app.include_router(labels.router, prefix=app_settings.api_prefix)
|
|
app.include_router(labels.router, prefix=app_settings.api_prefix)
|
|
|
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
|
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
|
|
app.include_router(cloud.router, prefix=app_settings.api_prefix)
|
|
app.include_router(cloud.router, prefix=app_settings.api_prefix)
|
|
|
|
|
+app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
|
|
|
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
|
|
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
|
|
|
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
|
|
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
|
|
|
app.include_router(print_log.router, prefix=app_settings.api_prefix)
|
|
app.include_router(print_log.router, prefix=app_settings.api_prefix)
|