|
@@ -6,6 +6,7 @@ bound to its dedicated IP address, regardless of mode.
|
|
|
|
|
|
|
|
import asyncio
|
|
import asyncio
|
|
|
import logging
|
|
import logging
|
|
|
|
|
+import time
|
|
|
from collections.abc import Callable
|
|
from collections.abc import Callable
|
|
|
from datetime import datetime, timezone
|
|
from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
@@ -110,6 +111,23 @@ DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
|
|
|
# the dict fills, so a long-running VP can't leak unbounded state.
|
|
# the dict fills, so a long-running VP can't leak unbounded state.
|
|
|
_SLICER_OPTIONS_CACHE_LIMIT = 128
|
|
_SLICER_OPTIONS_CACHE_LIMIT = 128
|
|
|
|
|
|
|
|
|
|
+# How long ``_add_to_print_queue`` waits for the slicer's MQTT
|
|
|
|
|
+# ``project_file`` after the FTP upload completes (#1780 round 3).
|
|
|
|
|
+# Bambu Studio sends FTP first, then MQTT immediately after — but on
|
|
|
|
|
+# wireless / loaded setups the MQTT command can land 2+ s after FTP,
|
|
|
|
|
+# which used to time the wait out and silently drop ``nozzle_mapping``
|
|
|
|
|
+# + the other slicer-driven flags. The bumped window covers the
|
|
|
|
|
+# observed worst case in the field; the late-MQTT fallback in
|
|
|
|
|
+# ``on_print_command`` covers the rest.
|
|
|
|
|
+_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
|
|
|
|
|
+
|
|
|
|
|
+# How long ``on_print_command`` will retroactively stamp slicer fields
|
|
|
|
|
+# onto a recently-committed queue item when the MQTT print command
|
|
|
|
|
+# arrives after ``_SLICER_OPTIONS_WAIT_TIMEOUT`` expired. Covers
|
|
|
|
|
+# extra-late MQTT (slow wireless slicer, NIC drop+retry) and the
|
|
|
|
|
+# scheduler tick interval before dispatch picks the item up.
|
|
|
|
|
+_RECENT_QUEUE_ITEM_TTL = 30.0
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
|
|
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
|
|
|
"""Get serial number for the given model and suffix."""
|
|
"""Get serial number for the given model and suffix."""
|
|
@@ -200,6 +218,16 @@ class VirtualPrinterInstance:
|
|
|
self._slicer_print_options: dict[str, dict] = {}
|
|
self._slicer_print_options: dict[str, dict] = {}
|
|
|
self._slicer_print_options_events: dict[str, asyncio.Event] = {}
|
|
self._slicer_print_options_events: dict[str, asyncio.Event] = {}
|
|
|
|
|
|
|
|
|
|
+ # Queue items recently committed by `_add_to_print_queue`, keyed by
|
|
|
|
|
+ # FTP filename. Used by `on_print_command` to retroactively stamp the
|
|
|
|
|
+ # slicer's nozzle_mapping (and the other slicer-driven flags) onto a
|
|
|
|
|
+ # queue item when the MQTT `project_file` arrives after the queue-add
|
|
|
|
|
+ # wait timed out — the #1780 round-3 race. Value is
|
|
|
|
|
+ # (queue_item_ids, monotonic_committed_at); entries older than
|
|
|
|
|
+ # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
|
|
|
|
|
+ # queue-add.
|
|
|
|
|
+ self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
|
|
|
|
|
+
|
|
|
# Per-instance services
|
|
# Per-instance services
|
|
|
self._proxy: SlicerProxyManager | None = None
|
|
self._proxy: SlicerProxyManager | None = None
|
|
|
self._ftp: VirtualPrinterFTPServer | None = None
|
|
self._ftp: VirtualPrinterFTPServer | None = None
|
|
@@ -342,6 +370,114 @@ class VirtualPrinterInstance:
|
|
|
event = self._slicer_print_options_events.get(stash_key)
|
|
event = self._slicer_print_options_events.get(stash_key)
|
|
|
if event:
|
|
if event:
|
|
|
event.set()
|
|
event.set()
|
|
|
|
|
+ return
|
|
|
|
|
+ # No consumer waiting: `_add_to_print_queue` either already gave up
|
|
|
|
|
+ # (wait_for timed out) or hasn't started yet (FTP still uploading).
|
|
|
|
|
+ # If a queue item was committed within the last
|
|
|
|
|
+ # `_RECENT_QUEUE_ITEM_TTL`, the wait timed out and the row holds
|
|
|
|
|
+ # settings defaults instead of the slicer's pick — retroactively
|
|
|
|
|
+ # stamp the slicer-driven fields so the dispatcher honours the
|
|
|
|
|
+ # user's choice. Covers the #1780 round-3 race where Bambu Studio's
|
|
|
|
|
+ # MQTT lands just past the bumped wait ceiling.
|
|
|
|
|
+ await self._restamp_recent_queue_item(stash_key, data)
|
|
|
|
|
+
|
|
|
|
|
+ async def _restamp_recent_queue_item(self, stash_key: str, data: dict) -> None:
|
|
|
|
|
+ """Patch slicer-driven fields onto a queue item the MQTT command missed.
|
|
|
|
|
+
|
|
|
|
|
+ ``_add_to_print_queue`` waits up to ``_SLICER_OPTIONS_WAIT_TIMEOUT``
|
|
|
|
|
+ for the slicer's MQTT ``project_file`` before committing the queue
|
|
|
|
|
+ item. If the MQTT command arrives after that window — observed in
|
|
|
|
|
+ the field at ~2.1 s on H2C / wireless setups (#1780 round 3) — the
|
|
|
|
|
+ row was already written with settings defaults. This method runs
|
|
|
|
|
+ on the late MQTT path: it looks up the most recent queue items
|
|
|
|
|
+ committed for this filename and patches in the slicer's
|
|
|
|
|
+ ``nozzle_mapping`` + workflow flags, but only while the items are
|
|
|
|
|
+ still ``pending`` (scheduler hasn't dispatched them yet).
|
|
|
|
|
+ """
|
|
|
|
|
+ if not self._session_factory:
|
|
|
|
|
+ return
|
|
|
|
|
+ entry = self._recent_queue_items.get(stash_key)
|
|
|
|
|
+ if entry is None:
|
|
|
|
|
+ return
|
|
|
|
|
+ queue_item_ids, committed_at = entry
|
|
|
|
|
+ if time.monotonic() - committed_at > _RECENT_QUEUE_ITEM_TTL:
|
|
|
|
|
+ self._recent_queue_items.pop(stash_key, None)
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ import json
|
|
|
|
|
+
|
|
|
|
|
+ # Mirror the field set `_add_to_print_queue` reads off slicer_opts.
|
|
|
|
|
+ # MQTT uses `bed_leveling` (single L); the column is `bed_levelling`.
|
|
|
|
|
+ # `nozzles_info` is intentionally not stamped — column kept for
|
|
|
|
|
+ # legacy rows but never written; see PrintQueueItem.nozzles_info.
|
|
|
|
|
+ patch: dict = {}
|
|
|
|
|
+ for mqtt_field, column in (
|
|
|
|
|
+ ("bed_leveling", "bed_levelling"),
|
|
|
|
|
+ ("flow_cali", "flow_cali"),
|
|
|
|
|
+ ("vibration_cali", "vibration_cali"),
|
|
|
|
|
+ ("layer_inspect", "layer_inspect"),
|
|
|
|
|
+ ("timelapse", "timelapse"),
|
|
|
|
|
+ ("use_ams", "use_ams"),
|
|
|
|
|
+ ):
|
|
|
|
|
+ if mqtt_field in data:
|
|
|
|
|
+ patch[column] = bool(data[mqtt_field])
|
|
|
|
|
+
|
|
|
|
|
+ raw = data.get("nozzle_mapping")
|
|
|
|
|
+ if raw is not None:
|
|
|
|
|
+ if isinstance(raw, str):
|
|
|
|
|
+ try:
|
|
|
|
|
+ raw = json.loads(raw)
|
|
|
|
|
+ except json.JSONDecodeError:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "[VP %s] Late MQTT nozzle_mapping is unparseable JSON, dropping: %r",
|
|
|
|
|
+ self.name,
|
|
|
|
|
+ raw,
|
|
|
|
|
+ )
|
|
|
|
|
+ raw = None
|
|
|
|
|
+ if raw is not None:
|
|
|
|
|
+ patch["nozzle_mapping"] = json.dumps(raw)
|
|
|
|
|
+
|
|
|
|
|
+ if not patch:
|
|
|
|
|
+ self._recent_queue_items.pop(stash_key, None)
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ from sqlalchemy import select, update
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ async with self._session_factory() as db:
|
|
|
|
|
+ # Only stamp items still pending; once the scheduler has
|
|
|
|
|
+ # picked the row up we can't safely race the dispatcher.
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(PrintQueueItem.id).where(
|
|
|
|
|
+ PrintQueueItem.id.in_(queue_item_ids),
|
|
|
|
|
+ PrintQueueItem.status == "pending",
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ eligible_ids = [row[0] for row in result.all()]
|
|
|
|
|
+ if not eligible_ids:
|
|
|
|
|
+ self._recent_queue_items.pop(stash_key, None)
|
|
|
|
|
+ return
|
|
|
|
|
+ await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
|
|
|
|
|
+ self.name,
|
|
|
|
|
+ stash_key,
|
|
|
|
|
+ sorted(patch.keys()),
|
|
|
|
|
+ eligible_ids,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(
|
|
|
|
|
+ "[VP %s] Failed to retroactively stamp queue item(s) %s for %s: %s",
|
|
|
|
|
+ self.name,
|
|
|
|
|
+ queue_item_ids,
|
|
|
|
|
+ stash_key,
|
|
|
|
|
+ e,
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self._recent_queue_items.pop(stash_key, None)
|
|
|
|
|
|
|
|
def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
|
|
def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
|
|
|
"""Re-set gcode_state=FINISH on the VP after the project_file ack.
|
|
"""Re-set gcode_state=FINISH on the VP after the project_file ack.
|
|
@@ -524,19 +660,21 @@ class VirtualPrinterInstance:
|
|
|
# queue item can inherit the slicer-side print options the user
|
|
# queue item can inherit the slicer-side print options the user
|
|
|
# picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
|
|
# picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
|
|
|
# first and the MQTT command immediately after, so the typical lag
|
|
# first and the MQTT command immediately after, so the typical lag
|
|
|
- # is a few hundred ms; 2 s is conservative without making every
|
|
|
|
|
- # VP-queue add visibly slow. Falls back to the global default_*
|
|
|
|
|
|
|
+ # is a few hundred ms. The window is generous enough to absorb
|
|
|
|
|
+ # wireless / loaded-Pi jitter without making every VP-queue add
|
|
|
|
|
+ # visibly slow — observed worst case in #1780 round 3 was 2.085 s,
|
|
|
|
|
+ # the previous 2.0 s ceiling. Falls back to the global default_*
|
|
|
# settings if MQTT doesn't arrive in time (legacy behaviour for
|
|
# settings if MQTT doesn't arrive in time (legacy behaviour for
|
|
|
# users on a slicer that doesn't send a print command). #1403.
|
|
# users on a slicer that doesn't send a print command). #1403.
|
|
|
# The wait is skipped when there's no MQTT server attached — covers
|
|
# The wait is skipped when there's no MQTT server attached — covers
|
|
|
# unit tests that invoke `_add_to_print_queue` directly without
|
|
# unit tests that invoke `_add_to_print_queue` directly without
|
|
|
- # going through `on_print_command`, so they don't pay the 2 s tax.
|
|
|
|
|
|
|
+ # going through `on_print_command`, so they don't pay the wait tax.
|
|
|
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
|
|
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
|
|
|
if slicer_opts is None and self._mqtt is not None:
|
|
if slicer_opts is None and self._mqtt is not None:
|
|
|
event = asyncio.Event()
|
|
event = asyncio.Event()
|
|
|
self._slicer_print_options_events[file_path.name] = event
|
|
self._slicer_print_options_events[file_path.name] = event
|
|
|
try:
|
|
try:
|
|
|
- await asyncio.wait_for(event.wait(), timeout=2.0)
|
|
|
|
|
|
|
+ await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT)
|
|
|
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
|
|
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
|
|
|
except asyncio.TimeoutError:
|
|
except asyncio.TimeoutError:
|
|
|
slicer_opts = None
|
|
slicer_opts = None
|
|
@@ -749,6 +887,34 @@ class VirtualPrinterInstance:
|
|
|
await db.flush() # populate queue_item.id before logging
|
|
await db.flush() # populate queue_item.id before logging
|
|
|
queue_item_ids.append(queue_item.id)
|
|
queue_item_ids.append(queue_item.id)
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
+ # Track the freshly-committed queue items so
|
|
|
|
|
+ # `on_print_command` can retroactively stamp slicer-side
|
|
|
|
|
+ # fields if the MQTT `project_file` lands AFTER the
|
|
|
|
|
+ # `_SLICER_OPTIONS_WAIT_TIMEOUT` window expired — the
|
|
|
|
|
+ # #1780 round-3 race. Eviction of stale entries here
|
|
|
|
|
+ # keeps the dict bounded; the queue path is the only
|
|
|
|
|
+ # writer, so doing it on commit is enough.
|
|
|
|
|
+ now = time.monotonic()
|
|
|
|
|
+ cutoff = now - _RECENT_QUEUE_ITEM_TTL
|
|
|
|
|
+ self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
|
|
|
|
|
+ self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
|
|
|
|
|
+ # Last-chance check: MQTT for this filename could have
|
|
|
|
|
+ # arrived during ANY await between the initial pop and
|
|
|
|
|
+ # now — wait_for itself, archive_print, db.flush,
|
|
|
|
|
+ # db.commit. In all those cases `on_print_command`
|
|
|
|
|
+ # stashed its data but neither the event-signal path nor
|
|
|
|
|
+ # the retroactive `_recent_queue_items` path was in
|
|
|
|
|
+ # place to consume it. Pop any late stash and apply
|
|
|
|
|
+ # inline so the late MQTT never leaks past the queue-add.
|
|
|
|
|
+ late_opts = self._slicer_print_options.pop(file_path.name, None)
|
|
|
|
|
+ if late_opts is not None:
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "[VP %s] Late slicer MQTT detected for %s during queue-add — "
|
|
|
|
|
+ "applying inline (race vs commit/archive/flush yield)",
|
|
|
|
|
+ self.name,
|
|
|
|
|
+ file_path.name,
|
|
|
|
|
+ )
|
|
|
|
|
+ await self._restamp_recent_queue_item(file_path.name, late_opts)
|
|
|
if len(queue_item_ids) == 1:
|
|
if len(queue_item_ids) == 1:
|
|
|
logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
|
|
logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
|
|
|
else:
|
|
else:
|