Sfoglia il codice sorgente

Merge remote-tracking branch 'upstream/dev' into feature/billing

behrinml 1 mese fa
parent
commit
0d6a28cd67
100 ha cambiato i file con 19303 aggiunte e 2820 eliminazioni
  1. 328 0
      .github/scripts/ghcr_inject.py
  2. 44 3
      .github/workflows/ci.yml
  3. 45 0
      .github/workflows/repo-stats.yml
  4. 39 3
      .github/workflows/security.yml
  5. 3 0
      .gitignore
  6. 3 1
      .pre-commit-config.yaml
  7. 22 2
      BACKERS.md
  8. 1 1
      CHANGELOG.md
  9. 8 3
      CONTRIBUTING.md
  10. 19 3
      Dockerfile
  11. 22 5
      README.md
  12. 14 10
      backend/app/api/routes/_oidc_helpers.py
  13. 10 56
      backend/app/api/routes/_spoolman_helpers.py
  14. 77 10
      backend/app/api/routes/_url_safety.py
  15. 12 0
      backend/app/api/routes/api_keys.py
  16. 298 171
      backend/app/api/routes/archives.py
  17. 55 5
      backend/app/api/routes/auth.py
  18. 0 32
      backend/app/api/routes/background_dispatch.py
  19. 427 53
      backend/app/api/routes/camera.py
  20. 95 0
      backend/app/api/routes/camwall.py
  21. 294 45
      backend/app/api/routes/cloud.py
  22. 27 0
      backend/app/api/routes/inventory.py
  23. 5 2
      backend/app/api/routes/labels.py
  24. 391 143
      backend/app/api/routes/library.py
  25. 6 1
      backend/app/api/routes/library_tags.py
  26. 14 0
      backend/app/api/routes/local_backup.py
  27. 24 4
      backend/app/api/routes/makerworld.py
  28. 2 0
      backend/app/api/routes/notifications.py
  29. 23 0
      backend/app/api/routes/obico.py
  30. 152 148
      backend/app/api/routes/orca_cloud.py
  31. 968 0
      backend/app/api/routes/pipeline_runs.py
  32. 280 85
      backend/app/api/routes/print_queue.py
  33. 403 76
      backend/app/api/routes/printers.py
  34. 102 2
      backend/app/api/routes/projects.py
  35. 130 60
      backend/app/api/routes/settings.py
  36. 12 7
      backend/app/api/routes/slice_jobs.py
  37. 199 0
      backend/app/api/routes/slicer_pipelines.py
  38. 63 8
      backend/app/api/routes/slicer_presets.py
  39. 19 11
      backend/app/api/routes/smart_plugs.py
  40. 83 18
      backend/app/api/routes/support.py
  41. 18 0
      backend/app/api/routes/system.py
  42. 16 9
      backend/app/api/routes/websocket.py
  43. 8 0
      backend/app/cli.py
  44. 247 44
      backend/app/core/auth.py
  45. 25 1
      backend/app/core/config.py
  46. 745 53
      backend/app/core/database.py
  47. 34 1
      backend/app/core/logging_filters.py
  48. 21 5
      backend/app/core/permissions.py
  49. 112 0
      backend/app/core/websocket.py
  50. 9009 0
      backend/app/data/hms_actions.json
  51. 589 186
      backend/app/main.py
  52. 5 0
      backend/app/models/__init__.py
  53. 9 0
      backend/app/models/api_key.py
  54. 24 0
      backend/app/models/archive.py
  55. 16 0
      backend/app/models/library.py
  56. 2 0
      backend/app/models/notification.py
  57. 6 0
      backend/app/models/notification_template.py
  58. 111 0
      backend/app/models/pipeline_run.py
  59. 37 4
      backend/app/models/print_queue.py
  60. 3 0
      backend/app/models/project.py
  61. 61 0
      backend/app/models/slicer_pipeline.py
  62. 16 0
      backend/app/models/smart_plug.py
  63. 8 0
      backend/app/models/user.py
  64. 11 0
      backend/app/schemas/api_key.py
  65. 3 24
      backend/app/schemas/archive.py
  66. 24 15
      backend/app/schemas/auth.py
  67. 4 0
      backend/app/schemas/cloud.py
  68. 4 29
      backend/app/schemas/library.py
  69. 4 0
      backend/app/schemas/makerworld.py
  70. 9 0
      backend/app/schemas/notification.py
  71. 27 35
      backend/app/schemas/orca_cloud.py
  72. 173 0
      backend/app/schemas/pipeline_run.py
  73. 86 17
      backend/app/schemas/print_queue.py
  74. 47 0
      backend/app/schemas/printer.py
  75. 20 0
      backend/app/schemas/project.py
  76. 129 9
      backend/app/schemas/settings.py
  77. 26 0
      backend/app/schemas/slicer.py
  78. 83 0
      backend/app/schemas/slicer_pipeline.py
  79. 12 0
      backend/app/schemas/smart_plug.py
  80. 77 58
      backend/app/services/archive.py
  81. 0 1157
      backend/app/services/background_dispatch.py
  82. 200 0
      backend/app/services/backup_path.py
  83. 305 28
      backend/app/services/bambu_cloud.py
  84. 377 25
      backend/app/services/bambu_ftp.py
  85. 875 25
      backend/app/services/bambu_mqtt.py
  86. 137 4
      backend/app/services/camera.py
  87. 25 2
      backend/app/services/camera_diagnose.py
  88. 120 9
      backend/app/services/camera_fanout.py
  89. 193 0
      backend/app/services/design_settings.py
  90. 149 28
      backend/app/services/external_camera.py
  91. 51 0
      backend/app/services/filament_requirements.py
  92. 28 9
      backend/app/services/ftp_profiles.py
  93. 13 6
      backend/app/services/git_providers/gitea.py
  94. 75 0
      backend/app/services/hms_actions.py
  95. 58 22
      backend/app/services/label_renderer.py
  96. 20 1
      backend/app/services/layer_timelapse.py
  97. 6 2
      backend/app/services/ldap_service.py
  98. 30 34
      backend/app/services/local_backup.py
  99. 24 2
      backend/app/services/log_reader.py
  100. 37 8
      backend/app/services/long_lived_tokens.py

+ 328 - 0
.github/scripts/ghcr_inject.py

@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+"""Inject GHCR container-download stats into the jgehrcke/github-repo-stats report.
+
+GHCR exposes a container's total + 30-day daily-pull series only in the
+package page HTML. There is no REST or GraphQL API for it. This script
+scrapes that page once per workflow run, merges the rolling 30-day window
+into a sidecar CSV on gh-pages, and re-injects a Vega-Lite chart at the
+top of ``latest-report/report.html``.
+
+Why the merge: each run only sees the last 30 days, but the CSV grows
+forever — days that fall off GitHub's 30-day window stay in the CSV
+because they were captured while in-window. Overlapping dates are
+overwritten on each run, so GitHub's late-arriving revisions to recent
+days self-correct.
+
+Hard-fails if either scrape pattern stops matching. Silent fallbacks
+would let the chart freeze at last-known-good and nobody would notice.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import re
+import sys
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+GHCR_URL = "https://github.com/{owner}/{pkg}/pkgs/container/{pkg}"
+USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) bambuddy-stats"
+
+TOTAL_RE = re.compile(
+    r'Total downloads</span>\s*<h3 title="(\d+)">([^<]+)</h3>',
+    re.DOTALL,
+)
+RECT_MERGE_FIRST_RE = re.compile(r'data-merge-count="(\d+)"[^>]*data-date="(\d{4}-\d{2}-\d{2})"')
+RECT_DATE_FIRST_RE = re.compile(r'data-date="(\d{4}-\d{2}-\d{2})"[^>]*data-merge-count="(\d+)"')
+
+
+def fetch_ghcr(owner: str, pkg: str) -> str:
+    req = urllib.request.Request(
+        GHCR_URL.format(owner=owner, pkg=pkg),
+        headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
+    )
+    with urllib.request.urlopen(req, timeout=30) as resp:
+        return resp.read().decode("utf-8")
+
+
+def parse_total(html: str) -> tuple[int, str]:
+    m = TOTAL_RE.search(html)
+    if not m:
+        raise RuntimeError(
+            "GHCR scrape: 'Total downloads' marker not found. GitHub markup likely changed — update TOTAL_RE."
+        )
+    return int(m.group(1)), m.group(2).strip()
+
+
+def parse_daily(html: str) -> dict[str, int]:
+    daily: dict[str, int] = {}
+    for m in RECT_MERGE_FIRST_RE.finditer(html):
+        daily[m.group(2)] = int(m.group(1))
+    for m in RECT_DATE_FIRST_RE.finditer(html):
+        daily.setdefault(m.group(1), int(m.group(2)))
+    if not daily:
+        raise RuntimeError(
+            "GHCR scrape: 30-day sparkline rects not found. GitHub markup likely changed — update RECT_*_RE."
+        )
+    return daily
+
+
+def merge_csv(csv_path: Path, fresh: dict[str, int]) -> dict[str, int]:
+    merged: dict[str, int] = {}
+    if csv_path.exists():
+        with csv_path.open() as fp:
+            for row in csv.DictReader(fp):
+                merged[row["date"]] = int(row["daily_count"])
+    merged.update(fresh)
+    return dict(sorted(merged.items()))
+
+
+def write_csv(csv_path: Path, series: dict[str, int]) -> None:
+    csv_path.parent.mkdir(parents=True, exist_ok=True)
+    with csv_path.open("w", newline="") as fp:
+        w = csv.writer(fp)
+        w.writerow(["date", "daily_count"])
+        for date, count in series.items():
+            w.writerow([date, count])
+
+
+# Cloned verbatim from jgehrcke's "Total clones" chart so the new chart
+# inherits the report's theme (fonts, palette, axis colors).
+VEGA_CONFIG = {
+    "arc": {"fill": "#1b1e23"},
+    "area": {"fill": "#1b1e23"},
+    "axisBottom": {
+        "domainColor": "#a9b4c4",
+        "gridColor": "#a9b4c4",
+        "labelColor": "#1b1e23",
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "tickColor": "#a9b4c4",
+        "titleColor": "#1b1e23",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "axisLeft": {
+        "domainColor": "#a9b4c4",
+        "gridColor": "#a9b4c4",
+        "labelColor": "#1b1e23",
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "tickColor": "#a9b4c4",
+        "titleColor": "#1b1e23",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "axisX": {"grid": False},
+    "axisY": {"grid": False, "labelBound": True},
+    "background": "#FFFFFF",
+    "group": {"fill": "#FFFFFF"},
+    "header": {
+        "fontWeight": 400,
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "legend": {
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "symbolSize": 200,
+        "symbolType": "circle",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "line": {"color": "#1b1e23", "stroke": "#1b1e23"},
+    "path": {"stroke": "#1b1e23"},
+    "point": {
+        "color": "#1b1e23",
+        "cursor": "pointer",
+        "filled": True,
+        "size": 20,
+    },
+    "range": {
+        "category": ["#85a2f7", "#ea9755", "#7eb36a", "#f07071", "#bc85d9", "#e587b6", "#a9b4c4", "#d4c05e", "#64b9c4"],
+    },
+    "style": {
+        "bar": {"fill": "#1b1e23"},
+        "text": {
+            "font": "relative-mono-11-pitch-pro, Menlo, monospace",
+            "fontWeight": 400,
+        },
+    },
+    "symbol": {"shape": "circle"},
+    "title": {
+        "anchor": "start",
+        "font": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "fontWeight": 400,
+    },
+    "trail": {"color": "#1b1e23", "stroke": "#1b1e23"},
+    "view": {"stroke": None},
+}
+
+
+def build_vega_spec(series: dict[str, int]) -> dict:
+    rows = [{"time": f"{date}T00:00:00+00:00", "daily_count": count} for date, count in series.items()]
+    counts = [r["daily_count"] for r in rows] or [1]
+    y_max = max(counts)
+    dates = sorted(series.keys())
+    x_domain = [dates[0], dates[-1]] if dates else None
+    return {
+        "$schema": "https://vega.github.io/schema/vega-lite/v4.17.0.json",
+        "config": VEGA_CONFIG,
+        "data": {"name": "data-ghcr-pulls"},
+        "datasets": {"data-ghcr-pulls": rows},
+        "encoding": {
+            "tooltip": [
+                {"field": "daily_count", "format": ".0f", "title": "pulls", "type": "quantitative"},
+                {"field": "time", "format": "%B %e, %Y", "title": "date", "type": "temporal"},
+            ],
+            "x": {
+                "axis": {"labelAngle": 25},
+                "field": "time",
+                "scale": {"domain": x_domain} if x_domain else {},
+                "timeUnit": "yearmonthdate",
+                "title": "date",
+                "type": "temporal",
+            },
+            "y": {
+                # Linear, not symlog: pulls sit in a tight band far above zero, so a log-ish
+                # axis squeezes the whole series into its top decade and flattens the line.
+                "axis": {"format": "~s"},
+                "field": "daily_count",
+                "scale": {
+                    "domain": [0, y_max * 1.1 if y_max > 0 else 1],
+                    "type": "linear",
+                    "zero": True,
+                },
+                "title": "container pulls per day",
+                "type": "quantitative",
+            },
+        },
+        "height": 200,
+        "mark": {"point": True, "type": "line"},
+        "padding": 10,
+        "width": "container",
+    }
+
+
+TOC_START = "<!-- ghcr:toc-start -->"
+TOC_END = "<!-- ghcr:toc-end -->"
+SECTION_START = "<!-- ghcr:section-start -->"
+SECTION_END = "<!-- ghcr:section-end -->"
+SCRIPT_START = "<!-- ghcr:script-start -->"
+SCRIPT_END = "<!-- ghcr:script-end -->"
+
+
+def _strip_existing(html: str, start: str, end: str) -> str:
+    pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
+    return pattern.sub("", html)
+
+
+def patch_report(
+    report_path: Path,
+    spec: dict,
+    cumulative: int,
+    cumulative_display: str,
+    fetched_at: str,
+    owner: str,
+    pkg: str,
+) -> None:
+    html = report_path.read_text(encoding="utf-8")
+
+    # Idempotency: if a prior run left markers (shouldn't happen because
+    # jgehrcke regenerates the file, but guard against partial re-runs),
+    # strip them before re-injecting.
+    for s, e in (
+        (TOC_START, TOC_END),
+        (SECTION_START, SECTION_END),
+        (SCRIPT_START, SCRIPT_END),
+    ):
+        html = _strip_existing(html, s, e)
+
+    toc_block = f'{TOC_START}\n<li><a href="#ghcr-pulls">Container pulls (ghcr.io)</a></li>\n{TOC_END}\n'
+    section_block = (
+        f"{SECTION_START}\n"
+        f'<h2 id="ghcr-pulls">Container pulls (ghcr.io)</h2>\n'
+        f"<p>Daily pulls of <code>ghcr.io/{owner}/{pkg}</code>. "
+        f"Cumulative: <strong>{cumulative:,}</strong> "
+        f"({cumulative_display}). Source refreshed {fetched_at}.</p>\n"
+        f'<h4 id="ghcr-pulls-daily">Pulls per day</h4>\n'
+        f'<div id="chart_ghcr_pulls_daily" class="full-width-chart">\n\n</div>\n'
+        f'<div class="pagebreak-for-print">\n\n</div>\n'
+        f"{SECTION_END}\n"
+    )
+    script_block = (
+        f"{SCRIPT_START}\n"
+        f'<script type="text/javascript">\n'
+        f"vegaEmbed('#chart_ghcr_pulls_daily', "
+        f"{json.dumps(spec, separators=(',', ':'))}, "
+        f'{{"actions": false, "renderer": "svg"}}).catch(console.error);\n'
+        f"</script>\n"
+        f"{SCRIPT_END}\n"
+    )
+
+    toc_anchor = "<p>Table of contents:</p>\n<ul>\n"
+    if toc_anchor not in html:
+        raise RuntimeError("report.html: TOC anchor not found; layout drift?")
+    html = html.replace(toc_anchor, toc_anchor + toc_block, 1)
+
+    section_anchor = "</nav>\n"
+    if section_anchor not in html:
+        raise RuntimeError("report.html: section anchor (</nav>) not found; layout drift?")
+    html = html.replace(section_anchor, section_anchor + section_block, 1)
+
+    script_anchor = "</article>\n"
+    if script_anchor not in html:
+        raise RuntimeError("report.html: script anchor (</article>) not found; layout drift?")
+    html = html.replace(script_anchor, script_block + script_anchor, 1)
+
+    report_path.write_text(html, encoding="utf-8")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--report", required=True, type=Path)
+    parser.add_argument("--csv", required=True, type=Path)
+    parser.add_argument("--owner", required=True)
+    parser.add_argument("--pkg", required=True)
+    parser.add_argument(
+        "--ghcr-cache",
+        type=Path,
+        default=None,
+        help="Read GHCR HTML from a local file instead of fetching. For local dry-runs only.",
+    )
+    args = parser.parse_args()
+
+    if not args.report.exists():
+        print(f"::error::report not found: {args.report}", file=sys.stderr)
+        return 1
+
+    if args.ghcr_cache:
+        html = args.ghcr_cache.read_text(encoding="utf-8")
+        print(f"Loaded cached GHCR HTML: {args.ghcr_cache} ({len(html):,} bytes)")
+    else:
+        html = fetch_ghcr(args.owner, args.pkg)
+        print(f"Fetched GHCR page: {len(html):,} bytes")
+
+    cumulative, cumulative_display = parse_total(html)
+    fresh_daily = parse_daily(html)
+    print(f"Cumulative pulls: {cumulative:,} ({cumulative_display})")
+    print(f"Fresh days from sparkline: {len(fresh_daily)}")
+
+    merged = merge_csv(args.csv, fresh_daily)
+    write_csv(args.csv, merged)
+    print(f"Merged CSV rows: {len(merged)} -> {args.csv}")
+
+    spec = build_vega_spec(merged)
+    fetched_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+    patch_report(
+        args.report,
+        spec,
+        cumulative,
+        cumulative_display,
+        fetched_at,
+        args.owner,
+        args.pkg,
+    )
+    print(f"Patched: {args.report}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 44 - 3
.github/workflows/ci.yml

@@ -42,7 +42,10 @@ jobs:
           python-version: ${{ env.PYTHON_VERSION }}
 
       - name: Install ruff
-        run: pip install ruff
+        # Install the exact pin from requirements-dev.txt rather than the latest
+        # release, so CI and contributors run the same linter. `pip install ruff`
+        # silently drifted ahead of every local venv.
+        run: pip install "$(grep -E '^ruff==' requirements-dev.txt)"
 
       - name: Run ruff check
         run: ruff check backend/
@@ -65,7 +68,10 @@ jobs:
 
       - name: Install dependencies
         run: |
-          python -m pip install --upgrade pip
+          # Upgrade setuptools too: the runner's Python toolcache ships an old
+          # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
+          # A fix exists, so we upgrade rather than --ignore-vuln.
+          python -m pip install --upgrade pip setuptools
           pip install -r requirements.txt
           pip install pip-audit
 
@@ -195,15 +201,50 @@ jobs:
               if path and not info.get('dev') and not info.get('devOptional'):
                   prod.add(path.split('node_modules/')[-1])
           vulns = data.get('vulnerabilities', {})
+          # Documented advisory exceptions: high/critical findings whose only offered
+          # 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
+          # Keyed by GHSA id; RE-REVIEW ON EVERY react-router BUMP.
+          #   GHSA-qwww-vcr4-c8h2 - React Router RSC-mode CSRF. Bambuddy is a Vite SPA
+          #   using BrowserRouter with no RSC runtime (@react-router/server is NOT
+          #   installed), so the vulnerable code path is unreachable. No non-major fix
+          #   exists (7.18.1 is the most-patched 7.x - it clears 14 other advisories that
+          #   older 7.x carry - and the RSC fix landed only in the 8.3.0 major). react-router
+          #   /-dom are pinned to 7.18.1 in package.json. If a non-major fix ships, this stops
+          #   being exempt (major-only guard below) and the gate fails until we take it.
+          ALLOWLIST = {'GHSA-qwww-vcr4-c8h2'}
+          def advisory_ids(name, seen=None):
+              seen = seen if seen is not None else set()
+              if name in seen:
+                  return set()
+              seen.add(name)
+              ids = set()
+              for item in vulns.get(name, {}).get('via', []):
+                  if isinstance(item, dict):
+                      url = item.get('url', '')
+                      if '/advisories/' in url:
+                          ids.add(url.rsplit('/', 1)[-1])
+                  elif isinstance(item, str):
+                      ids |= advisory_ids(item, seen)
+              return ids
+          def fix_is_major(v):
+              fa = v.get('fixAvailable')
+              return isinstance(fa, dict) and fa.get('isSemVerMajor')
+          def exempt(name, v):
+              ids = advisory_ids(name)
+              return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
           fixable = {n: v for n, v in vulns.items()
-                     if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
+                     if n in prod and v.get('severity') in ('high', 'critical')
+                     and v.get('fixAvailable') and not exempt(n, v)}
           skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
           if fixable:
               for name, v in fixable.items():
                   print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
               sys.exit(1)
           total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
+          exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
           print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
+          if exempted:
+              print('exempted (documented, unreachable): ' + ', '.join(exempted))
           "
 
   frontend-typecheck:

+ 45 - 0
.github/workflows/repo-stats.yml

@@ -19,3 +19,48 @@ jobs:
           repository: maziggy/bambuddy
           ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
           ghpagesprefix: https://maziggy.github.io/bambuddy
+
+      # Inject ghcr.io container-download stats (cumulative + 30-day
+      # sparkline merged into a sidecar CSV so the chart grows beyond
+      # the 30-day window GHCR exposes). Runs after jgehrcke regenerates
+      # report.html on gh-pages.
+      - name: checkout-source
+        uses: actions/checkout@v4
+        with:
+          path: source
+
+      # jgehrcke/github-repo-stats commits to the `github-repo-stats`
+      # branch by default (NOT gh-pages — Pages is configured to serve
+      # from that branch). Direct git clone with the PAT surfaces real
+      # git stderr if anything fails, unlike actions/checkout@v4 which
+      # swallows errors as opaque "exit code 1".
+      - name: checkout-data-branch
+        env:
+          GH_PAT: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
+        run: |
+          git clone --branch github-repo-stats --depth 1 \
+            "https://x-access-token:${GH_PAT}@github.com/${{ github.repository }}.git" \
+            data
+          cd data
+          git config user.name  "github-actions[bot]"
+          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+      - name: inject-ghcr-pulls
+        run: |
+          python3 source/.github/scripts/ghcr_inject.py \
+            --report data/maziggy/bambuddy/latest-report/report.html \
+            --csv    data/maziggy/bambuddy/ghcr-pulls.csv \
+            --owner  maziggy \
+            --pkg    bambuddy
+
+      - name: commit-and-push
+        working-directory: data
+        run: |
+          if [ -z "$(git status --porcelain)" ]; then
+            echo "no changes — skipping commit"
+            exit 0
+          fi
+          git add maziggy/bambuddy/latest-report/report.html \
+                  maziggy/bambuddy/ghcr-pulls.csv
+          git commit -m "ghcr-pulls: refresh container downloads chart"
+          git push

+ 39 - 3
.github/workflows/security.yml

@@ -125,7 +125,10 @@ jobs:
 
       - name: Install dependencies
         run: |
-          python -m pip install --upgrade pip
+          # Upgrade setuptools too: the runner's Python toolcache ships an old
+          # setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
+          # A fix exists, so we upgrade rather than --ignore-vuln.
+          python -m pip install --upgrade pip setuptools
           pip install -r requirements.txt
           pip install pip-audit
 
@@ -305,13 +308,46 @@ jobs:
               }
             }
             const vulns = results.vulnerabilities || {};
+            // Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
+            // full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
+            // Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
+            // (@react-router/server not installed); react-router/-dom pinned to 7.18.1
+            // (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
+            // non-major fix ships.
+            const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
+            function advisoryIds(name, seen) {
+              seen = seen || new Set();
+              if (seen.has(name)) return new Set();
+              seen.add(name);
+              const ids = new Set();
+              for (const item of (vulns[name] || {}).via || []) {
+                if (item && typeof item === 'object') {
+                  const url = item.url || '';
+                  if (url.includes('/advisories/')) ids.add(url.split('/').pop());
+                } else if (typeof item === 'string') {
+                  for (const id of advisoryIds(item, seen)) ids.add(id);
+                }
+              }
+              return ids;
+            }
+            function fixIsMajor(info) {
+              const fa = info.fixAvailable;
+              return fa && typeof fa === 'object' && fa.isSemVerMajor;
+            }
+            function exempt(name, info) {
+              const ids = advisoryIds(name);
+              return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
+            }
             const filtered = {};
+            const flagged = {};
             for (const [name, info] of Object.entries(vulns)) {
-              if (prodDeps.has(name)) filtered[name] = info;
+              if (!prodDeps.has(name)) continue;
+              filtered[name] = info;
+              if (!exempt(name, info)) flagged[name] = info;
             }
             results.vulnerabilities = filtered;
             fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
-            const count = Object.keys(filtered).length;
+            const count = Object.keys(flagged).length;
             console.log(count > 0
               ? count + ' production vulnerabilities found'
               : 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');

+ 3 - 0
.gitignore

@@ -92,3 +92,6 @@ gitleaks-report.json
 scripts/pip-audit.sh
 
 security/
+
+test_pipeline_archive_source.3mf
+test_pipeline_run_1.3mf

+ 3 - 1
.pre-commit-config.yaml

@@ -30,7 +30,9 @@ repos:
         exclude: ^(static/|frontend/tsconfig\.)
       - id: check-added-large-files
         args: ['--maxkb=1000']
-        exclude: ^static/assets/
+        # CHANGELOG.md is intentionally large (detailed per-release entries over
+        # many versions); exempt it while keeping the 1 MB guard for everything else.
+        exclude: ^(static/assets/|CHANGELOG\.md$)
       - id: check-merge-conflict
       - id: debug-statements
       - id: detect-private-key

+ 22 - 2
BACKERS.md

@@ -24,15 +24,20 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@VREmma](https://github.com/VREmma)
 - [@pwostran](https://github.com/pwostran)
 - [@Praxeis](https://github.com/Praxeis)
+- [@jmclaren7](https://github.com/jmclaren7)
+- [@RoBoT24-web](https://github.com/RoBoT24-web)
+- [@Rayvenhaus](https://github.com/Rayvenhaus)
+- [@TheUltimateC0der](https://github.com/TheUltimateC0der)
+- [@rstocks](https://github.com/rstocks)
 
 ## Supporters ($15/mo+)
 
 - [@rewart01](https://github.com/rewart01)
-- [@rstocks](https://github.com/rstocks)
 - [@sixfootseven](https://github.com/sixfootseven)
 - [@MethodicalMartian](https://github.com/MethodicalMartian)
-- [@jmclaren7](https://github.com/jmclaren7)
 - [@brianharwell](https://github.com/brianharwell)
+- [@shosier01](https://github.com/shosier01)
+- [@freifunk-bamberg](https://github.com/freifunk-bamberg)
 
 ## Backers ($5/mo+)
 
@@ -50,6 +55,21 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@andyspinball](https://github.com/andyspinball
 - [@avandeputte](https://github.com/avandeputte)
 - [@joeferrante](https://github.com/joeferrante)
+- [@GPop61](https://github.com)
+- [@CooleyMcCoolson](https://github.com/CooleyMcCoolson)
+- [@mikeloveridge](https://github.com/mikeloveridge)
+- [@boernie](https://github.com/boernie)
+- [@qoatzelcoat](https://github.com/qoatzelcoat)
+- [@Sanaki](https://github.com/Sanaki)
+- [@jlofshult](https://github.com/jlofshult)
+- [@TriadX1](https://github.com/TriadX1)
+- [@hazzardr](https://github.com/hazzardr)
+- [@Shihchiun](https://github.com/Shihchiun)
+- [@kycrna](https://github.com/kycrna)
+- [@iljur](https://github.com/iljur)
+- [@bhamiltoncx](https://github.com/bhamiltoncx)
+- [@g7ufo](https://github.com/g7ufo)
+
 ---
 
 ## One-time and historical supporters

File diff suppressed because it is too large
+ 1 - 1
CHANGELOG.md


+ 8 - 3
CONTRIBUTING.md

@@ -117,8 +117,9 @@ pip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, band
 pip install pre-commit
 pre-commit install
 
-# Run backend
-DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000
+# Run backend (--loop asyncio matches production; avoids a uvloop TLS bug
+# that can truncate Virtual Printer FTP uploads on slow storage — see #1896)
+DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 --loop asyncio
 ```
 
 ### Frontend Setup
@@ -295,7 +296,11 @@ Permissions follow the `resource:action` pattern (e.g., `filaments:read`, `print
 | `update` | Modify existing resources |
 | `delete` | Remove resources |
 
-Some resources have additional actions (e.g., `printers:control` for start/stop, `printers:files` for file transfer).
+Some resources have additional actions. Examples: `printers:control` for live printer controls
+such as stop/pause/resume, `printers:files` for printer storage access, `queue:create` for
+creating queue items that may dispatch immediately when scheduled ASAP, `library:upload` for
+File Manager uploads/imports, and `archives:reprint_own` / `archives:reprint_all` for archive
+reprint eligibility. Archive reprint still needs `queue:create` before it can enqueue a job.
 
 ### Adding New Permissions
 

+ 19 - 3
Dockerfile

@@ -54,7 +54,7 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 # wheels (so a hostile wheel could hijack stdlib imports during install).
 COPY requirements.txt ./
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install --root-user-action=ignore --upgrade 'pip>=26.1' \
+    pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
  && pip install --root-user-action=ignore -r requirements.txt
 
 # Copy backend
@@ -147,6 +147,22 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 
 # Run the application
 # Use standard asyncio loop (uvloop has permission issues in some Docker environments)
-# Port is configurable via PORT environment variable (default: 8000)
+# Port is configurable via PORT (default 8000); bind address via HOST (default
+# 0.0.0.0). Set HOST=127.0.0.1 to bind loopback only, e.g. when a reverse proxy
+# on the same host fronts the app.
+#
+# `exec` is load-bearing, not style. Without it the shell stays as PID 1 and
+# uvicorn runs as its child; dash does not forward signals, so `docker stop`
+# SIGTERMs the shell and uvicorn never hears about it. Every stop then ran to
+# the end of the grace period and died on SIGKILL (exit 137) — no WAL
+# checkpoint, no MQTT disconnect, no virtual-printer teardown, on every restart
+# and every image update. With `exec`, uvicorn *is* PID 1 and gets the signal.
+#
+# --timeout-graceful-shutdown caps the wait on in-flight requests. Uvicorn's
+# default is to wait forever, and an MJPEG camera stream is a response that
+# never completes, so a single open camera tile would otherwise pin the process
+# past Docker's 10s grace and back into SIGKILL. On timeout uvicorn cancels the
+# request tasks; the camera generators already unwind cleanly on CancelledError.
+ENV UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN=5
 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
-CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"]
+CMD ["sh", "-c", "exec uvicorn backend.app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --loop asyncio --timeout-graceful-shutdown ${UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN}"]

+ 22 - 5
README.md

@@ -57,6 +57,7 @@
   <a href="https://hackaday.com/2026/06/13/bambuddy-says-bye-to-bambu-lab-cloud-services/"><img src="https://img.shields.io/badge/Hackaday-Read-F2A724?style=flat-square&labelColor=000000" alt="Hackaday"></a>
   <a href="https://www.xda-developers.com/finally-have-full-control-bambu-lab-printer-ditched-bambu-cloud/"><img src="https://img.shields.io/badge/XDA--Developers-Read-C8102E?style=flat-square" alt="XDA-Developers"></a>
   <a href="https://www.howtogeek.com/free-your-bambu-lab-3d-printer-from-the-cloud/"><img src="https://img.shields.io/badge/How--To%20Geek-Read-33A6CA?style=flat-square" alt="How-To Geek"></a>
+  <a href="https://www.makeuseof.com/free-browser-tool-beats-bambu-lab-at-own-game/"><img src="https://img.shields.io/badge/MakeUseOf-Read-E02D2D?style=flat-square" alt="MakeUseOf"></a>
   <a href="https://www.fabbaloo.com/news/bambuddy-launches-as-open-source-alternative-to-bambu-labs-cloud"><img src="https://img.shields.io/badge/Fabbaloo-Read-F77B0F?style=flat-square" alt="Fabbaloo"></a>
   <a href="https://itsfoss.com/news/bambuddy-self-hosted-bambu-lab-alternative/"><img src="https://img.shields.io/badge/It's%20FOSS-Read-00B5AD?style=flat-square" alt="It's FOSS"></a>
   <a href="https://www.igorslab.de/en/bambuddy-the-silent-alternative-to-the-bamboo-cloud/"><img src="https://img.shields.io/badge/Igor's%20Lab-Read-E10000?style=flat-square" alt="Igor's Lab"></a>
@@ -111,6 +112,20 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ---
 
+## 🧩 NEW: Slicer Pipelines — Save a Recipe, Reuse in One Click
+
+**Stop re-picking the same printer + process + filament + bed-type combination every slice.** Save a Slicer **Pipeline** once from the Slice dialog, then apply the whole bundle to any file with a single click — from File Manager, Archives, or MakerWorld imports.
+
+- 🧩 **One-click reuse** — A pipeline captures the entire Slice modal selection (printer + process + per-AMS-slot filaments + bed type) and surfaces as **Run with pipeline → \<name\>** on every sliceable row.
+- 🎯 **Specific printer or printer class** — Pin a pipeline to one printer, or to a *class* (e.g. *any X1C*) and let the queue scheduler pick the first available match. Identical-fleet farms get a single recipe instead of one-per-printer.
+- 🪢 **Multi-copy fanout** — Slice once, dispatch up to N copies. With class targeting the copies fan out across the matching printers in parallel — **Spread** (fastest wall-clock), **Single printer** (minimise colour-change overhead), or **First N** (one to each).
+- 📊 **Runs dashboard** — A new **Pipelines** tab on the Print Queue page lists every run with colour-coded status badges (queued / slicing / dispatching / in-progress / completed / partial-failure / failed / cancelled), per-copy detail on expand, filter dropdowns (Pipeline / Status / Target), and a **Retry failed** button that re-runs only the copies that didn't complete — successful copies are never re-printed.
+- 🔒 **Permission-gated** — Three permissions (`pipelines:read` / `pipelines:write` / `pipelines:run`) let you split authoring the recipe from spending filament with it.
+
+👉 **[Slicer Pipelines Guide →](https://wiki.bambuddy.cool/features/slicer-pipelines/)**
+
+---
+
 ## Why Bambuddy?
 
 - **Own your data** — All print history stored locally, no cloud dependency
@@ -141,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
+- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
 - **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
 - **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
@@ -168,7 +184,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
 - Dual external spool support for H2D (Ext-L / Ext-R)
-- HMS error monitoring with history and clear errors
+- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 13 supported locales
 - **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
 - Print success rates & trends
 - Filament usage tracking
@@ -178,7 +194,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - CSV/Excel export
 
 ### ⏰ Scheduling & Automation
-- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
+- **Unified dispatch through the queue** — Every print Bambuddy starts (File Manager, archive reprint, printer-card upload-and-print, scheduled queue items) flows through the same queue scheduler, so each print is visible on the queue page, attributable to the user that started it, deficit-checked, and cancellable from one place. FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button). Installations with custom groups or API keys: the immediate-print actions now require the `queue:create` permission alongside the existing `printers:control` — see [the permissions guide](https://wiki.bambuddy.cool/admin/permissions/) if you've granted control without queue-create
 - Print queue with three tabs (Queue / History / Timeline), multi-select drag-and-drop, batch grouping, and a Gantt-style timeline
 - Multi-printer selection (send to multiple printers at once)
 - Batch grouping — multi-plate prints auto-group into a collapsible row; any 2+ selected items can be grouped manually via "Group as batch", with ungroup on the batch parent
@@ -195,6 +211,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)
+- **Preheat & Heat Soak before queued prints** — Heat the bed (and the chamber, on supported printers) and hold at temperature between FTP upload and print start. Per-print Inherit / On / Off override in the Print Options panel; per-filament chamber-target map under Settings → Workflow so PA wants 50°C, ABS 45°C, PETG-CF 40°C, PLA 0°C (skips chamber phase automatically). Hardware-aware: H-series / X2D / X1E actively heat the chamber via M141; X1C / P2S rely on bed radiation with a chamber-sensor wait; P1S / P1P / A1 family have no chamber sensor so only the soak timer applies. The cooling/heating airduct flap on H-series / X2D / P2S auto-switches to match the resolved chamber target — preheat for ABS opens nothing and recirculates warm air; preheat for PLA opens the exhaust and vents — so engineering filaments actually reach target instead of fighting the open flap, and PLA prints don't inherit a previously-hot recirculation. M191 (wait-for-chamber-temp) isn't honoured by Bambu firmware, so doing this at the orchestration layer is the only place it works
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
 - REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
@@ -358,7 +375,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ## 📸 Screenshots
 
-> **Refreshed printer card in 0.2.5b2** — tighter layout, popovers for all controls (temperature setpoints, fan speeds, jog), and a bottom-aligned power row. The screenshots below predate the refresh.
+> **Refreshed printer card in 1.2.5b2** — tighter layout, popovers for all controls (temperature setpoints, fan speeds, jog), and a bottom-aligned power row. The screenshots below predate the refresh.
 
 <details>
 <summary><strong>Click to expand screenshots</strong></summary>
@@ -650,8 +667,8 @@ python3 -m venv venv
 source venv/bin/activate
 pip install -r requirements.txt
 
-# Run
-uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+# Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
+uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio
 ```
 
 Open **http://localhost:8000** and add your printer!

+ 14 - 10
backend/app/api/routes/_oidc_helpers.py

@@ -1,9 +1,11 @@
 """Pure helper functions for OIDC routes.
 
-Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
-``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
-loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
-the public internet (IdP-hosted), so private addresses there are SSRF probes.
+Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
+and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
+than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
+allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
+reachable on the public internet, so a private address there is an SSRF probe
+rather than a configuration.
 """
 
 from __future__ import annotations
@@ -17,9 +19,10 @@ from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE
 def assert_safe_public_https_url(url: str) -> None:
     """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
 
-    Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
-    guard: also rejects loopback, private (RFC-1918), and link-local addresses
-    because an OIDC icon legitimately lives only on the public internet.
+    Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
+    than the LAN-service SSRF guard: also rejects loopback, private
+    (RFC-1918), and link-local addresses because an IdP and its icon
+    legitimately live only on the public internet.
 
     Checks performed:
     - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
@@ -35,9 +38,10 @@ def assert_safe_public_https_url(url: str) -> None:
     - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
       check so an attacker can't bypass via IPv6 encoding.
 
-    Hostname-based addresses are accepted without DNS resolution (consistent
-    with ``_validate_issuer_url`` policy — the operator is trusted to
-    configure a sensible IdP host).
+    Hostname-based addresses are accepted without DNS resolution — the
+    operator is trusted to configure a sensible IdP host, and resolving here
+    would both add a TOCTOU gap (DNS can change between validation and
+    request) and make the validator issue network requests of its own.
     """
     parsed = urlparse(url)
     if parsed.scheme.lower() != "https":

+ 10 - 56
backend/app/api/routes/_spoolman_helpers.py

@@ -5,17 +5,15 @@ No heavy dependencies — importable in unit tests without the full backend stac
 
 from __future__ import annotations
 
-import ipaddress
 import json
 import logging
 import math
 import re
 from typing import Any
-from urllib.parse import urlparse
 
 from typing_extensions import TypedDict
 
-from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
 
 logger = logging.getLogger(__name__)
 
@@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
 
 
 def assert_safe_spoolman_url(url: str) -> None:
-    """Raise ValueError if *url* should be blocked as an SSRF risk.
-
-    Bambuddy is typically deployed on a home LAN alongside Spoolman, so
-    loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
-    172.16-31.x) must be permitted — they are THE normal Spoolman topology.
-    This guard therefore targets the genuinely dangerous cases only.
-
-    Checks performed:
-    - Scheme must be http or https (no file://, gopher://, dict://, etc.).
-    - Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
-      (e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
-      ``ValueError`` for these forms so they would otherwise bypass the
-      explicit-IP block below, but libc (and browsers) resolve them as valid
-      IPv4 addresses.
-    - Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
-      fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
-    - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
-      are blocked — pointless as a destination and suggests misuse.
-    - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
-      bypass the checks above.
-
-    Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
-    are out of scope — DNS resolution is deliberately not performed here.
-    """
-    parsed = urlparse(url)
-    if parsed.scheme.lower() not in ("http", "https"):
-        raise ValueError("Spoolman URL must use http or https")
-
-    hostname = (parsed.hostname or "").lower()
+    """Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
 
-    # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
-    # http://0x7f000001/). These slip past ipaddress.ip_address() but libc
-    # (and browsers) parse them as IPv4 — an obvious bypass if not caught.
-    if NUMERIC_IP_RE.match(hostname):
-        raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+    Thin wrapper over the shared LAN-service policy — see
+    ``_url_safety.assert_safe_lan_service_url`` for what is and isn't
+    rejected, and why loopback/RFC-1918 are deliberately permitted (running
+    Spoolman on the same host or home LAN is THE normal topology).
 
-    try:
-        addr = ipaddress.ip_address(hostname)
-    except ValueError:
-        # Not a bare IP address — includes intentional cases such as "localhost" and
-        # RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
-        # a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
-        # Running Spoolman on the same host or home LAN is the standard Bambuddy
-        # topology, so loopback and private ranges are deliberately NOT blocked here.
-        return
-
-    # Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
-    # encode a blocked IPv4 into an IPv6 literal to bypass the check.
-    effective = unwrap_ipv4_mapped(addr)
-
-    if effective in CLOUD_METADATA_IPS:
-        raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
-
-    if effective.is_multicast or effective.is_unspecified:
-        raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
+    Kept as a named function because the "Spoolman URL …" wording in its
+    errors is user-facing and asserted by existing tests.
+    """
+    assert_safe_lan_service_url(url, label="Spoolman URL")
 
 
 _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")

+ 77 - 10
backend/app/api/routes/_url_safety.py

@@ -1,19 +1,31 @@
-"""Shared URL-safety primitives used by both SSRF guards in this package.
-
-The two top-level assertion functions —
-``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
-loopback/RFC-1918 because same-LAN deployment is the standard topology) and
-``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
-on the public internet, so loopback/private are rejected) — share the
-*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
-*policy*. Only the data lives here. The functions stay in their respective
-modules with their distinct policies intact.
+"""Shared URL-safety primitives for the SSRF guards in this package.
+
+Bambuddy has exactly two outbound-URL policies, and which one applies is a
+property of the *service*, not of the caller:
+
+- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
+  legitimately lives on the same host or home LAN, so loopback and RFC-1918
+  must be permitted; blocking them would break the normal topology. Used for
+  Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
+  webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
+- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
+  resource can only sensibly live on the public internet, so a private
+  address is an SSRF probe rather than a configuration. Used for OIDC issuer
+  and icon URLs.
+
+Both reject the cases that are dangerous regardless of topology: non-HTTP
+schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
+unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
+
+The LAN-service policy lives here because it now has several callers; the
+public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
 """
 
 from __future__ import annotations
 
 import ipaddress
 import re
+from urllib.parse import urlparse
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # targets. Both guards reject these unconditionally.
@@ -49,3 +61,58 @@ def unwrap_ipv4_mapped(
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
         return addr.ipv4_mapped
     return addr
+
+
+def assert_safe_lan_service_url(url: str, *, label: str) -> None:
+    """Raise ValueError if *url* is unsafe for a service that may live on the LAN.
+
+    ``label`` names the setting in the error message ("Spoolman URL", "ntfy
+    server URL", …) so the user sees which field they need to correct.
+
+    Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
+    **permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
+    Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
+    same host or home LAN is THE normal topology, not an attack. A blanket
+    private-address block would break those integrations for most installs.
+
+    What is rejected is dangerous under any topology:
+
+    - Schemes other than http/https. ``httpx`` already raises
+      ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
+      about returning a clear validation error at configuration time rather
+      than an opaque failure at delivery time.
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
+      libc and browsers resolve these, but Python's ``ipaddress`` raises
+      ValueError on them, so they would slip past the checks below.
+    - Cloud-provider metadata endpoints — the high-value SSRF target, and
+      never a legitimate destination for any of these services.
+    - Multicast and unspecified addresses — pointless as a destination and
+      indicative of misuse.
+    - IPv4-mapped IPv6 encodings of any of the above.
+
+    Symbolic hostnames are accepted without DNS resolution, matching the
+    public-internet guard: resolution here would be both a TOCTOU (DNS can
+    change between validation and request) and a request the validator
+    shouldn't be making.
+    """
+    parsed = urlparse(url)
+    if parsed.scheme.lower() not in ("http", "https"):
+        raise ValueError(f"{label} must use http or https")
+
+    hostname = (parsed.hostname or "").lower()
+
+    if NUMERIC_IP_RE.match(hostname):
+        raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+
+    try:
+        addr = ipaddress.ip_address(hostname)
+    except ValueError:
+        return  # symbolic hostname — out of scope by design (no DNS check)
+
+    effective = unwrap_ipv4_mapped(addr)
+
+    if effective in CLOUD_METADATA_IPS:
+        raise ValueError(f"{label} must not point to a cloud metadata endpoint")
+
+    if effective.is_multicast or effective.is_unspecified:
+        raise ValueError(f"{label} must not point to a multicast or unspecified address")

+ 12 - 0
backend/app/api/routes/api_keys.py

@@ -65,6 +65,9 @@ async def create_api_key(
         can_read_status=data.can_read_status,
         can_manage_library=data.can_manage_library,
         can_manage_inventory=data.can_manage_inventory,
+        can_manage_maintenance=data.can_manage_maintenance,
+        can_manage_archives=data.can_manage_archives,
+        can_manage_projects=data.can_manage_projects,
         can_access_cloud=data.can_access_cloud,
         can_update_energy_cost=data.can_update_energy_cost,
         printer_ids=data.printer_ids,
@@ -86,6 +89,9 @@ async def create_api_key(
         can_read_status=api_key.can_read_status,
         can_manage_library=api_key.can_manage_library,
         can_manage_inventory=api_key.can_manage_inventory,
+        can_manage_maintenance=api_key.can_manage_maintenance,
+        can_manage_archives=api_key.can_manage_archives,
+        can_manage_projects=api_key.can_manage_projects,
         can_access_cloud=api_key.can_access_cloud,
         can_update_energy_cost=api_key.can_update_energy_cost,
         printer_ids=api_key.printer_ids,
@@ -139,6 +145,12 @@ async def update_api_key(
         api_key.can_manage_library = data.can_manage_library
     if data.can_manage_inventory is not None:
         api_key.can_manage_inventory = data.can_manage_inventory
+    if data.can_manage_maintenance is not None:
+        api_key.can_manage_maintenance = data.can_manage_maintenance
+    if data.can_manage_archives is not None:
+        api_key.can_manage_archives = data.can_manage_archives
+    if data.can_manage_projects is not None:
+        api_key.can_manage_projects = data.can_manage_projects
     if data.can_access_cloud is not None:
         # Same constraint as create — flipping cloud access on a legacy key
         # without an owner would be silently broken; reject at the route layer.

+ 298 - 171
backend/app/api/routes/archives.py

@@ -25,14 +25,15 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
-from backend.app.services.finance_budget import validate_print_budget
+from backend.app.services.design_settings import overrides_from_config
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
@@ -42,6 +43,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/archives", tags=["archives"])
 
+# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
 
 def _safe_filename(filename: str) -> str:
     """Extract basename from a client-supplied filename, preventing path traversal.
@@ -120,6 +124,28 @@ def _match_timelapse_by_timestamp(
     return best_video, best_diff
 
 
+async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_id: int) -> set[str]:
+    """Video filenames already attached to another archive of this printer (#2704).
+
+    Lets the baseline diff drop a previous print's late-landing video from the
+    candidate list without ordering the candidates — ordering could only be done
+    on mtime or the filename timestamp, and both come from a clock the printer
+    can't sync in LAN-only mode. ``attach_timelapse`` stores the video under the
+    printer's own filename and the MP4 conversion keeps the stem, so the stem of
+    ``timelapse_path`` is what was claimed.
+    """
+    if printer_id is None:
+        return set()
+    rows = await db.execute(
+        select(PrintArchive.timelapse_path).where(
+            PrintArchive.printer_id == printer_id,
+            PrintArchive.id != exclude_archive_id,
+            PrintArchive.timelapse_path.is_not(None),
+        )
+    )
+    return {Path(p).stem for p in rows.scalars().all() if p}
+
+
 def _ensure_archive_visible(
     archive: PrintArchive | None,
     user: User | None,
@@ -571,6 +597,8 @@ async def list_archives_slim(
             PrintLogEntry.filament_color,
             PrintLogEntry.status,
             PrintLogEntry.cost,
+            PrintLogEntry.energy_kwh,
+            PrintLogEntry.energy_cost,
             PrintLogEntry.created_at,
         )
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@@ -594,8 +622,12 @@ async def list_archives_slim(
                 # print_time_seconds (slicer estimate) for non-completed
                 # events would diverge from Quick Stats — so expose the
                 # measured value here unconditionally.
+                #
+                # Trust an explicit 0 (reconciled aborts store it deliberately;
+                # their real end time is unknown) instead of recomputing the
+                # multi-day disconnect gap from the timestamps (#2592).
                 r.duration_seconds
-                if r.duration_seconds and r.duration_seconds > 0
+                if r.duration_seconds is not None
                 else (
                     int((r.completed_at - r.started_at).total_seconds())
                     if r.started_at and r.completed_at and (r.completed_at - r.started_at).total_seconds() > 0
@@ -609,6 +641,8 @@ async def list_archives_slim(
             "started_at": r.started_at,
             "completed_at": r.completed_at,
             "cost": r.cost,
+            "energy_kwh": r.energy_kwh,
+            "energy_cost": r.energy_cost,
             "quantity": 1,
             "created_at": r.created_at,
         }
@@ -1072,7 +1106,12 @@ async def get_archive_stats(
     )
     total_seconds = 0
     for duration_seconds, started_at, completed_at in time_rows.all():
-        if duration_seconds:
+        # Trust an explicitly stored duration, INCLUDING 0: a reconciled abort
+        # stores 0 on purpose because its real end time is unknown, and the
+        # started_at→completed_at fallback would otherwise bank the whole
+        # multi-day disconnect gap as print time (#2592). Only rows with a NULL
+        # duration (legacy entries that never recorded one) fall back.
+        if duration_seconds is not None:
             total_seconds += duration_seconds
         elif started_at and completed_at:
             elapsed = (completed_at - started_at).total_seconds()
@@ -1278,6 +1317,13 @@ async def _sum_snapshot_deltas(
     """
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+    from backend.app.utils.local_time import to_naive_utc
+
+    # ``recorded_at`` is a naive column holding UTC. Binding an aware datetime
+    # against it raises DataError on asyncpg (SQLite silently drops the offset),
+    # which took the whole date-filtered energy figure down on Postgres.
+    dt_from = to_naive_utc(dt_from)
+    dt_to = to_naive_utc(dt_to)
 
     plug_ids_result = await db.execute(select(SmartPlug.id))
     plug_ids = [row[0] for row in plug_ids_result.all()]
@@ -1652,13 +1698,17 @@ async def update_archive(
 async def toggle_favorite(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Toggle favorite status for an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     archive.is_favorite = not archive.is_favorite
     await db.commit()
@@ -2207,13 +2257,17 @@ async def get_timelapse(
 async def delete_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Remove the timelapse video from an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.timelapse_path:
         raise HTTPException(404, "No timelapse attached to this archive")
@@ -2233,34 +2287,41 @@ async def delete_timelapse(
 @router.post("/{archive_id}/timelapse/scan")
 async def scan_timelapse(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Scan printer for timelapse matching this archive and attach it."""
+    from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
+        delete_archived_timelapse,
         download_file_bytes_async,
         get_ftp_retry_settings,
         list_files_async,
+        remote_file_settled,
         with_ftp_retry,
     )
 
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    # Read the archive + printer in a short session and release the pooled DB
+    # connection BEFORE the FTP scan/download below — a timelapse pull walks
+    # several directories and fetches a 100MB+ video, so holding Depends(get_db)
+    # across it pinned one connection idle-in-transaction for minutes (#2572).
+    # Scalar columns stay readable on the detached rows (expire_on_commit=False);
+    # the attach at the end runs in its own fresh short session.
+    async with async_session() as db:
+        archive = await ArchiveService(db).get_archive(archive_id)
+        if not archive:
+            raise HTTPException(404, "Archive not found")
 
-    if archive.timelapse_path:
-        return {"status": "exists", "message": "Timelapse already attached"}
+        if archive.timelapse_path:
+            return {"status": "exists", "message": "Timelapse already attached"}
 
-    if not archive.printer_id:
-        raise HTTPException(400, "Archive has no associated printer")
+        if not archive.printer_id:
+            raise HTTPException(400, "Archive has no associated printer")
 
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+        result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+        printer = result.scalar_one_or_none()
+        if not printer:
+            raise HTTPException(404, "Printer not found")
 
     # Get base name from archive filename (without .3mf extension)
     base_name = Path(archive.filename).stem
@@ -2286,18 +2347,48 @@ async def scan_timelapse(
         f for f in files if not f.get("is_directory") and f.get("name", "").lower().endswith((".mp4", ".avi"))
     ]
 
+    # Strategy 0: snapshot diff against the baseline captured at print start
+    # (#2704). This is the same comparison the automatic scan makes, and the
+    # only one here that doesn't depend on the printer's clock — a printer in
+    # LAN-only mode can't reach Bambu's NTP server, so the timestamps in both
+    # the filename and the FTP mtime can be days out. One reporter's P1S was
+    # six and a half days off, which defeats every strategy below.
+    #
+    # When a baseline exists it is authoritative and the clock-based strategies
+    # are skipped entirely: they can only turn an honest "pick one yourself"
+    # into a confident wrong answer. Those strategies stay for archives created
+    # before the baseline was persisted.
+    used_baseline = archive.timelapse_baseline is not None
+    if used_baseline:
+        baseline = set(archive.timelapse_baseline)
+        async with async_session() as db:
+            claimed = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
+        candidates = [
+            f for f in video_files if f.get("name", "") not in baseline and Path(f.get("name", "")).stem not in claimed
+        ]
+        if len(candidates) == 1:
+            matching_file = candidates[0]
+            logger.info("Matched timelapse by print-start baseline: %s", matching_file.get("name"))
+        elif candidates:
+            # Ambiguous — offer only the plausible files instead of guessing.
+            video_files = candidates
+            logger.info("Baseline left %s unclaimed candidates for archive %s", len(candidates), archive_id)
+        else:
+            logger.info("Baseline shows no unclaimed new video on the printer for archive %s", archive_id)
+
     # Strategy 1: Match by print name in filename
-    for f in video_files:
-        fname = f.get("name", "")
-        if base_name.lower() in fname.lower():
-            matching_file = f
-            break
+    if not used_baseline:
+        for f in video_files:
+            fname = f.get("name", "")
+            if base_name.lower() in fname.lower():
+                matching_file = f
+                break
 
     # Strategy 2: Match by timestamp proximity against print START time.
     # Bambu timelapse filename embeds the print start time in printer-local clock.
     # See _match_timelapse_by_timestamp for the offset-search rationale and why we
     # intentionally don't try to match filename against end time here.
-    if not matching_file and archive.started_at:
+    if not used_baseline and not matching_file and archive.started_at:
         candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at)
         if candidate is not None:
             matching_file = candidate
@@ -2305,7 +2396,7 @@ async def scan_timelapse(
 
     # Strategy 3: Use file modification time from FTP listing
     # This handles cases where printer's filename timestamp is wrong but file mtime is correct
-    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
+    if not used_baseline and not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
         from datetime import datetime, timedelta
 
         _archive_start = archive.started_at
@@ -2333,7 +2424,7 @@ async def scan_timelapse(
 
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
-    if not matching_file and len(video_files) == 1:
+    if not used_baseline and not matching_file and len(video_files) == 1:
         from datetime import datetime, timedelta, timezone
 
         archive_completed = archive.completed_at or archive.created_at
@@ -2383,6 +2474,7 @@ async def scan_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=matching_file.get("size"),
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {matching_file['name']}",
@@ -2394,17 +2486,42 @@ async def scan_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=matching_file.get("size"),
         )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
-    # Attach timelapse to archive
-    success = await service.attach_timelapse(archive_id, timelapse_data, matching_file["name"])
+    # Confirm the printer has finished writing before we commit to this file and
+    # delete the original: matching the listing's size proves we got what it
+    # said, not that the file was complete (#2704).
+    if not await remote_file_settled(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        len(timelapse_data),
+        printer_model=printer.model,
+    ):
+        raise HTTPException(409, "The printer is still writing this video — try again in a moment")
+
+    # Attach in a fresh short session (the read session was released before FTP).
+    async with async_session() as db:
+        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, matching_file["name"])
 
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
 
+    # Safe now, and only now: the transfer matched the size the listing reported
+    # and the bytes are committed to the archive (#2704).
+    await delete_archived_timelapse(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        verified=matching_file.get("size") is not None,
+        printer_model=printer.model,
+        printer_name=printer.name,
+    )
+
     return {
         "status": "attached",
         "message": f"Timelapse '{matching_file['name']}' attached successfully",
@@ -2416,34 +2533,40 @@ async def scan_timelapse(
 async def select_timelapse(
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Manually select a timelapse from the printer to attach."""
+    from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
+        delete_archived_timelapse,
         download_file_bytes_async,
         get_ftp_retry_settings,
         list_files_async,
+        remote_file_settled,
         with_ftp_retry,
     )
 
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    # Read the archive + printer in a short session and release the pooled DB
+    # connection BEFORE the FTP scan/download below (#2572); scalars stay
+    # readable after close (expire_on_commit=False), the attach reopens one.
+    async with async_session() as db:
+        archive = await ArchiveService(db).get_archive(archive_id)
+        if not archive:
+            raise HTTPException(404, "Archive not found")
 
-    if not archive.printer_id:
-        raise HTTPException(400, "Archive has no associated printer")
+        if not archive.printer_id:
+            raise HTTPException(400, "Archive has no associated printer")
 
-    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+        result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+        printer = result.scalar_one_or_none()
+        if not printer:
+            raise HTTPException(404, "Printer not found")
 
     # Find the file on the printer
     files = []
     remote_path = None
+    expected_size = None
     for timelapse_dir in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
         try:
             files = await list_files_async(
@@ -2452,6 +2575,7 @@ async def select_timelapse(
             for f in files:
                 if f.get("name") == filename:
                     remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
+                    expected_size = f.get("size")
                     break
             if remote_path:
                 break
@@ -2472,6 +2596,7 @@ async def select_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=expected_size,
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {filename}",
@@ -2483,15 +2608,41 @@ async def select_timelapse(
             remote_path,
             socket_timeout=ftp_timeout,
             printer_model=printer.model,
+            expected_size=expected_size,
         )
 
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
-    success = await service.attach_timelapse(archive_id, timelapse_data, filename)
+    # Confirm the printer has finished writing before we commit to this file and
+    # delete the original: matching the listing's size proves we got what it
+    # said, not that the file was complete (#2704).
+    if not await remote_file_settled(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        len(timelapse_data),
+        printer_model=printer.model,
+    ):
+        raise HTTPException(409, "The printer is still writing this video — try again in a moment")
+
+    # Attach in a fresh short session (the read session was released before FTP).
+    async with async_session() as db:
+        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, filename)
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
 
+    # Safe now, and only now: the transfer matched the size the listing reported
+    # and the bytes are committed to the archive (#2704).
+    await delete_archived_timelapse(
+        printer.ip_address,
+        printer.access_code,
+        remote_path,
+        verified=expected_size is not None,
+        printer_model=printer.model,
+        printer_name=printer.name,
+    )
+
     return {
         "status": "attached",
         "message": f"Timelapse '{filename}' attached successfully",
@@ -2721,13 +2872,17 @@ async def upload_photo(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Upload a photo of the printed result."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
@@ -2811,13 +2966,17 @@ async def delete_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Delete a photo."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
@@ -3415,11 +3574,23 @@ async def get_archive_plates(
     # Printer / process preset names the 3MF was prepared with — used by the
     # SliceModal to default its dropdowns (#1325).
     embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
+    # Process settings the designer changed away from the stock preset (#2622),
+    # offered in the SliceModal for a cross-printer re-slice. Same payload the
+    # library plates endpoint returns — SliceModal reads one shape for both.
+    design_overrides: list[dict] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             embedded_presets = extract_embedded_presets_from_3mf(zf)
+            if _PROJECT_SETTINGS_PATH in namelist:
+                try:
+                    design_overrides = [
+                        o._asdict()
+                        for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
+                    ]
+                except (ValueError, OSError, KeyError):
+                    design_overrides = []
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -3681,6 +3852,7 @@ async def get_archive_plates(
         "has_gcode": has_gcode,
         "embedded_printer": embedded_presets["printer"],
         "embedded_process": embedded_presets["process"],
+        "design_overrides": design_overrides,
     }
 
 
@@ -3766,6 +3938,7 @@ async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
     request_id: str | None = None,
+    full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -3875,6 +4048,14 @@ async def get_filament_requirements(
                                 }
                             )
 
+            # Re-slicing a source that already carries slice_info (#2712).
+            # See library.py for the full rationale: the slice modal's list is
+            # positional, so a source using only slot 4 must still present
+            # four slots or the pick lands on slot 1. The print path keeps the
+            # used-only list it depends on.
+            if full_slots and filaments:
+                filaments = expand_to_project_slots(zf, filaments)
+
             # Unsliced project files: see library.py for full rationale.
             # Return the FULL project_settings.config slot list with a
             # used_in_plate flag derived from the preview slice; the
@@ -3941,8 +4122,12 @@ async def slice_archive(
     )
 
     archive = await db.get(PrintArchive, archive_id)
-    if archive is None:
-        raise HTTPException(status_code=404, detail="Archive not found")
+    # Per-row ownership gate — mirror the archive read routes. LIBRARY_UPLOAD
+    # alone let a READ_OWN caller slice another user's archive by raw id even
+    # though GET on that id returned 404. API-key / auth-disabled callers
+    # (current_user is None) keep can_read_all=True — no per-row identity.
+    can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
+    archive = _ensure_archive_visible(archive, current_user, can_read_all)
 
     src_relative = archive.source_3mf_path or archive.file_path
     if not src_relative:
@@ -4013,6 +4198,7 @@ async def slice_archive(
         kind="archive",
         source_id=archive.id,
         source_name=archive.print_name or archive.filename or f"archive {archive.id}",
+        owner_id=user_id,
         run=_run,
     )
     return {
@@ -4026,103 +4212,24 @@ async def slice_archive(
 async def reprint_archive(
     archive_id: int,
     printer_id: int,
-    body: ReprintRequest | None = None,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
-        require_ownership_permission(
-            Permission.ARCHIVES_REPRINT_ALL,
-            Permission.ARCHIVES_REPRINT_OWN,
-        )
-    ),
+    # SECURITY.md SEC-AUTH-1: every route either has an explicit auth dep or
+    # is in the route-auth-coverage allowlist. Gating the deprecation stub on
+    # QUEUE_CREATE matches the replacement route (POST /queue/) and means
+    # anonymous callers bounce at auth instead of seeing the deprecation
+    # message — leaking "this route exists" to unauthenticated callers is
+    # exactly the shape the backstop guards against.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
-    """Dispatch an archived 3MF file for send/start on a printer."""
-    from backend.app.models.printer import Printer
-    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
-    from backend.app.services.printer_manager import printer_manager
-
-    user, can_modify_all = auth_result
-
-    # Use defaults if no body provided
-    if body is None:
-        body = ReprintRequest()
-
-    # Get archive
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
-
-    # Ownership check
-    if not can_modify_all:
-        if archive.created_by_id != user.id:
-            raise HTTPException(403, "You can only reprint your own archives")
-
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
-
-    # Check printer is connected
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(400, "Printer is not connected")
-
-    if not archive.file_path:
-        raise HTTPException(
-            404,
-            "No 3MF file available for this archive. "
-            "The file could not be downloaded from the printer when the print was recorded.",
-        )
-
-    # Validate archive file exists
-    file_path = settings.base_dir / archive.file_path
-    if not file_path.is_file():
-        raise HTTPException(404, "Archive file not found")
-
-    await validate_print_budget(
-        db,
-        cost_center_id=body.cost_center_id,
-        estimated_cost=body.estimated_cost,
-        current_user=user,
-    )
-
-    plate_name = body.plate_name
-    if not plate_name and body.plate_id is not None:
-        plate_name = f"Plate {body.plate_id}"
-
-    dispatch_source_name = archive.filename
-    if plate_name:
-        dispatch_source_name = f"{archive.filename} • {plate_name}"
-
-    try:
-        dispatch_result = await background_dispatch.dispatch_reprint_archive(
-            archive_id=archive_id,
-            archive_name=dispatch_source_name,
-            printer_id=printer_id,
-            printer_name=printer.name,
-            options=body.model_dump(exclude_none=True),
-            requested_by_user_id=user.id if user else None,
-            requested_by_username=user.username if user else None,
-        )
-    except DispatchEnqueueRejected as e:
-        raise HTTPException(status_code=409, detail=str(e)) from e
-
-    logger.info(
-        "Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
+    """Legacy direct reprint endpoint. Use POST /queue/ instead."""
+    logger.warning(
+        "Gone API used: POST /archives/%s/reprint?printer_id=%s; use POST /queue/ instead",
         archive_id,
         printer_id,
-        dispatch_result["dispatch_job_id"],
-        dispatch_result["dispatch_position"],
     )
-
-    return {
-        "status": "dispatched",
-        "printer_id": printer_id,
-        "archive_id": archive_id,
-        "filename": archive.filename,
-        "dispatch_job_id": dispatch_result["dispatch_job_id"],
-        "dispatch_position": dispatch_result["dispatch_position"],
-    }
+    raise HTTPException(
+        status_code=410,
+        detail="Direct archive reprint has been removed. Create a print queue item with POST /queue/.",
+    )
 
 
 # =============================================================================
@@ -4164,15 +4271,19 @@ async def update_project_page(
     archive_id: int,
     update_data: dict,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
 
+    user, can_modify_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_modify_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4282,13 +4393,17 @@ async def upload_source_3mf(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Upload the original source 3MF project file for an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
@@ -4543,13 +4658,17 @@ async def upload_source_3mf_by_name(
 async def delete_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Delete the source 3MF project file from an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -4576,13 +4695,17 @@ async def upload_f3d(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Upload a Fusion 360 design file for an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not file.filename or not file.filename.endswith(".f3d"):
         raise HTTPException(400, "File must be a .f3d file")
@@ -4656,13 +4779,17 @@ async def download_f3d(
 async def delete_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Delete the Fusion 360 design file from an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")

+ 55 - 5
backend/app/api/routes/auth.py

@@ -190,9 +190,15 @@ async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
 
 async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
     """Set authentication enabled status."""
+    from backend.app.core.auth import invalidate_auth_enabled_cache
     from backend.app.core.db_dialect import upsert_setting
 
     await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false")
+    # Drop the cached auth-enabled flag so the change takes effect immediately
+    # instead of after the TTL (issue #2572). Safe pre-commit: only enabled=True
+    # is ever cached, and the newly-enabled True isn't visible to other sessions
+    # until this transaction commits, so no stale value can be re-cached here.
+    invalidate_auth_enabled_cache()
     # Note: Don't commit here - let get_db handle it or commit explicitly in the route
 
 
@@ -296,6 +302,38 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         detail="Failed to create admin user",
                     )
 
+        if request.auth_enabled:
+            # Enabling auth flips cloud-credential storage from the global
+            # Settings rows to User.cloud_token. Carry any token linked while
+            # auth was off across to the owning admin, or /cloud/* silently
+            # degrades to local presets with no indication anything broke
+            # (#2530). Only migrate when there is exactly one obvious owner:
+            # handing another admin's session a Bambu credential is not a
+            # guess worth making.
+            from backend.app.api.routes.cloud import (
+                get_stored_token,
+                migrate_global_cloud_token_to_user,
+            )
+
+            if admin_created:
+                cloud_owner = admin_user
+            elif len(existing_admin_users) == 1:
+                cloud_owner = existing_admin_users[0]
+            else:
+                cloud_owner = None
+
+            if cloud_owner is not None:
+                if await migrate_global_cloud_token_to_user(db, cloud_owner):
+                    logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username)
+            else:
+                global_token, _, _ = await get_stored_token(db, None)
+                if global_token:
+                    logger.warning(
+                        "A Bambu Cloud account is linked globally but %s admins exist; "
+                        "leaving it unassigned. Re-link the account from Settings after login.",
+                        len(existing_admin_users),
+                    )
+
         # Set auth enabled and mark setup as completed
         await set_auth_enabled(db, request.auth_enabled)
         await set_setup_completed(db, True)
@@ -350,6 +388,14 @@ async def disable_auth(
         )
 
     try:
+        # Mirror of the migration in setup_auth: with auth off the cloud routes
+        # read the global Settings rows and never look at User.cloud_token, so
+        # hand this admin's credential over rather than stranding it (#2530).
+        from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global
+
+        if await migrate_user_cloud_token_to_global(db, user):
+            logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username)
+
         await set_auth_enabled(db, False)
         await db.commit()
         logger.info("Authentication disabled by admin user: %s", user.username)
@@ -623,7 +669,7 @@ async def get_current_user_info(
                     headers={"WWW-Authenticate": "Bearer"},
                 )
             jti: str | None = payload.get("jti")
-            if not jti or await is_jti_revoked(jti):  # B1: logout bypass fix
+            if not jti or await is_jti_revoked(jti, db):  # B1: logout bypass fix
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     detail="Could not validate credentials",
@@ -1583,10 +1629,14 @@ async def provision_ldap_user(
 # =============================================================================
 # Long-lived camera-stream tokens (#1108)
 # =============================================================================
-# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
-# Frigate / a kiosk and have it keep working for days/weeks rather than
-# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
-# (same blast radius as the existing 60-min token-mint endpoint).
+# A token a user can paste into Home Assistant / Frigate / a kiosk and have it
+# keep working for days/weeks rather than refreshing the 60-minute ephemeral
+# token. Permission gate: CAMERA_VIEW (same blast radius as the existing 60-min
+# token-mint endpoint).
+#
+# Two scopes, both minted here — see ALLOWED_SCOPES in services/long_lived_tokens
+# for what each one reaches: "camera_stream" (video only) and "camwall" (video
+# plus the Cam Wall's read-only tile metadata, #2531).
 
 
 def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:

+ 0 - 32
backend/app/api/routes/background_dispatch.py

@@ -1,32 +0,0 @@
-from fastapi import APIRouter, HTTPException
-
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
-from backend.app.core.permissions import Permission
-from backend.app.models.user import User
-from backend.app.services.background_dispatch import background_dispatch
-
-router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
-
-
-@router.delete("/{job_id}")
-async def cancel_dispatch_job(
-    job_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-):
-    """Cancel a background-dispatch job.
-
-    Queued jobs are cancelled immediately. Active jobs are marked for
-    cooperative cancellation and will stop at the next cancellation checkpoint.
-    """
-    result = await background_dispatch.cancel_job(job_id)
-
-    if not result["cancelled"]:
-        raise HTTPException(status_code=404, detail="Dispatch job not found")
-
-    return {
-        "status": "cancelling" if result.get("pending") else "cancelled",
-        "job_id": result["job_id"],
-        "source_name": result["source_name"],
-        "printer_id": result["printer_id"],
-        "printer_name": result["printer_name"],
-    }

+ 427 - 53
backend/app/api/routes/camera.py

@@ -1,10 +1,13 @@
 """Camera streaming API endpoints for Bambu Lab printers."""
 
 import asyncio
+import contextlib
 import logging
 import os
 import subprocess
 import sys
+import time
+import uuid
 from collections.abc import AsyncGenerator
 
 from fastapi import APIRouter, Depends, HTTPException, Request
@@ -12,12 +15,14 @@ from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     create_camera_stream_token,
 )
 from backend.app.core.database import get_db
+from backend.app.core.logging_filters import redact_url_credentials
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
@@ -35,6 +40,7 @@ from backend.app.services.camera import (
 from backend.app.services.camera_fanout import (
     MjpegBroadcaster,
     get_or_create_broadcaster,
+    get_subscriber_count,
     iter_subscriber,
     shutdown_broadcaster,
 )
@@ -43,6 +49,27 @@ from backend.app.services.camera_profiles import get_camera_profile
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
 
+# Grace period for a SIGTERMed ffmpeg to shut down before we SIGKILL it. Only
+# reachable when ffmpeg genuinely ignores SIGTERM: _terminate_ffmpeg drains the
+# pipes first, and a drained ffmpeg exits in ~0.15s.
+_FFMPEG_TERM_TIMEOUT = 2.0
+
+# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580).
+#
+# The original diagnosis — "a killed ffmpeg stuck in uninterruptible I/O on a
+# dead RTSP socket" — was wrong, and this bound was capping a deadlock of our
+# own making rather than waiting out a stuck process. A process that survives
+# SIGKILL would have to be in uninterruptible sleep (state D); the ffmpeg seen
+# doing this was in state S, and its returncode was already set to -9 while
+# wait() was still blocked. The real cause was undrained pipes (see
+# _terminate_ffmpeg), which made this timeout fire on *every* camera close.
+#
+# Kept as a backstop now that the cause is fixed: it should no longer be
+# reachable, and if it ever is, abandoning the wait is still safe because
+# cleanup_orphaned_streams' /proc scan reaps any Bambu ffmpeg not attached to
+# an active stream on its next pass.
+_FFMPEG_KILL_TIMEOUT = 2.0
+
 # Track active ffmpeg processes for cleanup
 _active_streams: dict[str, asyncio.subprocess.Process] = {}
 
@@ -72,6 +99,14 @@ _disconnect_events: dict[str, asyncio.Event] = {}
 # Track last frame time per stream_id (not just per printer_id) for stale detection
 _stream_last_frame_times: dict[str, float] = {}
 
+# How much of a streaming ffmpeg's stderr to retain: enough for the input
+# analysis plus a burst of errors, capped so a long-running stream can't grow it.
+_FFMPEG_STDERR_TAIL_BYTES = 16384
+
+# Live stderr collectors by pid — see _FfmpegStderrTail. Present means "this
+# process's stderr already has a reader; do not open a second one".
+_stderr_tails: dict[int, "_FfmpegStderrTail"] = {}
+
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
@@ -183,8 +218,6 @@ async def generate_chamber_mjpeg_stream(
 
             # Save frame to buffer for photo capture and track timestamp
             if printer_id is not None:
-                import time
-
                 _last_frames[printer_id] = frame
                 _last_frame_times[printer_id] = time.time()
 
@@ -216,10 +249,7 @@ async def generate_chamber_mjpeg_stream(
             _stream_last_frame_times.pop(stream_id, None)
 
         # Clean up frame buffer and timestamps
-        if printer_id is not None:
-            _last_frames.pop(printer_id, None)
-            _last_frame_times.pop(printer_id, None)
-            _stream_start_times.pop(printer_id, None)
+        _release_printer_frame_state(printer_id)
 
         # Close the connection
         try:
@@ -230,23 +260,151 @@ async def generate_chamber_mjpeg_stream(
         logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
 
+def _new_fanout_stream_id(printer_id: int) -> str:
+    """Registry key for one fan-out stream INSTANCE, not for the printer.
+
+    A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
+    printer shared one key, so a departing generator's cleanup removed the entry
+    its successor had just registered. The external-camera path already carries a
+    per-instance suffix for exactly this reason (#2675); this gives the fan-out
+    path the same property.
+
+    The ``f"{printer_id}-"`` prefix is load-bearing — ``is_stream_active``,
+    ``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
+    scanning for it — so the suffix goes on the end.
+    """
+    return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
+
+
+def live_frame_for_capture(printer_id: int) -> tuple[bool, bytes | None]:
+    """Should a one-shot capture stand down for the live view, and to what frame?
+
+    Returns ``(defer, frame)``. ``defer`` True means DO NOT open a capture of
+    your own: use ``frame`` when it isn't None, and otherwise skip this attempt
+    rather than competing.
+
+    Both camera kinds allow exactly one reader — Bambu firmware permits one
+    connection, and a USB camera permits one V4L2 handle — so a capture that
+    races the live view doesn't degrade, it fails outright. #2707 measured 0 of
+    87 and 0 of 105 layer-timelapse captures on prints watched throughout, and
+    finish photos going out with no image attached.
+
+    Skipping when the buffer is momentarily empty (stream starting, mid-
+    reconnect) rather than falling through to a capture is the #1348 rule:
+    opening a competing handle kicks the viewer off, which is a worse outcome
+    than missing one frame.
+    """
+    if not is_stream_active(printer_id):
+        return False, None
+    return True, _last_frames.get(printer_id)
+
+
+def _release_printer_frame_state(printer_id: int | None) -> None:
+    """Drop a printer's buffered frame and timings — unless a stream still owns them.
+
+    These three dicts are keyed by printer, not by stream, so a departing
+    generator must not clear them while a newer stream for the same printer is
+    running. That used to happen routinely: stream ids were per-printer, so a
+    predecessor's cleanup wiped its successor's state, leaving
+    ``is_stream_active()`` False with a viewer attached (which is exactly what
+    the #1348 / #1271 guards read before deciding whether it is safe to open a
+    second camera connection), the janitor free to reap the live ffmpeg as an
+    orphan, and snapshots without a frame to reuse.
+
+    Call this AFTER removing the departing stream's own key, so the check
+    reports on other streams rather than on the caller.
+    """
+    if printer_id is None or is_stream_active(printer_id):
+        return
+    _last_frames.pop(printer_id, None)
+    _last_frame_times.pop(printer_id, None)
+    _stream_start_times.pop(printer_id, None)
+
+
+async def _drain_pipe(reader) -> None:
+    """Read a subprocess pipe to EOF and discard, so it can never block.
+
+    Best-effort by design: any read failure means we cannot drain further, and
+    the caller is tearing the process down regardless.
+    """
+    if reader is None:
+        return
+    try:
+        while await reader.read(65536):
+            pass
+    except asyncio.CancelledError:
+        raise
+    except Exception:  # noqa: BLE001 — teardown must not fail on a dying pipe
+        return
+
+
 async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
-    """Terminate an ffmpeg process gracefully, then kill if needed."""
+    """Terminate an ffmpeg process gracefully, then kill if needed.
+
+    Drains stdout/stderr throughout, which is load-bearing rather than hygiene.
+    ffmpeg is spawned with both as pipes, and every caller of this has already
+    stopped reading stdout — so by the time we get here ffmpeg is typically
+    blocked in write() on a full 64 KiB pipe. Two things then go wrong:
+
+    * SIGTERM cannot be acted on. ffmpeg's handler only sets a flag that its
+      main loop polls, and a loop blocked in write() never reaches the check,
+      so the whole grace period is dead time.
+    * SIGKILL does kill it, but wait() cannot observe that. asyncio resolves
+      Process.wait()'s waiter through BaseSubprocessTransport._try_finish(),
+      which requires every pipe transport to report disconnected; paused,
+      unread pipes never reach EOF, so wait() blocks with returncode already
+      set. That is what made the "did not exit within Ns of SIGKILL" error
+      fire on every single camera close, and unbounded it was the 12-hour
+      hang in #2580.
+
+    Draining fixes both: SIGTERM becomes actionable and the exit observable.
+    Measured on an H2D: 4.0s of dead time per close before, ~0.15s after —
+    which matters because the printer allows exactly one camera connection,
+    so every one of those seconds was a connection nobody could use.
+
+    Discarding what we drain is deliberate. The stream loop already reads
+    stderr on its error paths (_read_ffmpeg_stderr), and it does so before
+    calling this, so nothing diagnostic is lost.
+    """
     if process.returncode is not None:
+        _spawned_ffmpeg_pids.pop(process.pid, None)
         return  # Already dead
+
+    drainers = [asyncio.create_task(_drain_pipe(process.stdout))]
+    # A streaming ffmpeg's stderr already has a reader (_FfmpegStderrTail), and
+    # it keeps draining right through teardown, which is all we need here. Adding
+    # a second reader would race it — asyncio rejects concurrent reads on one
+    # StreamReader — so only drain stderr when nobody else owns it.
+    if process.pid not in _stderr_tails:
+        drainers.append(asyncio.create_task(_drain_pipe(process.stderr)))
     try:
         process.terminate()
         try:
-            await asyncio.wait_for(process.wait(), timeout=2.0)
+            await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
         except TimeoutError:
             logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
             process.kill()
-            await process.wait()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=_FFMPEG_KILL_TIMEOUT)
+            except TimeoutError:
+                # Do NOT keep waiting (#2580): the caller is the stream
+                # generator, and blocking here pins the fan-out pump forever.
+                # The orphan janitor reaps the process later. With the pipes
+                # drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
+                logger.error(
+                    "ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
+                    _FFMPEG_KILL_TIMEOUT,
+                    stream_id,
+                )
     except ProcessLookupError:
         pass  # Already dead
     except OSError as e:
         logger.warning("Error terminating ffmpeg: %s", e)
-    _spawned_ffmpeg_pids.pop(process.pid, None)
+    finally:
+        for drainer in drainers:
+            drainer.cancel()
+        await asyncio.gather(*drainers, return_exceptions=True)
+        _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
 def _summarize_ffmpeg_stderr(text: str | None) -> str:
@@ -256,9 +414,15 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
     any actual error message. Logging the full banner on every retry floods
     the log (hundreds of lines per failed stream). This filter drops the
     banner and caps output at the last 10 meaningful lines.
+
+    Credentials are masked here rather than at each ``logger`` call because
+    this is the one funnel every stderr log in this module passes through.
+    ffmpeg echoes the RTSP input URL back in its ``Input #0`` line, which
+    carries the printer access code.
     """
     if not text:
         return ""
+    text = redact_url_credentials(text) or ""
     banner_prefixes = (
         "ffmpeg version ",
         "  built with ",
@@ -276,6 +440,82 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
     return "\n".join(meaningful[-10:])
 
 
+class _FfmpegStderrTail:
+    """Owns a long-lived ffmpeg's stderr: drains it continuously, keeps the tail.
+
+    Reading stderr only when something has already gone wrong leaves a pipe
+    nobody reads for the whole life of the stream. ffmpeg writes its banner, the
+    input analysis and then a progress line at a steady rate, so a 64 KiB pipe
+    fills eventually and ffmpeg blocks writing to it — at which point it stops
+    producing frames, the stream's own read timeout fires, and the log says
+    "RTSP read timeout" with no hint that we starved it ourselves.
+
+    How long that takes is unmeasured and may be a long time: one H2D upstream
+    ran 21m36s continuously without stalling, so this is a bounded resource
+    being treated as unbounded rather than an observed failure. Draining removes
+    the ceiling either way, and the tail is *better* diagnostic material than
+    the old on-demand read: it holds ffmpeg's most recent output at the moment
+    things went wrong, where reading the buffered pipe returned whatever was
+    printed first (usually the startup banner, which the summariser then strips).
+
+    Registers itself in ``_stderr_tails`` so the two other readers of this pipe
+    can defer to it — asyncio raises if two coroutines read one StreamReader
+    concurrently. See ``_read_ffmpeg_stderr`` and ``_terminate_ffmpeg``.
+    """
+
+    def __init__(self, process: asyncio.subprocess.Process) -> None:
+        self._process = process
+        self._buffer = bytearray()
+        self._task: asyncio.Task | None = None
+        if process.stderr is None:
+            return
+        self._task = asyncio.create_task(self._pump())
+        _stderr_tails[process.pid] = self
+
+    async def _pump(self) -> None:
+        reader = self._process.stderr
+        try:
+            while True:
+                chunk = await reader.read(8192)
+                if not chunk:
+                    return  # EOF — ffmpeg has exited
+                self._buffer.extend(chunk)
+                excess = len(self._buffer) - _FFMPEG_STDERR_TAIL_BYTES
+                if excess > 0:
+                    del self._buffer[:excess]
+        except asyncio.CancelledError:
+            raise
+        except Exception:  # noqa: BLE001 — a broken pipe just ends the tail
+            return
+
+    def text(self) -> str | None:
+        """The retained tail, summarised. None when nothing was captured.
+
+        Goes through _summarize_ffmpeg_stderr like every other stderr log in
+        this module: ffmpeg echoes its input URL, which carries the access code.
+        """
+        if not self._buffer:
+            return None
+        return _summarize_ffmpeg_stderr(self._buffer.decode(errors="replace")) or None
+
+    async def aclose(self) -> None:
+        """Stop draining and release ownership of the pipe. Idempotent.
+
+        Awaits the cancelled pump rather than firing and forgetting, so the task
+        is finished before the caller moves on — an abandoned pending task
+        becomes an "unraisable exception" warning at an arbitrary later point,
+        usually during interpreter or loop teardown.
+        """
+        task, self._task = self._task, None
+        if _stderr_tails.get(self._process.pid) is self:
+            del _stderr_tails[self._process.pid]
+        if task is None:
+            return
+        task.cancel()
+        with contextlib.suppress(asyncio.CancelledError):
+            await task
+
+
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
     """Read whatever ffmpeg has written to stderr so far (best-effort).
 
@@ -286,8 +526,18 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
     banner + stream-analysis lines ffmpeg already printed. Reading in bounded
     chunks returns the buffered output promptly whether or not ffmpeg has
     exited. Returns the content with ffmpeg's boilerplate banner stripped.
+
+    When a _FfmpegStderrTail owns this process's stderr — every streaming
+    ffmpeg — its retained tail is returned instead. Reading the pipe here as
+    well would race that collector, and asyncio refuses two concurrent readers
+    on one StreamReader outright.
     """
-    if not process or not process.stderr:
+    if not process:
+        return None
+    tail = _stderr_tails.get(getattr(process, "pid", None))
+    if tail is not None:
+        return tail.text()
+    if not process.stderr:
         return None
     chunks: list[bytes] = []
     total = 0
@@ -408,6 +658,7 @@ async def generate_rtsp_mjpeg_stream(
     jpeg_end = b"\xff\xd9"
     reconnect_count = 0
     process = None
+    stderr_tail: _FfmpegStderrTail | None = None
     got_any_frames = False
 
     try:
@@ -460,6 +711,14 @@ async def generate_rtsp_mjpeg_stream(
                 reconnect_count += 1
                 continue
 
+            # Take ownership of stderr for the life of this process. Started
+            # only after the immediate-failure check above, which reads the pipe
+            # directly (correct there: the process is already dead, so
+            # read-to-EOF returns at once and cannot be raced by a collector).
+            # Nothing is lost by starting late — the banner ffmpeg printed in the
+            # meantime is still sitting in the pipe.
+            stderr_tail = _FfmpegStderrTail(process)
+
             # Read JPEG frames from ffmpeg stdout
             buffer = b""
             stream_ended = False
@@ -503,8 +762,6 @@ async def generate_rtsp_mjpeg_stream(
                         got_any_frames = True
 
                         if printer_id is not None:
-                            import time
-
                             _last_frames[printer_id] = frame
                             _last_frame_times[printer_id] = time.time()
                             if stream_id:
@@ -535,6 +792,12 @@ async def generate_rtsp_mjpeg_stream(
 
             # Clean up this ffmpeg process before reconnecting or exiting
             await _terminate_ffmpeg(process, stream_id)
+            # Released after teardown, not before: _terminate_ffmpeg deliberately
+            # leaves stderr to this collector, which has to keep draining while
+            # the process is stopped or wait() can't observe the exit.
+            if stderr_tail is not None:
+                await stderr_tail.aclose()
+                stderr_tail = None
             process = None
 
             if client_gone:
@@ -577,15 +840,16 @@ async def generate_rtsp_mjpeg_stream(
             _stream_last_frame_times.pop(stream_id, None)
 
         # Clean up frame buffer and timestamps
-        if printer_id is not None:
-            _last_frames.pop(printer_id, None)
-            _last_frame_times.pop(printer_id, None)
-            _stream_start_times.pop(printer_id, None)
+        _release_printer_frame_state(printer_id)
 
         if process:
             await _terminate_ffmpeg(process, stream_id)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
+        # Same order as in the loop: terminate first, then release stderr.
+        if stderr_tail is not None:
+            await stderr_tail.aclose()
+
         # Shut down the TLS proxy
         proxy_server.close()
         await proxy_server.wait_closed()
@@ -608,7 +872,6 @@ async def camera_stream(
     printer_id: int,
     request: Request,
     fps: int = 10,
-    db: AsyncSession = Depends(get_db),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Stream live video from printer camera as MJPEG.
@@ -627,12 +890,30 @@ async def camera_stream(
         printer_id: Printer ID
         fps: Target frames per second (default: 10, max: 30)
     """
-    printer = await get_printer_or_404(printer_id, db)
+    # Fetch the printer in a short-lived session so the pooled DB connection is
+    # released BEFORE we start streaming. A live MJPEG stream runs for as long
+    # as the browser tab stays open (potentially hours); holding the
+    # Depends(get_db) session across it pinned one pooled connection per open
+    # camera tab per printer — a top contributor to pool exhaustion on large
+    # farms (issue #2572). expire_on_commit=False keeps the printer's already-
+    # loaded columns readable after the session closes, and everything below
+    # reads only scalar attributes (model, ip_address, access_code,
+    # external_camera_*) — no lazy loads.
+    #
+    # Reference async_session via the module (not a top-level import binding) so
+    # the session maker is looked up at call time — that keeps it in sync with
+    # reinitialize_database() and lets the test harness's patch of
+    # backend.app.core.database.async_session take effect here.
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
-        import time
-
+        # NB: no `import time` / `import uuid` here, and don't reintroduce them.
+        # A local import anywhere in this function makes the name function-local
+        # for the WHOLE function, so the RTSP/chamber path below — which never
+        # executes this branch — would raise UnboundLocalError on any printer
+        # without an external camera. Both are imported at module level.
         from backend.app.services.external_camera import generate_mjpeg_stream
 
         # Limit external camera FPS to reduce browser load
@@ -641,22 +922,79 @@ async def camera_stream(
             "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
         )
 
+        # Register the stream into the SAME registries the RTSP/chamber paths use
+        # (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
+        # a leaked ffmpeg holding a USB device open. Before this, external streams
+        # only tracked _active_external_streams and were structurally invisible to
+        # both the stop endpoint and the janitor. The stream_id keeps the
+        # `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
+        # concurrent viewers of one printer don't clobber each other's entry.
+        stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
+        stop_event = asyncio.Event()
+        _disconnect_events[stream_id] = stop_event
         # Track stream start
         _stream_start_times[printer_id] = time.time()
         _active_external_streams.add(printer_id)
 
+        # Mutable holder so the wrapper's finally can unregister whatever process
+        # is currently registered (the RTSP path may respawn across reconnects).
+        current_proc: dict[str, asyncio.subprocess.Process] = {}
+
+        def _register_external_process(proc: asyncio.subprocess.Process) -> None:
+            prev = current_proc.get("proc")
+            if prev is not None and prev.pid != proc.pid:
+                _spawned_ffmpeg_pids.pop(prev.pid, None)
+            current_proc["proc"] = proc
+            _active_streams[stream_id] = proc
+            _spawned_ffmpeg_pids[proc.pid] = time.time()
+            _stream_last_frame_times[stream_id] = time.time()
+
+        def _publish_external_frame(frame: bytes) -> None:
+            """Make the live frame reusable by one-shot consumers (#2707).
+
+            Only the built-in camera paths populated _last_frames, so every
+            external-camera consumer — layer timelapse, finish photo, Obico,
+            plate check — found an empty buffer and opened its own handle on a
+            device that allows exactly one reader, which simply failed while a
+            viewer was attached. Raw frame, not the multipart-wrapped chunk the
+            generator yields, because that is what those consumers expect.
+            """
+            _last_frames[printer_id] = frame
+
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             try:
                 async for frame in generate_mjpeg_stream(
-                    printer.external_camera_url, printer.external_camera_type, fps
+                    printer.external_camera_url,
+                    printer.external_camera_type,
+                    fps,
+                    on_process=_register_external_process,
+                    on_frame=_publish_external_frame,
+                    stop_event=stop_event,
                 ):
                     # generate_mjpeg_stream already handles rate limiting;
-                    # just track frame times for stall detection
-                    _last_frame_times[printer_id] = time.time()
+                    # track frame times (per-printer + per-stream) for stall detection
+                    now = time.time()
+                    _last_frame_times[printer_id] = now
+                    _stream_last_frame_times[stream_id] = now
                     yield frame
             finally:
+                # Best-effort unregister. If an abrupt disconnect skips this
+                # finally, the registry entries persist — which is exactly what
+                # lets the stop endpoint / janitor reap the leaked process.
+                stop_event.set()
+                proc = current_proc.get("proc")
+                if proc is not None:
+                    _spawned_ffmpeg_pids.pop(proc.pid, None)
+                _active_streams.pop(stream_id, None)
+                _disconnect_events.pop(stream_id, None)
+                _stream_last_frame_times.pop(stream_id, None)
                 _active_external_streams.discard(printer_id)
+                # Now that this path publishes a buffered frame, it has to
+                # retract it too — ownership-checked, so a concurrent viewer of
+                # the same printer keeps its own. Also clears the per-printer
+                # timings this path used to leave behind.
+                _release_printer_frame_state(printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
 
         return StreamingResponse(
@@ -688,8 +1026,6 @@ async def camera_stream(
     # attached — otherwise /camera/status would report stream_uptime jumping
     # backward whenever a second viewer joins. The upstream generator's
     # finally clears this entry when the upstream actually ends.
-    import time
-
     _stream_start_times.setdefault(printer_id, time.time())
 
     # Fan-out broadcaster (#1089): one upstream connection per printer, shared
@@ -702,7 +1038,7 @@ async def camera_stream(
     # broadcaster. Concurrent viewers share that rate; new viewers after
     # teardown create a fresh broadcaster at their requested fps.
     fanout_key = f"printer-{printer_id}"
-    upstream_stream_id = f"{printer_id}-fanout"
+    upstream_stream_id = _new_fanout_stream_id(printer_id)
 
     def _factory(disconnect_event: asyncio.Event):
         # Re-bind locals into the closure so the async generator below sees
@@ -771,17 +1107,36 @@ async def stop_camera_stream(
     printer_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
-    """Stop all active camera streams for a printer.
-
-    This can be called by the frontend when the camera window is closed.
-    Accepts both GET and POST (POST for sendBeacon compatibility).
+    """Stop active camera streams for a printer.
+
+    Called by the frontend on viewer unmount (cam-wall tile, embedded viewer,
+    popup window). Accepts both GET and POST (POST for sendBeacon compatibility).
+
+    Reference-count guard: every viewer of a printer subscribes to the same
+    fan-out broadcaster, so a force-shutdown triggered by ONE leaving viewer
+    used to kill the others' streams (cam-wall tile froze when a user opened
+    then closed the embedded viewer). If any subscriber is still attached,
+    skip the force-teardown — the broadcaster's natural grace-shutdown (5 s
+    after subscribers drop to 0) handles cleanup when the leaving viewer's
+    HTTP connection actually closes.
     """
+    broadcaster_key = f"printer-{printer_id}"
+    remaining_subscribers = get_subscriber_count(broadcaster_key)
+    if remaining_subscribers >= 1:
+        logger.info(
+            "Skipping force-shutdown for printer %s: %d subscriber(s) still attached; "
+            "natural cleanup will tear down when last viewer disconnects",
+            printer_id,
+            remaining_subscribers,
+        )
+        return {"stopped": 0, "skipped": True}
+
     stopped = 0
 
     # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
     # all subscribed viewers and asks the upstream generator to stop
     # reconnecting before we fall back to forcefully killing the process below.
-    if await shutdown_broadcaster(f"printer-{printer_id}"):
+    if await shutdown_broadcaster(broadcaster_key):
         logger.info("Shut down camera fan-out broadcaster for printer %s", printer_id)
 
     # Stop ffmpeg/RTSP streams
@@ -794,20 +1149,13 @@ async def stop_camera_stream(
             if event:
                 event.set()
             if process.returncode is None:
-                try:
-                    process.terminate()
-                    try:
-                        await asyncio.wait_for(process.wait(), timeout=2.0)
-                    except TimeoutError:
-                        logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
-                        process.kill()
-                        await process.wait()
-                    stopped += 1
-                    logger.info("Terminated ffmpeg process for stream %s", stream_id)
-                except ProcessLookupError:
-                    pass  # Process already dead
-                except OSError as e:
-                    logger.warning("Error stopping stream %s: %s", stream_id, e)
+                # Shared helper, not an inline copy: it bounds the post-kill
+                # wait (#2580) — a killed-but-unreaped ffmpeg used to hang this
+                # request forever, exactly when the user hit Stop to recover a
+                # stuck stream.
+                await _terminate_ffmpeg(process, stream_id)
+                stopped += 1
+                logger.info("Terminated ffmpeg process for stream %s", stream_id)
             _spawned_ffmpeg_pids.pop(process.pid, None)
 
     for stream_id in to_remove:
@@ -843,7 +1191,6 @@ async def stop_camera_stream(
 @router.get("/{printer_id}/camera/snapshot")
 async def camera_snapshot(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Capture a single frame from the printer camera.
@@ -855,7 +1202,15 @@ async def camera_snapshot(
     import tempfile
     from pathlib import Path
 
-    printer = await get_printer_or_404(printer_id, db)
+    # Fetch the printer in a short-lived session and release the pooled DB
+    # connection BEFORE the camera capture below (up to 15s, longer under a
+    # saturated FTP/camera pool). Holding a Depends(get_db) session across the
+    # grab pinned one connection per snapshot — and the cam wall polls this
+    # per tile every 8s — so overlapping captures could pile up connections on
+    # a large farm (issue #2572, sibling of the camera_stream fix). Everything
+    # below reads only already-loaded scalar columns (expire_on_commit=False).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
@@ -1476,9 +1831,14 @@ async def delete_reference(
 
 
 def _scan_bambu_ffmpeg_pids() -> list[int]:
-    """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
+    """Scan /proc for ffmpeg processes that are ours.
+
+    Two shapes are matched, both unambiguously Bambuddy's:
+    - Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
+    - External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
+      stream (#2675). Only orphans are killed — the caller excludes PIDs still in
+      ``_active_streams``, so a live USB stream (now registered there) is spared.
 
-    These are definitely ours — no other software connects to rtsp(s)://bblp:.
     This catches orphans that survive app restarts and are not in any tracking dict.
     """
     import os
@@ -1491,8 +1851,11 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
             try:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                     cmdline = f.read()
-                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
-                if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
+                if b"ffmpeg" not in cmdline:
+                    continue
+                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
+                # the `-f v4l2` input flag our USB camera command always carries.
+                if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
                 continue
@@ -1580,9 +1943,20 @@ async def cleanup_orphaned_streams():
                 event.set()
             try:
                 proc.kill()
-                await proc.wait()
+                # Bounded (#2580): an unreaped SIGKILLed ffmpeg must not hang
+                # the periodic cleanup loop — this janitor is the safety net
+                # that recovers stalled streams, so it can least afford to
+                # block. The /proc scan above retries the kill next pass.
+                await asyncio.wait_for(proc.wait(), timeout=_FFMPEG_KILL_TIMEOUT)
             except (ProcessLookupError, OSError):
                 pass
+            except TimeoutError:
+                logger.error(
+                    "ffmpeg (pid=%d) did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
+                    proc.pid,
+                    _FFMPEG_KILL_TIMEOUT,
+                    sid,
+                )
             _active_streams.pop(sid, None)
             _disconnect_events.pop(sid, None)
             _stream_last_frame_times.pop(sid, None)

+ 95 - 0
backend/app/api/routes/camwall.py

@@ -0,0 +1,95 @@
+"""Read-only Cam Wall feed for token-authenticated kiosk displays (#2531).
+
+The Cam Wall inside the SPA runs on the ordinary printers API, behind a JWT. A
+wall pinned to a TV has no login, so it authenticates with a long-lived
+``camwall``-scoped token carried in the URL — and a URL on a lobby screen is
+about as private as a sticky note.
+
+That is why this endpoint exists instead of letting a token through to
+``GET /printers``: the printer list carries ``serial_number`` and
+``ip_address`` (see ``schemas/printer.py``), and neither belongs on a screen in
+a shared room. What a wall tile actually draws is the whole payload here — a
+name, a connection flag, a state, a progress bar.
+
+Notably absent is the print filename. A token wall renders the compact status
+overlay, so the part being printed is never named to the room; the field simply
+isn't served rather than being served and then hidden client-side.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.services.printer_manager import printer_manager
+
+_logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/camwall", tags=["camwall"])
+
+
+@router.get("/printers")
+async def list_camwall_printers(
+    _: None = RequireCamWallTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> list[dict]:
+    """Every printer plus the handful of status fields a Cam Wall tile draws.
+
+    One call for the whole wall rather than one per printer: a kiosk polls this
+    on a fixed interval with no WebSocket to invalidate it, and N+1 requests
+    every few seconds is a poor trade for a screen nobody is interacting with.
+
+    Ordered by name so tile positions stay put across polls — a wall that
+    reshuffles itself is unusable to watch.
+    """
+    result = await db.execute(select(Printer).order_by(Printer.name))
+    printers = list(result.scalars().all())
+
+    payload: list[dict] = []
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        entry: dict = {
+            "id": printer.id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            # Mirrors get_printer_status(): no state object at all means the
+            # printer was never connected this run; a state object still has
+            # to be asked whether its link is currently up.
+            "connected": bool(state and state.connected),
+            "state": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            # Codes only — enough for the client to run the same
+            # filterKnownHMSErrors() it uses on the authenticated wall, so the
+            # error chip means the same thing in both modes.
+            "hms_errors": [],
+        }
+        if state is not None:
+            entry.update(
+                {
+                    "state": state.state,
+                    "progress": state.progress,
+                    "remaining_time": state.remaining_time,
+                    "layer_num": state.layer_num,
+                    "total_layers": state.total_layers,
+                    "hms_errors": [
+                        {
+                            "code": e.code,
+                            "attr": e.attr,
+                            "module": e.module,
+                            "severity": e.severity,
+                            "actions": e.actions or [],
+                        }
+                        for e in (state.hms_errors or [])
+                    ],
+                }
+            )
+        payload.append(entry)
+
+    return payload

+ 294 - 45
backend/app/api/routes/cloud.py

@@ -4,14 +4,16 @@ Bambu Lab Cloud API Routes
 Handles authentication and profile management with Bambu Cloud.
 """
 
+import asyncio
 import json
 import logging
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import Literal
 
 from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -21,7 +23,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
     security,
 )
-from backend.app.core.database import get_db
+from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
@@ -46,6 +48,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudAuthError,
     BambuCloudError,
     BambuCloudService,
+    invalidate_validation_cache,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 
@@ -167,6 +170,9 @@ router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud
 CLOUD_TOKEN_KEY = "bambu_cloud_token"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
 CLOUD_REGION_KEY = "bambu_cloud_region"
+# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
+# an ISO timestamp; absent/empty means "not known to be dead".
+CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
 
 
 def _normalise_region(region: str | None) -> str:
@@ -174,6 +180,63 @@ def _normalise_region(region: str | None) -> str:
     return region if region in ("global", "china") else "global"
 
 
+async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
+    """Whether the stored Bambu token is known to have been rejected.
+
+    Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
+    cleared on a fresh login/logout. This is the only durable record we have:
+    Bambu's access token is opaque (no readable expiry) and Bambuddy does not
+    persist the refresh token, so without this flag a dead credential looks
+    exactly like a live one.
+    """
+    if user is not None:
+        return user.cloud_token_invalid_at is not None
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    return bool(row and row.value)
+
+
+async def mark_cloud_token_invalid(user_id: int | None) -> None:
+    """Record that Bambu rejected the stored token.
+
+    Opens its own session on purpose. This runs from
+    ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
+    is about to fail — writing through that route's session would tie the flag
+    to a transaction the route may still roll back, and the fact that the
+    credential is dead is true regardless of how the request ends.
+
+    Best-effort: a bookkeeping failure must never replace the 401 the caller
+    actually needs to see.
+    """
+    now = datetime.now(timezone.utc)
+    try:
+        async with async_session() as db:
+            if user_id is not None:
+                await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
+            else:
+                result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+                row = result.scalar_one_or_none()
+                if row:
+                    row.value = now.isoformat()
+                else:
+                    db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
+            await db.commit()
+        logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
+    except Exception:
+        logger.exception("Could not record the Bambu Cloud token as invalid")
+
+
+async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
+    """Clear the rejected-token flag — called on every fresh login and logout."""
+    if user is not None:
+        await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
+        return
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    if row:
+        await db.delete(row)
+
+
 async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
     """Get stored cloud token, email, and region.
 
@@ -202,15 +265,19 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
 
     When a user is provided (auth enabled), stores on the user record.
     When user is None (auth disabled), stores in global Settings table.
+
+    Always clears the rejected-token flag: this is a *fresh* credential, and
+    leaving the flag set would report the new sign-in as expired.
     """
     region = _normalise_region(region)
+    invalidate_validation_cache(token)
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
-        from sqlalchemy import update
-
         await db.execute(
-            update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
+            update(User)
+            .where(User.id == user.id)
+            .values(cloud_token=token, cloud_email=email, cloud_region=region, cloud_token_invalid_at=None)
         )
         await db.commit()
         return
@@ -223,6 +290,7 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
             setting.value = value
         else:
             db.add(Settings(key=key, value=value))
+    await _clear_cloud_token_invalid(db, None)
     await db.commit()
 
 
@@ -231,25 +299,98 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
 
     When a user is provided (auth enabled), clears that user's credentials.
     When user is None (auth disabled), clears from global Settings table.
+
+    The rejected-token flag goes with the token: once there is no credential,
+    "the credential is dead" is not a state worth remembering, and leaving it
+    behind would make the next login look expired the moment it is stored.
     """
-    if user is not None:
-        from sqlalchemy import update
+    token, _email, _region = await get_stored_token(db, user)
+    if token:
+        invalidate_validation_cache(token)
 
+    if user is not None:
         await db.execute(
-            update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
+            update(User)
+            .where(User.id == user.id)
+            .values(cloud_token=None, cloud_email=None, cloud_region=None, cloud_token_invalid_at=None)
         )
         await db.commit()
         return
 
     # Fallback: global storage (auth disabled)
     result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+        select(Settings).where(
+            Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY, CLOUD_TOKEN_INVALID_KEY])
+        )
     )
     for setting in result.scalars().all():
         await db.delete(setting)
     await db.commit()
 
 
+async def migrate_global_cloud_token_to_user(db: AsyncSession, user: User) -> bool:
+    """Move a globally-stored cloud token onto ``user`` (auth being enabled).
+
+    ``get_stored_token`` reads the global ``Settings`` rows when auth is off and
+    ``User.cloud_token`` when it's on. Enabling auth therefore switches which
+    column the cloud routes consult — without this migration the token linked
+    before setup is stranded in ``Settings``, ``build_authenticated_cloud``
+    returns ``None``, and every ``/cloud/*`` route silently degrades (#2530).
+
+    The global rows are deleted after the copy so the credential isn't left at
+    rest in a table nothing reads any more. Does **not** commit — the caller
+    owns the transaction. Returns True when a token was actually migrated.
+    """
+    token, email, region = await get_stored_token(db, None)
+    if not token:
+        return False
+
+    user.cloud_token = token
+    user.cloud_email = email
+    user.cloud_region = _normalise_region(region)
+
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
+    for setting in result.scalars().all():
+        await db.delete(setting)
+    return True
+
+
+async def migrate_user_cloud_token_to_global(db: AsyncSession, user: User) -> bool:
+    """Move ``user``'s cloud token into global storage (auth being disabled).
+
+    The mirror of :func:`migrate_global_cloud_token_to_user`: once auth is off,
+    ``get_stored_token`` stops consulting ``User.cloud_token`` entirely, so the
+    admin who turns auth off would otherwise lose their own cloud link.
+
+    Refuses to overwrite an existing global token — a stale row from a previous
+    no-auth stint is still someone's credential, and clobbering it silently is
+    worse than leaving this admin to re-link. Does **not** commit. Returns True
+    when a token was actually migrated.
+    """
+    if not user.cloud_token:
+        return False
+
+    existing, _, _ = await get_stored_token(db, None)
+    if existing:
+        return False
+
+    for key, value in [
+        (CLOUD_TOKEN_KEY, user.cloud_token),
+        (CLOUD_EMAIL_KEY, user.cloud_email),
+        (CLOUD_REGION_KEY, _normalise_region(user.cloud_region)),
+    ]:
+        if value is None:
+            continue
+        db.add(Settings(key=key, value=value))
+
+    user.cloud_token = None
+    user.cloud_email = None
+    user.cloud_region = None
+    return True
+
+
 def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
     """Reject API keys that aren't authorised to read cloud data.
 
@@ -284,11 +425,17 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
 
     Returns ``None`` when no token is stored, so callers can 401 without constructing
     (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
+
+    The service is wired to persist a rejected-token flag the moment Bambu
+    answers 401, so every route that builds a client this way makes the whole
+    app agree the sign-in is dead — rather than each feature discovering it
+    separately and reporting Bambu's own opaque "Please login." at the user.
     """
     token, _email, region = await get_stored_token(db, user)
     if not token:
         return None
-    cloud = BambuCloudService(region=region)
+    user_id = user.id if user is not None else None
+    cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
     cloud.set_token(token)
     return cloud
 
@@ -300,27 +447,55 @@ async def get_auth_status(
 ):
     """Get current cloud authentication status.
 
-    Reads the stored credentials in one DB round-trip (we used to call
-    ``get_stored_token`` twice — once here and once inside
-    ``build_authenticated_cloud``). ``region`` is exposed so the frontend can
-    show "Connected (China)" after a reload without relying on local state.
+    "We hold a token" is not the same claim as "Bambu accepts it", and this
+    endpoint used to make the former while reporting the latter: it asked
+    ``cloud.is_authenticated``, which was a string-presence check behind a
+    self-renewing expiry, so it answered ``true`` for as long as any token
+    existed — including tokens Bambu had been rejecting for months (#2562
+    follow-up). It now asks Bambu.
+
+    The verdict is cached for five minutes inside the service, so the several
+    components polling this endpoint don't each pay a round-trip. When Bambu
+    can't be reached the answer is ``None`` and we report the last known state
+    rather than signing the user out over a transient outage.
+
+    ``region`` is exposed so the frontend can show "Connected (China)" after a
+    reload without relying on local state.
     """
     token, email, region = await get_stored_token(db, current_user)
     if not token:
-        return CloudAuthStatus(is_authenticated=False, email=None, region=None)
+        return CloudAuthStatus(is_authenticated=False, email=None, region=None, sign_in_expired=False)
+
+    known_invalid = await is_cloud_token_invalid(db, current_user)
 
-    cloud = BambuCloudService(region=region)
+    user_id = current_user.id if current_user is not None else None
+    cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
     cloud.set_token(token)
     try:
-        authenticated = cloud.is_authenticated
-        return CloudAuthStatus(
-            is_authenticated=authenticated,
-            email=email if authenticated else None,
-            region=region if authenticated else None,
-        )
+        if known_invalid:
+            # Already recorded as dead. Don't re-ask Bambu on every poll — only a
+            # new login can change this, and that clears the flag.
+            accepted: bool | None = False
+        else:
+            accepted = await cloud.validate_token()
     finally:
         await cloud.close()
 
+    if accepted is None:
+        # Bambu unreachable / 5xx / Cloudflare challenge. Report what we last
+        # knew — a cloud outage must not present as "your sign-in expired".
+        accepted = not known_invalid
+
+    return CloudAuthStatus(
+        is_authenticated=bool(accepted),
+        email=email if accepted else None,
+        region=region if accepted else None,
+        # Distinguishes "you were signed in and the token died" from "you never
+        # signed in" — the UI shows the same login form either way, but only the
+        # former deserves an explanation for why it reappeared.
+        sign_in_expired=not accepted,
+    )
+
 
 @router.post("/login", response_model=CloudLoginResponse)
 async def login(
@@ -563,6 +738,92 @@ _filament_cache: dict[str, dict] = {}
 _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
+# In-flight cloud lookups, keyed by setting_id (#2572). The printer overview
+# mounts one filament-info request per printer card, so at farm scale several
+# browsers ask for the same uncached preset within the same instant. Without
+# coalescing each request issues its own Bambu Cloud round-trip for the same id
+# (a thundering herd against a rate-limited API). The first caller to miss a
+# given id becomes the leader and resolves it; concurrent callers await its
+# future and reuse the result instead of duplicating the call.
+_filament_inflight: dict[str, asyncio.Future] = {}
+
+
+async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
+    """Fetch a single filament preset from Bambu Cloud.
+
+    Returns ``{"name", "k"}`` on success (name may be empty when the preset
+    resolves but carries no display name), or ``None`` when the lookup fails.
+    Never raises — a 400 is the expected answer for many bare preset IDs and is
+    logged at DEBUG; anything else is a real fault logged at WARNING.
+    """
+    try:
+        api_setting_id = _filament_id_to_setting_id(setting_id)
+        data = await cloud.get_setting_detail(api_setting_id)
+        setting = data.get("setting", {})
+        name = data.get("name", "")
+        k_value = setting.get("pressure_advance")
+        if k_value is not None:
+            try:
+                k_value = float(k_value)
+            except (ValueError, TypeError):
+                k_value = None
+        return {"name": name, "k": k_value}
+    except Exception as e:
+        # A 400 here is the *expected* answer, not a fault, and the local-preset
+        # fallback (Phase 3) exists to handle it (#2530). Two routine causes:
+        #   * Many official presets are only addressable with a printer variant
+        #     suffix — "GFSA00" resolves, "GFSL05" does not, only "GFSL05_07"
+        #     (@BBL A1) does. The bare ID is all the AMS reports, so the lookup
+        #     legitimately misses.
+        #   * Personal presets ("P…") belong to the Bambu account that sliced the
+        #     file; another account will never resolve them.
+        # Logging those at WARNING on every AMS tooltip refresh trains users to
+        # ignore the log. Anything else — expired token, 5xx, a connection
+        # failure — stays at WARNING because it is a fault.
+        expected_miss = isinstance(e, BambuCloudError) and e.status_code == 400
+        logger.log(
+            logging.DEBUG if expected_miss else logging.WARNING,
+            "Failed to get cloud preset %s (API ID: %s): %s",
+            setting_id,
+            _filament_id_to_setting_id(setting_id),
+            e,
+        )
+        return None
+
+
+async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
+    """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
+
+    Concurrent callers for the same ``setting_id`` share one cloud round-trip:
+    the first caller resolves it while the rest await the shared future. Returns
+    the info dict (also populating ``_filament_cache``) or ``None`` on failure.
+    """
+    if setting_id in _filament_cache:
+        return _filament_cache[setting_id]
+
+    existing = _filament_inflight.get(setting_id)
+    if existing is not None:
+        # Another request is already fetching this id — reuse its result.
+        # shield() so our own cancellation can't cancel the shared leader.
+        try:
+            return await asyncio.shield(existing)
+        except Exception:
+            return None
+
+    fut: asyncio.Future = asyncio.get_event_loop().create_future()
+    _filament_inflight[setting_id] = fut
+    info: dict | None = None
+    try:
+        info = await _fetch_one_cloud_filament(setting_id, cloud)
+        return info
+    finally:
+        if info is not None:
+            _filament_cache[setting_id] = info
+        if not fut.done():
+            fut.set_result(info)
+        _filament_inflight.pop(setting_id, None)
+
+
 # Built-in filament ID → name mapping (fallback when cloud API and local profiles
 # don't have the entry). Based on Bambu Lab's known filament catalogue.
 _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
@@ -775,35 +1036,23 @@ async def get_filament_info(
     # Phase 2: Try cloud for uncached IDs
     if unresolved_ids:
         cloud = await build_authenticated_cloud(db, current_user)
+        # Release the request's DB transaction before the sequential Bambu Cloud
+        # round-trips below (#2572). build_authenticated_cloud has read the
+        # stored token — the only DB access this phase needs — and nothing until
+        # Phase 3 touches the DB again. Without this the session sat "idle in
+        # transaction" for the full duration of N external HTTP calls, pinning a
+        # pooled connection per in-flight request. Phase 3's read transparently
+        # opens a fresh transaction on the same still-open session.
+        await db.rollback()
         if cloud is not None and cloud.is_authenticated:
             try:
                 still_unresolved: list[str] = []
                 for setting_id in unresolved_ids:
-                    try:
-                        api_setting_id = _filament_id_to_setting_id(setting_id)
-                        data = await cloud.get_setting_detail(api_setting_id)
-                        setting = data.get("setting", {})
-                        name = data.get("name", "")
-                        k_value = setting.get("pressure_advance")
-                        if k_value is not None:
-                            try:
-                                k_value = float(k_value)
-                            except (ValueError, TypeError):
-                                k_value = None
-
-                        info = {"name": name, "k": k_value}
-                        _filament_cache[setting_id] = info
+                    info = await _resolve_cloud_filament(setting_id, cloud)
+                    if info is not None:
                         result[setting_id] = info
-
-                        if not name:
-                            still_unresolved.append(setting_id)
-                    except Exception as e:
-                        logger.warning(
-                            f"Failed to get cloud preset {setting_id} "
-                            f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
-                        )
+                    if info is None or not info.get("name"):
                         still_unresolved.append(setting_id)
-
                 unresolved_ids = still_unresolved
             finally:
                 await cloud.close()

+ 27 - 0
backend/app/api/routes/inventory.py

@@ -287,6 +287,21 @@ async def apply_spool_to_slot_via_mqtt(
             spool.id,
         )
 
+    # Register a read-back verification so the next AMS pushes can confirm the
+    # tray actually accepted this assignment (#2582). We record the same
+    # effective filament id we pushed plus the cali_idx we selected (or -1 for
+    # the Default-K reset above), and the client fires on_assignment_verified
+    # on match/timeout. Colour is informational only — the match keys on the
+    # filament id the slicer echoes back.
+    verify_cali_idx = matching_kp.cali_idx if (matching_kp and matching_kp.cali_idx is not None) else -1
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=verify_cali_idx,
+    )
+
     # Persist slot preset mapping for UI display (preset_name on hover card).
     # Shared with the RFID auto-assign path — both must keep this row in sync
     # with the currently-assigned spool, otherwise the slot card surfaces the
@@ -1803,6 +1818,18 @@ async def assign_spool(
             )
         except Exception as e:
             logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+        else:
+            # Nudge a fresh pushall so the read-back verification registered in
+            # apply_spool_to_slot_via_mqtt (#2582) has current tray telemetry to
+            # compare against within its window, instead of waiting for the next
+            # idle push. Best-effort — the periodic push is the fallback.
+            if configured:
+                try:
+                    client = printer_manager.get_client(data.printer_id)
+                    if client:
+                        client.request_status_update()
+                except Exception:
+                    pass
     # pending_config is the "config not landed yet" UI marker. True when the
     # firmware said empty, OR when MQTT couldn't actually publish (printer
     # offline, no client, transient failure). on_ams_change replay re-fires

+ 5 - 2
backend/app/api/routes/labels.py

@@ -60,6 +60,9 @@ class LabelRequest(BaseModel):
         "avery_5160",
         "avery_l7160",
     ]
+    # Black-and-white thermal printers: drop the colour swatch (prints as a
+    # muddy grey block) and widen the text column instead (#1870).
+    monochrome: bool = False
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
@@ -170,7 +173,7 @@ async def render_local_inventory_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
 
@@ -214,6 +217,6 @@ async def render_spoolman_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     return _stream_pdf(pdf, filename)

+ 391 - 143
backend/app/api/routes/library.py

@@ -49,7 +49,6 @@ from backend.app.schemas.library import (
     FileDuplicate,
     FileListResponse,
     FileMoveRequest,
-    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUploadResponse,
@@ -65,11 +64,16 @@ from backend.app.schemas.library import (
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
-from backend.app.services.finance_budget import validate_print_budget
+from backend.app.services.design_settings import (
+    apply_design_overrides,
+    extract_design_process_overrides,
+    overrides_from_config,
+)
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
@@ -79,6 +83,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/library", tags=["library"])
 
+# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
 
 def _ensure_library_file_visible(
     library_file: LibraryFile | None,
@@ -197,11 +204,11 @@ def validate_print_file_upload(filename: str, content: bytes) -> None:
     — raw ``.gcode`` and corrupt/non-zip ``.3mf`` uploads cascade into a
     confusing "Printing stopped because the printer was unable to parse the
     3mf file" rejection 30 seconds after the user clicks Print. The
-    background dispatcher (``background_dispatch.py``) appends ``.3mf`` to
-    a raw-gcode filename when constructing the FTP destination, which is
-    how the printer ends up with a file named ``.gcode.3mf`` whose body is
-    raw gcode — exactly the shape that triggers the firmware parse
-    failure. Catching both classes here gives an actionable error at the
+    the queue dispatch path appends ``.3mf`` to a raw-gcode filename when
+    constructing the FTP destination, which is how the printer ends up with a
+    file named ``.gcode.3mf`` whose body is raw gcode — exactly the shape that
+    triggers the firmware parse failure. Catching both classes here gives an
+    actionable error at the
     upload itself.
 
     Compares the filename suffix rather than ``os.path.splitext`` because
@@ -753,24 +760,34 @@ async def list_folders(
     )
     file_counts = dict(file_counts_result.all())
 
-    # Latest immediate-child file activity per folder (#1770). Sibling of the
-    # file_counts subquery — same WHERE clause, MAX(updated_at) instead of
-    # COUNT(id). Subfolder descent is not aggregated here; the frontend's
-    # "sort by recent activity" mode is satisfied by immediate-parent bubble.
+    # Latest immediate-child file activity per folder (#1770/#2680). Real on-disk
+    # mtime when we have it (external scans populate ``fs_modified_at``), else the
+    # DB ``updated_at`` — COALESCE so external rows scanned before this field
+    # existed, and internal uploads, still contribute a signal. This is the
+    # per-folder *leaf* value; subtree descent is aggregated recursively below.
     latest_file_activity_result = await db.execute(
-        select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
+        select(
+            LibraryFile.folder_id,
+            func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
+        )
         .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
         .group_by(LibraryFile.folder_id)
     )
     latest_file_activity = dict(latest_file_activity_result.all())
 
-    # Build tree structure
+    # Build tree structure. Each folder's initial ``latest_activity_at`` is its own
+    # leaf activity: the newer of its real directory mtime (fallback updated_at)
+    # and its immediate files' mtime. The recursive bubble below then rolls each
+    # subtree's newest descendant up to its ancestors (#2680 — sorting must match
+    # ``ls -t`` recursively, so a freshly-added deep file lifts every parent).
     folder_map = {}
     root_folders = []
 
     for folder, project_name, archive_name in rows:
+        own_activity = folder.fs_modified_at or folder.updated_at
         latest_file = latest_file_activity.get(folder.id)
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        if latest_file is not None and latest_file > own_activity:
+            own_activity = latest_file
         folder_item = FolderTreeItem(
             id=folder.id,
             name=folder.name,
@@ -783,7 +800,7 @@ async def list_folders(
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
-            latest_activity_at=latest_activity_at,
+            latest_activity_at=own_activity,
             children=[],
         )
         folder_map[folder.id] = folder_item
@@ -796,6 +813,28 @@ async def list_folders(
         elif folder.parent_id in folder_map:
             folder_map[folder.parent_id].children.append(folder_item)
 
+    # Recursive newest-descendant bubble (#2680). Post-order: a folder's activity
+    # becomes the max of its own leaf activity and every descendant's, so sorting
+    # the tree by ``latest_activity_at`` surfaces the branch with the most recent
+    # activity anywhere inside it. Iterative stack keeps deep external mounts off
+    # Python's recursion limit.
+    def _bubble(root: FolderTreeItem) -> None:
+        order: list[FolderTreeItem] = []
+        stack = [root]
+        while stack:
+            node = stack.pop()
+            order.append(node)
+            stack.extend(node.children)
+        for node in reversed(order):  # deepest first
+            for child in node.children:
+                if child.latest_activity_at is not None and (
+                    node.latest_activity_at is None or child.latest_activity_at > node.latest_activity_at
+                ):
+                    node.latest_activity_at = child.latest_activity_at
+
+    for root in root_folders:
+        _bubble(root)
+
     return root_folders
 
 
@@ -821,11 +860,12 @@ async def get_folders_by_project(
 
     folders = []
     for folder, project_name in rows:
-        # Get file count + latest file activity (#1770) in one trip
+        # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
+        # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
             select(
                 func.count(LibraryFile.id),
-                func.max(LibraryFile.updated_at),
+                func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
             ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
@@ -833,7 +873,8 @@ async def get_folders_by_project(
         )
         file_count, latest_file = agg_result.one()
         file_count = file_count or 0
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        own_activity = folder.fs_modified_at or folder.updated_at
+        latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
         folders.append(
             FolderResponse(
@@ -880,11 +921,12 @@ async def get_folders_by_archive(
 
     folders = []
     for folder, archive_name in rows:
-        # Get file count + latest file activity (#1770) in one trip
+        # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
+        # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
             select(
                 func.count(LibraryFile.id),
-                func.max(LibraryFile.updated_at),
+                func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
             ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
@@ -892,7 +934,8 @@ async def get_folders_by_archive(
         )
         file_count, latest_file = agg_result.one()
         file_count = file_count or 0
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        own_activity = folder.fs_modified_at or folder.updated_at
+        latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
         folders.append(
             FolderResponse(
@@ -1211,23 +1254,61 @@ async def update_folder(
     )
 
 
+async def _restricted_folder_delete_blocker(db: AsyncSession, folder: LibraryFolder) -> str | None:
+    """Why a library:delete_own user may NOT delete this folder, or None if they may.
+
+    Folders have no ownership tracking, so users without library:delete_all may
+    only delete folders that are truly empty — an empty folder contains nobody's
+    data (#1781). "Empty" must include trashed files: LibraryFile.folder_id
+    cascades on folder delete, so a folder holding another user's trashed file
+    would silently break trash restore.
+    """
+    if folder.is_external:
+        return "External folders can only be deleted by users with library:delete_all"
+    if folder.project_id is not None or folder.archive_id is not None:
+        return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
+
+    child_result = await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == folder.id))
+    if (child_result.scalar() or 0) > 0:
+        return "Only empty folders can be deleted without library:delete_all"
+
+    # Includes trashed files (no deleted_at filter) — see docstring.
+    file_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id))
+    if (file_result.scalar() or 0) > 0:
+        return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
+
+    return None
+
+
 @router.delete("/folders/{folder_id}")
 async def delete_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
 ):
     """Delete a folder and all its contents (cascade).
 
-    Note: Folders require library:delete_all permission since they don't have
-    ownership tracking.
+    Folders have no ownership tracking, so cascade deletion requires
+    library:delete_all. Users with only library:delete_own may delete empty,
+    non-external, non-linked folders (#1781).
     """
+    _, can_modify_all = auth_result
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
 
     if not folder:
         raise HTTPException(status_code=404, detail="Folder not found")
 
+    if not can_modify_all:
+        blocker = await _restricted_folder_delete_blocker(db, folder)
+        if blocker:
+            raise HTTPException(status_code=403, detail=blocker)
+
     # External folders: only remove DB records, never delete files from external path
     is_ext = folder.is_external
 
@@ -1355,6 +1436,7 @@ _SCANNABLE_EXTENSIONS = {
     ".gif",
     ".webp",
     ".svg",
+    ".md",
 }
 
 
@@ -1483,6 +1565,16 @@ async def create_external_folder(
     )
 
 
+def _mtime_to_datetime(mtime: float) -> datetime:
+    """Convert an ``os.stat().st_mtime`` epoch value to a naive-UTC datetime (#2680).
+
+    Naive UTC to match the other library timestamp columns (``created_at`` /
+    ``updated_at`` are naive ``func.now()``), so activity comparisons never mix
+    naive and aware values on either dialect.
+    """
+    return datetime.fromtimestamp(mtime, tz=timezone.utc).replace(tzinfo=None)
+
+
 @router.post("/folders/{folder_id}/scan")
 async def scan_external_folder(
     folder_id: int,
@@ -1558,6 +1650,8 @@ async def scan_external_folder(
     removed = 0
     found_paths: set[str] = set()
     seen_rel_dirs: set[str] = set()
+    # Real on-disk mtime per visited folder id (#2680), applied after the walk.
+    folder_mtimes: dict[int, datetime] = {}
 
     for dirpath, dirnames, filenames in os.walk(ext_path):
         # Filter hidden directories unless configured
@@ -1607,6 +1701,15 @@ async def scan_external_folder(
 
         target_folder_id = folder_cache.get(rel_dir, folder_id)
 
+        # Record this directory's own mtime (#2680). os.walk visits every
+        # directory once, so this covers the root external folder and every
+        # subfolder (existing or just created). Applied to the folder rows
+        # after the walk completes.
+        try:
+            folder_mtimes[target_folder_id] = _mtime_to_datetime(os.stat(dirpath).st_mtime)
+        except OSError:
+            pass
+
         for filename in filenames:
             # Skip hidden files unless configured
             if not folder.external_show_hidden and filename.startswith("."):
@@ -1635,7 +1738,17 @@ async def scan_external_folder(
             found_paths.add(file_path_str)
 
             if file_path_str in existing_files:
-                continue  # Already tracked
+                # Already tracked — refresh its on-disk mtime (#2680) so a file
+                # edited/replaced over the mount (samba, etc.) re-sorts correctly
+                # and old rows scanned before this field existed get backfilled.
+                tracked = existing_files[file_path_str]
+                try:
+                    fs_mtime = _mtime_to_datetime(filepath.stat().st_mtime)
+                except OSError:
+                    fs_mtime = None
+                if fs_mtime is not None and tracked.fs_modified_at != fs_mtime:
+                    tracked.fs_modified_at = fs_mtime
+                continue
 
             # Get file info
             try:
@@ -1718,13 +1831,22 @@ async def scan_external_folder(
                 file_hash=None,  # Skip hashing external files for performance
                 thumbnail_path=thumbnail_path,
                 file_metadata=_without_print_name(file_metadata),
+                fs_modified_at=_mtime_to_datetime(stat.st_mtime),  # #2680: real on-disk mtime
             )
             db.add(db_file)
             added += 1
 
-    # Remove DB entries for files that no longer exist on disk
+    # Remove DB entries for files that no longer exist on disk.
+    #
+    # Gate on actual disk presence, NOT merely absence from found_paths:
+    # found_paths only collects extensions in _SCANNABLE_EXTENSIONS, so a
+    # record for any other file the upload path admitted (e.g. a .md README,
+    # #2520) would otherwise be treated as "deleted from disk" and purged on
+    # every scan even though the file is still there. os.path.exists keeps
+    # such records; genuinely-deleted files (absent from disk) are still
+    # cleaned up. External file_path is the absolute on-disk path.
     for path_str, db_file in existing_files.items():
-        if path_str not in found_paths:
+        if path_str not in found_paths and not os.path.exists(path_str):
             # Clean up thumbnail if we generated one
             if db_file.thumbnail_path:
                 try:
@@ -1760,6 +1882,16 @@ async def scan_external_folder(
                 sub_folder_obj = sub_folder_result.scalar_one_or_none()
                 if sub_folder_obj:
                     await db.delete(sub_folder_obj)
+                    folder_mtimes.pop(sub_fid, None)
+
+    # Persist each visited folder's real directory mtime (#2680). Fetched in one
+    # trip; folders deleted by the cleanup above were dropped from folder_mtimes.
+    if folder_mtimes:
+        folders_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id.in_(list(folder_mtimes.keys()))))
+        for folder_obj in folders_result.scalars().all():
+            new_mtime = folder_mtimes.get(folder_obj.id)
+            if new_mtime is not None and folder_obj.fs_modified_at != new_mtime:
+                folder_obj.fs_modified_at = new_mtime
 
     await db.commit()
 
@@ -1921,6 +2053,7 @@ async def list_files(
                 created_by_id=f.created_by_id,
                 created_by_username=f.created_by.username if f.created_by else None,
                 created_at=f.created_at,
+                fs_modified_at=f.fs_modified_at,
                 print_name=print_name,
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
@@ -2535,6 +2668,17 @@ async def add_files_to_queue(
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
     files = {f.id: f for f in result.scalars().all()}
 
+    # Project attribution (#1897): a file queued from a project-linked folder
+    # inherits that project, so the resulting archive counts toward the
+    # project's progress. A file's own project link wins over its folder's.
+    folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None}
+    folder_projects: dict[int, int | None] = {}
+    if folder_ids:
+        folder_result = await db.execute(
+            select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids))
+        )
+        folder_projects = dict(folder_result.all())
+
     # Get max position for queue ordering
     pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
     max_position = pos_result.scalar() or 0
@@ -2572,6 +2716,8 @@ async def add_files_to_queue(
             queue_item = PrintQueueItem(
                 printer_id=None,  # Unassigned
                 library_file_id=file_id,
+                project_id=lib_file.project_id
+                or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
             )
@@ -2637,11 +2783,23 @@ async def get_library_file_plates(
     # SliceModal to default its dropdowns (#1325). Initialised here so the
     # final return never raises NameError when the file isn't a valid zip.
     embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
+    # Process settings the designer changed away from the stock preset (#2622).
+    # Offered in the SliceModal so a cross-printer re-slice can carry them
+    # instead of silently losing them to the picked process profile.
+    design_overrides: list[dict] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             embedded_presets = extract_embedded_presets_from_3mf(zf)
+            if _PROJECT_SETTINGS_PATH in namelist:
+                try:
+                    design_overrides = [
+                        o._asdict()
+                        for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
+                    ]
+                except (ValueError, OSError, KeyError):
+                    design_overrides = []
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -2870,6 +3028,7 @@ async def get_library_file_plates(
         "is_multi_plate": len(plates) > 1,
         "embedded_printer": embedded_presets["printer"],
         "embedded_process": embedded_presets["process"],
+        "design_overrides": design_overrides,
     }
 
 
@@ -2956,6 +3115,7 @@ async def get_library_file_filament_requirements(
     file_id: int,
     plate_id: int | None = None,
     request_id: str | None = None,
+    full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -2972,6 +3132,10 @@ async def get_library_file_filament_requirements(
     Args:
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
+        full_slots: Return one entry per *project* slot rather than only the
+            slots the plate consumes. See :func:`_expand_to_project_slots`.
+            Only the slice modal wants this; print-time AMS matching must keep
+            the used-only list.
     """
     import defusedxml.ElementTree as ET
 
@@ -3074,6 +3238,17 @@ async def get_library_file_filament_requirements(
                                 }
                             )
 
+            # Re-slicing a source that already carries slice_info (#2712).
+            # The block above answers "what does this plate consume", which is
+            # what print-time AMS matching needs. The slice modal needs "what
+            # slots exist", because its list is positional and the CLI binds
+            # entry N to slot N — so a source using only slot 4 handed the
+            # user's single pick to slot 1 and sliced slot 4 with the source's
+            # embedded default. Widen here rather than in the modal so the
+            # print path keeps the narrow list it depends on.
+            if full_slots and filaments:
+                filaments = expand_to_project_slots(zf, filaments)
+
             # Unsliced project files: slice_info had no per-plate data.
             # Return the FULL project_settings.config AMS slot list so
             # the slicer CLI receives a profile for every project slot
@@ -3287,12 +3462,79 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     return json.dumps(profile)
 
 
+# Support-related keys we lift from the source 3MF's project_settings.config
+# into the picked process preset before `--load-settings` sees it (#1881).
+# BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
+# define `enable_support: 0` as their default — supports are a per-print
+# decision, not a per-quality one. `--load-settings` is authoritative, so
+# without preserving these fields the source's per-project support intent
+# (supports on, PVA in the interface slot, tree vs normal) gets discarded
+# and the slicer produces a single-material output with no supports at all.
+_SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = (
+    "enable_support",
+    "support_filament",
+    "support_interface_filament",
+    "support_type",
+)
+
+
+def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str:
+    """Overlay the source 3MF's support configuration onto the process JSON.
+
+    Only fires on 3MF sources — STL / STEP don't carry `project_settings.
+    config`. Silently no-ops when the source doesn't have the config, has
+    a malformed one, or when the process JSON isn't parseable — the slice
+    then runs with the process preset's own defaults, which is the safe
+    fall-back for both this bug and the pre-fix behaviour.
+    """
+    from io import BytesIO
+
+    try:
+        with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
+            if "Metadata/project_settings.config" not in zf.namelist():
+                return process_json
+            src_cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return process_json
+    if not isinstance(src_cfg, dict):
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE:
+        if key in src_cfg:
+            process_cfg[key] = src_cfg[key]
+
+    return json.dumps(process_cfg)
+
+
 # The sidecar prefixes the slicer CLI's own error_string with this when the
 # slicer ran and rejected the job (model off the bed, incompatible filament
 # temps, range validation) — as opposed to the CLI crashing before it could
 # evaluate the job at all.
 _SLICER_REJECTION_MARKER = "Slicing failed with error from slicer:"
 
+# The CLI writes its real diagnostic to stdout/stderr on the `[error]` level.
+# Format is `[<timestamp>] [error] run <NNNN>: <message>` (or sometimes without
+# the `run NNNN:` prefix). The bracketed timestamp is optional; the `[error]`
+# tag is what we anchor on. Used to recover the actual rejection reason for
+# the `error_string: "The input preset file is invalid and can not be parsed."`
+# case (#1851) — the CLI emits that generic placeholder for every -5 exit
+# including real preset-compat rejections, and the per-incident specifics
+# only live in the stdout dump.
+_CLI_ERROR_LINE_RE = re.compile(r"\[error\]\s*(?:run\s+\d+:\s*)?(.+?)\s*$", re.MULTILINE)
+
+# The placeholder error_string Bambu Studio writes to result.json for any
+# `--load-settings` parse / compat rejection (-5 exit). When the sidecar
+# surfaces this, the real reason lives in the stdout `[error]` line that we
+# mine via _CLI_ERROR_LINE_RE.
+_INPUT_PRESET_INVALID_PLACEHOLDER = "The input preset file is invalid and can not be parsed."
+
 
 def _slicer_rejection_message(error_text: str) -> str | None:
     """Extract the slicer's own rejection reason from a sidecar error string,
@@ -3303,16 +3545,34 @@ def _slicer_rejection_message(error_text: str) -> str | None:
     no. Retrying with the 3MF's embedded settings would then only "succeed"
     by silently reverting to the source file's original printer, masking the
     real problem; such failures must reach the user instead.
+
+    When the sidecar's `error_string` is Bambu Studio's generic
+    "The input preset file is invalid and can not be parsed." placeholder
+    (#1851) — emitted for every -5 exit, including the actual preset-compat
+    rejections whose real reason is logged to stdout as
+    `[error] run NNNN: <diagnostic>` — prefer the stdout `[error]` line so
+    the user sees which preset clashed with which printer.
     """
     if _SLICER_REJECTION_MARKER not in error_text:
         return None
     reason = error_text.split(_SLICER_REJECTION_MARKER, 1)[1]
+    # Mine the stdout/stderr dump for a more specific CLI diagnostic before
+    # we trim it off below. Done first so the lookup window covers the full
+    # response, not just the headline.
+    cli_diagnostic_match = _CLI_ERROR_LINE_RE.search(reason)
+    cli_diagnostic = cli_diagnostic_match.group(1).strip() if cli_diagnostic_match else None
     # Trim the sidecar's trailing exit-code note and any stderr/stdout dump.
     for cut in (": Slicer process failed", "\nstderr:", "\nstdout:"):
         idx = reason.find(cut)
         if idx != -1:
             reason = reason[:idx]
-    return reason.strip() or None
+    reason = reason.strip() or None
+    # When the headline is Bambu Studio's catch-all placeholder, the real
+    # reason is in the stdout `[error]` line. Substitute it. The placeholder
+    # by itself tells the user nothing about why their slice was rejected.
+    if cli_diagnostic and (reason is None or reason == _INPUT_PRESET_INVALID_PLACEHOLDER):
+        return cli_diagnostic
+    return reason
 
 
 async def _run_slicer_with_fallback(
@@ -3426,7 +3686,37 @@ async def _run_slicer_with_fallback(
         # didn't touch) still drive the slice.
         primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
 
+        # #1881: preserve the source 3MF's support configuration on top of
+        # the picked process preset. Bambu's shipped process presets set
+        # `enable_support: 0` by default (supports are a per-print, not
+        # per-quality, decision); `--load-settings` is authoritative so
+        # without patching, the source's `enable_support: 1` + support-slot
+        # assignments get discarded and the slice comes out single-material
+        # with a PVA slot loaded but never used.
+        presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
+
+        # #2622: carry the designer's own process tweaks onto the picked preset.
+        # BambuStudio records exactly which keys deviate from the system preset
+        # in `different_settings_to_system`, so a MakerWorld author's 5 walls /
+        # 100% infill / 0.1mm first layer survive a re-slice for another printer
+        # instead of being flattened by --load-settings. Opt-in per key: only the
+        # keys the caller names are applied, and only if the source really lists
+        # them as changed. Runs after the #1881 support patch so an explicit
+        # design pick wins over the blanket support carry-over.
+        if request.design_overrides:
+            presets["process"] = apply_design_overrides(
+                presets["process"],
+                extract_design_process_overrides(primary_bytes),
+                request.design_overrides,
+            )
+
     used_embedded_settings = False
+    # "Slice as designed" (#2611): honour the file's embedded
+    # project_settings.config instead of the picked profile triplet. Only
+    # meaningful for a 3MF that actually carries embedded settings; the UI
+    # gates the toggle on the picked printer matching the design's target,
+    # so this path never re-targets across printer models.
+    embedded_mode = bool(request.use_embedded_settings and is_3mf)
     service = SlicerApiService(api_url)
 
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
@@ -3483,13 +3773,31 @@ async def _run_slicer_with_fallback(
     # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
     # BambuStudio reject the slice with "the temperature difference of
     # the filaments used is too large" (exit 194) even though the G-code
-    # never touches the unused slot. Replace unused-slot entries with the
-    # slot-1 selection before the real slice so the loaded-filament set
-    # is materially homogeneous.
-    if is_3mf and request.plate is not None:
+    # never touches the unused slot; a default scoped to another printer
+    # gets it rejected with "filament preset (slot N) is not compatible
+    # with printer …" (#2628). Replace unused-slot entries with the
+    # plate's lowest used slot before the real slice so the loaded set is
+    # materially homogeneous and printer-correct.
+    #
+    # ``plate`` is absent for single-plate and STL sources — the SliceModal
+    # skips the picker and omits the field — and absent means plate 1, the
+    # same reading as ``plate_num`` further down and as the schema's own
+    # description. Treating it as "unknown plate" instead is what left every
+    # single-plate 3MF unsubstituted (#2711): a MakerWorld project defining
+    # four filaments but painting only one reached the CLI with the other
+    # three still holding presets baked into the source for a different
+    # printer, and the slice died on the first of them.
+    #
+    # ``plate=0`` is the slice-all sentinel, not a plate: every slot is used
+    # by some plate, so there is nothing to substitute. It has to be excluded
+    # explicitly because the support-filament slots unioned in below are
+    # read from the project config and are not plate-scoped — they would
+    # survive the (empty) geometry lookup for plate 0 and become the anchor,
+    # collapsing every colour of a slice-all onto the support filament.
+    if is_3mf and request.plate != 0:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
-        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
+        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
 
     # Cross-class slice-all loop (#1493): when the user asks for
     # ``plate=0`` (all plates) AND the source's nozzle class differs from
@@ -3505,7 +3813,22 @@ async def _run_slicer_with_fallback(
 
     try:
         try:
-            if use_cross_class_slice_all:
+            if embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
+            elif use_cross_class_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     merge_plate_3mfs,
@@ -3607,7 +3930,11 @@ async def _run_slicer_with_fallback(
                 # (e.g. re-slicing an H2D model for an X1C: the object is off
                 # the smaller bed). Surface the slicer's reason instead.
                 raise HTTPException(status_code=400, detail=rejection) from exc
-            if not is_3mf:
+            if not is_3mf or embedded_mode:
+                # embedded_mode already sliced with the file's own settings —
+                # there is nothing to fall back TO, so surface the server
+                # error (the outer handler turns it into a 502) instead of
+                # re-running the same embedded slice.
                 raise
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
@@ -4032,8 +4359,14 @@ async def slice_library_file(
 
     src_result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = src_result.scalar_one_or_none()
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
+    # Per-row ownership gate. LIBRARY_UPLOAD alone let a READ_OWN caller (e.g. the
+    # built-in Operators group) slice another user's model by raw id even though
+    # GET on that id returned 404 — the sliced output was then attributed to and
+    # downloadable by the requester. Enforce the same visibility the read routes
+    # use before reading the source off disk. API-key / auth-disabled callers
+    # (current_user is None) keep can_read_all=True — no per-row identity.
+    can_read_all = current_user is None or current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+    lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
 
     src_lower = (lib_file.filename or "").lower()
     if not (
@@ -4105,6 +4438,7 @@ async def slice_library_file(
         kind="library_file",
         source_id=lib_file.id,
         source_name=lib_file.filename,
+        owner_id=user_id,
         run=_run,
     )
     return {
@@ -4118,108 +4452,23 @@ async def slice_library_file(
 async def print_library_file(
     file_id: int,
     printer_id: int,
-    body: FilePrintRequest | None = None,
-    db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
+    # SECURITY.md SEC-AUTH-1: every route either has an explicit auth dep or
+    # is in the route-auth-coverage allowlist. Gating the deprecation stub on
+    # QUEUE_CREATE matches the replacement route (POST /queue/) and means
+    # anonymous callers bounce at auth instead of seeing the deprecation
+    # message.
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
-    """Dispatch a library file for send/start on a printer.
-
-    The actual send/start work is handled asynchronously by background
-    dispatch so the UI can continue immediately.
-
-    Only sliced files (.gcode or .gcode.3mf) can be printed.
-    """
-    from backend.app.models.printer import Printer
-    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
-    from backend.app.services.printer_manager import printer_manager
-
-    # Use defaults if no body provided
-    if body is None:
-        body = FilePrintRequest()
-
-    # Get the library file
-    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
-
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
-
-    # Validate file is sliced
-    if not is_sliced_file(lib_file.filename):
-        raise HTTPException(
-            status_code=400,
-            detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
-        )
-
-    # Filenames containing FAT32/exFAT-illegal characters would 553 at
-    # FTP upload time (#1540). Older rows may pre-date the rename-time
-    # validation, so reject the print attempt with an actionable message
-    # rather than silently renaming user data.
-    try:
-        validate_print_filename(lib_file.filename)
-    except InvalidFilenameError as e:
-        raise HTTPException(status_code=400, detail=str(e)) from e
-
-    # Get the full file path
-    file_path = Path(app_settings.base_dir) / lib_file.file_path
-
-    if not file_path.exists():
-        raise HTTPException(status_code=404, detail="File not found on disk")
-
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(status_code=404, detail="Printer not found")
-
-    # Check printer is connected
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(status_code=400, detail="Printer is not connected")
-
-    # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
-    if body.project_id is not None:
-        project_result = await db.execute(select(Project).where(Project.id == body.project_id))
-        if not project_result.scalar_one_or_none():
-            raise HTTPException(status_code=404, detail="Project not found")
-
-    await validate_print_budget(
-        db,
-        cost_center_id=body.cost_center_id,
-        estimated_cost=body.estimated_cost,
-        current_user=current_user,
+    """Legacy direct library print endpoint. Use POST /queue/ instead."""
+    logger.warning(
+        "Gone API used: POST /library/files/%s/print?printer_id=%s; use POST /queue/ instead",
+        file_id,
+        printer_id,
+    )
+    raise HTTPException(
+        status_code=410,
+        detail="Direct library-file print has been removed. Create a print queue item with POST /queue/.",
     )
-
-    plate_name = body.plate_name
-    if not plate_name and body.plate_id is not None:
-        plate_name = f"Plate {body.plate_id}"
-
-    dispatch_source_name = lib_file.filename
-    if plate_name:
-        dispatch_source_name = f"{lib_file.filename} • {plate_name}"
-
-    try:
-        dispatch_result = await background_dispatch.dispatch_print_library_file(
-            file_id=file_id,
-            filename=dispatch_source_name,
-            printer_id=printer_id,
-            printer_name=printer.name,
-            options=body.model_dump(exclude_none=True, exclude={"cleanup_library_after_dispatch"}),
-            project_id=body.project_id,
-            requested_by_user_id=current_user.id if current_user else None,
-            requested_by_username=current_user.username if current_user else None,
-            cleanup_library_after_dispatch=body.cleanup_library_after_dispatch,
-        )
-    except DispatchEnqueueRejected as e:
-        raise HTTPException(status_code=409, detail=str(e)) from e
-
-    return {
-        "status": "dispatched",
-        "printer_id": printer_id,
-        "archive_id": None,
-        "filename": lib_file.filename,
-        "dispatch_job_id": dispatch_result["dispatch_job_id"],
-        "dispatch_position": dispatch_result["dispatch_position"],
-    }
 
 
 # ============ File Detail Endpoints ============
@@ -4760,16 +5009,15 @@ async def bulk_delete(
             file.deleted_at = now
         deleted_files += 1
 
-    # Delete folders (cascade will handle contents)
-    # Note: Folders don't have ownership tracking currently, require *_all permission
+    # Delete folders (cascade will handle contents). Folders have no ownership
+    # tracking, so users without *_all permission may only delete empty,
+    # non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
     for folder_id in data.folder_ids:
-        if not can_modify_all:
-            # Users without *_all permission cannot delete folders
-            continue
-
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder = result.scalar_one_or_none()
         if folder:
+            if not can_modify_all and await _restricted_folder_delete_blocker(db, folder):
+                continue
             # Count files that will be deleted
             file_count_result = await db.execute(
                 select(func.count(LibraryFile.id)).where(

+ 6 - 1
backend/app/api/routes/library_tags.py

@@ -186,7 +186,12 @@ async def update_tag(
     )
 
 
-@router.delete("/{tag_id}", status_code=204)
+# response_model=None is load-bearing under `from __future__ import annotations`:
+# the `-> None` return annotation reaches FastAPI as the string "None", which it
+# resolves to NoneType — a truthy class — and then asserts a 204 may carry no
+# response body. fastapi >= 0.116 special-cases NoneType; on the 0.109-0.115
+# releases requirements.txt still allows, the app fails at import without this.
+@router.delete("/{tag_id}", status_code=204, response_model=None)
 async def delete_tag(
     tag_id: int,
     db: AsyncSession = Depends(get_db),

+ 14 - 0
backend/app/api/routes/local_backup.py

@@ -39,6 +39,20 @@ async def get_status(
     }
 
 
+@router.get("/path-check")
+async def check_path(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Check that the configured output directory can actually be written to.
+
+    Writes and removes a probe file. A path the service cannot write to — a NAS
+    share outside the systemd unit's ReadWritePaths, say — otherwise only shows
+    up as a failed backup hours later (#2544).
+    """
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.check_path(settings["path"])
+
+
 @router.post("/run")
 async def trigger_backup(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),

+ 24 - 4
backend/app/api/routes/makerworld.py

@@ -21,7 +21,12 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
+from backend.app.api.routes.cloud import (
+    get_stored_token,
+    is_cloud_token_invalid,
+    mark_cloud_token_invalid,
+    resolve_api_key_cloud_owner,
+)
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
@@ -58,10 +63,16 @@ async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldServi
     stored Bambu Cloud bearer token when available.
 
     Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
-    optional; anonymous calls (metadata, URL resolution) still work.
+    optional; anonymous calls (metadata, URL resolution) still work — and,
+    like it, records a rejected token so the whole app agrees the sign-in is
+    dead rather than each feature failing on its own.
     """
     token, _email, _region = await get_stored_token(db, user)
-    return MakerWorldService(auth_token=token)
+    user_id = user.id if user is not None else None
+    return MakerWorldService(
+        auth_token=token,
+        on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
+    )
 
 
 def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
@@ -156,7 +167,16 @@ async def get_status(
     cloud_token_user = current_user or api_key_cloud_owner
     token, _email, _region = await get_stored_token(db, cloud_token_user)
     has_token = bool(token)
-    return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
+    # A token Bambu has already rejected downloads nothing. ``can_download``
+    # used to be a bare alias for ``has_cloud_token``, so the import button
+    # stayed enabled against a dead credential and the user found out via a
+    # 401 toast (#2562 follow-up).
+    expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
+    return MakerWorldStatus(
+        has_cloud_token=has_token,
+        can_download=has_token and not expired,
+        sign_in_expired=expired,
+    )
 
 
 @router.post("/resolve", response_model=MakerWorldResolvedModel)

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -58,6 +58,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
+        "on_plate_clear_required": provider.on_plate_clear_required,
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
@@ -139,6 +140,7 @@ async def create_notification_provider(
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         # Build plate detection
         on_plate_not_empty=provider_data.on_plate_not_empty,
+        on_plate_clear_required=provider_data.on_plate_clear_required,
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete

+ 23 - 0
backend/app/api/routes/obico.py

@@ -37,6 +37,29 @@ async def get_status(
     }
 
 
+@router.get("/printer-status")
+async def get_printer_status(
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """Per-printer live classification for the printer cards (#1546).
+
+    Deliberately excludes configuration (ML URL, action, history) so users
+    with printers:read but no settings:read can still render the badge.
+    """
+    settings = await obico_detection_service._load_settings()
+    enabled_printers = settings["enabled_printers"]
+    # Error strings can embed configured URLs (ML API base, external URL), so
+    # they stay behind settings:read like the rest of the configuration.
+    can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
+    return {
+        "enabled": settings["enabled"],
+        # None = all printers are monitored
+        "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
+        "per_printer": obico_detection_service.get_per_printer(),
+        "last_error": obico_detection_service._last_error if can_see_error else None,
+    }
+
+
 @router.post("/test-connection")
 async def test_connection(
     req: TestConnectionRequest,

+ 152 - 148
backend/app/api/routes/orca_cloud.py

@@ -1,31 +1,34 @@
 """
 Orca Cloud API Routes
 
-PKCE-based connect/disconnect + profile sync endpoints for the
-Orca Cloud (Supabase) profile-sync surface.
+Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
+Orca Cloud external-app surface.
 
 Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
 
-    POST /orca-cloud/auth/start
-        Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
-    POST /orca-cloud/auth/finish
-        Parse the pasted callback URL, validate state for CSRF, exchange the
-        code for tokens, persist them atomically.
+    POST /orca-cloud/device/start
+        Request a device code, persist it server-side (TTL 10 min), return the
+        user_code + verification URIs + poll interval.
+    POST /orca-cloud/device/poll
+        One poll of the token endpoint. Returns an in-progress status while the
+        user approves; on approval, persists the token pair and reports
+        connected. The frontend calls this every ``interval`` seconds.
     GET  /orca-cloud/status
-        Connected/disconnected + email + user_id.
+        Connected/disconnected + user_id.
     POST /orca-cloud/logout
-        Clear stored tokens (no Supabase-side revocation — token still
-        survives until its 1h expiry, but Bambuddy has no way to use it).
+        Clear stored tokens (Bambuddy then has no token to use; the user can
+        also disconnect from Orca Cloud's own settings to revoke server-side).
     GET  /orca-cloud/profiles
-        Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
-        access token if it's within the 5-min leeway of expiry.
+        List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
+        the access token if it's within the refresh leeway of expiry.
     GET  /orca-cloud/profiles/{id}
         Single profile's full content.
 
-Storage shape mirrors the Bambu Cloud surface: per-user columns on
-``users`` when auth is enabled, fallback to global ``settings`` keys when
-auth is disabled. The transient PKCE state (verifier, state, pending_at)
-is stored alongside the tokens — same dual-mode pattern.
+Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users``
+when auth is enabled, fallback to global ``settings`` keys when auth is
+disabled. The transient pending device-code state (device_code, interval,
+started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
+pattern; no schema change from the previous PKCE flow.
 """
 
 from __future__ import annotations
@@ -33,7 +36,7 @@ from __future__ import annotations
 import logging
 from datetime import datetime, timezone
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -43,23 +46,19 @@ from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.orca_cloud import (
-    OrcaAuthFinishRequest,
-    OrcaAuthPasswordRequest,
-    OrcaAuthStartRequest,
-    OrcaAuthStartResponse,
     OrcaAuthStatusResponse,
+    OrcaDevicePollResponse,
+    OrcaDeviceStartResponse,
     OrcaProfileDetail,
     OrcaProfileListResponse,
     OrcaProfileMeta,
 )
 from backend.app.services.orca_cloud import (
-    PENDING_PKCE_TTL,
+    DEVICE_CODE_TTL,
+    DevicePoll,
     OrcaCloudAuthError,
     OrcaCloudError,
     OrcaCloudService,
-    build_authorize_url,
-    generate_pkce,
-    parse_callback_url,
 )
 
 logger = logging.getLogger(__name__)
@@ -90,9 +89,9 @@ _ORCA_TYPE_TO_BAMBU = {
 
 
 def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
-    """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
-    into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
-    isn't a dict or the type isn't one we render."""
+    """Normalize one Orca profile (``{id, name, content, ...}``) into a
+    ``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict
+    or the type isn't one we render."""
     content = orca_profile.get("content") or {}
     if not isinstance(content, dict):
         return None
@@ -130,15 +129,16 @@ def _str_or_none(value: object) -> str | None:
 
 # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
 # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
-# settings table see a consistent prefix.
+# settings table see a consistent prefix. The ``pending_*`` keys hold the
+# transient device-code state (device_code / interval / started_at).
 _SETTINGS_KEYS = {
     "token": "orca_cloud_token",
     "refresh_token": "orca_cloud_refresh_token",
     "expires_at": "orca_cloud_expires_at",  # ISO 8601 UTC string
     "email": "orca_cloud_email",
     "user_id": "orca_cloud_user_id",
-    "pending_verifier": "orca_cloud_pending_verifier",
-    "pending_state": "orca_cloud_pending_state",
+    "pending_device_code": "orca_cloud_pending_verifier",  # reused column
+    "pending_interval": "orca_cloud_pending_state",  # reused column
     "pending_at": "orca_cloud_pending_at",  # ISO 8601 UTC string
 }
 
@@ -184,7 +184,11 @@ def _parse_iso(value: str | None) -> datetime | None:
 class _OrcaCredentials:
     """Lightweight bag for stored Orca Cloud credentials. We use a class
     rather than a dataclass so the helpers can mutate it as needed during
-    JIT-refresh without rebuilding the whole object."""
+    JIT-refresh without rebuilding the whole object.
+
+    ``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
+    in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
+    columns that the old PKCE flow used for its verifier/state)."""
 
     __slots__ = (
         "token",
@@ -192,8 +196,8 @@ class _OrcaCredentials:
         "expires_at",
         "email",
         "user_id",
-        "pending_verifier",
-        "pending_state",
+        "pending_device_code",
+        "pending_interval",
         "pending_at",
     )
 
@@ -203,8 +207,8 @@ class _OrcaCredentials:
         self.expires_at: datetime | None = None
         self.email: str | None = None
         self.user_id: str | None = None
-        self.pending_verifier: str | None = None
-        self.pending_state: str | None = None
+        self.pending_device_code: str | None = None
+        self.pending_interval: str | None = None
         self.pending_at: datetime | None = None
 
 
@@ -227,8 +231,8 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
         creds.expires_at = _as_utc(user.orca_cloud_expires_at)
         creds.email = user.orca_cloud_email
         creds.user_id = user.orca_cloud_user_id
-        creds.pending_verifier = user.orca_cloud_pending_verifier
-        creds.pending_state = user.orca_cloud_pending_state
+        creds.pending_device_code = user.orca_cloud_pending_verifier
+        creds.pending_interval = user.orca_cloud_pending_state
         creds.pending_at = _as_utc(user.orca_cloud_pending_at)
         return creds
 
@@ -239,27 +243,28 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
     creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
     creds.email = raw.get(_SETTINGS_KEYS["email"])
     creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
-    creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
-    creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
+    creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
+    creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
     creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
     return creds
 
 
-async def _persist_pending_pkce(
+async def _persist_pending_device(
     db: AsyncSession,
     user: User | None,
-    verifier: str,
-    state: str,
+    device_code: str,
+    interval: int,
     when: datetime,
 ) -> None:
-    """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
+    """Store the transient device-code state used by ``/device/start`` ->
+    ``/device/poll``. The device_code is a secret kept server-side."""
     if user is not None:
         await db.execute(
             update(User)
             .where(User.id == user.id)
             .values(
-                orca_cloud_pending_verifier=verifier,
-                orca_cloud_pending_state=state,
+                orca_cloud_pending_verifier=device_code,
+                orca_cloud_pending_state=str(interval),
                 orca_cloud_pending_at=when,
             )
         )
@@ -268,13 +273,38 @@ async def _persist_pending_pkce(
     await _upsert_settings(
         db,
         {
-            _SETTINGS_KEYS["pending_verifier"]: verifier,
-            _SETTINGS_KEYS["pending_state"]: state,
+            _SETTINGS_KEYS["pending_device_code"]: device_code,
+            _SETTINGS_KEYS["pending_interval"]: str(interval),
             _SETTINGS_KEYS["pending_at"]: _iso(when),
         },
     )
 
 
+async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
+    """Wipe just the pending device-code state (on terminal poll outcomes),
+    leaving any existing tokens untouched."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_pending_verifier=None,
+                orca_cloud_pending_state=None,
+                orca_cloud_pending_at=None,
+            )
+        )
+        await db.commit()
+        return
+    await _upsert_settings(
+        db,
+        {
+            _SETTINGS_KEYS["pending_device_code"]: None,
+            _SETTINGS_KEYS["pending_interval"]: None,
+            _SETTINGS_KEYS["pending_at"]: None,
+        },
+    )
+
+
 async def _persist_tokens(
     db: AsyncSession,
     user: User | None,
@@ -285,8 +315,8 @@ async def _persist_tokens(
     user_id: str | None,
 ) -> None:
     """Atomically write the new access/refresh pair to whichever backing store
-    the deployment uses. Also clears the pending PKCE state on the same write,
-    since by this point the handshake is complete."""
+    the deployment uses. Also clears the pending device-code state on the same
+    write, since by this point the pairing is complete."""
     if user is not None:
         await db.execute(
             update(User)
@@ -312,8 +342,8 @@ async def _persist_tokens(
             _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
             _SETTINGS_KEYS["email"]: email,
             _SETTINGS_KEYS["user_id"]: user_id,
-            _SETTINGS_KEYS["pending_verifier"]: None,
-            _SETTINGS_KEYS["pending_state"]: None,
+            _SETTINGS_KEYS["pending_device_code"]: None,
+            _SETTINGS_KEYS["pending_interval"]: None,
             _SETTINGS_KEYS["pending_at"]: None,
         },
     )
@@ -327,7 +357,7 @@ async def _persist_rotated_tokens(
     expires_at: datetime | None,
 ) -> None:
     """Persist tokens after a refresh — does NOT touch email/user_id and does
-    NOT touch the pending PKCE state (refresh happens long after the handshake)."""
+    NOT touch the pending state (refresh happens long after pairing)."""
     if user is not None:
         await db.execute(
             update(User)
@@ -405,7 +435,12 @@ async def _build_authenticated_service(
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     credentials. If the access token is within the refresh-leeway of expiry,
     proactively refresh and persist the new pair BEFORE returning, so the
-    next API call doesn't time out mid-flight on an expired token."""
+    next API call doesn't time out mid-flight on an expired token.
+
+    We don't lock around the refresh: Orca tolerates concurrent refreshes for
+    ~60s (each racer gets its own valid pair on the same connection rather than
+    a revoke), so a lost race here is harmless — last-write-wins on the stored
+    pair, and whichever pair we keep is valid."""
     creds = await _load_credentials(db, user)
     if not creds.token:
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -439,128 +474,97 @@ async def _build_authenticated_service(
 # ---------------------------------------------------------------------------
 
 
-@router.post("/auth/start", response_model=OrcaAuthStartResponse)
-async def auth_start(
-    payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
-    db: AsyncSession = Depends(get_db),
-    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
-):
-    """Generate PKCE state and return the Supabase authorize URL for the
-    requested OAuth provider (google / apple / github). The frontend opens
-    the URL in a new tab; after sign-in the user pastes the callback URL
-    back into ``/auth/finish``.
-
-    ``state`` is generated but NOT sent to Supabase (it would clash with
-    GoTrue's internal redirect_to-tracking state). We still persist it so
-    a future flow change can re-introduce state-based CSRF if needed; CSRF
-    protection today comes from the PKCE verifier itself, which is
-    single-use, server-side, and bound to the caller's user row."""
-    verifier, challenge, state = generate_pkce()
-    await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
-    return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
-
-
-@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
-async def auth_password(
-    payload: OrcaAuthPasswordRequest,
+@router.post("/device/start", response_model=OrcaDeviceStartResponse)
+async def device_start(
+    request: Request,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Direct email+password sign-in. No browser redirect, no paste flow —
-    Bambuddy POSTs the credentials to Supabase and stores the returned
-    tokens. Whether this succeeds depends on Orca's Supabase project
-    accepting the password grant; if it rejects (the SDK refuses passwords
-    by design, the backend may follow suit), the caller falls back to an
-    OAuth provider via ``/auth/start``."""
+    """Begin device pairing. Requests a device code from Orca, stores it
+    server-side (the device_code is a secret and never leaves the backend),
+    and returns the user_code + verification URIs + poll interval for the
+    frontend to display and poll against."""
     svc = OrcaCloudService()
+    # instance_url/label are display-only anti-phishing context on the approval
+    # card. base_url may be off behind a reverse proxy, but it's harmless if so.
+    instance_url = str(request.base_url).rstrip("/") or None
     try:
-        await svc.password_login(payload.email, payload.password)
+        data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
     except OrcaCloudAuthError as e:
-        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+        # invalid_client etc. — an operator misconfiguration, not user error.
+        raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
     except OrcaCloudError as e:
         raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
 
-    email: str | None = None
-    user_id: str | None = None
-    try:
-        user_info = await svc.get_user_info()
-        if isinstance(user_info, dict):
-            email = user_info.get("email")
-            user_id = user_info.get("id")
-    except OrcaCloudError as e:
-        logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
-
-    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
-    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+    device_code = data.get("device_code")
+    user_code = data.get("user_code")
+    if not device_code or not user_code:
+        raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
+
+    interval = int(data.get("interval") or 5)
+    expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
+    await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
+
+    return OrcaDeviceStartResponse(
+        user_code=user_code,
+        verification_uri=str(data.get("verification_uri") or ""),
+        verification_uri_complete=str(data.get("verification_uri_complete") or ""),
+        interval=interval,
+        expires_in=expires_in,
+    )
 
 
-@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
-async def auth_finish(
-    payload: OrcaAuthFinishRequest,
+@router.post("/device/poll", response_model=OrcaDevicePollResponse)
+async def device_poll(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Complete the PKCE handshake — parse the pasted callback URL, validate
-    state (CSRF), exchange the code for tokens, persist."""
+    """Poll the token endpoint once for the in-flight pairing. Returns an
+    in-progress status while the user approves; on approval persists the token
+    pair (clearing the pending state) and reports connected."""
     creds = await _load_credentials(db, current_user)
-    if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
+    if not creds.pending_device_code or not creds.pending_at:
         raise HTTPException(
             status_code=400,
-            detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
+            detail="No pending Orca Cloud pairing. Click Connect first to start the flow.",
         )
 
     # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
-    # normalization. Subtracting two aware UTC datetimes gives a real wall-clock
-    # delta with no local-offset shift.
+    # normalization. Subtracting two aware UTC datetimes gives a real delta.
     age = datetime.now(timezone.utc) - creds.pending_at
-    if age > PENDING_PKCE_TTL:
-        # Don't leave the stale state in the DB — clear it so the user has to
-        # restart fresh, which forces a new verifier/state pair.
-        await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
-        raise HTTPException(
-            status_code=400,
-            detail=(
-                f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
-                "Click Connect again to start over."
-            ),
-        )
-
-    code, _callback_state = parse_callback_url(payload.callback_url)
-    if not code:
-        raise HTTPException(
-            status_code=400,
-            detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
-        )
-    # We do NOT validate ``state`` here: Supabase doesn't echo back a state we
-    # don't send (see :func:`build_authorize_url` for why we can't send one).
-    # CSRF is protected by PKCE: the verifier is server-side and single-use,
-    # so an attacker can't complete the exchange with a code they obtained
-    # separately. ``pending_state`` is still stored for forward compatibility
-    # if Supabase ever supports a client-passed state alongside redirect_to.
+    if age > DEVICE_CODE_TTL:
+        await _clear_pending_device(db, current_user)
+        return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
 
     svc = OrcaCloudService()
     try:
-        await svc.exchange_code(code, creds.pending_verifier)
-    except OrcaCloudAuthError as e:
-        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+        status, token_data = await svc.poll_token(creds.pending_device_code)
     except OrcaCloudError as e:
         raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
 
-    # Fetch user info so we can show the connected email in the UI.
-    email: str | None = None
+    if status in DevicePoll.ONGOING:
+        return OrcaDevicePollResponse(status=status, connected=False)
+
+    if status in DevicePoll.TERMINAL:
+        # access_denied / expired_token — the attempt is dead; clear it so the
+        # user starts fresh next time.
+        await _clear_pending_device(db, current_user)
+        return OrcaDevicePollResponse(status=status, connected=False)
+
+    # COMPLETE — tokens issued and applied to svc. Introspect for the user_id
+    # (the external API's /me doesn't return an email, so email stays None).
     user_id: str | None = None
     try:
-        user_info = await svc.get_user_info()
-        if isinstance(user_info, dict):
-            email = user_info.get("email")
-            user_id = user_info.get("id")
+        info = await svc.introspect()
+        if isinstance(info, dict):
+            user_id = _str_or_none(info.get("user_id"))
     except OrcaCloudError as e:
-        # Don't fail the whole connect flow just because the user-info side
-        # call hiccuped — we have valid tokens, that's the load-bearing part.
-        logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
+        # Don't fail the whole pairing over the side introspection call — we
+        # have valid tokens, which is the load-bearing part.
+        logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
 
-    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
-    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
+    return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id)
 
 
 @router.get("/status", response_model=OrcaAuthStatusResponse)
@@ -583,9 +587,9 @@ async def logout(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Clear stored Orca Cloud credentials. Does not call Supabase's
-    ``/logout`` endpoint (the token would still survive its 1h expiry there
-    either way, and Bambuddy will no longer have it to use)."""
+    """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
+    endpoint (the user can revoke server-side from Orca Cloud's own settings;
+    Bambuddy will no longer have the token to use either way)."""
     await _clear_credentials(db, current_user)
     return {"success": True}
 

+ 968 - 0
backend/app/api/routes/pipeline_runs.py

@@ -0,0 +1,968 @@
+"""API routes for Slicer Pipeline runs (#1425 PR B + PR C).
+
+PR B implemented single-target dispatch: one Run-pipeline click =
+  slice the source once → enqueue ONE print on ``target_printer_id``.
+
+PR C extends this with:
+  * ``copies > 1`` — slice once, enqueue N copies.
+  * ``target_kind='printer_class'`` — pipeline targets a Bambu model code
+    (X1C / P1S / H2D / …); orchestrator distributes copies across matching
+    printers using the pipeline's ``fanout_strategy``.
+  * Retry-failed runs that re-attempt only the failed/cancelled copies of
+    a partial-failure run.
+  * Dashboard list endpoint (``GET /pipeline-runs``) with status + pipeline
+    filters and pagination.
+  * WebSocket ``pipeline_run_updated`` events on state transitions so the
+    dashboard refreshes live without polling.
+
+The slice itself runs through ``slice_dispatch`` (same path as the manual
+SliceModal), so the ``Slicing X — Generating G-code 75%`` toast renders
+end-to-end. The slice job's id rides on the run response so the frontend
+can call ``trackJob`` directly.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, desc, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.pipeline_run import (
+    CheckEligibilityRequest,
+    EligibilityIssueResponse,
+    EligibilityReportResponse,
+    PerPrinterReport as PerPrinterReportResponse,
+    PipelineJobResponse,
+    PipelineRunCreateRequest,
+    PipelineRunListResponse,
+    PipelineRunResponse,
+)
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.services.pipeline_eligibility import (
+    EligibilityReport,
+    check_pipeline_eligibility,
+)
+
+logger = logging.getLogger(__name__)
+
+
+pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
+    return EligibilityReportResponse(
+        ok=report.ok,
+        target_kind=report.target_kind,
+        target_printer_id=report.target_printer_id,
+        target_printer_name=report.target_printer_name,
+        target_model_class=report.target_model_class,
+        issues=[
+            EligibilityIssueResponse(
+                kind=issue.kind,
+                slot_index=issue.slot_index,
+                expected=issue.expected,
+                actual=issue.actual,
+            )
+            for issue in report.issues
+        ],
+        printer_reports=[
+            PerPrinterReportResponse(
+                printer_id=r.printer_id,
+                printer_name=r.printer_name,
+                ok=r.ok,
+                issues=[
+                    EligibilityIssueResponse(
+                        kind=i.kind,
+                        slot_index=i.slot_index,
+                        expected=i.expected,
+                        actual=i.actual,
+                    )
+                    for i in r.issues
+                ],
+            )
+            for r in report.printer_reports
+        ],
+    )
+
+
+async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
+    pipeline = (
+        await db.execute(
+            select(SlicerPipeline).where(
+                SlicerPipeline.id == pipeline_id,
+                SlicerPipeline.is_deleted.is_(False),
+            )
+        )
+    ).scalar_one_or_none()
+    if pipeline is None:
+        raise HTTPException(404, "Pipeline not found")
+    return pipeline
+
+
+async def _load_printer_status(printer_id: int | None) -> dict | None:
+    """Snapshot the printer_manager's live PrinterState for the eligibility
+    matcher. Returns ``None`` when the printer has no MQTT client."""
+    if printer_id is None:
+        return None
+    from backend.app.services.printer_manager import printer_manager
+
+    state = printer_manager.get_status(printer_id)
+    if state is None:
+        return None
+    return {"connected": state.connected, "raw_data": state.raw_data}
+
+
+def _make_status_lookup():
+    """Closure that snapshots the printer_manager once per printer_id call.
+    Passed to the matcher's class-targeting branch so it can read live state
+    for every candidate printer."""
+
+    def _lookup(printer_id: int) -> dict | None:
+        from backend.app.services.printer_manager import printer_manager
+
+        state = printer_manager.get_status(printer_id)
+        if state is None:
+            return None
+        return {"connected": state.connected, "raw_data": state.raw_data}
+
+    return _lookup
+
+
+def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+    try:
+        raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        raw_filaments = []
+    filament_presets = [
+        PresetRef(source=r["source"], id=r["id"])
+        for r in raw_filaments
+        if isinstance(r, dict) and "source" in r and "id" in r
+    ]
+    return SliceRequest(
+        printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
+        process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=pipeline.bed_type,
+        export_3mf=True,
+    )
+
+
+def _compute_job_status(
+    persisted: str,
+    queue_entry: PrintQueueItem | None,
+) -> str:
+    if persisted in ("failed", "cancelled", "completed"):
+        return persisted
+    if queue_entry is None:
+        return persisted
+    qs = queue_entry.status
+    if qs == "completed":
+        return "completed"
+    if qs in ("failed", "aborted"):
+        return "failed"
+    if qs == "cancelled":
+        return "cancelled"
+    if qs == "printing":
+        return "printing"
+    return "queued"
+
+
+def _roll_up_run_status(
+    persisted: str,
+    job_statuses: list[str],
+) -> str:
+    """Compute the run-level status from the per-job statuses.
+
+    Terminal-persisted always wins for explicit cancels / hard failures so
+    the dashboard doesn't flicker when one job's queue entry hasn't caught
+    up. Otherwise:
+      - all completed → completed
+      - any in_progress / printing / queued / dispatching → in_progress
+      - any failed alongside any completed → partial_failure
+      - all failed/cancelled → failed
+    """
+    if persisted in ("cancelled",):
+        return persisted
+    if not job_statuses:
+        return persisted
+
+    completed = sum(1 for s in job_statuses if s == "completed")
+    failed = sum(1 for s in job_statuses if s == "failed")
+    cancelled = sum(1 for s in job_statuses if s == "cancelled")
+    in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
+    total = len(job_statuses)
+
+    if completed == total:
+        return "completed"
+    if in_flight > 0:
+        return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
+    # All copies are in terminal states.
+    if failed == 0 and cancelled == total:
+        return "cancelled"
+    if completed > 0 and (failed > 0 or cancelled > 0):
+        return "partial_failure"
+    if failed > 0:
+        return "failed"
+    return persisted
+
+
+async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
+    pipeline_name: str | None = None
+    target_kind = None
+    target_printer_id = None
+    target_model_class = None
+    fanout_strategy = None
+    if run.pipeline_id:
+        pipeline = (
+            await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
+        ).scalar_one_or_none()
+        if pipeline:
+            pipeline_name = pipeline.name
+            target_kind = pipeline.target_kind  # type: ignore[assignment]
+            target_printer_id = pipeline.target_printer_id
+            target_model_class = pipeline.target_model_class
+            fanout_strategy = pipeline.fanout_strategy  # type: ignore[assignment]
+
+    source_filename: str | None = None
+    if run.source_library_file_id:
+        src = (
+            await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
+        ).scalar_one_or_none()
+        source_filename = src.filename if src else None
+    elif run.source_archive_id:
+        arc = (
+            await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
+        ).scalar_one_or_none()
+        source_filename = (arc.print_name or arc.filename) if arc else None
+
+    job_rows = (
+        (
+            await db.execute(
+                select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id).order_by(PipelineJob.copy_index)
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    job_responses: list[PipelineJobResponse] = []
+    job_live_statuses: list[str] = []
+    for job in job_rows:
+        queue_entry = None
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+
+        printer_name: str | None = None
+        if job.assigned_printer_id:
+            p = (await db.execute(select(Printer).where(Printer.id == job.assigned_printer_id))).scalar_one_or_none()
+            printer_name = p.name if p else None
+
+        live_job_status = _compute_job_status(job.status, queue_entry)
+        # If the job WAS dispatched (had a queue_entry_id) but the entry has
+        # since been deleted from the queue page, the user's intent was
+        # cancellation. Otherwise the run would stay forever showing as
+        # ``queued`` because the persisted job.status hasn't been updated.
+        if (
+            job.queue_entry_id is not None
+            and queue_entry is None
+            and live_job_status not in ("completed", "failed", "cancelled")
+        ):
+            live_job_status = "cancelled"
+        job_live_statuses.append(live_job_status)
+        job_responses.append(
+            PipelineJobResponse(
+                id=job.id,
+                pipeline_run_id=job.pipeline_run_id,
+                copy_index=job.copy_index,
+                assigned_printer_id=job.assigned_printer_id,
+                assigned_printer_name=printer_name,
+                queue_entry_id=job.queue_entry_id,
+                status=live_job_status,  # type: ignore[arg-type]
+                error_message=job.error_message,
+                dispatched_at=job.dispatched_at,
+                completed_at=job.completed_at,
+            )
+        )
+
+    rolled_up = _roll_up_run_status(run.status, job_live_statuses)
+
+    return PipelineRunResponse(
+        id=run.id,
+        pipeline_id=run.pipeline_id,
+        pipeline_name=pipeline_name,
+        source_library_file_id=run.source_library_file_id,
+        source_archive_id=run.source_archive_id,
+        source_filename=source_filename,
+        parent_run_id=run.parent_run_id,
+        copies=run.copies,
+        copies_completed=sum(1 for s in job_live_statuses if s == "completed"),
+        copies_failed=sum(1 for s in job_live_statuses if s == "failed"),
+        copies_cancelled=sum(1 for s in job_live_statuses if s == "cancelled"),
+        copies_in_progress=sum(
+            1 for s in job_live_statuses if s in ("printing", "queued", "awaiting_printer", "pending")
+        ),
+        status=rolled_up,  # type: ignore[arg-type]
+        slice_job_id=run.slice_job_id,
+        sliced_library_file_id=run.sliced_library_file_id,
+        eligibility_overridden=run.eligibility_overridden,
+        error_message=run.error_message,
+        created_by=run.created_by,
+        created_at=run.created_at,
+        started_at=run.started_at,
+        completed_at=run.completed_at,
+        jobs=job_responses,
+        target_kind=target_kind,
+        target_printer_id=target_printer_id,
+        target_model_class=target_model_class,
+        fanout_strategy=fanout_strategy,
+    )
+
+
+async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
+    """Broadcast a ``pipeline_run_updated`` event with the full materialised
+    run. Per-user routing via ``broadcast_to_user`` falls back to a global
+    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    try:
+        payload = await _materialise_run(db, run)
+        await ws_manager.broadcast_to_user(
+            run.created_by,
+            {
+                "type": "pipeline_run_updated",
+                "run": payload.model_dump(mode="json"),
+            },
+        )
+    except Exception:
+        logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
+
+
+# ---------------------------------------------------------------------------
+# Source resolution + orchestration
+# ---------------------------------------------------------------------------
+
+
+SourceKind = Literal["library_file", "archive"]
+
+
+async def _resolve_source(
+    db: AsyncSession,
+    *,
+    library_file_id: int | None,
+    archive_id: int | None,
+    user: User | None,
+) -> tuple[SourceKind, int, str, Path]:
+    # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
+    # source they can see. Without this a READ_OWN caller could reference
+    # another user's library file / archive by raw id and have it sliced (and,
+    # via /run, printed) even though a direct GET on that id returned 404.
+    # Auth-disabled and API-key callers (user is None) keep can_read_all=True —
+    # no per-row identity, matching the library/archive read helpers.
+    from backend.app.api.routes.archives import _ensure_archive_visible
+    from backend.app.api.routes.library import _ensure_library_file_visible
+
+    if library_file_id is not None:
+        lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
+        can_read_all = user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
+        lib = _ensure_library_file_visible(lib, user, can_read_all)
+        src_path = (
+            Path(app_settings.base_dir) / lib.file_path
+        )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
+        if not src_path.exists():
+            raise HTTPException(404, "Source library file missing on disk")
+        return ("library_file", lib.id, lib.filename, src_path)
+
+    assert archive_id is not None
+    arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
+    arc = _ensure_archive_visible(arc, user, can_read_all)
+    rel = arc.source_3mf_path or arc.file_path
+    if not rel:
+        raise HTTPException(400, "Archive has no source file to slice")
+    src_path = (
+        Path(app_settings.base_dir) / rel
+    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
+    if not src_path.exists():
+        raise HTTPException(404, "Archive source file missing on disk")
+    name = arc.filename or arc.print_name or src_path.name
+    return ("archive", arc.id, name, src_path)
+
+
+async def _pick_assignments(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    copies: int,
+) -> list[tuple[int | None, str | None]]:
+    """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
+    ``copies`` per the pipeline's fanout strategy. ``target_model_class``
+    items leave ``printer_id`` None so the scheduler picks any free matching
+    printer; specific assignments fill ``printer_id``."""
+    target_kind = pipeline.target_kind or "specific_printer"
+    if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
+        assert pipeline.target_printer_id is not None
+        return [(pipeline.target_printer_id, None)] * copies
+
+    # Class-targeting. Enumerate matching printers + apply the strategy.
+    matching = (
+        (
+            await db.execute(
+                select(Printer)
+                .where(Printer.model == pipeline.target_model_class)
+                .where(Printer.is_active.is_(True))
+                .order_by(Printer.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    if not matching:
+        # Shouldn't reach here when eligibility passes, but failing gracefully
+        # is better than a TypeError on next-slot pick.
+        return [(None, pipeline.target_model_class)] * copies
+
+    strategy = pipeline.fanout_strategy or "max_parallel"
+    if strategy == "fill_one_first":
+        # Pin every copy to the first match. Scheduler dispatches them serially
+        # to that printer. If the printer breaks, copies wait; that's the
+        # documented trade-off.
+        return [(matching[0].id, None)] * copies
+    if strategy == "round_robin":
+        # Cycle through eligible printers — copy ``i`` lands on
+        # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
+        return [(matching[i % len(matching)].id, None) for i in range(copies)]
+    # max_parallel — leave printer_id=None, set target_model so the scheduler
+    # picks any free X1C / P1S / … for each item independently.
+    return [(None, pipeline.target_model_class)] * copies
+
+
+def _make_orchestration_callable(
+    *,
+    run_id: int,
+    pipeline_id: int,
+    src_kind: SourceKind,
+    src_id: int,
+    src_filename: str,
+    src_path: Path,
+    creator_user_id: int | None,
+    copies: int,
+):
+    """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
+    background slice job. Wraps slice + multi-copy enqueue + state update."""
+
+    async def _orchestrate(slice_job_id: int) -> dict:
+        from backend.app.api.routes.library import slice_and_persist
+
+        async with async_session() as session:
+            run = (await session.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+            pipeline = (
+                await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
+            ).scalar_one_or_none()
+            if run is None or pipeline is None:
+                logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
+                return {}
+
+            # Honour a cancel that landed between ``POST /run`` returning and
+            # this background task starting. If the run was cancelled while
+            # still in ``queued`` we must NOT flip it back to ``slicing`` —
+            # the operator's intent was to stop, and overwriting status here
+            # was the bug that left runs stuck at ``dispatching`` after a
+            # user-side cancel (#1425 PR C bug report).
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d was cancelled before slicing started", run_id)
+                return {}
+
+            run.status = "slicing"
+            run.started_at = datetime.now(timezone.utc)
+            await session.commit()
+            await _publish_run_event(session, run)
+
+            slice_request = _slice_request_from_pipeline(pipeline)
+            model_bytes = src_path.read_bytes()
+
+            folder_id: int | None = None
+            if src_kind == "library_file":
+                lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
+                if lib is not None:
+                    folder_id = lib.folder_id
+
+            try:
+                slice_response = await slice_and_persist(
+                    session,
+                    model_bytes=model_bytes,
+                    model_filename=src_filename,
+                    folder_id=folder_id,
+                    extra_metadata={
+                        f"sliced_from_{src_kind}_id": src_id,
+                        "sliced_via_pipeline_id": pipeline.id,
+                        "sliced_via_pipeline_run_id": run.id,
+                    },
+                    request=slice_request,
+                    current_user_id=creator_user_id,
+                    job_id=slice_job_id,
+                )
+            except HTTPException as exc:
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc.detail}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                raise
+            except Exception as exc:
+                logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                raise
+
+            run.sliced_library_file_id = slice_response.library_file_id
+
+            # Re-check cancellation: the slice can take minutes, and the
+            # operator may have hit Cancel during that window. Refresh from
+            # the DB rather than trusting our in-memory `run` (the cancel
+            # route writes via a separate session). When cancelled, don't
+            # enqueue print queue items — that's the whole point of cancel.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
+                await session.commit()
+                return slice_response.model_dump()
+
+            # PR C: enqueue N copies per the picked assignment strategy.
+            assignments = await _pick_assignments(session, pipeline, copies)
+
+            jobs = (
+                (
+                    await session.execute(
+                        select(PipelineJob)
+                        .where(PipelineJob.pipeline_run_id == run_id)
+                        .order_by(PipelineJob.copy_index)
+                    )
+                )
+                .scalars()
+                .all()
+            )
+            if len(jobs) != copies:
+                logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
+
+            for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
+                queue_item = PrintQueueItem(
+                    printer_id=printer_id,
+                    target_model=target_model,
+                    library_file_id=slice_response.library_file_id,
+                    created_by_id=creator_user_id,
+                    status="pending",
+                )
+                session.add(queue_item)
+                await session.flush()
+
+                job.queue_entry_id = queue_item.id
+                job.assigned_printer_id = printer_id  # may be None for max_parallel
+                # Don't write job.status yet — final cancellation check below
+                # may flip it to 'cancelled' instead. dispatched_at is fine to
+                # set unconditionally since the orchestration actually got here.
+                job.dispatched_at = datetime.now(timezone.utc)
+
+            # Final cancellation check before committing 'dispatching'. The
+            # cancel route writes via a separate session so we have to refresh
+            # to see the latest. If the cancel landed in this narrow window —
+            # AFTER the post-slice refresh but BEFORE this commit — the queue
+            # entries we just created would otherwise pick up and print. Mark
+            # them + the per-copy jobs cancelled so the user's intent sticks.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info(
+                    "pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
+                    run_id,
+                    len(jobs),
+                )
+                for job in jobs:
+                    if job.queue_entry_id:
+                        qe = (
+                            await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+                        ).scalar_one_or_none()
+                        if qe is not None and qe.status in ("pending", "queued"):
+                            qe.status = "cancelled"
+                    if job.status not in ("completed", "failed", "cancelled"):
+                        job.status = "cancelled"
+                        job.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                return slice_response.model_dump()
+
+            for job in jobs:
+                job.status = "queued"
+            run.status = "dispatching"
+            await session.commit()
+            await _publish_run_event(session, run)
+
+            return slice_response.model_dump()
+
+    return _orchestrate
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/check-eligibility
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/check-eligibility", response_model=EligibilityReportResponse)
+async def check_eligibility(
+    pipeline_id: int,
+    body: CheckEligibilityRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    pipeline = await _load_pipeline(db, pipeline_id)
+    await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+        user=current_user,
+    )
+    if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
+        report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+    return _serialise_status(report)
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/run
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/run", response_model=PipelineRunResponse, status_code=202)
+async def run_pipeline(
+    pipeline_id: int,
+    body: PipelineRunCreateRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.slice_dispatch import slice_dispatch
+
+    pipeline = await _load_pipeline(db, pipeline_id)
+    src_kind, src_id, src_filename, src_path = await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+        user=current_user,
+    )
+
+    # Cap copies against the configured ceiling.
+    raw_cap = await get_setting(db, "pipeline_max_copies")
+    try:
+        cap = int(raw_cap) if raw_cap else 50
+    except (TypeError, ValueError):
+        cap = 50
+    if body.copies > cap:
+        raise HTTPException(
+            422,
+            f"copies={body.copies} exceeds pipeline_max_copies setting ({cap})",
+        )
+
+    # Eligibility pre-flight.
+    if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
+        report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+
+    if not report.ok and not body.force:
+        raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
+
+    # Need a target — specific or class — to dispatch.
+    if pipeline.target_printer_id is None and not pipeline.target_model_class:
+        raise HTTPException(
+            400,
+            "Pipeline has no target. Open the pipeline in Settings → Workflow → Pipelines and choose a target printer or printer class.",
+        )
+
+    run = PipelineRun(
+        pipeline_id=pipeline.id,
+        source_library_file_id=src_id if src_kind == "library_file" else None,
+        source_archive_id=src_id if src_kind == "archive" else None,
+        copies=body.copies,
+        status="queued",
+        eligibility_overridden=(not report.ok and body.force),
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(run)
+    await db.flush()
+
+    # One PipelineJob per copy. PR B was copies=1, PR C generalises.
+    for i in range(body.copies):
+        db.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=i,
+                status="pending",
+            )
+        )
+    await db.commit()
+    await db.refresh(run)
+    await _publish_run_event(db, run)
+
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind=src_kind,
+        src_id=src_id,
+        src_filename=src_filename,
+        src_path=src_path,
+        creator_user_id=current_user.id if current_user else None,
+        copies=body.copies,
+    )
+    slice_job = await slice_dispatch.enqueue(
+        kind="library_file" if src_kind == "library_file" else "archive",
+        source_id=src_id,
+        source_name=src_filename,
+        owner_id=current_user.id if current_user else None,
+        run=orchestrate,
+    )
+
+    run.slice_job_id = slice_job.id
+    await db.commit()
+    await db.refresh(run)
+
+    return await _materialise_run(db, run)
+
+
+# ---------------------------------------------------------------------------
+# Lists, reads, cancel, retry-failed
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
+async def list_runs_for_pipeline(
+    pipeline_id: int,
+    limit: int = 10,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    limit = max(1, min(limit, 100))
+    rows = (
+        (
+            await db.execute(
+                select(PipelineRun)
+                .where(PipelineRun.pipeline_id == pipeline_id)
+                .order_by(PipelineRun.id.desc())
+                .limit(limit)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    total = (
+        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
+    ).scalar() or 0
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+@pipeline_run_router.get("", response_model=PipelineRunListResponse)
+async def list_all_runs(
+    limit: int = 25,
+    offset: int = 0,
+    pipeline_id: int | None = None,
+    status: str | None = None,
+    target_printer_id: int | None = None,
+    target_model_class: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Dashboard list. Newest first; filters on pipeline_id + status +
+    target_printer_id + target_model_class. The ``status`` filter matches
+    the persisted snapshot, not the live roll-up — in-progress runs may
+    appear under ``dispatching`` until the next state transition writes
+    through. ``target_*`` filters JOIN to the pipeline so runs whose
+    pipeline currently points at the printer / class are returned."""
+    limit = max(1, min(limit, 100))
+    offset = max(0, offset)
+
+    stmt = select(PipelineRun)
+    count_stmt = select(func.count()).select_from(PipelineRun)
+    if pipeline_id is not None:
+        stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
+        count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
+    if status:
+        stmt = stmt.where(PipelineRun.status == status)
+        count_stmt = count_stmt.where(PipelineRun.status == status)
+    if target_printer_id is not None or target_model_class is not None:
+        stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        if target_printer_id is not None:
+            stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+            count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+        if target_model_class is not None:
+            stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
+            count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
+
+    rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
+    total = (await db.execute(count_stmt)).scalar() or 0
+
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
+
+
+@pipeline_run_router.post("/clear")
+async def clear_terminal_runs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete every terminal pipeline run (completed / failed / cancelled /
+    partial_failure). In-flight runs (queued / slicing / dispatching /
+    in_progress) are preserved — clearing those mid-flight would lose the
+    operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
+    relationship; the linked PrintQueueItem rows stay (they have their own
+    lifecycle on the queue page)."""
+    # Count first so the response can report how many got cleared. Done
+    # under the same session/transaction as the delete so the numbers can't
+    # drift if another caller races in.
+    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    n = (await db.execute(count_stmt)).scalar() or 0
+    if n > 0:
+        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.commit()
+    return {"deleted": n}
+
+
+@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
+async def get_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
+async def cancel_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Cancel a queued / in-flight run. Cascades to all non-terminal queue
+    entries; in-flight prints continue on the printer (operator must Stop)."""
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+
+    if run.status in ("completed", "failed", "cancelled", "partial_failure"):
+        return await _materialise_run(db, run)
+
+    run.status = "cancelled"
+    run.completed_at = datetime.now(timezone.utc)
+    if not run.error_message:
+        run.error_message = "Cancelled by user"
+
+    job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
+    for job in job_rows:
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+            if queue_entry is not None and queue_entry.status in ("pending", "queued"):
+                queue_entry.status = "cancelled"
+        if job.status not in ("completed", "failed", "cancelled"):
+            job.status = "cancelled"
+            job.completed_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    await db.refresh(run)
+    await _publish_run_event(db, run)
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
+async def retry_failed(
+    run_id: int,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new run with copies = (failed + cancelled count) from the
+    parent. Same pipeline, same source. Eligibility re-checked at run time
+    (it might pass this time — operator may have fixed the issue)."""
+    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if parent is None:
+        raise HTTPException(404, "Pipeline run not found")
+    if parent.pipeline_id is None:
+        raise HTTPException(400, "Original pipeline was deleted; cannot retry")
+    if parent.source_library_file_id is None and parent.source_archive_id is None:
+        raise HTTPException(400, "Original source was deleted; cannot retry")
+
+    # Count the parent's failed + cancelled jobs.
+    parent_jobs = (
+        (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
+    )
+    fail_count = 0
+    for j in parent_jobs:
+        queue_entry = None
+        if j.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
+            ).scalar_one_or_none()
+        live = _compute_job_status(j.status, queue_entry)
+        if live in ("failed", "cancelled"):
+            fail_count += 1
+
+    if fail_count == 0:
+        raise HTTPException(400, "No failed copies to retry")
+
+    # Build the request payload the same way the user would have via /run.
+    body = PipelineRunCreateRequest(
+        source_library_file_id=parent.source_library_file_id,
+        source_archive_id=parent.source_archive_id,
+        copies=fail_count,
+        force=True,  # operator already accepted eligibility on the parent
+    )
+
+    # Reuse the run_pipeline route logic via a direct call — keeps the
+    # orchestration single-sourced. The result inherits parent_run_id.
+    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+
+    # Stamp parent_run_id on the freshly-created run.
+    new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
+    if new_row is not None:
+        new_row.parent_run_id = parent.id
+        await db.commit()
+        await db.refresh(new_row)
+        return await _materialise_run(db, new_row)
+    return new_run_response

+ 280 - 85
backend/app/api/routes/print_queue.py

@@ -8,7 +8,7 @@ from pathlib import Path
 
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import and_, func, or_, select
+from sqlalchemy import and_, func, or_, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -16,7 +16,6 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_owners
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch
@@ -36,12 +35,16 @@ from backend.app.schemas.print_queue import (
     PrintQueueReorder,
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
+from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.finance_budget import validate_print_budget
 from backend.app.services.notification_service import notification_service
-from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+from backend.app.utils.printer_models import (
+    is_gcode_compatible,
+    normalize_printer_model,
+    normalize_printer_model_id,
+)
 from backend.app.utils.threemf_tools import (
-    extract_bed_type_from_3mf,
-    extract_filament_usage_from_3mf,
+    extract_plate_metadata_from_3mf,
     extract_print_time_from_3mf,
 )
 
@@ -117,6 +120,22 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
 _extract_print_time_from_3mf = extract_print_time_from_3mf
 
 
+async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path | None:
+    """Resolve an existing queue item's source 3MF on disk, or None."""
+    if item.archive_id:
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+        archive = result.scalar_one_or_none()
+        if archive:
+            return settings.base_dir / archive.file_path
+    elif item.library_file_id:
+        result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
+        library_file = result.scalar_one_or_none()
+        if library_file:
+            lib_path = Path(library_file.file_path)
+            return lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+    return None
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -154,6 +173,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             nozzle_mapping_parsed = None
 
+    nozzles_info_parsed = None
+    if item.nozzles_info:
+        try:
+            nozzles_info_parsed = json.loads(item.nozzles_info)
+        except json.JSONDecodeError:
+            nozzles_info_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
@@ -183,6 +209,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "timelapse": item.timelapse,
         "use_ams": item.use_ams,
         "nozzle_offset_cali": item.nozzle_offset_cali,
+        "preheat_override": item.preheat_override,
+        "preheat_chamber_target_override": item.preheat_chamber_target_override,
         "status": item.status,
         "started_at": item.started_at,
         "completed_at": item.completed_at,
@@ -200,6 +228,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "gcode_injection": item.gcode_injection,
         # H2C rack-swap nozzle pick (#1780)
         "nozzle_mapping": nozzle_mapping_parsed,
+        "nozzles_info": nozzles_info_parsed,
+        "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -226,17 +256,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
-                    plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
-                    plate_weight = sum(
-                        f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
-                    )
-                    plate_bed = extract_bed_type_from_3mf(archive_path, item.plate_id)
-                    if plate_time is not None:
-                        response.print_time_seconds = plate_time
-                    if plate_weight > 0:
-                        response.filament_used_grams = plate_weight
-                    if plate_bed:
-                        response.bed_type = plate_bed
+                    # One cached parse for all three per-plate overrides (#2573).
+                    plate_meta = extract_plate_metadata_from_3mf(archive_path, item.plate_id)
+                    if plate_meta.print_time_seconds is not None:
+                        response.print_time_seconds = plate_meta.print_time_seconds
+                    if plate_meta.filament_used_grams > 0:
+                        response.filament_used_grams = plate_meta.filament_used_grams
+                    if plate_meta.bed_type:
+                        response.bed_type = plate_meta.bed_type
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -258,17 +285,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
-                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
-                plate_weight = sum(
-                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
-                )
-                plate_bed = extract_bed_type_from_3mf(library_file_path, item.plate_id)
-                if plate_time is not None:
-                    response.print_time_seconds = plate_time
-                if plate_weight > 0:
-                    response.filament_used_grams = plate_weight
-                if plate_bed:
-                    response.bed_type = plate_bed
+                # One cached parse for all three per-plate overrides (#2573).
+                plate_meta = extract_plate_metadata_from_3mf(library_file_path, item.plate_id)
+                if plate_meta.print_time_seconds is not None:
+                    response.print_time_seconds = plate_meta.print_time_seconds
+                if plate_meta.filament_used_grams > 0:
+                    response.filament_used_grams = plate_meta.filament_used_grams
+                if plate_meta.bed_type:
+                    response.bed_type = plate_meta.bed_type
     if item.printer:
         response.printer_name = item.printer.name
     return response
@@ -400,6 +424,23 @@ async def add_to_queue(
             and archive.created_by_id != current_user.id
         ):
             raise HTTPException(404, "Archive not found")
+        # Reprint perm gate (#1625): the legacy /archives/{id}/reprint endpoint
+        # required ARCHIVES_REPRINT_OWN/ALL; the unified queue route must keep
+        # that gate or an operator with QUEUE_CREATE could reprint via direct
+        # API call even if explicitly denied reprint perm. Mirrors the
+        # frontend `canModify('archives', 'reprint', ...)` helper:
+        # REPRINT_ALL allows any archive, REPRINT_OWN allows own only,
+        # ownerless archives require REPRINT_ALL (fail-closed).
+        if current_user is not None:
+            owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
+            has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
+                owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
+            )
+            if not has_reprint:
+                raise HTTPException(
+                    status_code=403,
+                    detail="Permission archives:reprint_own or archives:reprint_all required",
+                )
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
@@ -425,11 +466,27 @@ async def add_to_queue(
         except InvalidFilenameError as e:
             raise HTTPException(400, str(e)) from e
 
+    # Cross-model safety gate (#2578): a G-code 3MF sliced for one model must
+    # not be queued for dispatch to an incompatible model. The UI can no longer
+    # produce such rows, but API-created rows must be rejected here too — the
+    # scheduler assigns model-based items to hardware with no human in the loop.
+    if target_model_norm:
+        sliced_for = None
+        if archive:
+            sliced_for = archive.sliced_for_model
+        elif library_file and library_file.file_metadata:
+            sliced_for = library_file.file_metadata.get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, target_model_norm):
+            raise HTTPException(
+                400,
+                f"File was sliced for {sliced_for} and cannot be dispatched to {target_model_norm} printers",
+            )
+
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None
+    file_path = None
     if target_model_norm:
         # Get file path from archive or library file
-        file_path = None
         if archive:
             file_path = settings.base_dir / archive.file_path
         elif library_file:
@@ -445,15 +502,17 @@ async def add_to_queue(
     # If filament overrides are provided, update required_filament_types to match override types
     filament_overrides_json = None
     if data.filament_overrides and target_model_norm:
-        filament_overrides_json = json.dumps(data.filament_overrides)
-        # Update required_filament_types from overrides so scheduler validates against overridden types
-        override_types = sorted({o["type"] for o in data.filament_overrides if "type" in o})
-        if override_types:
-            # Merge with existing types (overrides may only cover some slots)
-            existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
-            # Replace types for overridden slots, keep others
-            all_types = existing_types | set(override_types)
-            required_filament_types = json.dumps(sorted(all_types))
+        plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
+        if plate_overrides:
+            filament_overrides_json = json.dumps(plate_overrides)
+            # Update required_filament_types from overrides so scheduler validates against overridden types
+            override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
+            if override_types:
+                # Merge with existing types (overrides may only cover some slots)
+                existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
+                # Replace types for overridden slots, keep others
+                all_types = existing_types | set(override_types)
+                required_filament_types = json.dumps(sorted(all_types))
 
     # Validate quantity
     quantity = max(1, data.quantity)
@@ -505,21 +564,59 @@ async def add_to_queue(
         await db.flush()  # Get batch.id before creating items
         batch_id = batch.id
 
-    # Get next position for this printer (or for unassigned/model-based items)
+    # Get queue scope for this printer (or for unassigned/model-based items).
     if data.printer_id is not None:
-        result = await db.execute(
-            select(func.max(PrintQueueItem.position))
-            .where(PrintQueueItem.printer_id == data.printer_id)
-            .where(PrintQueueItem.status == "pending")
+        queue_scope = (
+            PrintQueueItem.printer_id == data.printer_id,
+            PrintQueueItem.status == "pending",
         )
     else:
-        # For unassigned/model-based items, get max position across all unassigned
-        result = await db.execute(
-            select(func.max(PrintQueueItem.position))
-            .where(PrintQueueItem.printer_id.is_(None))
-            .where(PrintQueueItem.status == "pending")
+        # For unassigned/model-based items, scope across all unassigned.
+        queue_scope = (
+            PrintQueueItem.printer_id.is_(None),
+            PrintQueueItem.status == "pending",
+        )
+
+    # Serialize concurrent queue inserts to the same scope (#1625-followup).
+    # The race: two concurrent ASAP inserts both compute MAX(position) before
+    # either commits; in an empty scope, both INSERT at position 1 (duplicate).
+    # In a non-empty scope, Postgres's row-level locks on the UPDATE shift
+    # serialize naturally, but the empty-scope path has no rows to lock.
+    # A transaction-scoped advisory lock keyed on the printer_id closes that
+    # window; the lock is released automatically at commit/rollback. Different
+    # printers don't contend. SQLite serializes writes implicitly so this is a
+    # no-op there.
+    #
+    # Dialect is checked against the actual session binding, NOT the
+    # `is_sqlite()` helper, because the test fixture overrides `get_db` with a
+    # SQLite engine while `settings.database_url` still points at Postgres
+    # (the helper reads settings). Inspecting the connection directly is the
+    # right shape for any code that mutates SQL based on the live dialect.
+    from sqlalchemy import text
+
+    bind = db.get_bind()
+    if bind.dialect.name == "postgresql":
+        scope_key = data.printer_id if data.printer_id is not None else 0
+        # 1625 namespaces the lock so it can't collide with other advisory
+        # locks elsewhere in the codebase.
+        await db.execute(text("SELECT pg_advisory_xact_lock(1625, :k)"), {"k": scope_key})
+
+    insert_position = max(1, data.insert_position or 1)
+    if data.insert_at_top or data.insert_position is not None:
+        result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
+        max_pos = result.scalar() or 0
+        insert_position = min(insert_position, max_pos + 1)
+        await db.execute(
+            update(PrintQueueItem)
+            .where(*queue_scope)
+            .where(PrintQueueItem.position >= insert_position)
+            .values(position=PrintQueueItem.position + quantity)
         )
-    max_pos = result.scalar() or 0
+        start_position = insert_position
+    else:
+        result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
+        max_pos = result.scalar() or 0
+        start_position = max_pos + 1
 
     # Resolve print_time_seconds for SJF scheduling (cache on item at creation)
     cached_print_time = None
@@ -583,9 +680,12 @@ async def add_to_queue(
             timelapse=data.timelapse,
             use_ams=data.use_ams,
             nozzle_offset_cali=data.nozzle_offset_cali,
+            preheat_override=data.preheat_override,
+            preheat_chamber_target_override=data.preheat_chamber_target_override,
             gcode_injection=data.gcode_injection,
+            cleanup_library_after_dispatch=data.cleanup_library_after_dispatch,
             project_id=data.project_id,
-            position=max_pos + 1 + i,
+            position=start_position + i,
             status="pending",
             created_by_id=current_user.id if current_user else None,
             batch_id=batch_id,
@@ -688,7 +788,10 @@ async def bulk_update_queue_items(
     validates_billing_fields = "cost_center_id" in update_data or "estimated_cost" in update_data
 
     for item in items:
-        if item.status != "pending":
+        # Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
+        # editing a claimed row mid-upload would split it from the in-flight
+        # dispatch, so it's excluded from the bulk change (cancel to move it).
+        if item.status != "pending" or item.dispatching_at is not None:
             skipped_count += 1
             continue
 
@@ -1005,6 +1108,14 @@ async def update_queue_item(
     if item.status != "pending":
         raise HTTPException(400, "Can only update pending items")
 
+    # Dispatch claim (#2615): the row is pending but a scheduler worker has
+    # already claimed it and is uploading to its printer. Editing now (e.g.
+    # reassigning printer_id) would split the queue row from the in-flight
+    # archive/expected-print/physical command. Reject until dispatch finishes;
+    # to move it, cancel first (the coordinated escape) and re-queue.
+    if item.dispatching_at is not None:
+        raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
+
     update_data = data.model_dump(exclude_unset=True)
 
     # Normalize target_model if being updated
@@ -1035,14 +1146,45 @@ async def update_queue_item(
         if not result.scalars().first():
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
 
+        # Cross-model safety gate (#2578) — same check as the create route, so
+        # a mismatched target can't be introduced by editing either.
+        sliced_for = None
+        if item.archive_id:
+            result = await db.execute(select(PrintArchive.sliced_for_model).where(PrintArchive.id == item.archive_id))
+            sliced_for = result.scalar_one_or_none()
+        elif item.library_file_id:
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            lib = result.scalar_one_or_none()
+            if lib and lib.file_metadata:
+                sliced_for = lib.file_metadata.get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, update_data["target_model"]):
+            raise HTTPException(
+                400,
+                f"File was sliced for {sliced_for} and cannot be dispatched to {update_data['target_model']} printers",
+            )
+
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
 
-    # Serialize filament_overrides to JSON for TEXT column storage
+    # Serialize filament_overrides to JSON for TEXT column storage, keeping only
+    # the slots this item's plate actually prints (#2551 — same shared-override
+    # list the create path narrows).
     if "filament_overrides" in update_data:
-        update_data["filament_overrides"] = (
-            json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
+        overrides = update_data["filament_overrides"]
+        if overrides:
+            overrides = overrides_for_plate(
+                overrides,
+                await _resolve_source_path(db, item),
+                update_data.get("plate_id", item.plate_id),
+            )
+        update_data["filament_overrides"] = json.dumps(overrides) if overrides else None
+
+    # Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
+    # storage; same Text-as-opaque-blob convention as ams_mapping above.
+    if "nozzle_mapping" in update_data:
+        update_data["nozzle_mapping"] = (
+            json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
 
     await validate_print_budget(
@@ -1053,6 +1195,16 @@ async def update_queue_item(
         exclude_queue_item_id=item.id,
     )
 
+    # Re-check the dispatch claim right before mutating (#2615). Several awaited
+    # validations ran since the guard above, and a scheduler worker may have
+    # claimed the row in that gap. A fresh read (item isn't dirty yet, so no
+    # autoflush races the check) narrows the window to effectively nothing.
+    claimed = (
+        await db.execute(select(PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item_id))
+    ).scalar_one_or_none()
+    if claimed is not None:
+        raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
+
     for field, value in update_data.items():
         setattr(item, field, value)
 
@@ -1212,19 +1364,37 @@ async def cancel_queue_item(
 async def stop_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
-    """Stop an actively printing queue item."""
+    """Stop an actively printing queue item.
+
+    Ownership-scoped (#1625-followup): callers with QUEUE_UPDATE_OWN can stop
+    their own items; callers with QUEUE_UPDATE_ALL can stop any item. Mirrors
+    the /cancel shape. Pre-fix this required QUEUE_UPDATE_ALL — Operators
+    holding only _OWN saw the Stop button in the queue UI but got 403 on click.
+    """
 
-    from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.printer_manager import printer_manager
-    from backend.app.services.tasmota import tasmota_service
+
+    user, can_modify_all = auth_result
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     if not item:
         raise HTTPException(404, "Queue item not found")
 
+    # Ownership check — mirrors /cancel. Ownerless items (created_by_id IS NULL)
+    # require _ALL: stop is destructive and an _OWN holder can't claim "they
+    # own it" the way /start does (#1670).
+    if not can_modify_all and user is not None:
+        if item.created_by_id is None or item.created_by_id != user.id:
+            raise HTTPException(403, "You can only stop your own queue items")
+
     if item.status != "printing":
         raise HTTPException(400, f"Can only stop items that are printing, current status: '{item.status}'")
 
@@ -1256,35 +1426,39 @@ async def stop_queue_item(
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
-    await db.commit()
 
-    # Get smart plug info if auto-off is enabled
-    plug_ip = None
-    if auto_off_after:
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        plug = result.scalar_one_or_none()
-        if plug and plug.enabled:
-            plug_ip = plug.ip_address
-
-    logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
+    # Reconcile the linked archive when the printer is offline (#2603). When the
+    # stop command reaches the printer it later reports the stop over MQTT and
+    # on_print_complete flips the archive to cancelled/failed. When the printer is
+    # offline no such event ever arrives, so the archive would stay "printing"
+    # forever (queue row cancelled, archive still printing — the reporter's
+    # archive 436). Close it out here, mirroring what the MQTT path would have
+    # done. Only touch a still-"printing" archive so we never overwrite a real
+    # completion that raced in.
+    if not stop_sent and item.archive_id:
+        archive = await db.get(PrintArchive, item.archive_id)
+        if archive and archive.status == "printing":
+            archive.status = "cancelled"
+            archive.completed_at = datetime.now(timezone.utc)
+            archive.failure_reason = "Stopped by user (printer was offline)"
 
-    # Schedule background task for cooldown + power off
-    if plug_ip:
+    await db.commit()
 
-        async def cooldown_and_poweroff():
-            logger.info("Auto-off: Waiting for printer %s to cool down before power off...", printer_id)
-            await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
-            # Re-fetch plug since we're in a new async context
-            from backend.app.core.database import async_session
+    logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
 
-            async with async_session() as new_db:
-                result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                plug = result.scalar_one_or_none()
-                if plug and plug.enabled:
-                    logger.info("Auto-off: Powering off printer %s", printer_id)
-                    await tasmota_service.turn_off(plug)
+    # Schedule power-off if the queue item opted in. Delegates to the smart-plug
+    # manager so the off honours each plug's configured strategy (time delay or
+    # temperature threshold), is cancelled if the printer starts printing again,
+    # and never cuts power on a loaded print (#1890). Previously an inline block
+    # hardcoded a 50°C / 600s cooldown wait and powered off on the timeout
+    # regardless of print state.
+    if auto_off_after:
+        from backend.app.services.smart_plug_manager import smart_plug_manager
 
-        spawn_background_task(cooldown_and_poweroff(), name=f"queue-cooldown-poweroff-{printer_id}")
+        try:
+            await smart_plug_manager.schedule_off_after_queue_job(printer_id, db)
+        except Exception as e:
+            logger.warning("Auto-off: Failed to schedule power-off for printer %s: %s", printer_id, e)
 
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
 
@@ -1294,10 +1468,21 @@ async def start_queue_item(
     item_id: int,
     skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
     """Manually start a staged (manual_start) queue item.
 
+    Ownership-scoped (#1625-followup): callers with QUEUE_UPDATE_OWN can
+    start their own items + claim ownership of NULL-owner items (VP-uploaded
+    items arrive unattributed per #1670). Callers with QUEUE_UPDATE_ALL can
+    start any item. Pre-fix this required QUEUE_UPDATE_OWN with no ownership
+    check, so _OWN holders could start anyone's queue items via direct API.
+
     Clears the manual_start flag so the scheduler picks it up. When
     ``skip_filament_check`` is false (the default) the live filament
     deficit (#1496) is checked first — if the assigned spool can't satisfy
@@ -1305,6 +1490,8 @@ async def start_queue_item(
     payload so the caller can show a confirm dialog and retry with
     ``skip_filament_check=true``.
     """
+    user, can_modify_all = auth_result
+
     result = await db.execute(
         select(PrintQueueItem)
         .options(
@@ -1319,6 +1506,14 @@ async def start_queue_item(
     if not item:
         raise HTTPException(404, "Queue item not found")
 
+    # Ownership check — softer than /cancel because /start is the entry point
+    # for #1670's VP-import flow: an unowned item is claimable by the first
+    # _OWN holder who clicks ▶, and the route below credits them as owner.
+    # An item with a DIFFERENT owner → 403.
+    if not can_modify_all and user is not None:
+        if item.created_by_id is not None and item.created_by_id != user.id:
+            raise HTTPException(403, "You can only start your own queue items")
+
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
 
@@ -1326,7 +1521,7 @@ async def start_queue_item(
         db,
         cost_center_id=item.cost_center_id,
         estimated_cost=item.estimated_cost,
-        current_user=current_user,
+        current_user=user,
         exclude_queue_item_id=item.id,
     )
 
@@ -1359,8 +1554,8 @@ async def start_queue_item(
     # (#1670). An item that already has a creator (UI-added queue items)
     # keeps that attribution; the dispatcher is not promoted over the
     # original uploader.
-    if current_user is not None and item.created_by_id is None:
-        item.created_by_id = current_user.id
+    if user is not None and item.created_by_id is None:
+        item.created_by_id = user.id
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 

+ 403 - 76
backend/app/api/routes/printers.py

@@ -8,8 +8,10 @@ from fastapi.responses import Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
+    RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     is_auth_enabled,
 )
@@ -27,6 +29,7 @@ from backend.app.schemas.printer import (
     AMSUnit,
     DiagnosticRequest,
     FilaSwitchResponse,
+    HmsActionBody,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
@@ -49,19 +52,28 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
+    drying_screen_only,
     get_derived_status_name,
     printer_manager,
+    resolve_expected_tray,
     resolve_plate_id,
     supports_chamber_heater,
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
 )
+from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.http import build_content_disposition
+from backend.app.utils.printer_models import uses_exhaust_fan_label
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 
+# Seconds the /hms/execute-action route waits for a printer status push
+# confirming the command landed before reporting 502 to the UI. Module-level
+# so tests can monkeypatch a near-zero value instead of mocking asyncio.sleep.
+HMS_ACTION_ACK_WAIT_SECONDS = 2.5
+
 
 async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool:
     """Whether the caller is trusted enough to see ``access_code`` on a printer
@@ -456,7 +468,15 @@ async def get_printer_status(
 
     # Convert HMS errors to response format
     hms_errors = [
-        HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)
+        HMSErrorResponse(
+            code=e.code,
+            attr=e.attr,
+            module=e.module,
+            severity=e.severity,
+            actions=e.actions,
+            job_id=e.job_id,
+            full_code=e.full_code,
+        )
         for e in (state.hms_errors or [])
     ]
 
@@ -521,6 +541,7 @@ async def get_printer_status(
                         drying_temp=tray_data.get("drying_temp"),
                         drying_time=tray_data.get("drying_time"),
                         state=tray_data.get("state"),
+                        exists=tray_data.get("exists"),
                     )
                 )
             # Prefer humidity_raw (percentage) over humidity (index 1-5)
@@ -749,6 +770,26 @@ async def get_printer_status(
         ams_mapping=ams_mapping,
         ams_extruder_map=ams_extruder_map,
         tray_now=tray_now,
+        # Runout guidance (#2587): resolve the firmware's target/previous slot to a
+        # global tray ID, but only while PAUSED — the moment the operator needs it.
+        expected_tray=(
+            resolve_expected_tray(
+                state.tray_tar,
+                [(u.id, u.is_ams_ht) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
+        previous_tray=(
+            resolve_expected_tray(
+                state.tray_pre,
+                [(u.id, u.is_ams_ht) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
         ams_status_main=state.ams_status_main,
         ams_status_sub=state.ams_status_sub,
         mc_print_sub_stage=state.mc_print_sub_stage,
@@ -758,12 +799,15 @@ async def get_printer_status(
         big_fan1_speed=state.big_fan1_speed,
         big_fan2_speed=state.big_fan2_speed,
         heatbreak_fan_speed=state.heatbreak_fan_speed,
+        left_aux_fan_speed=state.left_aux_fan_speed,
+        exhaust_fan_present=state.exhaust_fan_present,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
         ams_filament_backup=state.ams_filament_backup if state else None,
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
         supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
+        drying_screen_only=drying_screen_only(printer.model),
         supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
@@ -781,6 +825,70 @@ async def get_printer_status(
     )
 
 
+@router.get("/{printer_id}/overlay-status")
+async def get_overlay_status(
+    printer_id: int,
+    _: None = RequireOverlayTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Everything the streaming overlay (#2613) draws for one printer.
+
+    A token-authenticated sibling of ``get_printer_status`` for embeds with no
+    login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
+    Deliberately flat and minimal (name, camera rotation, live print state, and
+    the one setting the overlay reads) rather than the full ``PrinterStatus``:
+    a token holder gets exactly the fields the overlay renders, nothing more.
+
+    Unlike the Cam Wall feed this *includes the print filename* — the overlay
+    names the part on screen — which is why it sits behind its own ``overlay``
+    scope rather than ``camwall``.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    time_format = await get_setting(db, "time_format") or "system"
+    state = printer_manager.get_status(printer_id)
+
+    if not state:
+        # Never connected this run — mirror get_printer_status()'s disconnected
+        # shape so the overlay renders its offline state rather than erroring.
+        return {
+            "id": printer_id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            "connected": False,
+            "state": None,
+            "current_print": None,
+            "gcode_file": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            "stg_cur_name": None,
+            "time_format": time_format,
+        }
+
+    return {
+        "id": printer_id,
+        "name": printer.name,
+        "camera_rotation": printer.camera_rotation or 0,
+        "connected": state.connected,
+        "state": state.state,
+        "current_print": state.current_print,
+        "gcode_file": state.gcode_file,
+        "progress": state.progress,
+        "remaining_time": state.remaining_time,
+        "layer_num": state.layer_num,
+        "total_layers": state.total_layers,
+        "stg_cur_name": get_derived_status_name(state, printer.model),
+        "time_format": time_format,
+    }
+
+
 @router.get("/{printer_id}/current-print-user")
 async def get_current_print_user(
     printer_id: int,
@@ -924,6 +1032,15 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 # Cleared on print start alongside _cover_cache.
 _cover_404_cache: dict[int, set[tuple[str, str]]] = {}
 
+# In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572).
+# The farm dashboard mounts a cover tile per printer card, so several browsers
+# request the same printer's cover in the same instant, all miss the cache, and
+# each runs the full multi-path FTP lookup + 3MF extraction (one observed live
+# transfer pulled an 81 MB 3MF while real print uploads were in flight). The
+# first request to miss becomes the leader; concurrent requests await its future
+# and then serve from the positive/negative cache it filled.
+_cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {}
+
 
 def clear_cover_cache(printer_id: int) -> None:
     """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
@@ -935,17 +1052,30 @@ def clear_cover_cache(printer_id: int) -> None:
 async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
-    db: AsyncSession = Depends(get_db),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the cover image for the current print job.
 
     Args:
-        view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
+        view: Optional view type. Use "top" for the top-down build plate view or
+              "pick" for the slicer's object-ID mask used by skip objects.
               Default returns angled 3D perspective view.
     """
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
+    # Fetch the printer in a short-lived session and release the pooled DB
+    # connection BEFORE the FTP download below. Previously this route took its
+    # row via Depends(get_db), whose session stays open for the whole request —
+    # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
+    # under FTP contention) pinned one pooled connection idle-in-transaction the
+    # entire time (issue #2572). db is used only for this one SELECT; everything
+    # after reads already-loaded printer.* scalars (expire_on_commit=False keeps
+    # them readable), printer_manager, and FTP/zip — no lazy loads.
+    #
+    # Reference async_session via the module so the maker is looked up at call
+    # time — keeps it in sync with reinitialize_database() and lets the test
+    # harness's patch of backend.app.core.database.async_session take effect.
+    async with database.async_session() as db:
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
 
@@ -988,6 +1118,53 @@ async def get_printer_cover(
     if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
         raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
 
+    # Coalesce concurrent downloads for the same cover (#2572). The positive and
+    # negative caches were just checked above; if another request is already
+    # downloading this exact cover, wait for it and serve from the cache it fills
+    # instead of launching a duplicate multi-path FTP + 3MF extraction.
+    inflight_key = (printer_id, subtask_name, view_key)
+    leader = _cover_inflight.get(inflight_key)
+    if leader is not None:
+        # shield() so our own cancellation can't cancel the shared leader.
+        try:
+            await asyncio.shield(leader)
+        except Exception:
+            pass
+        if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
+            return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
+        if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
+            raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
+        # Leader finished without filling either cache (a transient 503) — fall
+        # through and try the download ourselves.
+
+    fut: asyncio.Future = asyncio.get_event_loop().create_future()
+    _cover_inflight[inflight_key] = fut
+    try:
+        image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
+        return Response(content=image_data, media_type="image/png")
+    finally:
+        if not fut.done():
+            fut.set_result(None)
+        _cover_inflight.pop(inflight_key, None)
+
+
+async def _produce_cover_image(
+    printer: Printer,
+    printer_id: int,
+    subtask_name: str,
+    view: str | None,
+    view_key: str,
+    plate_num: int | None,
+    cache_key: tuple[str, str],
+) -> bytes:
+    """Download the active-print 3MF and extract its cover thumbnail (#2572).
+
+    Split out of ``get_printer_cover`` so concurrent requests for the same cover
+    can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
+    on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
+    failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
+    work — the caller already released the pooled connection before this runs.
+    """
     # Build possible 3MF filenames from subtask_name
     # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
     # or just "name.3mf" (uploaded directly)
@@ -1124,7 +1301,14 @@ async def get_printer_cover(
             # Try common thumbnail paths in 3MF files
             # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use top-down view if requested (better for skip objects modal)
-            if view == "top":
+            if view == "pick":
+                # Only the active plate's mask, with no fallback: every other view
+                # falls back to plate 1 because a slightly wrong picture is better
+                # than none, but a mask is coordinates, not decoration. Plate 1's
+                # mask over plate 3's layout would resolve clicks to whichever
+                # object happened to occupy that pixel on a different plate.
+                thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
+            elif view == "top":
                 thumbnail_paths = [
                     f"Metadata/top_{plate_num}.png",
                     # Fall back to plate 1 if specific plate not found
@@ -1151,18 +1335,25 @@ async def get_printer_cover(
                     if printer_id not in _cover_cache:
                         _cover_cache[printer_id] = {}
                     _cover_cache[printer_id][(subtask_name, view_key)] = image_data
-                    return Response(content=image_data, media_type="image/png")
+                    return image_data
                 except KeyError:
                     continue
 
-            # If no specific thumbnail found, try any PNG in Metadata
-            for name in zf.namelist():
-                if name.startswith("Metadata/") and name.endswith(".png"):
-                    image_data = zf.read(name)
-                    if printer_id not in _cover_cache:
-                        _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
-                    return Response(content=image_data, media_type="image/png")
+            # If no specific thumbnail found, try any PNG in Metadata. Never for
+            # "pick": handing back a rendered thumbnail in place of the object-ID
+            # mask is worse than nothing, because the caller can't tell the
+            # difference and decodes the render's pixel colours as object IDs —
+            # dark pixels yield small integers that collide with real IDs, so a
+            # click would select an arbitrary object and skip it irreversibly.
+            # A 404 is what tells the UI to fall back to the checklist.
+            if view != "pick":
+                for name in zf.namelist():
+                    if name.startswith("Metadata/") and name.endswith(".png"):
+                        image_data = zf.read(name)
+                        if printer_id not in _cover_cache:
+                            _cover_cache[printer_id] = {}
+                        _cover_cache[printer_id][(subtask_name, view_key)] = image_data
+                        return image_data
 
             _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -1182,18 +1373,37 @@ async def get_printer_cover(
 # ============================================
 
 
+async def _load_printer_or_404(printer_id: int) -> Printer:
+    """Load a printer in a short-lived session, releasing the pooled DB
+    connection before the caller starts any FTP/network I/O (#2572).
+
+    The file-manager and storage routes talk FTP to the printer, which can
+    block for the full socket timeout — longer when a saturated FTP pool backs
+    up. Holding the request's Depends(get_db) session across that FTP pinned one
+    pooled connection idle-in-transaction per in-flight request, a top cause of
+    pool exhaustion on large farms. The returned row's scalar columns stay
+    readable after the session closes (expire_on_commit=False). Raises 404 when
+    the printer doesn't exist.
+
+    Reference async_session via the module so the maker is resolved at call time
+    — keeps it in sync with reinitialize_database() and lets tests patch it.
+    """
+    async with database.async_session() as db:
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+    return printer
+
+
 @router.get("/{printer_id}/files")
 async def list_printer_files(
     printer_id: int,
     path: str = "/",
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """List files on the printer at the specified path."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
 
@@ -1212,13 +1422,9 @@ async def download_printer_file(
     printer_id: int,
     path: str,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Download a file from the printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
     if data is None:
@@ -1253,16 +1459,11 @@ async def get_printer_file_gcode(
     printer_id: int,
     path: str,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get gcode for a file stored on a printer (for preview)."""
     import io
 
-    # Validate printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
     if data is None:
@@ -1292,7 +1493,6 @@ async def get_printer_file_plates(
     printer_id: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get available plates from a multi-plate 3MF file stored on a printer."""
     import io
@@ -1300,11 +1500,7 @@ async def get_printer_file_plates(
 
     import defusedxml.ElementTree as ET
 
-    # Validate printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     filename = path.split("/")[-1]
     if not filename.lower().endswith(".3mf"):
@@ -1537,15 +1733,11 @@ async def get_printer_file_plate_thumbnail(
     plate_index: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get a plate thumbnail image from a printer-stored 3MF file."""
     import io
 
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
     if data is None:
@@ -1568,7 +1760,6 @@ async def download_printer_files_as_zip(
     printer_id: int,
     request: dict,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Download multiple files from the printer as a ZIP archive."""
     import io
@@ -1577,10 +1768,7 @@ async def download_printer_files_as_zip(
     if not paths:
         raise HTTPException(400, "No files specified")
 
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     # Create ZIP in memory
     zip_buffer = io.BytesIO()
@@ -1615,13 +1803,9 @@ async def delete_printer_file(
     printer_id: int,
     path: str,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Delete a file from the printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     from backend.app.services.bambu_ftp import DeleteResult
 
@@ -1638,13 +1822,9 @@ async def delete_printer_file(
 async def get_printer_storage(
     printer_id: int,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get storage information from the printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
 
@@ -1741,6 +1921,11 @@ async def clear_mqtt_logs(
 # AMS Drying Endpoints
 # ============================================
 
+# The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
+# — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
+# (#2533). Refuse the command rather than let the caller believe it landed.
+_DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
+
 
 @router.post("/{printer_id}/drying/start")
 async def start_drying(
@@ -1762,6 +1947,8 @@ async def start_drying(
     # Server-side guard: reject if this model/firmware doesn't support drying
     live_state = printer_manager.get_status(printer_id)
     firmware = live_state.firmware_version if live_state else None
+    if drying_screen_only(printer.model):
+        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
     if not supports_drying(printer.model, firmware):
         raise HTTPException(400, "Drying not supported for this printer model or firmware version")
 
@@ -1834,6 +2021,11 @@ async def stop_drying(
     if not printer:
         raise HTTPException(404, "Printer not found")
 
+    # Screen-only models ignore stop just as they ignore start — a cycle running on a
+    # P1S was started at the printer and has to be ended there too (#2533).
+    if drying_screen_only(printer.model):
+        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
+
     success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
     if not success:
         raise HTTPException(400, "Printer not connected")
@@ -2339,6 +2531,18 @@ async def configure_ams_slot(
         if kprofile_setting_id:
             effective_setting_id = kprofile_setting_id
 
+    # Back-fill setting_id from the resolved filament id when the client sent
+    # none. Built-in / local / Orca-generic presets in the Configure AMS Slot
+    # modal leave setting_id empty (they carry only a GF* tray_info_idx), and
+    # the printer treats a filament-id-without-setting-id slot as half
+    # configured: it shows the new material briefly, then reverts to its
+    # previously stored profile (#2604). This mirrors the derivation the
+    # inventory/assignment path already does (inventory.py). filament_id_to_
+    # setting_id leaves P* user presets and already-GFS* values unchanged, so
+    # only the empty-setting_id generic paths are affected.
+    if effective_tray_info_idx and not effective_setting_id:
+        effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx)
+
     # Always send ams_set_filament_setting — the user explicitly clicked
     # "Configure Slot", so honor that.  Previous versions skipped this for
     # RFID-tagged slots to preserve the slicer eye icon, but printers cache
@@ -2534,6 +2738,17 @@ async def configure_ams_slot(
             except Exception:
                 pass
 
+    # Register a read-back verification (#2582) so the tray telemetry that the
+    # status push below returns can confirm the printer accepted this manual
+    # slot configuration. Mirrors the inventory/assignment path.
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=cali_idx,
+    )
+
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()
@@ -2981,16 +3196,28 @@ async def set_chamber_temperature(
 @router.post("/{printer_id}/fan-speed")
 async def set_fan_speed(
     printer_id: int,
-    fan: str = Query(..., description="Fan to control: part, aux, or chamber"),
+    fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
-    """Set a fan speed by percentage."""
-    fan_ids = {"part": 1, "aux": 2, "chamber": 3}
+    """Set a fan speed by percentage.
+
+    Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
+    P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
+    profile gcode does. It only exists when the printer reports airduct part 10,
+    so the request is rejected rather than sending M106 P10 into the void on a
+    machine that has no such fan.
+
+    That gate also rejects for the short window between connecting and the
+    first airduct push, when nothing is known about the fan yet. The card hides
+    the badge over the same window, so there is no control to click; a direct
+    API caller gets a 400 and should retry once the status reports the fan.
+    """
+    fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
     fan_id = fan_ids.get(fan)
     if fan_id is None:
-        raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'")
+        raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -3001,12 +3228,31 @@ async def set_fan_speed(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
+    # Presence gate for the accessory fan. Without this, aux2 is accepted for
+    # every model and an A1 would be sent M106 P10 for a fan it does not have.
+    # The UI already hides the badge; this closes the same hole on the API.
+    if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
+        raise HTTPException(
+            400,
+            "This printer does not report a left auxiliary fan "
+            "(no airduct part 10). The fan is an accessory kit on the P2S "
+            "and factory-fitted on the X2D.",
+        )
+
     pwm_speed = round(speed * 255 / 100)
     success = client.set_fan_speed(fan_id, pwm_speed)
     if not success:
         raise HTTPException(500, "Failed to set fan speed")
 
-    fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"}
+    # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
+    # match whatever the printer card badge shows so the toast agrees with the
+    # control the user just clicked.
+    fan_names = {
+        "part": "Part cooling fan",
+        "aux": "Auxiliary fan",
+        "aux2": "Left auxiliary fan",
+        "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
+    }
     return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
 
 
@@ -3097,16 +3343,29 @@ async def bed_jog(
             "translates this into the right G-code Z sign per printer model."
         ),
     ),
-    force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Adjust the nozzle-bed gap by a relative distance.
 
-    Emits a short G-code sequence via MQTT. When ``force`` is true the soft
-    endstops are disabled for the duration of the move, matching the
-    "ignore and move anyway" option Bambu Studio offers when the printer
-    is not homed.
+    Emits a short G-code sequence via MQTT.
+
+    Soft-endstop policy (#2579). The printer's software travel limits are the
+    only thing between a jog button and a bed crash — on Bambu machines the
+    physical endstops are homing-only (there is no runtime limit switch in the
+    travel path), so once they are disabled nothing stops the move. The old
+    code disabled them (``M211 S0``) around every forced jog, and the UI sent
+    ``force`` on every jog, so the limits were off on every bed move — that is
+    what let a jog drive the nozzle into the bed on all models (#2579). This
+    endpoint now emits a **bare relative move and never touches ``M211`` at
+    all** — byte-for-byte what the printer's own touchscreen jog sends, which
+    stops at the travel limit. Bambuddy no longer disables the firmware's soft
+    endstops, and it no longer sends ``M211 S1`` either: that was an unverified
+    attempt to re-enable a printer left disabled by an older build, and on real
+    hardware the jog moved past the limit *with* it. If a printer still jogs
+    past its limits, its endstops were disabled at the firmware level by the old
+    build — power-cycle it once to restore them; from then on Bambuddy leaves
+    them alone.
 
     Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
     is the Z-axis, and Bambu's home convention puts Z=0 at the top with
@@ -3133,12 +3392,10 @@ async def bed_jog(
 
     gcode_distance = -distance if is_bed_slinger(printer.model) else distance
 
-    lines = []
-    if force:
-        lines.append("M211 S0")
-    lines += ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
-    if force:
-        lines.append("M211 S1")
+    # Bare relative move — exactly what the touchscreen sends. Never touch M211
+    # (#2579): the firmware keeps its soft endstops on by default and clamps the
+    # move at the travel limit.
+    lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
 
     if not client.send_gcode("\n".join(lines)):
         raise HTTPException(500, "Failed to send bed-jog command")
@@ -3173,6 +3430,9 @@ async def xy_jog(
     if y:
         axes.append(f"Y{y:.2f}")
 
+    # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
+    # endstops on by default and clamps the move at the travel limit; a printer
+    # left disabled by an older build is recovered with a power cycle.
     if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
         raise HTTPException(500, "Failed to send XY jog command")
 
@@ -3347,7 +3607,14 @@ async def get_printable_objects(
                 if downloaded and temp_path.exists():
                     with open(temp_path, "rb") as f:
                         data = f.read()
-                    objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
+                    # Scope to the running plate: an all-plates 3MF lists every
+                    # plate's objects, and offering plate 1's while the printer
+                    # runs plate 2 makes every skip a misfire (#2522).
+                    objects, bbox_all = extract_printable_objects_from_3mf(
+                        data,
+                        plate_number=resolve_plate_id(client.state),
+                        include_positions=True,
+                    )
                     if objects:
                         client.state.printable_objects = objects
                         client.state.printable_objects_bbox_all = bbox_all
@@ -3707,8 +3974,12 @@ async def ams_load(
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     """
-    if tray_id not in range(16) and tray_id not in (254, 255):
-        raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
+    # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
+    # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
+    if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
+        raise HTTPException(
+            400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+        )
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -3787,3 +4058,59 @@ async def get_runtime_debug(
         else None,
         "is_active": printer.is_active,
     }
+
+
+@router.post("/{printer_id}/hms/execute-action")
+async def execute_hms_action(
+    printer_id: int,
+    body: HmsActionBody,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Execute an HMS action on the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    # Snapshot pre-state so we can verify the printer actually acted on the
+    # command. publish() success is NOT the same as printer-ack: Bambu's
+    # firmware silently rejects malformed HMS commands at QoS 1 (the broker
+    # ACKs the publish, but the printer drops it). Verified end-to-end against
+    # a live H2D — see #1830 §(3).
+    #
+    # We probe `_last_message_time` (bumped on every MQTT push) rather than a
+    # (gcode_state, hms_errors-length) diff. The old diff missed the
+    # wrong-plate IGNORE_RESUME case where the printer briefly resumes and
+    # re-pauses with the same fault inside the 2.5s window: both fields
+    # round-trip to their pre-publish values → false 502 even though the
+    # firmware fully ack'd the resume. Every accepted command triggers a
+    # pushall response within ~100-500ms, so a fresh inbound message after
+    # the publish is the robust ack signal.
+    pre_last_message = client._last_message_time
+
+    success = client.execute_hms_action(body.print_error, body.action, body.job_id)
+    if not success:
+        raise HTTPException(400, "Failed to execute HMS action")
+
+    # Give the printer time to push a state update. The dispatch helper already
+    # publishes a pushall after every command, so a fresh status should arrive
+    # within ~1s; the default 2.5s covers slower firmware variants without
+    # making the UI feel hung. Plain sleep is fine — paho's MQTT callback
+    # runs in its own thread and updates state regardless of whether this
+    # coroutine is awaiting.
+    await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
+
+    acked = client._last_message_time > pre_last_message
+    if not acked:
+        # Publish succeeded but the printer sent nothing back. Almost always
+        # firmware-side silent rejection (err mismatch, command/state mismatch)
+        # or a dropped MQTT route. 502 makes it visible at the UI instead of
+        # the 200-but-broken loop #1830 reported.
+        raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
+
+    return {"success": True, "message": "HMS action executed"}

+ 102 - 2
backend/app/api/routes/projects.py

@@ -34,6 +34,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectCreate,
+    ProjectFileProgress,
     ProjectImport,
     ProjectListResponse,
     ProjectResponse,
@@ -262,7 +263,11 @@ async def list_projects(
                 status=project.status,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
+                tags=project.tags,
+                due_date=project.due_date,
+                priority=project.priority,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 total_items=total_items,
@@ -301,6 +306,7 @@ async def create_project(
         color=data.color,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -323,6 +329,7 @@ async def create_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -370,7 +377,12 @@ async def list_templates(
                 color=project.color,
                 status=project.status,
                 target_count=project.target_count,
+                target_parts_count=project.target_parts_count,
+                target_sets=project.target_sets,
                 budget=project.budget,
+                tags=project.tags,
+                due_date=project.due_date,
+                priority=project.priority,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 queue_count=0,
@@ -408,6 +420,7 @@ async def create_project_from_template(
         color=template.color,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         tags=template.tags,
         priority=template.priority,
@@ -450,6 +463,7 @@ async def create_project_from_template(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -535,6 +549,7 @@ async def get_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -583,11 +598,18 @@ async def update_project(
         project.target_count = data.target_count
     if data.target_parts_count is not None:
         project.target_parts_count = data.target_parts_count
+    # Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
+    # alone (same #2536 semantics as tags/due_date below).
+    if "target_sets" in data.model_fields_set:
+        project.target_sets = data.target_sets
     if data.notes is not None:
         project.notes = data.notes
-    if data.tags is not None:
+    # Sent-but-null clears the field; omitted leaves it alone. Guarding on
+    # ``is not None`` would make an emptied tags field or a removed due date
+    # silently revert to the stored value (#2536).
+    if "tags" in data.model_fields_set:
         project.tags = data.tags
-    if data.due_date is not None:
+    if "due_date" in data.model_fields_set:
         project.due_date = data.due_date
     if data.priority is not None:
         if data.priority not in ["low", "normal", "high", "urgent"]:
@@ -632,6 +654,7 @@ async def update_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -730,6 +753,76 @@ async def list_project_queue(
     return items
 
 
+@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
+async def get_project_file_progress(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+):
+    """Completed-run counts per library file inside a project (#1897).
+
+    Counts completed ``PrintLogEntry`` rows (same source as the aggregate
+    project stats) of archives attributed to this project, and maps each run to
+    one of the project's library files — the files living in folders linked to
+    the project, the same set the project detail page renders.
+
+    A run is attributed to exactly one file, by the strongest available match:
+    1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
+    2. content hash (covers historical rows),
+    3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
+    Files with no completed runs are omitted — the frontend treats absence as 0.
+    """
+    result = await db.execute(select(Project.id).where(Project.id == project_id))
+    if result.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    files_result = await db.execute(
+        select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
+        .join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        .where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
+    )
+    file_rows = files_result.all()
+    if not file_rows:
+        return []
+
+    # First match wins within each tier, so iteration order (file id) is stable
+    # when duplicates share a hash or filename.
+    by_id = {fid for fid, _, _ in file_rows}
+    by_hash: dict[str, int] = {}
+    by_name: dict[str, int] = {}
+    for fid, fhash, fname in file_rows:
+        if fhash and fhash not in by_hash:
+            by_hash[fhash] = fid
+        if fname not in by_name:
+            by_name[fname] = fid
+
+    runs_result = await db.execute(
+        select(
+            PrintArchive.library_file_id,
+            PrintArchive.content_hash,
+            PrintArchive.filename,
+            func.count(PrintLogEntry.id),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed")
+        .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
+    )
+
+    counts: dict[int, int] = {}
+    for lib_file_id, content_hash, filename, run_count in runs_result.all():
+        if lib_file_id in by_id:
+            fid = lib_file_id
+        elif content_hash and content_hash in by_hash:
+            fid = by_hash[content_hash]
+        elif filename in by_name:
+            fid = by_name[filename]
+        else:
+            continue
+        counts[fid] = counts.get(fid, 0) + run_count
+
+    return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
+
+
 @router.post("/{project_id}/add-archives")
 async def add_archives_to_project(
     project_id: int,
@@ -1392,6 +1485,7 @@ async def create_template_from_project(
         color=source.color,
         target_count=source.target_count,
         target_parts_count=source.target_parts_count,
+        target_sets=source.target_sets,
         notes=source.notes,
         tags=source.tags,
         priority=source.priority,
@@ -1434,6 +1528,7 @@ async def create_template_from_project(
         status=template.status,
         target_count=template.target_count,
         target_parts_count=template.target_parts_count,
+        target_sets=template.target_sets,
         notes=template.notes,
         attachments=template.attachments,
         url=template.url,
@@ -1643,6 +1738,7 @@ async def export_project(
         "status": project.status,
         "target_count": project.target_count,
         "target_parts_count": project.target_parts_count,
+        "target_sets": project.target_sets,
         "notes": project.notes,
         "tags": project.tags,
         "due_date": project.due_date.isoformat() if project.due_date else None,
@@ -1694,6 +1790,7 @@ async def import_project(
         status=data.status,
         target_count=data.target_count,
         target_parts_count=data.target_parts_count,
+        target_sets=data.target_sets,
         notes=data.notes,
         tags=data.tags,
         due_date=data.due_date,
@@ -1756,6 +1853,7 @@ async def import_project(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,
@@ -1819,6 +1917,7 @@ async def import_project_file(
         status=data.get("status", "active"),
         target_count=data.get("target_count"),
         target_parts_count=data.get("target_parts_count"),
+        target_sets=data.get("target_sets"),
         notes=data.get("notes"),
         tags=data.get("tags"),
         due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
@@ -1947,6 +2046,7 @@ async def import_project_file(
         status=project.status,
         target_count=project.target_count,
         target_parts_count=project.target_parts_count,
+        target_sets=project.target_sets,
         notes=project.notes,
         attachments=project.attachments,
         url=project.url,

+ 130 - 60
backend/app/api/routes/settings.py

@@ -35,32 +35,6 @@ _SENSITIVE_FIELDS_FOR_API_KEY = (
 )
 
 
-def _sqlalchemy_type_to_sqlite_type(type_repr: str) -> str:
-    """Map a SQLAlchemy column type's ``str()`` to a SQLite-native column type.
-
-    Used by ``create_backup_zip`` to reconstruct a portable SQLite database
-    file from PostgreSQL data. Falling through to TEXT for binary columns
-    corrupts non-UTF8 bytes — the BLOB branch is the #1333 regression guard
-    for OIDC icon BLOBs.
-
-    Extracted as a pure helper so it can be unit-tested without spinning up
-    the full FastAPI app + backup pipeline.
-    """
-    type_str = type_repr.upper()
-    if "INT" in type_str:
-        return "INTEGER"
-    if "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
-        return "REAL"
-    if "BOOL" in type_str:
-        return "BOOLEAN"
-    if "BLOB" in type_str or "BYTEA" in type_str or "BINARY" in type_str:
-        # OIDC icon BLOB column (#1333) — without this branch the column
-        # was created as TEXT and non-UTF8 bytes were corrupted during the
-        # PG→SQLite-ZIP backup round trip.
-        return "BLOB"
-    return "TEXT"
-
-
 async def get_setting(db: AsyncSession, key: str) -> str | None:
     """Get a single setting value by key."""
     result = await db.execute(select(Settings).where(Settings.key == key))
@@ -68,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
     return setting.value if setting else None
 
 
+# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
+# column and every reader compares them as strings, so these are normalised to
+# "true"/"false" on the way in. The sets are deliberately generous: these
+# endpoints are part of the documented REST surface, reached by scripts and by
+# Home Assistant rest_command, where "True", "1" and "on" are all natural.
+_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
+_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
+
+
+def setting_is_true(value: object) -> bool:
+    """Return True if a *stored* settings value means "on".
+
+    Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
+    it matches only what every other reader in the codebase treats as on
+    (``value.lower() == "true"``). Submitted values are canonicalised on write,
+    so a stored value is always "true"/"false"/""; accepting "1" or "on" here
+    would make this function disagree with the rest of the app about any legacy
+    row containing them.
+
+    A bool is tolerated for the case of a row written before values were
+    normalised, where SQLite coerced a raw bool into the VARCHAR column.
+    """
+    if isinstance(value, bool):
+        return value
+    if value is None:
+        return False
+    return str(value).strip().lower() == "true"
+
+
+def normalize_bool_setting(key: str, value: object) -> str:
+    """Coerce a boolean-ish settings value to the canonical "true"/"false".
+
+    Raises HTTPException(400) for values with no sensible interpretation, so an
+    API client gets a message naming the field instead of a 500.
+
+    A JSON boolean is the natural thing for an API client to send, and before
+    this normalisation it caused two distinct failures on
+    ``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
+    raw bool was written into a VARCHAR column, which SQLite silently coerces
+    to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
+    """
+    if isinstance(value, bool):  # must precede the int branch — bool is an int
+        return "true" if value else "false"
+    if isinstance(value, int):
+        if value in (0, 1):
+            return "true" if value else "false"
+        raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
+    if isinstance(value, str):
+        candidate = value.strip().lower()
+        if not candidate:
+            # Empty is stored verbatim rather than normalised to "false".
+            # get_spoolman_settings reads these with ``or "<default>"``, so an
+            # empty stored value means "use the default" — and two of them
+            # (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
+            # ON. Rewriting "" to "false" would silently switch them off for any
+            # client that submits a blank value.
+            return ""
+        if candidate in _TRUTHY_SETTING_VALUES:
+            return "true"
+        if candidate in _FALSY_SETTING_VALUES:
+            return "false"
+        raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
+    raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
+
+
+def normalize_str_setting(key: str, value: object) -> str:
+    """Return a string settings value, rejecting types that would store garbage.
+
+    ``str()`` on a dict or list would persist its repr, so those are refused
+    rather than silently written. Numbers are accepted and stringified: a port
+    or a bare host submitted unquoted is a plausible client mistake, not a
+    reason to fail the request.
+    """
+    if isinstance(value, str):
+        return value
+    if value is None:
+        return ""
+    if isinstance(value, bool | int | float):
+        return str(value)
+    raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
+
+
 async def get_external_login_url(db: AsyncSession) -> str:
     """Get the external URL for the login page.
 
@@ -131,17 +187,18 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "print_drying_enabled",
             "require_plate_clear",
             "queue_shortest_first",
-            "default_bed_levelling",
-            "default_flow_cali",
+            # default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
+            # are tri-state strings (off/on/auto) — parsed via the raw-string else
+            # branch; the TriState validator coerces legacy "true"/"false" rows.
             "default_vibration_cali",
             "default_layer_inspect",
             "default_timelapse",
             "billing_enabled",
             "printer_kill_switch_enabled",
-            "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_auto_provision",
             "local_login_enabled",
+            "preheat_enabled",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -167,6 +224,10 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "forecast_global_lead_time_days",
             "finance_budget_reset_day",
             "session_max_hours",
+            "pipeline_max_copies",
+            "preheat_max_wait_seconds",
+            "preheat_soak_seconds",
+            "queue_max_concurrent_uploads",
         ]:
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":
@@ -459,14 +520,20 @@ async def update_spoolman_settings(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Update Spoolman integration settings."""
+    """Update Spoolman integration settings.
+
+    The body is a free-form dict rather than a schema, so each value is
+    normalised before it is persisted — see ``normalize_bool_setting`` for why
+    a JSON boolean used to produce a 500 here.
+    """
     if "spoolman_enabled" in settings:
-        old_val = await get_setting(db, "spoolman_enabled") or "false"
-        new_val = settings["spoolman_enabled"]
+        was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
+        new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
+        now_enabled = new_val == "true"
         await set_setting(db, "spoolman_enabled", new_val)
 
         # Switching to Spoolman: clear built-in inventory slot assignments
-        if old_val.lower() != "true" and new_val.lower() == "true":
+        if not was_enabled and now_enabled:
             from backend.app.models.spool_assignment import SpoolAssignment
 
             result = await db.execute(delete(SpoolAssignment))
@@ -476,21 +543,20 @@ async def update_spoolman_settings(
         # spoolman_slot_assignments rows linger and would wrongly count as
         # "assigned" in any mode-agnostic check (e.g. the missing-spool-
         # assignment notification, which unions both tables — #1473).
-        elif old_val.lower() == "true" and new_val.lower() != "true":
+        elif was_enabled and not now_enabled:
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
             result = await db.execute(delete(SpoolmanSlotAssignment))
             logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
     if "spoolman_url" in settings:
-        await set_setting(db, "spoolman_url", settings["spoolman_url"])
+        await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
     if "spoolman_sync_mode" in settings:
-        await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
-    if "spoolman_disable_weight_sync" in settings:
-        await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
-    if "spoolman_report_partial_usage" in settings:
-        await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
-    if "auto_add_unknown_rfid" in settings:
-        await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
+        await set_setting(
+            db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
+        )
+    for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
+        if bool_key in settings:
+            await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
 
     spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
 
@@ -581,25 +647,31 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
             import json
             import sqlite3
 
+            from sqlalchemy import create_engine as create_sync_engine
+
             from backend.app.core.database import Base, engine
 
             backup_db_path = temp_path / "bambuddy.db"
-            dst = sqlite3.connect(str(backup_db_path))
             metadata = Base.metadata
 
-            # Create tables in SQLite backup (simplified — just column names and types)
-            for table in metadata.sorted_tables:
-                cols = []
-                pk_cols = [col.name for col in table.columns if col.primary_key]
-                for col in table.columns:
-                    col_type = _sqlalchemy_type_to_sqlite_type(str(col.type))
-                    # Only inline PRIMARY KEY for single-column PKs
-                    pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
-                    cols.append(f"{col.name} {col_type}{pk}")
-                # Add composite primary key constraint if needed
-                if len(pk_cols) > 1:
-                    cols.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
-                dst.execute(f"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})")  # noqa: S608
+            # Build the portable SQLite schema with SQLAlchemy's own DDL rather
+            # than a hand-rolled CREATE TABLE. metadata.create_all() emits the
+            # exact schema a native SQLite install gets — NOT NULL, DEFAULT
+            # (server_default=func.now() → CURRENT_TIMESTAMP), foreign keys,
+            # unique constraints and indexes. The previous name+type-only
+            # rebuild dropped all of these, so a Postgres→SQLite restore left
+            # server_default columns (e.g. spoolbuddy_devices.created_at) with
+            # no DEFAULT — SQLAlchemy omits such columns on INSERT and the DB
+            # then wrote NULL, which 500'd on the next read (#2526). Using the
+            # real DDL also keeps the #1333 BLOB guard: LargeBinary still
+            # renders as BLOB, so OIDC icon bytes survive the round trip.
+            schema_engine = create_sync_engine(f"sqlite:///{backup_db_path}")
+            try:
+                metadata.create_all(schema_engine)
+            finally:
+                schema_engine.dispose()
+
+            dst = sqlite3.connect(str(backup_db_path))
 
             # Export data from Postgres to SQLite
             async with engine.connect() as conn:
@@ -977,8 +1049,8 @@ async def restore_backup(
             # 3b. Pause timer-based background services BEFORE the DB swap.
             # close_all_connections() below only disposes the engine's pool,
             # not the asyncio tasks that opened sessions from it. The print
-            # scheduler (30 s cadence), smart-plug snapshot loop (30 s),
-            # notification digest loop, and background dispatch worker all
+            # scheduler (30 s cadence), smart-plug snapshot loop (30 s), and
+            # notification digest loop all
             # wake up and call async_session(), which lazily re-creates a
             # pool connection holding RowExclusiveLock on print_queue /
             # smart_plug_energy_snapshots / etc. The DROP TABLE CASCADE
@@ -987,7 +1059,6 @@ async def restore_backup(
             # full restore rollback. Successful restore already requires a
             # container restart, so we don't restart the services here.
             try:
-                from backend.app.services.background_dispatch import background_dispatch
                 from backend.app.services.notification_service import notification_service
                 from backend.app.services.print_scheduler import scheduler as print_scheduler
                 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -996,7 +1067,6 @@ async def restore_backup(
                 print_scheduler.stop()
                 smart_plug_manager.stop_scheduler()
                 notification_service.stop_digest_scheduler()
-                await background_dispatch.stop()
                 # In-flight loop iterations need a moment to commit + release
                 # their DB sessions before we dispose() the engine pool.
                 await asyncio.sleep(1.0)

+ 12 - 7
backend/app/api/routes/slice_jobs.py

@@ -18,22 +18,27 @@ router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
 @router.get("/{job_id}")
 async def get_slice_job(
     job_id: int,
-    # Job IDs are sequential integers and the body leaks source filenames
-    # plus the resulting library_file_id / archive_id. Gate on the library
-    # read permission family (own/all). NOTE: SliceJob is in-memory with no
-    # owner field, so we cannot per-row scope; callers with either OWN or
-    # ALL can poll any job_id. Adding owner_id to SliceJob is the proper
-    # follow-up (out of scope for the IDOR fix train).
-    _: tuple[User | None, bool] = Depends(
+    # Job IDs are sequential integers and the body leaks source filenames plus
+    # the resulting library_file_id / archive_id. Gate on the library read
+    # permission family (own/all) and then scope per-row: a READ_OWN caller may
+    # only poll jobs they started (SliceJob.owner_id).
+    auth: tuple[User | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
         )
     ),
 ):
+    user, can_read_all = auth
     job = slice_dispatch.get(job_id)
     if job is None:
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
+    # Per-row scoping. Jobs started by API-key / auth-disabled callers have
+    # owner_id=None and are visible only to READ_ALL pollers (fail-closed,
+    # mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
+    # enumeration.
+    if not can_read_all and (user is None or job.owner_id != user.id):
+        raise HTTPException(status_code=404, detail="Slice job not found or expired")
     body: dict = {
         "job_id": job.id,
         "status": job.status,

+ 199 - 0
backend/app/api/routes/slicer_pipelines.py

@@ -0,0 +1,199 @@
+"""API routes for Slicer Pipelines (#1425, PR A — definitions only).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks so the
+SliceModal can apply them in one click. PR A surfaces only CRUD + an
+``apply`` helper that returns the pipeline as the four ``PresetRef`` slots a
+``SliceRequest`` expects. PR B adds single-target dispatch; PR C adds
+multi-copy fanout and the run dashboard.
+"""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
+from backend.app.schemas.slicer_pipeline import (
+    SlicerPipelineCreate,
+    SlicerPipelineListResponse,
+    SlicerPipelineResponse,
+    SlicerPipelineUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+
+
+def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
+    """Materialise the JSON filament list back into PresetRef objects so the
+    response shape matches the create/update input shape exactly."""
+    try:
+        raw = json.loads(row.filament_presets_json) if row.filament_presets_json else []
+    except (json.JSONDecodeError, TypeError):
+        # Row was hand-edited or corrupted — return an empty list rather than
+        # 500ing on a list endpoint. Edit/run paths will surface the problem.
+        logger.warning("slicer_pipeline %d has invalid filament_presets_json", row.id)
+        raw = []
+    filament_presets = [PresetRef(**f) for f in raw if isinstance(f, dict)]
+
+    return SlicerPipelineResponse(
+        id=row.id,
+        name=row.name,
+        description=row.description,
+        printer_preset=PresetRef(source=row.printer_preset_source, id=row.printer_preset_id),
+        process_preset=PresetRef(source=row.process_preset_source, id=row.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=row.bed_type,
+        target_kind=row.target_kind,  # type: ignore[arg-type]
+        target_printer_id=row.target_printer_id,
+        target_model_class=row.target_model_class,
+        fanout_strategy=row.fanout_strategy,  # type: ignore[arg-type]
+        created_by=row.created_by,
+        created_at=row.created_at,
+        updated_at=row.updated_at,
+    )
+
+
+@router.get("/", response_model=SlicerPipelineListResponse)
+async def list_pipelines(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all pipelines, newest first. Soft-deleted rows are hidden."""
+    result = await db.execute(
+        select(SlicerPipeline).where(SlicerPipeline.is_deleted.is_(False)).order_by(SlicerPipeline.id.desc())
+    )
+    rows = result.scalars().all()
+    return SlicerPipelineListResponse(pipelines=[_to_response(r) for r in rows])
+
+
+@router.post("/", response_model=SlicerPipelineResponse, status_code=201)
+async def create_pipeline(
+    data: SlicerPipelineCreate,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new pipeline."""
+    row = SlicerPipeline(
+        name=data.name.strip(),
+        description=data.description,
+        printer_preset_source=data.printer_preset.source,
+        printer_preset_id=data.printer_preset.id,
+        process_preset_source=data.process_preset.source,
+        process_preset_id=data.process_preset.id,
+        filament_presets_json=json.dumps([f.model_dump() for f in data.filament_presets]),
+        bed_type=data.bed_type,
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.get("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def get_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Read one pipeline by id."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    return _to_response(row)
+
+
+@router.put("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def update_pipeline(
+    pipeline_id: int,
+    data: SlicerPipelineUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a pipeline. Only fields present in the payload are written."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+
+    if data.name is not None:
+        row.name = data.name.strip()
+    if data.description is not None:
+        row.description = data.description
+    if data.printer_preset is not None:
+        row.printer_preset_source = data.printer_preset.source
+        row.printer_preset_id = data.printer_preset.id
+    if data.process_preset is not None:
+        row.process_preset_source = data.process_preset.source
+        row.process_preset_id = data.process_preset.id
+    if data.filament_presets is not None:
+        row.filament_presets_json = json.dumps([f.model_dump() for f in data.filament_presets])
+    if data.bed_type is not None:
+        row.bed_type = data.bed_type
+
+    # PR B target binding. The schema accepts ``target_kind=specific_printer``
+    # without ``target_printer_id`` (operator may be saving the kind first),
+    # but a 'specific_printer' kind with a printer_id of 0 is rejected since
+    # printer ids are always positive — guard against the JSON-coerced
+    # empty-string case from the frontend.
+    if data.target_kind is not None:
+        row.target_kind = data.target_kind
+    if data.target_printer_id is not None:
+        # ``target_printer_id=0`` from the frontend means "clear the target"
+        # (the <option value=""> case). Anything positive must reference an
+        # actual printer row.
+        if data.target_printer_id == 0:
+            row.target_printer_id = None
+        else:
+            row.target_printer_id = data.target_printer_id
+    # PR C — class targeting + fanout strategy. Empty string from the frontend
+    # also clears the class (radio toggled away).
+    if data.target_model_class is not None:
+        row.target_model_class = data.target_model_class or None
+    if data.fanout_strategy is not None:
+        row.fanout_strategy = data.fanout_strategy
+
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.delete("/{pipeline_id}", status_code=204)
+async def delete_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
+    still resolve pipeline metadata)."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    row.is_deleted = True
+    await db.commit()

+ 63 - 8
backend/app/api/routes/slicer_presets.py

@@ -259,15 +259,23 @@ async def _fetch_orca_cloud_presets(
                     filament_colour = fc[0]
                 elif isinstance(fc, str):
                     filament_colour = fc
-            slots[slot].append(
-                UnifiedPreset(
-                    id=str(preset_id),
-                    name=str(name),
-                    source="orca_cloud",
-                    filament_type=filament_type,
-                    filament_colour=filament_colour,
-                )
+            preset = UnifiedPreset(
+                id=str(preset_id),
+                name=str(name),
+                source="orca_cloud",
+                filament_type=filament_type,
+                filament_colour=filament_colour,
             )
+            if slot in ("process", "filament"):
+                # The profile's own compatible-printer list, straight out of
+                # the content Orca already hands us (#2628). Without it the
+                # SliceModal falls back to reading the printer out of the
+                # profile NAME — and a profile whose name carries no model
+                # ("Overture PLA Matte @0.2") then reads as "can't tell",
+                # which the picker treats as usable and auto-picks for a
+                # printer the profile was never built for.
+                preset.compatible_printers = _content_compatible_printers(content)
+            slots[slot].append(preset)
         _orca_cloud_cache[cache_key] = (now, slots)
         return slots, "ok"
     finally:
@@ -297,6 +305,25 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
     return slots
 
 
+def _content_compatible_printers(content: dict) -> list[str] | None:
+    """Pull ``compatible_printers`` out of an inline profile content dict.
+
+    Orca profiles carry it as a list of printer-preset names (the same shape
+    ``orca_profiles.py`` stores on import); a single-printer profile may store
+    a bare string. Returns ``None`` for missing / empty / malformed values so
+    the caller leaves the field unset and the SliceModal falls back to the
+    name-based matcher, rather than treating "no data" as "compatible with
+    nothing".
+    """
+    raw = content.get("compatible_printers")
+    if isinstance(raw, str):
+        raw = [raw]
+    if not isinstance(raw, list):
+        return None
+    names = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
+    return names or None
+
+
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
     """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
     names. Return the parsed list, or ``None`` on missing / malformed data so
@@ -442,6 +469,16 @@ def _enrich_cloud_metadata(
     in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
     this function exists post-#1712 — without the enrich the Bambu Cloud
     tier can't score in ``pickFilamentForSlot``.
+
+    Compatibility merge (#2628): the same name bridge carries
+    ``compatible_printers`` onto any process / filament entry that lacks it.
+    Bambu Cloud never ships the list, so a profile whose NAME carries no
+    printer model reads as "compatibility unknown" — which the SliceModal
+    treats as usable and auto-picks for whatever printer is selected. When
+    the very same profile is also present as a local import or an Orca Cloud
+    profile, that copy states the truth; borrowing it turns the auto-pick
+    into a correctly-rejected mismatch. Only ever fills a gap: an entry that
+    carries its own list keeps it.
     """
     # Build a name → metadata lookup from the tiers that carry it (local,
     # orca_cloud, standard). Bambu cloud is intentionally skipped — it
@@ -464,6 +501,24 @@ def _enrich_cloud_metadata(
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
 
+    # Compatibility bridge (#2628). Runs over both slots that carry the
+    # list, and in both directions between the cloud tiers — whichever copy
+    # of a profile knows its printers teaches the ones that don't.
+    for slot in ("process", "filament"):
+        compat_by_name: dict[str, list[str]] = {}
+        for tier in (local, orca_cloud, cloud, standard):
+            for p in tier[slot]:
+                if p.compatible_printers and p.name not in compat_by_name:
+                    compat_by_name[p.name] = p.compatible_printers
+        if not compat_by_name:
+            continue
+        for tier in (orca_cloud, cloud):
+            for p in tier[slot]:
+                if not p.compatible_printers:
+                    borrowed = compat_by_name.get(p.name)
+                    if borrowed:
+                        p.compatible_printers = list(borrowed)
+
     return orca_cloud, cloud, local, standard
 
 

+ 19 - 11
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,7 @@
 """API routes for smart plug management."""
 
 import logging
-from datetime import datetime, timedelta, timezone
+from datetime import timedelta
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from pydantic import BaseModel
@@ -36,9 +36,11 @@ from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.notification_service import notification_service
+from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.local_time import to_naive_utc, utcnow_naive
 
 logger = logging.getLogger(__name__)
 
@@ -581,10 +583,11 @@ async def control_smart_plug(
         plug.last_state = expected_state
         if expected_state == "ON":
             plug.auto_off_executed = False  # Reset flag when manually turning on
-        elif expected_state == "OFF" and plug.printer_id:
-            # Mark printer offline immediately for faster UI update
+        elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
+            # Mark printer offline immediately for faster UI update. Skipped for
+            # accessory plugs, which are linked to a printer but don't feed it (#2629).
             printer_manager.mark_printer_offline(plug.printer_id)
-    plug.last_checked = datetime.now(timezone.utc)
+    plug.last_checked = utcnow_naive()
     await db.commit()
 
     # Trigger associated scripts if this is a main (non-script) plug
@@ -671,7 +674,7 @@ async def get_plug_status(
             # Update last state in database
             if is_reachable and data.state:
                 plug.last_state = data.state
-                plug.last_checked = datetime.now(timezone.utc)
+                plug.last_checked = utcnow_naive()
                 await db.commit()
 
             energy_data = None
@@ -706,7 +709,7 @@ async def get_plug_status(
     # Update last state in database
     if status["reachable"]:
         plug.last_state = status["state"]
-        plug.last_checked = datetime.now(timezone.utc)
+        plug.last_checked = utcnow_naive()
         await db.commit()
 
     # Fetch energy data if device is reachable
@@ -714,6 +717,11 @@ async def get_plug_status(
     if status["reachable"]:
         energy = await service.get_energy(plug)
         if energy:
+            # Most plugs report only a lifetime counter — a Shelly has no notion
+            # of "today" at all, and Home Assistant never reports "yesterday".
+            # Fill those in from the hourly snapshots (#2539). Tasmota, which
+            # knows its own daily figures, is left alone.
+            energy = await fill_derived_energy(db, plug.id, energy)
             energy_data = SmartPlugEnergy(**energy)
 
             # Check power alerts
@@ -735,10 +743,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
     # Cooldown: don't alert more than once per 5 minutes
     cooldown_minutes = 5
     if plug.power_alert_last_triggered:
-        last_triggered = plug.power_alert_last_triggered
-        if last_triggered.tzinfo is None:
-            last_triggered = last_triggered.replace(tzinfo=timezone.utc)
-        time_since_last = datetime.now(timezone.utc) - last_triggered
+        # Naive UTC on both sides: the column is naive, so a row loaded fresh from
+        # the DB comes back without an offset and subtracting an aware now() would
+        # raise TypeError.
+        time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
         if time_since_last < timedelta(minutes=cooldown_minutes):
             return
 
@@ -759,7 +767,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
         threshold = plug.power_alert_low
 
     if alert_triggered:
-        plug.power_alert_last_triggered = datetime.now(timezone.utc)
+        plug.power_alert_last_triggered = utcnow_naive()
         await db.commit()
 
         # Send notification

+ 83 - 18
backend/app/api/routes/support.py

@@ -1119,21 +1119,51 @@ async def _collect_support_info() -> dict:
 
 
 def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
-    """Get log file content, limited to max_bytes from the end."""
+    """Get recent log content, limited to max_bytes from the end.
+
+    Spans the rotated files as well as the live one. ``bambuddy.log`` is capped
+    at 5 MB by the RotatingFileHandler, and the bundle used to ship only that
+    file — so on a large fleet with debug logging on, the window we ask a
+    reporter for was far shorter than anyone realised. The 19-printer farm in
+    #2555 emits ~100 lines/s of MQTT frame dumps, which fills 5 MB in under five
+    minutes: the bundle we received to diagnose a *queue* problem barely
+    contained one upload. The three rotated backups were sitting on disk unread.
+
+    Reads oldest -> newest so the result is chronological, then takes the last
+    ``max_bytes``, which is where the budget was all along.
+    """
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
         return b"Log file not found"
 
-    file_size = log_file.stat().st_size
-    if file_size <= max_bytes:
-        content = log_file.read_text(encoding="utf-8", errors="replace")
-    else:
-        # Read last max_bytes
-        with open(log_file, "rb") as f:
-            f.seek(file_size - max_bytes)
-            # Skip partial line at start
-            f.readline()
-            content = f.read().decode("utf-8", errors="replace")
+    # RotatingFileHandler names its backups .log.1 (newest) .. .log.N (oldest).
+    # Walk them in reverse so the concatenation reads forwards in time.
+    candidates: list[Path] = []
+    for index in range(settings.log_backup_count, 0, -1):
+        rotated = log_file.with_name(f"{log_file.name}.{index}")
+        if rotated.exists():
+            candidates.append(rotated)
+    candidates.append(log_file)
+
+    chunks: list[str] = []
+    remaining = max_bytes
+    # Fill from the newest backwards so the byte budget is spent on recent
+    # history, then flip back to chronological order for the reader.
+    for path in reversed(candidates):
+        if remaining <= 0:
+            break
+        try:
+            size = path.stat().st_size
+            with open(path, "rb") as f:
+                if size > remaining:
+                    f.seek(size - remaining)
+                    f.readline()  # discard the partial line the seek landed in
+                chunks.append(f.read().decode("utf-8", errors="replace"))
+            remaining -= min(size, remaining)
+        except OSError:
+            logger.debug("Failed to read log file %s for support bundle", path, exc_info=True)
+
+    content = "".join(reversed(chunks))
 
     # Sanitize sensitive data
     content = sanitize_log_content(content, sensitive_strings)
@@ -1197,6 +1227,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
     return out
 
 
+def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
+    """Sanitize a push_status snapshot's string *values*, never its JSON text.
+
+    This used to run :func:`sanitize_log_content` over the serialised snapshot.
+    That pass includes a generic Bambu-serial regex
+    (``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
+    decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
+    factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
+    shipped invalid JSON — unusable for exactly the ground-truth purpose the
+    snapshot exists for (found while diagnosing #2702).
+
+    Walking the structure instead leaves numbers, bools and None untouched, so
+    the output always parses. Keys are structural and never rewritten.
+    """
+    if isinstance(node, str):
+        return sanitize_log_content(node, sensitive_strings)
+    if isinstance(node, dict):
+        return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
+    if isinstance(node, list | tuple):
+        # Tuples too: `json.dumps` renders them as arrays, so stringifying one
+        # here would change the file's shape rather than just its content.
+        return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
+    if node is None or isinstance(node, bool | int | float):
+        return node
+    # Anything else (datetime, Decimal, …) would be stringified by json.dumps'
+    # ``default=str`` *after* this pass and so escape sanitisation entirely.
+    return sanitize_log_content(str(node), sensitive_strings)
+
+
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
@@ -1270,15 +1329,21 @@ async def generate_support_bundle(
                 "captured_at": datetime.now(timezone.utc).isoformat(),
                 "raw_data": redacted,
             }
-            # Belt-and-suspenders: pass the JSON text through the string-based
-            # sanitizer so any user-named string (printer name, serial baked
-            # into a tray uuid) the structural pass missed still gets caught.
-            snapshot_json = json.dumps(snapshot, indent=2, default=str)
-            snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
-            zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
+            # Belt-and-suspenders: pass every string value through the
+            # string-based sanitizer so any user-named string (printer name,
+            # serial baked into a tray uuid) the structural pass missed still
+            # gets caught. Values only — sanitizing the serialised JSON text
+            # corrupted numeric literals (see _sanitize_push_status_values).
+            snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
+            zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
 
         # Add log file
-        log_content = _get_log_content(sensitive_strings=sensitive_strings)
+        # Off the event loop: this reads up to 10 MB and then runs one full regex
+        # pass per sensitive string over it. Now that the bundle spans the rotated
+        # files it can genuinely reach that ceiling, and the blocking cost scales
+        # with the number of printers (4 redaction patterns each) — i.e. it is
+        # worst on exactly the fleet size this change was written for.
+        log_content = await asyncio.to_thread(_get_log_content, sensitive_strings=sensitive_strings)
         zf.writestr("bambuddy.log", log_content)
 
     zip_buffer.seek(0)

+ 18 - 0
backend/app/api/routes/system.py

@@ -606,6 +606,24 @@ async def get_system_health(
     return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
 
 
+@router.get("/db-pool")
+async def get_db_pool(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
+    """Live database connection-pool gauges for large-farm diagnostics (#2572).
+
+    Reports the resolved pool configuration plus current checked-out /
+    checked-in / overflow counts. Deliberately takes no DB session — reading
+    the pool's own counters must not itself consume a connection, so this stays
+    truthful even when the pool is saturated. On a healthy install ``checked_out``
+    sits well below ``config.pool_size + config.max_overflow``; sustained
+    saturation points at connections held across slow I/O (see #2572).
+    """
+    from backend.app.core.database import get_pool_status
+
+    return get_pool_status()
+
+
 @router.get("/appliance")
 async def get_appliance_defaults():
     """Expose appliance-set state for the SPA's bootstrap surface.

+ 16 - 9
backend/app/api/routes/websocket.py

@@ -19,11 +19,12 @@ from __future__ import annotations
 import logging
 
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
+from sqlalchemy import select
 
 from backend.app.core.auth import is_auth_enabled, verify_websocket_token
 from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
-from backend.app.services.background_dispatch import background_dispatch
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
 logger = logging.getLogger(__name__)
@@ -87,6 +88,20 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # ``broadcast_to_principal()`` helper can filter on it without
     # touching every call site.
     websocket.state.bambuddy_principal = principal
+    # Resolve principal username → User.id once at connect so
+    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
+    # per message. Auth-disabled path keeps None (broadcast_to_user fans
+    # out to all when target is None — matches the legacy single-user
+    # toast behaviour). API-keyed principal is empty string → None.
+    principal_user_id: int | None = None
+    if principal:
+        try:
+            async with async_session() as db:
+                row = await db.execute(select(User.id).where(User.username == principal))
+                principal_user_id = row.scalar_one_or_none()
+        except Exception:  # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
+            logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
+    websocket.state.bambuddy_principal_user_id = principal_user_id
     logger.info("WebSocket client connected")
 
     try:
@@ -106,14 +121,6 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                 }
             )
 
-        dispatch_state = await background_dispatch.get_state()
-        if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
-            await websocket.send_json(
-                {
-                    "type": "background_dispatch",
-                    "data": dispatch_state,
-                }
-            )
         logger.info("Sent initial status for %s printers", len(statuses))
 
         # Keep connection alive and handle incoming messages.

+ 8 - 0
backend/app/cli.py

@@ -70,6 +70,14 @@ async def kiosk_bootstrap(
             # commands via the /spoolbuddy/* routes — all gated by
             # can_manage_inventory now, so the bundled key must opt in.
             can_manage_inventory=True,
+            # Kiosk doesn't need maintenance writes; keep it False so the
+            # bundled key stays minimally scoped (#1832 follow-up).
+            can_manage_maintenance=False,
+            # Kiosk doesn't manage print archives either — keep it minimally
+            # scoped (#1888).
+            can_manage_archives=False,
+            # Kiosk doesn't manage projects — keep it minimally scoped (#1893).
+            can_manage_projects=False,
             printer_ids=None,
             enabled=True,
             expires_at=None,

+ 247 - 44
backend/app/core/auth.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import logging
 import os
 import secrets
+import time
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -49,15 +50,17 @@ logger = logging.getLogger(__name__)
 # entries also satisfy "not in the allowlist", so they fail closed regardless.
 #
 # Mapping rationale (see wiki/features/api-keys.md):
-#   can_read_status     → every ``*_READ`` + camera + stats + system + websocket
-#   can_queue           → queue write ops + archive reprint
-#   can_control_printer → physical printer + smart-plug control
-#   can_manage_library  → library upload/own + MakerWorld import (separate
-#                         trust level from queue management, hence its own flag)
-#   admin-only          → unmapped (default-deny); covers all create/update/
-#                         delete of admin resources, settings writes, user/
-#                         group/api-key/backup admin ops, discovery scan,
-#                         cloud auth, library ALL-ownership perms, purges
+#   can_read_status       → every ``*_READ`` + camera + stats + system + websocket
+#   can_queue             → queue write ops + archive reprint
+#   can_control_printer   → physical printer + smart-plug control
+#   can_manage_library    → library upload/own + MakerWorld import (separate
+#                           trust level from queue management, hence its own flag)
+#   can_manage_inventory  → spool/catalog/forecast writes + SpoolBuddy kiosk writes
+#   can_manage_maintenance→ per-printer maintenance log/reset + type-catalog CRUD
+#   admin-only            → unmapped (default-deny); covers all create/update/
+#                           delete of admin resources, settings writes, user/
+#                           group/api-key/backup admin ops, discovery scan,
+#                           cloud auth, library ALL-ownership perms, purges
 _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     # can_read_status — read-only access to status, history, and configuration
     Permission.PRINTERS_READ: "can_read_status",
@@ -112,13 +115,21 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.PRINTERS_AMS_RFID: "can_control_printer",
     Permission.PRINTERS_CLEAR_PLATE: "can_control_printer",
     Permission.SMART_PLUGS_CONTROL: "can_control_printer",
-    # can_manage_library — file-manager scope (upload/rename/delete OWN library
+    # can_manage_library — file-manager scope (upload/rename/delete library
     # entries + MakerWorld import which downloads files into the library).
-    # Bulk/ALL-ownership library ops (UPDATE_ALL / DELETE_ALL / PURGE) stay
-    # admin-only because they cross the user boundary.
+    # OWN and ALL ownership variants map to the same scope so the
+    # `require_ownership_permission` checker (which gates on `all_perm`)
+    # passes the API key through. This matches `can_queue` and the
+    # archives/inventory scopes — API keys have no per-row ownership identity
+    # (line 1663), so splitting OWN/ALL across allowlist/denylist made the
+    # whole library curation surface unreachable for API keys (#1832).
+    # LIBRARY_PURGE stays admin-only as a genuinely destructive op that
+    # bypasses the soft-delete window.
     Permission.LIBRARY_UPLOAD: "can_manage_library",
     Permission.LIBRARY_UPDATE_OWN: "can_manage_library",
+    Permission.LIBRARY_UPDATE_ALL: "can_manage_library",
     Permission.LIBRARY_DELETE_OWN: "can_manage_library",
+    Permission.LIBRARY_DELETE_ALL: "can_manage_library",
     Permission.MAKERWORLD_IMPORT: "can_manage_library",
     # can_manage_inventory — inventory write scope. Covers the documented
     # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
@@ -130,6 +141,44 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.INVENTORY_UPDATE: "can_manage_inventory",
     Permission.INVENTORY_DELETE: "can_manage_inventory",
     Permission.INVENTORY_FORECAST_WRITE: "can_manage_inventory",
+    # can_manage_maintenance — carved out of the admin denylist so HA-style
+    # automations can log "cleaned nozzle" / reset a maintenance counter via
+    # `POST /maintenance/items/{item_id}/perform` without granting broader
+    # printer control or settings write (#1832 follow-up). Also covers the
+    # per-printer maintenance CRUD (assign/remove items, edit intervals) and
+    # the type-catalog CRUD — the type catalog is a config surface (system
+    # types are auto-seeded, custom types are user-defined), so grouping it
+    # with the item writes matches the operator mental model of "keys that
+    # log maintenance can also manage what gets tracked." MAINTENANCE_READ
+    # stays under can_read_status.
+    Permission.MAINTENANCE_CREATE: "can_manage_maintenance",
+    Permission.MAINTENANCE_UPDATE: "can_manage_maintenance",
+    Permission.MAINTENANCE_DELETE: "can_manage_maintenance",
+    # can_manage_archives — print-history curation. Carved out of the admin
+    # denylist so automations can prune old prints via API key (#1888): the
+    # archive delete/update routes gate on
+    # ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, which
+    # resolves the ALL permission for API keys (no per-row ownership identity,
+    # same as can_queue / can_manage_library), so OWN and ALL map to the same
+    # scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
+    # destructive op that drops the stats contribution, mirroring LIBRARY_PURGE.
+    # ARCHIVES_REPRINT_* stays under can_queue (it enqueues a print).
+    Permission.ARCHIVES_CREATE: "can_manage_archives",
+    Permission.ARCHIVES_UPDATE_OWN: "can_manage_archives",
+    Permission.ARCHIVES_UPDATE_ALL: "can_manage_archives",
+    Permission.ARCHIVES_DELETE_OWN: "can_manage_archives",
+    Permission.ARCHIVES_DELETE_ALL: "can_manage_archives",
+    # can_manage_projects — project curation. Carved out of the admin denylist
+    # so automations can create projects and batch-add archives via API key
+    # (#1893). The project mutation routes gate on plain
+    # ``RequirePermissionIfAuthEnabled(Permission.PROJECTS_*)`` (no OWN/ALL
+    # ownership split — projects have no per-row ownership permission), so the
+    # three CRUD permissions map directly to the one scope. Membership edits
+    # (e.g. add-archives-to-project) gate on PROJECTS_UPDATE, so they're covered.
+    # PROJECTS_READ stays under can_read_status (unchanged).
+    Permission.PROJECTS_CREATE: "can_manage_projects",
+    Permission.PROJECTS_UPDATE: "can_manage_projects",
+    Permission.PROJECTS_DELETE: "can_manage_projects",
     # can_access_cloud — narrow opt-in scope, gated by the router-level
     # ``_cloud_api_key_gate`` and additionally enforced here so the route-
     # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
@@ -177,24 +226,29 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.PRINTERS_CREATE,
         Permission.PRINTERS_UPDATE,
         Permission.PRINTERS_DELETE,
-        Permission.ARCHIVES_CREATE,
-        Permission.ARCHIVES_UPDATE_OWN,
-        Permission.ARCHIVES_UPDATE_ALL,
-        Permission.ARCHIVES_DELETE_OWN,
-        Permission.ARCHIVES_DELETE_ALL,
+        # ARCHIVES_CREATE / _UPDATE_OWN / _UPDATE_ALL / _DELETE_OWN /
+        # _DELETE_ALL moved to the allowlist under `can_manage_archives`
+        # (#1888) — split between allow/deny made the whole archive-management
+        # surface unreachable for API keys via `require_ownership_permission`
+        # (same regression class as the library/maintenance carve-outs in
+        # #1832). ARCHIVES_PURGE stays denied as a genuinely destructive op
+        # that drops the print's stats contribution.
         Permission.ARCHIVES_PURGE,
-        Permission.LIBRARY_UPDATE_ALL,
-        Permission.LIBRARY_DELETE_ALL,
+        # LIBRARY_UPDATE_ALL / LIBRARY_DELETE_ALL moved to the allowlist
+        # under `can_manage_library` (#1832) — split between allow/deny made
+        # the whole library curation surface unreachable for API keys via
+        # `require_ownership_permission`. Purge stays denied as a genuinely
+        # destructive op.
         Permission.LIBRARY_PURGE,
-        Permission.PROJECTS_CREATE,
-        Permission.PROJECTS_UPDATE,
-        Permission.PROJECTS_DELETE,
+        # PROJECTS_CREATE / _UPDATE / _DELETE moved to the allowlist under
+        # `can_manage_projects` (#1893) — they were denied for every API key,
+        # making the project-management surface (create, add-archives, delete)
+        # unreachable, same regression class as the archives/library carve-outs.
         Permission.FILAMENTS_CREATE,
         Permission.FILAMENTS_UPDATE,
         Permission.FILAMENTS_DELETE,
-        Permission.MAINTENANCE_CREATE,
-        Permission.MAINTENANCE_UPDATE,
-        Permission.MAINTENANCE_DELETE,
+        # MAINTENANCE_CREATE / MAINTENANCE_UPDATE / MAINTENANCE_DELETE moved
+        # to the allowlist under `can_manage_maintenance` (#1832 follow-up).
         Permission.KPROFILES_CREATE,
         Permission.KPROFILES_UPDATE,
         Permission.KPROFILES_DELETE,
@@ -211,6 +265,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
+        # Slicer Pipelines (#1425) — admin authoring + the print-spending Run
+        # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
+        # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
+        # all three denied so they fail closed for any API-key surface.
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
     }
 )
 
@@ -335,7 +396,7 @@ def require_energy_cost_update():
                 if username is None:
                     raise credentials_exception
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise credentials_exception
                 iat: int | float | None = payload.get("iat")
             except JWTError:
@@ -640,9 +701,44 @@ async def verify_camera_stream_token(token: str) -> bool:
 
         # Long-lived path. Imported lazily so the auth module stays importable
         # at startup before the long_lived_tokens model is registered.
+        from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
+        return record is not None
+
+
+async def verify_camwall_token(token: str) -> bool:
+    """Verify a Cam Wall token (#2531). Reusable — does not consume it.
+
+    Deliberately narrower than :func:`verify_camera_stream_token`: only the
+    long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
+    to a logged-in browser, which already reaches the wall's metadata through
+    the ordinary printers API and has no need of this endpoint; and a
+    ``camera_stream`` token was handed out for video alone, so it must not
+    acquire the ability to enumerate printers by name just because a new
+    feature shipped.
+    """
+    async with async_session() as db:
+        from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope="camwall")
+        return record is not None
+
+
+async def verify_overlay_token(token: str) -> bool:
+    """Verify a streaming-overlay token (#2613). Reusable — does not consume it.
+
+    Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
+    the overlay status feed names the file being printed, so it must not be
+    reachable by a ``camwall`` token (which is trusted to hide the part name) or
+    a bare ``camera_stream`` token (handed out for video alone). The 60-minute
+    ephemeral token belongs to a logged-in browser, which reaches the same data
+    through the ordinary printers API and has no need of this endpoint.
+    """
+    async with async_session() as db:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
-        record = await verify_long_lived(db, token, scope="camera_stream")
+        record = await verify_long_lived(db, token, scope="overlay")
         return record is not None
 
 
@@ -720,10 +816,18 @@ async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None
             await db.rollback()  # jti already revoked — desired state, ignore
 
 
-async def is_jti_revoked(jti: str) -> bool:
-    """Return True if the given jti has been revoked."""
-    async with async_session() as db:
-        result = await db.execute(
+async def is_jti_revoked(jti: str, db: AsyncSession | None = None) -> bool:
+    """Return True if the given jti has been revoked.
+
+    Pass ``db`` to reuse the caller's session instead of opening a new one
+    (issue #2572): the permission dependencies already hold a session, and a
+    second checkout per request doubled pool pressure — a login burst then
+    exhausted the pool. With ``db`` omitted a short session is opened as before,
+    for callers that check the jti before they have a session open.
+    """
+
+    async def _query(session: AsyncSession) -> bool:
+        result = await session.execute(
             select(AuthEphemeralToken).where(
                 AuthEphemeralToken.token == jti,
                 AuthEphemeralToken.token_type == "revoked_jti",
@@ -731,6 +835,11 @@ async def is_jti_revoked(jti: str) -> bool:
         )
         return result.scalar_one_or_none() is not None
 
+    if db is not None:
+        return await _query(db)
+    async with async_session() as own_db:
+        return await _query(own_db)
+
 
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
     """Get a user by username (case-insensitive) with groups loaded for permission checks."""
@@ -784,6 +893,33 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
     return user
 
 
+# Short-lived cache for the auth-enabled flag (issue #2572). The middleware
+# and every ownership/permission dependency probe this once (or more) per
+# request; on a large farm that DB round-trip is pure overhead because the
+# value changes only when an admin toggles auth.
+#
+# SECURITY: only a ``True`` (auth-enabled) result is EVER cached. A disabled /
+# unconfigured result is never cached, so a stale cache can only ever cause a
+# request to REQUIRE auth that a moment ago wasn't required — it can never skip
+# an auth check that is now required. Staleness fails CLOSED, never open (cf.
+# GHSA-6mf4-q26m-47pv). ``set_auth_enabled`` invalidates explicitly on any
+# toggle; the TTL is only a backstop for out-of-band changes (a direct DB edit,
+# or another worker process in a multi-worker deployment).
+_AUTH_ENABLED_CACHE_TTL_SECONDS = 30.0
+_auth_enabled_cached_value: bool = False
+_auth_enabled_cached_until: float = 0.0
+
+
+def invalidate_auth_enabled_cache() -> None:
+    """Drop the cached auth-enabled flag so the next probe re-reads the DB.
+
+    Call after any write that toggles the ``auth_enabled`` setting.
+    """
+    global _auth_enabled_cached_value, _auth_enabled_cached_until
+    _auth_enabled_cached_value = False
+    _auth_enabled_cached_until = 0.0
+
+
 async def is_auth_enabled(db: AsyncSession) -> bool:
     """Check if authentication is enabled.
 
@@ -800,12 +936,25 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
     no exception. Any OTHER failure (connection error, fd exhaustion,
     schema mismatch, …) propagates so the caller can deny the request
     (503 / 500). Fail-closed is the only safe default for an auth probe.
+
+    Result is cached briefly to cut per-request DB load on large farms; only
+    the enabled=True result is cached, so a stale read can only fail closed.
+    See the module-level cache comment above.
     """
+    global _auth_enabled_cached_value, _auth_enabled_cached_until
+    if _auth_enabled_cached_value and time.monotonic() < _auth_enabled_cached_until:
+        return True
+
     result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
     setting = result.scalar_one_or_none()
-    if setting is None:
-        return False
-    return setting.value.lower() == "true"
+    enabled = setting is not None and setting.value.lower() == "true"
+    if enabled:
+        _auth_enabled_cached_value = True
+        _auth_enabled_cached_until = time.monotonic() + _AUTH_ENABLED_CACHE_TTL_SECONDS
+    else:
+        # Never cache "disabled" — keep failing closed on any future staleness.
+        _auth_enabled_cached_value = False
+    return enabled
 
 
 async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
@@ -895,13 +1044,16 @@ async def get_current_user_optional(
         if username is None:
             raise _unauthorized
         jti: str | None = payload.get("jti")
-        if not jti or await is_jti_revoked(jti):
-            raise _unauthorized  # I6: revoked token → 401, not anonymous
         iat: int | float | None = payload.get("iat")
     except JWTError:
         raise _unauthorized
 
+    if not jti:
+        raise _unauthorized  # I6: revoked token → 401, not anonymous
+
     async with async_session() as db:
+        if await is_jti_revoked(jti, db):
+            raise _unauthorized  # I6: revoked token → 401, not anonymous
         user = await get_user_by_username(db, username)
         if user is None or not user.is_active:
             raise _unauthorized
@@ -928,13 +1080,16 @@ async def get_current_user(
         if username is None:
             raise credentials_exception
         jti: str | None = payload.get("jti")
-        if not jti or await is_jti_revoked(jti):
-            raise credentials_exception
         iat: int | float | None = payload.get("iat")
     except JWTError:
         raise credentials_exception
 
+    if not jti:
+        raise credentials_exception
+
     async with async_session() as db:
+        if await is_jti_revoked(jti, db):
+            raise credentials_exception
         user = await get_user_by_username(db, username)
         if user is None:
             raise credentials_exception
@@ -1004,7 +1159,7 @@ async def require_auth_if_enabled(
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
@@ -1113,7 +1268,7 @@ def require_admin_if_auth_enabled():
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
@@ -1353,7 +1508,7 @@ def require_permission(*permissions: str | Permission):
                 if username is None:
                     raise credentials_exception
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise credentials_exception
                 iat: int | float | None = payload.get("iat")
             except JWTError:
@@ -1440,7 +1595,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                     jti: str | None = payload.get("jti")
-                    if not jti or await is_jti_revoked(jti):
+                    if not jti or await is_jti_revoked(jti, db):
                         raise HTTPException(
                             status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="Could not validate credentials",
@@ -1540,7 +1695,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                     jti: str | None = payload.get("jti")
-                    if not jti or await is_jti_revoked(jti):
+                    if not jti or await is_jti_revoked(jti, db):
                         raise HTTPException(
                             status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="Could not validate credentials",
@@ -1613,6 +1768,54 @@ def require_camera_stream_token_if_auth_enabled():
 RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
 
 
+def require_camwall_token_if_auth_enabled():
+    """Dependency that validates a Cam Wall token query param when auth is enabled.
+
+    Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
+    with the token in the URL because it has no login session to carry a JWT.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_camwall_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
+            )
+
+    return checker
+
+
+RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
+
+
+def require_overlay_token_if_auth_enabled():
+    """Dependency that validates a streaming-overlay token query param when auth
+    is enabled.
+
+    Used by the read-only overlay status feed (#2613), which OBS (or any
+    embed with no login session) loads with the token in the URL because it
+    has no JWT to carry.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_overlay_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
+            )
+
+    return checker
+
+
+RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,
@@ -1694,7 +1897,7 @@ def require_ownership_permission(
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                     jti: str | None = payload.get("jti")
-                    if not jti or await is_jti_revoked(jti):
+                    if not jti or await is_jti_revoked(jti, db):
                         raise HTTPException(
                             status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="Could not validate credentials",

+ 25 - 1
backend/app/core/config.py

@@ -3,10 +3,11 @@ import os
 import re as _re
 from pathlib import Path
 
+from pydantic import Field
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.5b1"
+APP_VERSION = "1.2.6b1"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
@@ -73,9 +74,32 @@ class Settings(BaseSettings):
     log_dir: Path = _log_dir
     database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
 
+    # Database connection pool sizing. ``None`` = use the built-in, dialect-aware
+    # default (PostgreSQL: pool_size 20 + max_overflow 80; SQLite: 20 + 200).
+    # Large PostgreSQL printer farms can raise these via the DB_POOL_SIZE /
+    # DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars (issue #2572).
+    # Make sure PostgreSQL ``max_connections`` comfortably exceeds
+    # (pool_size + max_overflow) x number of app worker processes.
+    db_pool_size: int | None = Field(default=None, gt=0)
+    db_max_overflow: int | None = Field(default=None, ge=0)
+    db_pool_timeout: int | None = Field(default=None, gt=0)
+    db_pool_recycle: int | None = Field(default=None, gt=0)
+    # LIFO checkout (PostgreSQL default on): reuse the most-recently-returned
+    # connection so a bursty farm keeps a small hot set busy and lets the excess
+    # overflow connections age out via pool_recycle instead of churning the whole
+    # pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
+    db_pool_use_lifo: bool | None = Field(default=None)
+
     # Logging
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true
     log_to_file: bool = True  # Set to false to disable file logging
+    # Rotation for bambuddy.log. Read by main.py (which owns the handler) and by
+    # the support bundle (which harvests the backups as well as the live file);
+    # they must agree on the backup count or the bundle silently skips history.
+    # Bounded: RotatingFileHandler treats maxBytes=0 as "never rotate", so a
+    # zero/negative override would grow the log without limit.
+    log_max_bytes: int = Field(default=5 * 1024 * 1024, gt=0)
+    log_backup_count: int = Field(default=3, ge=0)
 
     # API
     api_prefix: str = "/api/v1"

+ 745 - 53
backend/app/core/database.py

@@ -23,12 +23,63 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
     cursor.close()
 
 
-def _create_engine():
-    """Create the async engine with dialect-appropriate settings."""
+# Resolved connection-pool configuration, captured at engine creation so
+# /system/db-pool can report it without re-deriving the dialect defaults.
+_pool_config: dict = {}
+
+# What the PostgreSQL server itself will allow, read once at startup. None on
+# SQLite, or when the probe could not run. Reported by get_pool_status() so a
+# support bundle carries both sides of the comparison.
+_server_connection_limits: dict | None = None
+
+
+def _resolve_pool_kwargs() -> dict:
+    """Build the pool kwargs for ``create_async_engine`` (issue #2572).
+
+    Dialect-aware defaults, each overridable via env (``DB_POOL_SIZE`` etc.):
+      - PostgreSQL: pool_size 20 + max_overflow 80, ``pool_pre_ping`` (recover
+        server-dropped connections instead of erroring the request) and
+        ``pool_recycle`` 1800s. The old hard-coded 10 + 20 exhausted on large
+        farms while printer callbacks held connections.
+      - SQLite: pool_size 20 + max_overflow 200 (unchanged); no pre-ping /
+        recycle — the connection is a local file, not a server socket.
+    """
     if is_sqlite():
-        kwargs = {"pool_size": 20, "max_overflow": 200}
+        pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
+        max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 200
+        kwargs = {"pool_size": pool_size, "max_overflow": max_overflow}
     else:
-        kwargs = {"pool_size": 10, "max_overflow": 20}
+        pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
+        max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 80
+        kwargs = {
+            "pool_size": pool_size,
+            "max_overflow": max_overflow,
+            "pool_pre_ping": True,
+            "pool_recycle": settings.db_pool_recycle if settings.db_pool_recycle is not None else 1800,
+            # LIFO checkout keeps a bursty farm on a small hot connection set and
+            # lets overflow connections recycle out during quiet spells (#2572).
+            "pool_use_lifo": settings.db_pool_use_lifo if settings.db_pool_use_lifo is not None else True,
+        }
+    if settings.db_pool_timeout is not None:
+        kwargs["pool_timeout"] = settings.db_pool_timeout
+    return kwargs
+
+
+def _create_engine():
+    """Create the async engine with dialect-appropriate settings."""
+    kwargs = _resolve_pool_kwargs()
+
+    global _pool_config
+    _pool_config = {
+        "pool_size": kwargs["pool_size"],
+        "max_overflow": kwargs["max_overflow"],
+        # SQLAlchemy's own defaults when we don't pass the kwarg.
+        "pool_timeout": kwargs.get("pool_timeout", 30),
+        "pool_recycle": kwargs.get("pool_recycle", -1),
+        "pool_pre_ping": kwargs.get("pool_pre_ping", False),
+        "pool_use_lifo": kwargs.get("pool_use_lifo", False),
+    }
+
     eng = create_async_engine(
         settings.database_url,
         echo=settings.debug,
@@ -79,6 +130,40 @@ async_session = async_sessionmaker(
 )
 
 
+def get_pool_status() -> dict:
+    """Snapshot the DB connection pool for diagnostics (issue #2572).
+
+    Returns the resolved configuration plus live gauges (checked-out /
+    checked-in / overflow). Reads the pool's own counters — it does NOT
+    check out a connection, so it stays truthful even when the pool is
+    exhausted. Gauges a given pool implementation doesn't expose come back
+    as ``None`` rather than raising.
+    """
+    pool = engine.sync_engine.pool
+    gauges: dict = {}
+    for key, method_name in (
+        ("current_size", "size"),
+        ("checked_out", "checkedout"),
+        ("checked_in", "checkedin"),
+        ("overflow", "overflow"),
+    ):
+        method = getattr(pool, method_name, None)
+        try:
+            gauges[key] = method() if callable(method) else None
+        except Exception:
+            # A gauge should never take down the diagnostics endpoint.
+            gauges[key] = None
+    return {
+        "dialect": "sqlite" if is_sqlite() else "postgresql",
+        "config": dict(_pool_config),
+        # Both sides of the ceiling-vs-server comparison, so a support bundle
+        # shows whether a TooManyConnectionsError was a misconfiguration or a
+        # genuine leak. None on SQLite or if the startup probe couldn't run.
+        "server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
+        **gauges,
+    }
+
+
 async def run_with_retry(fn, *, max_attempts: int = 3, label: str = ""):
     """Run an async DB operation with retry for SQLite 'database is locked' errors.
 
@@ -190,6 +275,7 @@ async def init_db():
         oidc_provider,
         orca_base_cache,
         pending_upload,
+        pipeline_run,
         print_batch,
         print_log,
         print_queue,
@@ -199,6 +285,7 @@ async def init_db():
         project_bom,
         settings,
         shopping_list,
+        slicer_pipeline,
         slot_preset,
         smart_plug,
         smart_plug_energy_snapshot,
@@ -240,6 +327,107 @@ async def init_db():
     await seed_spool_catalog()
     await seed_color_catalog()
 
+    await check_pool_fits_server()
+
+
+async def check_pool_fits_server() -> None:
+    """Warn when the pool may ask PostgreSQL for more connections than it allows.
+
+    ``pool_size + max_overflow`` is the most connections one worker process will
+    ever open. If that exceeds what the server permits, the pool never reaches
+    its own limit and so never queues: it goes straight to the server, which
+    refuses with ``TooManyConnectionsError``. That surfaces wherever the next
+    connection happened to be needed — in the reported case, halfway through a
+    queue dispatch, which then left an expected-print registration and a dispatch
+    claim behind (#2702 follow-up).
+
+    The distinction is worth knowing when reading a log: SQLAlchemy's own
+    ``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
+    concurrency, or connections held too long), whereas asyncpg's
+    ``TooManyConnectionsError`` means the pool's ceiling is above the server's.
+
+    Not clamped, deliberately. Pool sizes are fixed when the engine is created,
+    which happens at import — before any connection exists to ask the server
+    with — and ``engine`` / ``async_session`` are imported by name in ~150 places,
+    so swapping the engine afterwards would leave stale references. The correct
+    ceiling also depends on the worker count and on anything else sharing the
+    server, neither of which Bambuddy can see. So this reports the mismatch with
+    both numbers and the knobs to fix it, and leaves the choice to the operator.
+    """
+    global _server_connection_limits
+    if is_sqlite():
+        return
+
+    from sqlalchemy import text
+
+    in_use: int | None = None
+    try:
+        async with engine.connect() as conn:
+            max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
+            reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
+            try:
+                in_use = int(
+                    (
+                        await conn.execute(
+                            text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
+                        )
+                    ).scalar_one()
+                )
+            except Exception as exc:
+                # `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
+                # restricted role sees fewer rows. The count is a nice-to-have
+                # for spotting other clients; the warning itself only needs the
+                # two settings above, so losing it must not cost the warning.
+                # Done last on purpose: a failed statement can abort the
+                # transaction, and nothing else uses this connection after it.
+                logger.debug("Could not count client backends: %s", exc)
+    except Exception as exc:
+        # A diagnostic must never be the reason startup fails. An older server
+        # or a restricted role may refuse these.
+        logger.debug("Could not read PostgreSQL connection limits: %s", exc)
+        return
+
+    available = max_conn - reserved
+    ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
+    _server_connection_limits = {
+        "max_connections": max_conn,
+        "superuser_reserved_connections": reserved,
+        "available_to_bambuddy": available,
+        "client_backends_at_startup": in_use,
+        "pool_ceiling_per_worker": ceiling,
+    }
+
+    if ceiling > available:
+        in_use_note = (
+            f" {in_use} client connection(s) are open on the server right now, including "
+            "this one — a count well above 1 means something else shares it."
+            if in_use is not None
+            else ""
+        )
+        logger.warning(
+            "DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
+            "connections (pool_size %d + max_overflow %d) but the server permits %d "
+            "(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
+            "as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
+            "Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
+            "max_connections — and account for every worker process and any other "
+            "client sharing this server.",
+            ceiling,
+            _pool_config.get("pool_size", 0),
+            _pool_config.get("max_overflow", 0),
+            available,
+            max_conn,
+            reserved,
+            in_use_note,
+        )
+    else:
+        logger.info(
+            "DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
+            ceiling,
+            available,
+            max_conn,
+        )
+
 
 # B2: Module-level counter exposing the number of rows skipped during the last
 # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
@@ -438,6 +626,187 @@ async def _migrate_normalize_printer_ids(conn) -> None:
             await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
 
 
+async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
+    """Re-scope queue items that carry another plate's filament overrides (#2551).
+
+    Queueing several plates of one 3MF used to store the union of every selected
+    plate's overrides on each item, so a ``force_color_match`` plate printing one
+    colour sat at Waiting until a printer had the whole batch's palette loaded.
+    The write paths now narrow to the plate, but items queued before the fix would
+    stay stuck until the user deleted and re-added them by hand — with a waiting
+    reason that gives no hint as to why. Repair them here instead.
+
+    Only pending items are touched: a printing or finished item's overrides are a
+    record of what it dispatched with, not an instruction. An item whose plate we
+    cannot read keeps every override, per ``overrides_for_plate``. Idempotent —
+    an already-scoped item narrows to itself and is not rewritten.
+    """
+    import json
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.services.filament_requirements import overrides_for_plate
+
+    rows = (
+        await conn.execute(
+            text(
+                "SELECT q.id, q.plate_id, q.filament_overrides, "
+                "a.file_path AS archive_path, l.file_path AS library_path "
+                "FROM print_queue q "
+                "LEFT JOIN print_archives a ON a.id = q.archive_id "
+                "LEFT JOIN library_files l ON l.id = q.library_file_id "
+                "WHERE q.status = 'pending' "
+                "AND q.plate_id IS NOT NULL "
+                "AND q.filament_overrides IS NOT NULL"
+            )
+        )
+    ).fetchall()
+
+    repaired = 0
+    for row in rows:
+        try:
+            overrides = json.loads(row.filament_overrides)
+        except (json.JSONDecodeError, TypeError):
+            continue
+        if not isinstance(overrides, list) or not overrides:
+            continue
+
+        stored_path = row.archive_path or row.library_path
+        if not stored_path:
+            continue
+        path = Path(stored_path)
+        if not path.is_absolute():
+            path = settings.base_dir / stored_path
+
+        scoped = overrides_for_plate(overrides, path, row.plate_id)
+        if len(scoped) == len(overrides):
+            continue
+
+        async with conn.begin_nested():
+            await conn.execute(
+                text("UPDATE print_queue SET filament_overrides = :overrides WHERE id = :id"),
+                {"overrides": json.dumps(scoped) if scoped else None, "id": row.id},
+            )
+        repaired += 1
+
+    if repaired:
+        logger.info(
+            "Re-scoped the filament overrides of %d queued item(s) to the plate they print (#2551)",
+            repaired,
+        )
+
+
+async def _migrate_scope_run_filament_to_plate(conn) -> None:
+    """Repair completed print-log rows that stored a multi-plate 3MF's whole-file
+    filament (and cost) instead of the printed plate's (#2614).
+
+    When the AMS tracker measured nothing for a completed run, the per-run filament
+    fell back to ``PrintArchive.filament_used_grams`` — the sum over EVERY plate of
+    the source 3MF (right for the archive card / project rollup, wrong for one
+    printed plate). So each printed plate of a 22-plate file logged the full ~12 kg,
+    inflating lifetime / user / project / filament stats by the plate count. The
+    forward fix scopes new rows; this repairs the rows already written.
+
+    Only completed rows whose stored grams EXACTLY equal the archive's whole-file
+    value are touched — that is the mis-copy signature. Tracker-measured rows (a
+    rounded spool-delta sum) and partial-progress rows (scaled to progress) never
+    match, so they are never clobbered. Cost is scaled by the plate's share of the
+    whole so it stays consistent with the corrected grams. Runs AFTER the #2603
+    archive plate_id backfill so ``print_archives.plate_id`` is populated.
+
+    Gated to run **exactly once** via a settings flag. This is not merely for
+    idempotency: a genuine single-plate print carries a ``plate_id`` too (the UI
+    always sends one), and for it the plate estimate legitimately equals the
+    whole-file value — so those rows match the signature on every boot. Without
+    the one-shot gate we would re-parse every single-plate 3MF on the print log at
+    each startup, a cost that grows without bound with print history. One pass is
+    enough: the forward fix keeps all new rows correct.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+    flag = "_backfill_2614_plate_filament_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT ple.id AS entry_id, ple.filament_used_grams AS grams, ple.cost AS cost, "
+                    "a.plate_id AS plate_id, a.filament_used_grams AS whole_grams, a.file_path AS file_path "
+                    "FROM print_log_entries ple "
+                    "JOIN print_archives a ON a.id = ple.archive_id "
+                    "WHERE ple.status = 'completed' "
+                    "AND a.plate_id IS NOT NULL "
+                    "AND a.file_path IS NOT NULL "
+                    "AND a.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams = a.filament_used_grams"
+                )
+            )
+        ).fetchall()
+
+        corrected = 0
+        grams_removed = 0.0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            if not path.exists():
+                continue
+            try:
+                plate_grams = extract_plate_metadata_from_3mf(path, row.plate_id).filament_used_grams
+            except Exception as exc:
+                logger.warning(
+                    "[#2614] could not read plate %s of %s for log entry %s: %s",
+                    row.plate_id,
+                    row.file_path,
+                    row.entry_id,
+                    exc,
+                )
+                continue
+            if not plate_grams or plate_grams <= 0:
+                continue
+            new_grams = round(plate_grams, 2)
+            if abs(new_grams - (row.grams or 0)) < 0.01:
+                continue  # nothing to change (e.g. a genuine single-plate file)
+            new_cost = row.cost
+            whole = row.whole_grams or 0
+            if row.cost and whole > 0:
+                new_cost = round(row.cost * (plate_grams / whole), 2)
+            await conn.execute(
+                text("UPDATE print_log_entries SET filament_used_grams = :g, cost = :c WHERE id = :id"),
+                {"g": new_grams, "c": new_cost, "id": row.entry_id},
+            )
+            corrected += 1
+            grams_removed += (row.grams or 0) - new_grams
+
+        if corrected:
+            logger.info(
+                "[#2614] Re-scoped %d completed print-log row(s) from whole-file to plate filament "
+                "(removed %.0f g of over-counted usage from statistics)",
+                corrected,
+                grams_removed,
+            )
+
+        # Mark done unconditionally (even when nothing matched) so this one-shot
+        # never re-scans the print log on subsequent boots. id/timestamps come
+        # from the table's own defaults; "key" is quoted as it's a keyword.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_drop_library_print_name(conn) -> None:
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
 
@@ -669,6 +1038,23 @@ async def run_migrations(conn):
     """
     from sqlalchemy import text
 
+    # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
+    # Links a retry-failed run back to its parent so the dashboard can show
+    # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL",
+    )
+
+    # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
+    # Allows a pipeline run to source from an archive's source 3MF in addition
+    # to a library file. Idempotent — _safe_execute swallows the "already exists"
+    # case on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
@@ -693,6 +1079,13 @@ async def run_migrations(conn):
     # Migration: Add f3d_path column to print_archives for Fusion 360 design files
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
 
+    # Migration: Add plate_id column to print_archives (#2603). The selected plate
+    # of a multi-plate 3MF is copied from the queue item at dispatch so Print
+    # History can show the actual plate instead of falling back to Plate 1.
+    # Nullable, no default — identical DDL on SQLite and Postgres. Backfilled from
+    # linked queue rows below.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_id INTEGER")
+
     # Migration: Add on_maintenance_due column to notification_providers
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
 
@@ -1005,6 +1398,16 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
 
+    # Migration: cleanup flag for transient printer-card uploads routed through
+    # the scheduler. The archive copy is durable; the library row/file can be
+    # deleted after dispatch.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT false"
+        )
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler
@@ -1220,6 +1623,78 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
 
+    # Migration: convert bed_levelling / flow_cali / nozzle_offset_cali from
+    # boolean to tri-state strings (off/on/auto). BambuStudio exposes a third
+    # "auto" state for these (skip the calibration if it was done recently); our
+    # booleans could only send force-on / off. Legacy rows map true->'on',
+    # false->'off'; the new default is 'auto'. Idempotent on both dialects:
+    # SQLite leans on column affinity (a BOOLEAN-declared column stores text
+    # fine) and only rewrites rows still holding 0/1; PostgreSQL alters the
+    # column type only while it is still boolean, so re-runs and fresh
+    # create_all() schemas (already VARCHAR) are skipped. Column names are
+    # hardcoded constants, not user input.
+    _tristate_cols = ("bed_levelling", "flow_cali", "nozzle_offset_cali")
+    if is_sqlite():
+        for _col in _tristate_cols:
+            async with conn.begin_nested():
+                # B608 is a false positive here: _col is a hardcoded constant
+                # from _tristate_cols, never user input, and SQL identifiers
+                # can't be bound as parameters. Suppressed inline below.
+                await conn.execute(
+                    text(f"UPDATE print_queue SET {_col} = 'on' WHERE {_col} IN (1, '1', 'true', 'True')")  # nosec B608
+                )
+                await conn.execute(
+                    text(f"UPDATE print_queue SET {_col} = 'off' WHERE {_col} IN (0, '0', 'false', 'False')")  # nosec B608
+                )
+    else:
+        for _col in _tristate_cols:
+            result = await conn.execute(
+                text(
+                    "SELECT data_type FROM information_schema.columns "
+                    "WHERE table_name = 'print_queue' AND column_name = :col"
+                ),
+                {"col": _col},
+            )
+            row = result.fetchone()
+            if row and row[0] == "boolean":
+                await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} DROP DEFAULT")
+                await _safe_execute(
+                    conn,
+                    f"ALTER TABLE print_queue ALTER COLUMN {_col} TYPE VARCHAR(8) "
+                    f"USING (CASE WHEN {_col} THEN 'on' ELSE 'off' END)",
+                )
+                await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} SET DEFAULT 'auto'")
+
+    # Migration: normalise the workflow-default settings rows that back these
+    # options from legacy "true"/"false" to the tri-state vocabulary so the API
+    # returns real values (the AppSettings validator also coerces on read, but
+    # rewriting keeps the stored data honest). Only these three became tri-state.
+    for _skey in ("default_bed_levelling", "default_flow_cali", "default_nozzle_offset_cali"):
+        async with conn.begin_nested():
+            await conn.execute(
+                text("UPDATE settings SET value = 'on' WHERE key = :k AND lower(value) IN ('true', '1')"),
+                {"k": _skey},
+            )
+            await conn.execute(
+                text("UPDATE settings SET value = 'off' WHERE key = :k AND lower(value) IN ('false', '0')"),
+                {"k": _skey},
+            )
+
+    # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
+    # is one of {inherit, on, off} — 'inherit' falls back to the global
+    # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
+    # target column overrides the filament-map derivation when not null.
+    # Existing rows default to 'inherit' + NULL so behaviour is unchanged for
+    # in-flight queues.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_queue ADD COLUMN preheat_override VARCHAR(10) DEFAULT 'inherit'",
+    )
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_queue ADD COLUMN preheat_chamber_target_override INTEGER",
+    )
+
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
     # This allows queue items to reference library files directly (archive created at print start)
     try:
@@ -1300,6 +1775,22 @@ async def run_migrations(conn):
         except (OperationalError, ProgrammingError):
             pass  # Already applied
 
+    # Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
+    # timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
+    # TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
+    # exist" on Postgres. On a fresh DB create_all() already built the column, so
+    # the ALTER is swallowed as "already exists".
+    #
+    # Placed AFTER the print_queue_new2 table-recreate above: that recreate
+    # (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
+    # rebuilds print_queue from an explicit column list that doesn't carry this
+    # column, so adding it earlier would let the recreate silently drop it. Adding
+    # it here means it survives that path.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
+
     # Migration: Add HA energy sensor entity columns to smart_plugs
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
@@ -2186,6 +2677,14 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0")
 
+    # Migration (#2539): a REST plug's lifetime energy counter, separate from its
+    # today counter. Devices differ in which they expose — a Shelly reports only
+    # a cumulative `aenergy.total`, a Tasmota behind a REST bridge reports both —
+    # and conflating the two made the cumulative value read as "today", so it
+    # never reset at midnight and "Total" stayed empty forever.
+    await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_path VARCHAR(200)")
+    await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_multiplier REAL DEFAULT 1.0")
+
     # Migration: Add batch_id column to print_queue for batch grouping
     try:
         async with conn.begin_nested():
@@ -2621,6 +3120,63 @@ async def run_migrations(conn):
         async with conn.begin_nested():
             await conn.execute(text("UPDATE api_keys SET can_manage_inventory = can_queue"))
 
+    # #1832 follow-up: carve maintenance CRUD out of the admin denylist so
+    # HA-style automations can log "cleaned nozzle" via API key. Distinct
+    # from the two backfills above: MAINTENANCE_CREATE / _UPDATE / _DELETE
+    # were EXPLICITLY denied for every API key under the pre-migration model
+    # (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no existing
+    # integration relies on them. Column default TRUE matches the "safe,
+    # on-by-default" pattern for keys created via the UI going forward;
+    # existing rows backfill to FALSE so the upgrade path does not silently
+    # widen scope for keys created before this flag existed. Users opt in
+    # via Settings → API Keys per key.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_maintenance")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_maintenance = FALSE"))
+
+    # #1888: carve archive CRUD (create/update/delete — NOT purge) out of the
+    # admin denylist so automations can prune old prints via API key. Same
+    # shape and reasoning as can_manage_maintenance above: ARCHIVES_CREATE /
+    # _UPDATE_* / _DELETE_* were EXPLICITLY denied for every API key under the
+    # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
+    # existing integration relies on them. Column default TRUE for keys created
+    # via the UI going forward; existing rows backfill to FALSE so the upgrade
+    # path does not silently widen scope for keys created before this flag
+    # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid
+    # on both SQLite and Postgres, so no dialect branch is needed.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_archives")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_archives BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_archives = FALSE"))
+
+    # #1893: carve project CRUD + membership (create/update/delete, add-archives)
+    # out of the admin denylist so automations can manage projects via API key.
+    # Identical shape and reasoning to can_manage_archives above: PROJECTS_CREATE
+    # / _UPDATE / _DELETE were EXPLICITLY denied for every API key under the
+    # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
+    # existing integration relies on them. Column default TRUE for keys created
+    # via the UI going forward; existing rows backfill to FALSE so the upgrade
+    # path does not silently widen scope for keys created before this flag
+    # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid on
+    # both SQLite and Postgres, so no dialect branch is needed.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_projects")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_projects BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_projects = FALSE"))
+
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
     # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
@@ -3208,10 +3764,24 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
 
+    # Migration: record when Bambu rejects a stored cloud token. Until now the
+    # only state we kept was the token string itself, so a dead credential was
+    # indistinguishable from a live one and the UI reported "connected" forever
+    # while every cloud call 401'd. DATETIME is SQLite-only — Postgres uses
+    # TIMESTAMP, so the column is dialect-branched per project convention.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token_invalid_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS cloud_token_invalid_at TIMESTAMP")
+
     # Data migration: drop the embedded 3MF Title (`print_name`) from library
     # file metadata so the FileManager displays the filename, not the title (#1489).
     await _migrate_drop_library_print_name(conn)
 
+    # Data migration: queue items written before #2551 carry every selected plate's
+    # filament overrides, so a force-colour plate waits on colours it never prints.
+    await _migrate_scope_force_color_overrides_to_plate(conn)
+
     # Backfill NULL print_archives.created_at — older rows (and rows imported
     # via the SQLite ↔ Postgres cross-DB restore path) can land with NULL
     # because the column was originally created without a DEFAULT clause and
@@ -3371,10 +3941,139 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
 
+    # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
+    # the start-watchdog reverted the row from 'printing' back to 'pending' so a
+    # printer that never actually starts stops being retried forever. INTEGER
+    # DEFAULT 0 is spelled identically on SQLite and Postgres — no dialect branch.
+    # Verified on both dialects: ADD COLUMN ... DEFAULT 0 backfills existing rows,
+    # so no separate UPDATE is needed (and _safe_execute is DDL-only — see its
+    # docstring). The scheduler reads it as `(item.dispatch_attempts or 0) + 1`
+    # regardless, so even a NULL row could not disable the retry cap.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatch_attempts INTEGER DEFAULT 0")
+
+    # Backfill: copy the selected plate from linked queue rows onto their archives
+    # (#2603). Recovers the plate for archives created before print_archives had a
+    # plate_id column, wherever the queue row still points at the archive and
+    # carries a plate. Runs here — after every print_queue column migration
+    # (plate_id, archive_id) — because it reads print_queue.plate_id, which is
+    # added far earlier in this function but must exist before this DML runs on a
+    # first-ever migration pass. Correlated-subquery form so the DML is identical
+    # on SQLite and Postgres; the WHERE plate_id IS NULL guard makes it idempotent
+    # and keeps it from clobbering values set on later runs.
+    async with conn.begin_nested():
+        # Only do any work (and, on SQLite, the FTS rebuild below) when there is
+        # actually a plate to recover — so this is a one-off cost on the upgrade
+        # boot, not an every-boot tax once every archive is backfilled.
+        has_work = (
+            await conn.execute(
+                text(
+                    "SELECT 1 FROM print_archives a "
+                    "JOIN print_queue q ON q.archive_id = a.id "
+                    "WHERE a.plate_id IS NULL AND q.plate_id IS NOT NULL "
+                    "LIMIT 1"
+                )
+            )
+        ).first() is not None
+        if has_work:
+            # SQLite: print_archives has an external-content FTS index (archive_fts,
+            # created above) whose AFTER UPDATE trigger issues an FTS 'delete' for
+            # the row. Archives created before that table existed were never indexed
+            # (its creation runs no rebuild), and updating an un-indexed row trips
+            # "database disk image is malformed". plate_id isn't even an FTS column,
+            # so the trigger's re-index is pointless here — but it still fires. Rebuild
+            # the index from the content table first so every row is present and the
+            # trigger's 'delete' is well-defined. Postgres has no such FTS table.
+            if is_sqlite():
+                await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
+            await conn.execute(
+                text(
+                    "UPDATE print_archives "
+                    "SET plate_id = ("
+                    "  SELECT pq.plate_id FROM print_queue pq "
+                    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
+                    "  LIMIT 1"
+                    ") "
+                    "WHERE plate_id IS NULL "
+                    "AND EXISTS ("
+                    "  SELECT 1 FROM print_queue pq "
+                    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
+                    ")"
+                )
+            )
+
+    # Migration: repair completed print-log rows that stored a multi-plate 3MF's
+    # whole-file filament instead of the printed plate's (#2614). Runs AFTER the
+    # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
+    await _migrate_scope_run_filament_to_plate(conn)
+
+    # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
+    # whether a plug actually feeds the printer's own power — only then may an
+    # auto-off mark the printer offline. Defaults to true so existing plugs
+    # keep the previous behaviour; accessory plugs (filter fan, lights) are
+    # opted out by the user. BOOLEAN literals differ per dialect (SQLite has
+    # no true/false keyword), so the default is dialect-branched.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
+        )
+
+    # Migration: real filesystem mtime for library files/folders (#2680). The
+    # folder tree's "sort by recent activity" and the file pane's date sort must
+    # track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
+    # a bulk external scan every row's ``updated_at`` is the same scan instant, so
+    # ordering was arbitrary. Nullable; the timestamp type differs by dialect
+    # (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
+    # "type datetime does not exist" on Postgres. On a fresh DB create_all() already
+    # built the column, so the ALTER is swallowed as "already exists".
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
+        await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
+        await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
 
+    # Migration: per-file print progress inside a project (#1897).
+    # - print_archives.library_file_id: which library file a queued run was
+    #   dispatched from; nullable, no FK constraint added to existing tables
+    #   (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
+    #   via the ORM on fresh installs and tolerates dangling ids by matching
+    #   hash/filename as fallback anyway).
+    # - projects.target_sets: optional copies-per-file target. INTEGER is
+    #   spelled identically on SQLite and Postgres — no dialect branch.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
+    await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
+
+    # Migration: persist the timelapse snapshot-diff baseline (#2704).
+    # The list of video filenames present on the printer when the print began,
+    # so the diff survives a restart and the manual scan can use it instead of
+    # the clock-based matching that a LAN-only printer defeats. No dialect
+    # branch: SQLAlchemy renders this column as `JSON` on both SQLite and
+    # Postgres for a fresh install (checked with CreateTable against each
+    # dialect), so spelling the ALTER the same way keeps a migrated database
+    # identical to a new one. Matching matters on Postgres in particular —
+    # asyncpg binds the serialised value as json and would reject a TEXT column
+    # (mirrors the `projects.attachments JSON` migration above).
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline JSON")
+
+    # Migration: plate-clear-required notification opt-in (#2525). Off by
+    # default — it fires after every print, at the same moment as the
+    # print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
+    if is_sqlite():
+        await _safe_execute(
+            conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
+        )
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
+        )
+
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),
@@ -3458,7 +4157,7 @@ async def seed_default_groups():
 
     from sqlalchemy import select
 
-    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.models.group import Group
     from backend.app.models.user import User
 
@@ -3633,63 +4332,31 @@ async def seed_default_groups():
                 group.permissions = perms
         await session.commit()
 
-        # Backfill library:purge + archives:purge for the Administrators group
-        # on existing installs. Both permissions were added after Administrators
-        # was first seeded, so upgrading users miss them even though the default
-        # config (ALL_PERMISSIONS) includes them for fresh installs.
-        result = await session.execute(select(Group).where(Group.name == "Administrators"))
-        admin_group = result.scalar_one_or_none()
-        if admin_group and admin_group.permissions is not None:
-            perms = list(admin_group.permissions)
-            added = False
-            for new_perm in ("library:purge", "archives:purge"):
-                if new_perm not in perms:
-                    perms.append(new_perm)
-                    added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
-            if added:
-                admin_group.permissions = perms
-        await session.commit()
-
-        # Backfill the read flag set for the Administrators group on existing
-        # installs (maziggy/bambuddy-security #2). Two layers:
+        # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
+        # Administrators' contract is full access to every feature — fresh
+        # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
+        # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
+        # whatever permission set existed when they were first seeded, so a
+        # newly-added Permission enum member silently leaves admins gated out
+        # of the feature it controls.
         #
-        # (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
-        #     these via ALL_PERMISSIONS; upgrades need the explicit backfill
-        #     so admin's permission set matches a fresh install's.
-        #
-        # (b) Legacy `archives:read` / `library:read` / `queue:read`. The
-        #     frontend still gates download / preview UI on these LEGACY
-        #     strings (see ArchivesPage / FileManagerPage), so admin needs
-        #     them retained even though the new API uses the OWN/ALL split.
-        #     The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
-        #     read flags for admin — this backfill ensures they're present
-        #     even if they were stripped by hand or by an older migration.
-        #
-        # Also includes orca_cloud:auth for parity with fresh-install
-        # behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
-        # admin role that's been customised since seed still has it).
+        # Generalises the previous one-off admin backfills (library:purge,
+        # archives:purge, the OWN/ALL read-flag set + legacy read flags,
+        # orca_cloud:auth, printer_sensor_history:read, …): every current
+        # Permission enum value is appended to the admin group if missing.
+        # Additive only — never removes a permission an operator added by
+        # hand. Run AFTER the legacy-rename migration above so the renamed
+        # OWN/ALL variants land in the group before the sync sees them.
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         admin_group = result.scalar_one_or_none()
         if admin_group and admin_group.permissions is not None:
             perms = list(admin_group.permissions)
             added = False
-            for new_perm in (
-                "archives:read",
-                "archives:read_own",
-                "archives:read_all",
-                "library:read",
-                "library:read_own",
-                "library:read_all",
-                "queue:read",
-                "queue:read_own",
-                "queue:read_all",
-                "orca_cloud:auth",
-            ):
+            for new_perm in ALL_PERMISSIONS:
                 if new_perm not in perms:
                     perms.append(new_perm)
                     added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+                    logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
             if added:
                 admin_group.permissions = perms
         await session.commit()
@@ -3748,6 +4415,31 @@ async def seed_default_groups():
                 group.permissions = perms
         await session.commit()
 
+        # Backfill pipeline permissions (#1425) for non-admin groups.
+        # Administrators is handled by the ALL_PERMISSIONS sync above.
+        #   - Operators: all three (matches fresh-install DEFAULT_GROUPS)
+        #   - Any other group with library:read_own or settings:read:
+        #     pipelines:read only
+        result = await session.execute(select(Group))
+        for group in result.scalars().all():
+            if not group.permissions or group.name == "Administrators":
+                continue
+            perms = list(group.permissions)
+            changed = False
+            if group.name == "Operators":
+                for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
+                    if new_perm not in perms:
+                        perms.append(new_perm)
+                        changed = True
+                        logger.info("Added %s to Operators group (backfill)", new_perm)
+            elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms):
+                perms.append("pipelines:read")
+                changed = True
+                logger.info("Added pipelines:read to group '%s' (backfill)", group.name)
+            if changed:
+                group.permissions = perms
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
             # Refresh to get newly created groups

+ 34 - 1
backend/app/core/logging_filters.py

@@ -1,4 +1,4 @@
-"""Logging filters for the Bambuddy log pipeline.
+"""Logging filters and redaction helpers for the Bambuddy log pipeline.
 
 Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
 uvicorn access log focused on state-changing HTTP methods, and
@@ -6,12 +6,45 @@ uvicorn access log focused on state-changing HTTP methods, and
 caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
 (see the filter's docstring for details). Both live here so tests can
 import them without pulling in ``backend.app.main``'s startup graph.
+
+Also holds :data:`URL_CREDENTIALS_PATTERN` and
+:func:`redact_url_credentials`, the single place where the shape of a
+credentialed URL is defined for the whole backend.
 """
 
 from __future__ import annotations
 
 import asyncio
 import logging
+import re
+
+# ``scheme://user:secret@host`` — the only URL shape that carries a secret.
+# Both userinfo parts exclude ``/`` so the match can never run past the
+# authority into the path, and exclude whitespace so a wrapped log line can't
+# glue two URLs together. ``secret`` is otherwise unrestricted and greedy so
+# it reaches the *last* ``@`` before the path, which is where RFC 3986 ends
+# the userinfo — that keeps an unescaped ``@`` inside a password (legal in an
+# external camera URL) from leaving its tail in the log. Named groups let
+# callers choose how much to mask: the log pipeline keeps the username, the
+# support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
+URL_CREDENTIALS_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@")
+
+
+def redact_url_credentials(text: str | None) -> str | None:
+    """Mask the password in every ``scheme://user:secret@host`` URL in *text*.
+
+    Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
+    input in its ``Input #0`` line, so logging its stderr verbatim publishes
+    the printer access code (or an external camera's password) into
+    ``bambuddy.log``, which users routinely attach to public issues.
+
+    The username, host, port and path survive so the line stays useful for
+    diagnosis; only the secret is replaced. Returns *text* unchanged when
+    there is nothing to mask, including ``None``/``""``.
+    """
+    if not text or "://" not in text or "@" not in text:
+        return text
+    return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
 
 
 class WriteRequestsOnlyFilter(logging.Filter):

+ 21 - 5
backend/app/core/permissions.py

@@ -19,7 +19,7 @@ class Permission(StrEnum):
     PRINTERS_CREATE = "printers:create"
     PRINTERS_UPDATE = "printers:update"
     PRINTERS_DELETE = "printers:delete"
-    PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
+    PRINTERS_CONTROL = "printers:control"  # Printer controls: stop/pause/resume, lights, motors, drying, etc.
     PRINTERS_FILES = "printers:files"  # Send files to printer
     PRINTERS_AMS_RFID = "printers:ams_rfid"  # Re-read AMS RFID tags
     PRINTERS_CLEAR_PLATE = "printers:clear_plate"  # Confirm plate cleared for next print
@@ -36,15 +36,15 @@ class Permission(StrEnum):
     ARCHIVES_UPDATE_ALL = "archives:update_all"
     ARCHIVES_DELETE_OWN = "archives:delete_own"
     ARCHIVES_DELETE_ALL = "archives:delete_all"
-    ARCHIVES_REPRINT_OWN = "archives:reprint_own"
-    ARCHIVES_REPRINT_ALL = "archives:reprint_all"
+    ARCHIVES_REPRINT_OWN = "archives:reprint_own"  # Reprint own archives; queue:create is also required to enqueue
+    ARCHIVES_REPRINT_ALL = "archives:reprint_all"  # Reprint any archive; queue:create is also required to enqueue
     ARCHIVES_PURGE = "archives:purge"
 
     # Queue
     QUEUE_READ = "queue:read"
     QUEUE_READ_OWN = "queue:read_own"
     QUEUE_READ_ALL = "queue:read_all"
-    QUEUE_CREATE = "queue:create"
+    QUEUE_CREATE = "queue:create"  # Create queue items, including ASAP items eligible for immediate dispatch
     QUEUE_UPDATE_OWN = "queue:update_own"
     QUEUE_UPDATE_ALL = "queue:update_all"
     QUEUE_DELETE_OWN = "queue:delete_own"
@@ -55,7 +55,7 @@ class Permission(StrEnum):
     LIBRARY_READ = "library:read"
     LIBRARY_READ_OWN = "library:read_own"
     LIBRARY_READ_ALL = "library:read_all"
-    LIBRARY_UPLOAD = "library:upload"
+    LIBRARY_UPLOAD = "library:upload"  # Upload/import/slice library files; queue:create is also required to print
     LIBRARY_UPDATE_OWN = "library:update_own"
     LIBRARY_UPDATE_ALL = "library:update_all"
     LIBRARY_DELETE_OWN = "library:delete_own"
@@ -184,6 +184,11 @@ class Permission(StrEnum):
     GROUPS_UPDATE = "groups:update"
     GROUPS_DELETE = "groups:delete"
 
+    # Slicer Pipelines (#1425)
+    PIPELINES_READ = "pipelines:read"  # View pipeline definitions and run history
+    PIPELINES_WRITE = "pipelines:write"  # Create / edit / delete pipeline definitions
+    PIPELINES_RUN = "pipelines:run"  # Kick off a pipeline run (PR C); separate because spending filament is a different trust dimension than authoring the recipe
+
     # WebSocket connection
     WEBSOCKET_CONNECT = "websocket:connect"
 
@@ -349,6 +354,11 @@ PERMISSION_CATEGORIES = {
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_DELETE,
     ],
+    "Slicer Pipelines": [
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
+    ],
     "WebSocket": [
         Permission.WEBSOCKET_CONNECT,
     ],
@@ -466,6 +476,10 @@ DEFAULT_GROUPS = {
             Permission.COST_CENTERS_READ_OWN.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - full access
+            Permission.PIPELINES_READ.value,
+            Permission.PIPELINES_WRITE.value,
+            Permission.PIPELINES_RUN.value,
             # WebSocket
             Permission.WEBSOCKET_CONNECT.value,
         ],
@@ -497,6 +511,8 @@ DEFAULT_GROUPS = {
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - read only
+            Permission.PIPELINES_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,

+ 112 - 0
backend/app/core/websocket.py

@@ -43,6 +43,42 @@ class ConnectionManager:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
 
+    async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
+        """Send a message to every connection authenticated as the given user.
+
+        When ``user_id`` is None the message fans out to all connections —
+        this is the auth-disabled single-user path, where neither the queue
+        item's ``created_by_id`` nor the WS principal is set, and the
+        existing fan-out semantics are exactly what the user wants.
+
+        Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
+        stamped at connect time (``routes/websocket.py``). Connections
+        without a stamped id are skipped on the targeted path so an
+        anonymous reader never receives another user's dispatch toast.
+        """
+        if user_id is None:
+            await self.broadcast(message)
+            return
+
+        if not self.active_connections:
+            return
+
+        data = json.dumps(message)
+        async with self._lock:
+            disconnected = []
+            for connection in self.active_connections:
+                conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
+                if conn_uid != user_id:
+                    continue
+                try:
+                    await connection.send_text(data)
+                except Exception:
+                    disconnected.append(connection)
+
+            for conn in disconnected:
+                if conn in self.active_connections:
+                    self.active_connections.remove(conn)
+
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
         await self.broadcast(
@@ -91,6 +127,82 @@ class ConnectionManager:
             }
         )
 
+    async def send_queue_item_uploading(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int,
+        printer_name: str | None,
+        file_name: str,
+        total_bytes: int,
+    ):
+        """Toast trigger: scheduler picked the item up, FTP upload starts."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_uploading",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "file_name": file_name,
+                "total_bytes": total_bytes,
+            },
+        )
+
+    async def send_queue_item_upload_progress(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        bytes_transferred: int,
+        total_bytes: int,
+    ):
+        """Toast update: throttled byte-level progress during the FTP upload."""
+        pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_upload_progress",
+                "queue_item_id": queue_item_id,
+                "bytes_transferred": bytes_transferred,
+                "total_bytes": total_bytes,
+                "pct": pct,
+            },
+        )
+
+    async def send_queue_item_acked(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int,
+    ):
+        """Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_acked",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+            },
+        )
+
+    async def send_queue_item_failed(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int | None,
+        reason: str,
+    ):
+        """Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_failed",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+                "reason": reason,
+            },
+        )
+
     async def send_missing_spool_assignment(
         self,
         printer_id: int,

+ 9009 - 0
backend/app/data/hms_actions.json

@@ -0,0 +1,9009 @@
+{
+    "31B": {
+        "07008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "18008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1802802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1807802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1804802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18078029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18068026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1805802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18068029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18048029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1803802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "0703802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1806802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0701802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0700802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1801802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0702802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18048026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18078026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1800802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008065": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008042": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0700860000020002": [],
+        "03004031": [],
+        "03004035": [],
+        "03004036": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0502402C": [
+            "OK_BUTTON"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "07FF200000020002": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FE200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "18FE200000020002": [],
+        "0702200000020001": [],
+        "05008098": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF200000020001": [],
+        "0702230000020001": [],
+        "18FE200000020001": [],
+        "0702210000020001": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "18FF200000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0C00030000030008": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FE200000020001": [],
+        "18FF200000020002": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18058028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008051": [],
+        "07018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18048028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "18028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18068028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18078028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008055": [],
+        "07018037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07048033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "05008093": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806F": [],
+        "05024019": [
+            "OK_BUTTON"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806B": [],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008024": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05004047": [
+            "OK_BUTTON"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500040000030054": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "05008071": [],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "03008025": [
+            "RESUME_PRINTING"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "07FFC012": [],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008067": [],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "07038034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07008018": [
+            "RESUME_PRINTING"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020037": [],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "0500040000020031": [],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008073": [],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "05024023": [
+            "OK_JUMP_RACK"
+        ],
+        "18038033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05024021": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "0300802A": [
+            "RESUME_PRINTING",
+            "CHECK_ASSISTANT"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0501040000030010": [],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05028022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008018": [
+            "RESUME_PRINTING"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008026": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05024017": [
+            "OK_JUMP_RACK"
+        ],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008063": [],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0500807C": [],
+        "07FEC011": [],
+        "05008056": [],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07058037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "0500040000020036": [],
+        "07008036": [
+            "REMOVE_CLOSE_BTN",
+            "RETRY_PROBLEM_SOLVED",
+            "ABORT"
+        ],
+        "0500806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0501040000030004": [],
+        "0500040000020041": [],
+        "05008077": [],
+        "05008078": [],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC012": [],
+        "1A004007": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "0500806C": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18018030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700550000020001": [],
+        "18028034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07028030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008029": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008058": [],
+        "0501040000030002": [],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "18058033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807D": [],
+        "18038030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "03008071": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05024018": [
+            "OK_BUTTON"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "18028030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008084": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "18038034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07048037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500040000020034": [],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008082": [],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07018033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020035": [],
+        "03008081": [],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008053": [
+            "OK_BUTTON"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500807B": [],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1A00120000020010": [],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05004046": [
+            "OK_BUTTON"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030006": [],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008074": [],
+        "18048034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "1A008004": [
+            "OK_BUTTON"
+        ],
+        "03008023": [
+            "RESUME_PRINTING"
+        ],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008083": [],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07058034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07048034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC011": [],
+        "18018033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008059": [],
+        "05008079": [],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07018030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807E": [],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "05008066": [],
+        "05008068": [],
+        "07008033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "07008035": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300802B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1A004008": [
+            "OK_BUTTON"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "1A004009": [],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805C": [],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0500805A": [],
+        "18008030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030005": [],
+        "07008032": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008031": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07028037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "05008090": [],
+        "18058034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C00040000030024": [],
+        "07058033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008064": [],
+        "18048033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "18028033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05024016": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07028033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "07038037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008027": [
+            "RESUME_PRINTING"
+        ],
+        "1A008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "20P": {
+        "07008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1805700000020007": [],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "18028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1802802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701220000020001": [],
+        "18058028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004035": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "03008051": [],
+        "18038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1807802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004036": [],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "18078027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1802700000020007": [],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0502802B": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0703220000020001": [],
+        "07018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "07038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05028037": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1804802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "18078029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18068026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700200000020001": [],
+        "18008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1805802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004037": [],
+        "0702700000020007": [],
+        "18068029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0702220000020001": [],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0700700000020007": [],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "0702200000020001": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07FEC012": [],
+        "18018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "0700550000020001": [],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18048028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502C033": [
+            "PROCEED",
+            "DONT_REMIND_NEXT_TIME"
+        ],
+        "0501040000030002": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05024029": [
+            "OK_BUTTON"
+        ],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "18048029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC011": [],
+        "0702230000020001": [],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "18028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "05004045": [
+            "OK_BUTTON"
+        ],
+        "07FFC012": [],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1803802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05024035": [
+            "OK_BUTTON"
+        ],
+        "0702210000020001": [],
+        "07FEC011": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004030": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0703210000020001": [],
+        "03004038": [],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF800D": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT",
+            "STOP_PRINTING"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1806802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0300C012": [
+            "OK_BUTTON",
+            "IGNORE_NO_REMINDER_NEXT_TIME"
+        ],
+        "0701802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701230000020001": [],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "0700230000020001": [],
+        "03004032": [],
+        "0700210000020001": [],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "18038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004033": [],
+        "1800700000020007": [],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "18028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "1804700000020007": [],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0700802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18058027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "0700860000020002": [],
+        "18038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0700220000020001": [],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "1801802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0702802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18068027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0703200000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0502802A": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "18048026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18078026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8017": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "05028036": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "1806700000020007": [],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1800802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0501040000030003": [],
+        "07028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18048027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "0701210000020001": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "07028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801700000020007": [],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1807700000020007": [],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0703700000020007": [],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ]
+    },
+    "094": {
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008065": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008042": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004036": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "07FF200000020002": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FE200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "18FE200000020002": [],
+        "0702200000020001": [],
+        "05008098": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF200000020001": [],
+        "0702230000020001": [],
+        "18FE200000020001": [],
+        "0702210000020001": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "18FF200000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0C00030000030008": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FE200000020001": [],
+        "18FF200000020002": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008051": [],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "03008071": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008053": [
+            "OK_BUTTON"
+        ],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807E": [],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500040000030054": [],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0501040000030010": [],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0501040000030004": [],
+        "0501040000030002": [],
+        "03008081": [],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05024005": [],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "05FF8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008090": [],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008077": [],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "05008055": [],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0500806F": [],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "0500806B": [],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "05008071": [],
+        "05008067": [],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "0500806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0500040000020037": [],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020031": [],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008073": [],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FEC011": [],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008063": [],
+        "0500807C": [],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008056": [],
+        "0500040000020041": [],
+        "0500040000020036": [],
+        "05008078": [],
+        "0700550000020001": [],
+        "0500806C": [],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008058": [],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807D": [],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008082": [],
+        "0500040000020034": [],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0500807B": [],
+        "0500040000020035": [],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008074": [],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "07FFC012": [],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "05008083": [],
+        "07FEC012": [],
+        "05008059": [],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "05008079": [],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008066": [],
+        "05008068": [],
+        "0500805C": [],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0500805A": [],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C00040000030024": [],
+        "05008064": [],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "07FFC011": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500806E": []
+    },
+    "239": {
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008065": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008042": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004036": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "07FF200000020002": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FE200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "18FE200000020002": [],
+        "0702200000020001": [],
+        "05008098": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF200000020001": [],
+        "0702230000020001": [],
+        "18FE200000020001": [],
+        "0702210000020001": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "18FF200000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0C00030000030008": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FE200000020001": [],
+        "18FF200000020002": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008051": [],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "03008071": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008053": [
+            "OK_BUTTON"
+        ],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807E": [],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "05008055": [],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "0500806F": [],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806B": [],
+        "05008071": [],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0500040000030054": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05008067": [],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020037": [],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020031": [],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "0500806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008073": [],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008063": [],
+        "07FEC011": [],
+        "0501040000030010": [],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807C": [],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008056": [],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020041": [],
+        "05008077": [],
+        "0500040000020036": [],
+        "0501040000030004": [],
+        "0500806C": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008078": [],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008058": [],
+        "0501040000030002": [],
+        "0700550000020001": [],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807D": [],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008082": [],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500040000020034": [],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500807B": [],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020035": [],
+        "03008081": [],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008074": [],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC012": [],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008083": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FEC012": [],
+        "05008059": [],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008079": [],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "05FF8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "05008066": [],
+        "05008068": [],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805C": [],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805A": [],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "05008090": [],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C00040000030024": [],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "05008064": [],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "07FFC011": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "00W": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008006": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0501040000030001": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008002": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "00M": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008006": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0501040000030001": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008002": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "03W": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008006": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0501040000030001": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008002": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801B": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "01S": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "01P": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "093": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "05008098": [],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004037": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0703220000020001": [],
+        "18FF200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0702230000020001": [],
+        "18FF200000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FF200000020002": [],
+        "0701210000020001": [],
+        "07FF200000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008051": [],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05004045": [
+            "OK_BUTTON"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "05008055": [],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008050": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806F": [],
+        "0500806B": [],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000030054": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030004": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05008071": [],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "05008067": [],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020037": [],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020031": [],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008073": [],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "0501040000030010": [],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008063": [],
+        "0500807C": [],
+        "05008056": [],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020036": [],
+        "0500040000020041": [],
+        "05008077": [],
+        "05008078": [],
+        "0500806C": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008058": [],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807D": [],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC012": [],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500040000020034": [],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008082": [],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500807B": [],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020035": [],
+        "03008081": [],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008074": [],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008083": [],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008059": [],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "07FFC011": [],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008079": [],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0500807E": [],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008068": [],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008066": [],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805C": [],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500805A": [],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008090": [],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C00040000030024": [],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008064": [],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "039": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12018011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12028011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12008011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "03008015": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "1200210000020001": [],
+        "18FF200000020002": [],
+        "12FF200000020001": [],
+        "1200220000020001": [],
+        "1200230000020001": [],
+        "1200200000020001": [],
+        "07FF200000020001": [],
+        "18FF200000020001": [],
+        "07FF200000020002": [],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500402F": [],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "12008006": [
+            "CONTINUE"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8010": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "050040A4": [
+            "OK_BUTTON"
+        ],
+        "050040A5": [
+            "OK_BUTTON"
+        ],
+        "12008012": [
+            "RESUME_PRINTING"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "12008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8006": [
+            "CONTINUE"
+        ],
+        "12008010": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008015": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC006": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8003": [
+            "CONTINUE"
+        ],
+        "12FFC003": [
+            "CONTINUE"
+        ],
+        "12008014": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "12008016": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500403A": [
+            "OK_BUTTON"
+        ]
+    },
+    "030": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12018011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12028011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12008011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "03008015": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "1200210000020001": [],
+        "18FF200000020002": [],
+        "12FF200000020001": [],
+        "1200220000020001": [],
+        "1200230000020001": [],
+        "1200200000020001": [],
+        "07FF200000020001": [],
+        "18FF200000020001": [],
+        "07FF200000020002": [],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500402F": [],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "12008006": [
+            "CONTINUE"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8010": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "050040A4": [
+            "OK_BUTTON"
+        ],
+        "050040A5": [
+            "OK_BUTTON"
+        ],
+        "12008012": [
+            "RESUME_PRINTING"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "12008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8006": [
+            "CONTINUE"
+        ],
+        "12008010": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008015": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC006": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8003": [
+            "CONTINUE"
+        ],
+        "12FFC003": [
+            "CONTINUE"
+        ],
+        "12008014": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "12008016": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "22E": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004037": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FF200000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0703210000020001": [],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "18FF200000020002": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "07FF200000020002": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "18FF200000020001": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "03008051": [],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008093": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03002E0000030001": [],
+        "05004045": [
+            "OK_BUTTON"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC011": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC012": [],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030003": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ]
+    },
+    "default": {
+        "07FF8030": [
+            "CONTINUE"
+        ],
+        "07FE8030": [
+            "CONTINUE"
+        ],
+        "07FEC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300806F": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0500040000030057": [
+            "DISABLE_PURIFICATION"
+        ],
+        "0502C031": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0300806E": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05004095": [],
+        "05008057": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0502C028": [
+            "OK_BUTTON"
+        ],
+        "0502C026": [
+            "OK_BUTTON"
+        ],
+        "0500400E": [
+            "OK_BUTTON"
+        ],
+        "05004037": [
+            "OK_BUTTON"
+        ],
+        "0502C014": [
+            "OK_BUTTON"
+        ],
+        "0502C012": [
+            "OK_BUTTON"
+        ],
+        "05008092": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "05004042": [
+            "CANCLE"
+        ],
+        "05004040": [
+            "OK_BUTTON"
+        ],
+        "05004007": [
+            "OK_BUTTON"
+        ],
+        "0502C010": [
+            "STOP_DRYING"
+        ],
+        "05004041": [
+            "OK_BUTTON"
+        ],
+        "05004043": [
+            "OK_BUTTON"
+        ],
+        "03008000": [
+            "RESUME_PRINTING"
+        ],
+        "0300800C": [
+            "RESUME_PRINTING"
+        ],
+        "0300800D": [
+            "RESUME_PRINTING",
+            "CHECK_ASSISTANT"
+        ],
+        "03008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING",
+            "CHECK_ASSISTANT"
+        ],
+        "05004003": [
+            "OK_BUTTON"
+        ],
+        "0C00402D": [
+            "OK_BUTTON"
+        ],
+        "05024001": [
+            "OK_BUTTON"
+        ],
+        "05004004": [
+            "OK_BUTTON"
+        ],
+        "05004014": [
+            "OK_BUTTON"
+        ],
+        "03008013": [
+            "RESUME_PRINTING"
+        ],
+        "0C00402C": [
+            "OK_BUTTON"
+        ],
+        "03008007": [
+            "RESUME_PRINTING",
+            "STOP_PRINTING"
+        ]
+    }
+}

File diff suppressed because it is too large
+ 589 - 186
backend/app/main.py


+ 5 - 0
backend/app/models/__init__.py

@@ -18,11 +18,13 @@ from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
+from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.sponsor_toast_state import SponsorToastState
@@ -69,6 +71,9 @@ __all__ = [
     "OIDCProvider",
     "UserOIDCLink",
     "OrcaBaseProfile",
+    "PipelineJob",
+    "PipelineRun",
+    "SlicerPipeline",
     "Spool",
     "SpoolKProfile",
     "SpoolAssignment",

+ 9 - 0
backend/app/models/api_key.py

@@ -36,6 +36,15 @@ class APIKey(Base):
     can_manage_inventory: Mapped[bool] = mapped_column(
         Boolean, default=True
     )  # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
+    can_manage_maintenance: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Log/reset per-printer maintenance, edit intervals, manage the type catalog (#1832 follow-up)
+    can_manage_archives: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Create/update/delete print archives (not purge) (#1888)
+    can_manage_projects: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Create/update/delete projects + manage membership (add archives) (#1893)
     can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
     # Narrowly-scoped settings write: only POST /settings/electricity-price.
     # Lets HA/Tibber-style automations push dynamic tariff updates without

+ 24 - 0
backend/app/models/archive.py

@@ -12,6 +12,12 @@ class PrintArchive(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    # Which library file this run was dispatched from (#1897). Set by the queue
+    # scheduler when it archives a library-file print; older rows are matched by
+    # content_hash/filename instead. SET NULL so deleting a file keeps history.
+    library_file_id: Mapped[int | None] = mapped_column(
+        ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
+    )
     cost_center_id: Mapped[int | None] = mapped_column(
         ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
     )
@@ -29,6 +35,16 @@ class PrintArchive(Base):
     # both locally and on the printer's SD after extraction — the user
     # didn't opt in to a timelapse recording.
     bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    # Video filenames present in the printer's /timelapse directory when this
+    # print started (#2704). The printer writes its video only at print end, so
+    # anything not in this list belongs to this print — a comparison that needs
+    # no clock, which matters because a LAN-only printer can't reach Bambu's NTP
+    # server and its filename timestamps are arbitrarily wrong. Persisted (not
+    # just held in memory) so the diff survives a restart and so the manual
+    # "Scan for Timelapse" button can use it instead of guessing from
+    # timestamps. NULL for archives predating this, and for baselines taken at
+    # completion time, which are useless by construction.
+    timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
     source_3mf_path: Mapped[str | None] = mapped_column(String(500))  # Original project 3MF from slicer
     f3d_path: Mapped[str | None] = mapped_column(String(500))  # Fusion 360 design file
 
@@ -59,6 +75,14 @@ class PrintArchive(Base):
     # print and keep the original row instead of cancel-then-create.
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
 
+    # Which plate of a multi-plate 3MF this print was for (1-based), copied from
+    # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
+    # under one filename with no plate suffix, so the parser can't recover the
+    # selected plate and extra_data holds all-plates aggregate metadata; without
+    # this the history UI can't tell which plate was printed and falls back to
+    # Plate 1. NULL for archives with no specific selected plate.
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 

+ 16 - 0
backend/app/models/library.py

@@ -31,6 +31,14 @@ class LibraryFolder(Base):
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
+    # Real on-disk modification time of the directory this folder mirrors (#2680).
+    # For external folders this is captured from ``os.stat().st_mtime`` on scan so
+    # the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
+    # the DB row's ``updated_at`` (which is the scan instant, identical for every
+    # row of a bulk scan). Null for managed (internal) folders, which have no
+    # meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
+    fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Relationships
     parent: Mapped["LibraryFolder | None"] = relationship(
         "LibraryFolder",
@@ -102,6 +110,14 @@ class LibraryFile(Base):
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
+    # Real on-disk modification time of the file (#2680). Captured from
+    # ``os.stat().st_mtime`` for external files on scan so the file pane's date
+    # sort and the folder tree's recursive "recent activity" bubble reflect the
+    # actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
+    # scan instant, identical across a bulk scan). Null for managed uploads —
+    # callers fall back to ``created_at``.
+    fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Relationships
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()

+ 2 - 0
backend/app/models/notification.py

@@ -84,6 +84,8 @@ class NotificationProvider(Base):
 
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
+    # Off by default: fires after every print, alongside the print-complete alert (#2525)
+    on_plate_clear_required = Column(Boolean, default=False)  # Print ended, queue gated until plate is confirmed clear
 
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print

+ 6 - 0
backend/app/models/notification_template.py

@@ -85,6 +85,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Plate Not Empty - Print Paused",
         "body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
     },
+    {
+        "event_type": "plate_clear_required",
+        "name": "Plate Clear Required",
+        "title_template": "Plate Clear Required",
+        "body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
+    },
     {
         "event_type": "filament_low",
         "name": "Filament Low",

+ 111 - 0
backend/app/models/pipeline_run.py

@@ -0,0 +1,111 @@
+"""Models for a Slicer Pipeline run (#1425 PR B).
+
+A PipelineRun is one "Run pipeline" click: slice the source file once with the
+pipeline's four preset slots, then enqueue a single print on the pipeline's
+pinned target printer (PR B = single-target dispatch). PR C extends this with
+copies > 1 and class targeting + fanout strategies.
+
+Status on a PipelineRun is mostly COMPUTED from the underlying slice_job
+(in-memory) + the linked queue_entry's state at read time — see
+``api/routes/pipeline_runs.py`` ``_compute_run_status`` for the rules. The
+``status`` column is the persisted snapshot used as a fallback / for filtering
+in list queries; it's updated on terminal transitions (slice failure, cancel,
+or queue-entry completion).
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PipelineRun(Base):
+    """One run-pipeline invocation. PR B always carries exactly one
+    PipelineJob (copies=1); PR C will allow N."""
+
+    __tablename__ = "pipeline_runs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+
+    # Pipeline + source. ``ondelete='SET NULL'`` on both so run history survives
+    # the user soft-deleting a pipeline or removing the source library file.
+    pipeline_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("slicer_pipelines.id", ondelete="SET NULL"))
+    source_library_file_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_files.id", ondelete="SET NULL")
+    )
+    # Mutually exclusive with source_library_file_id. When set, the orchestrator
+    # reads ``archive.source_3mf_path`` (falling back to ``file_path``) for the
+    # slice input. Lets ArchiveCard's "Run with pipeline" reuse the same /run
+    # endpoint instead of growing a second route.
+    source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
+
+    # Set when this run was created by ``POST /pipeline-runs/{parent}/retry-failed``.
+    # Chains the new run back to the run whose failed copies it re-attempts so
+    # the dashboard can show "Retry of run #N" inline. ``SET NULL`` so cleaning
+    # up old runs doesn't dangle retries.
+    parent_run_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="SET NULL"))
+
+    copies: Mapped[int] = mapped_column(Integer, default=1)
+
+    # Snapshot status — terminal transitions are persisted here, in-flight
+    # reads compute from slice_job + queue_entry. Values:
+    #   'queued', 'slicing', 'dispatching', 'in_progress',
+    #   'completed', 'failed', 'cancelled'
+    status: Mapped[str] = mapped_column(String(20), default="queued")
+
+    # Slice integration. slice_job_id is the in-memory slice_dispatch id (so
+    # it's a plain int, not an FK). sliced_library_file_id is the produced
+    # gcode.3mf row.
+    slice_job_id: Mapped[int | None] = mapped_column(Integer)
+    sliced_library_file_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_files.id", ondelete="SET NULL")
+    )
+
+    # True when the operator chose to "Run anyway" past eligibility issues
+    # (filament mismatch, etc.). Surfaced in run history so the audit log
+    # shows which runs bypassed the pre-flight.
+    eligibility_overridden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    error_message: Mapped[str | None] = mapped_column(Text)
+
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    started_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    jobs: Mapped[list["PipelineJob"]] = relationship(
+        back_populates="run",
+        cascade="all, delete-orphan",
+        order_by="PipelineJob.copy_index",
+    )
+
+
+class PipelineJob(Base):
+    """One copy within a PipelineRun. PR B: always exactly one per run.
+
+    Each job binds the run to one queue entry (``queue_entry_id``). The
+    queue entry's status drives this job's status; this row mostly carries
+    the run-side narrative (dispatch timestamps, error message) so deleting
+    the queue entry later doesn't lose the audit trail.
+    """
+
+    __tablename__ = "pipeline_jobs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pipeline_run_id: Mapped[int] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="CASCADE"))
+    copy_index: Mapped[int] = mapped_column(Integer, default=0)
+
+    assigned_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    queue_entry_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_queue.id", ondelete="SET NULL"))
+
+    # Values: 'pending', 'awaiting_printer', 'queued', 'printing',
+    #         'completed', 'failed', 'cancelled'
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+    error_message: Mapped[str | None] = mapped_column(Text)
+
+    dispatched_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    run: Mapped["PipelineRun"] = relationship(back_populates="jobs")

+ 37 - 4
backend/app/models/print_queue.py

@@ -69,6 +69,14 @@ class PrintQueueItem(Base):
     # Auto-print G-code injection (#422)
     gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # How many times the start-watchdog has reverted this item from 'printing'
+    # back to 'pending' (#2555). A printer that accepts project_file but never
+    # starts (#1678) used to be retried forever: upload, wait out the watchdog,
+    # revert, upload again — burning a full 3MF transfer per cycle and, with
+    # the queue dispatching serially, dragging every other printer's start time
+    # out with it. The counter bounds that loop; see DISPATCH_MAX_ATTEMPTS.
+    dispatch_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
     # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
     # project_file MQTT command for rack-swap-capable models (O1C2 today)
     # carries per-filament physical nozzle position IDs in `nozzle_mapping`,
@@ -81,19 +89,44 @@ class PrintQueueItem(Base):
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
 
-    # Print options
-    bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
-    flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Printer-card direct uploads create transient library rows. When this is
+    # true, the scheduler deletes the source row/files after archiving a copy.
+    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
+
+    # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
+    # strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
+    # The remaining three stay boolean (BambuStudio exposes no auto for them).
+    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
+    flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
     vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
-    nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
+    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
+
+    # Preheat / heat-soak override (#1468). 'inherit' uses the global
+    # preheat_enabled setting; 'on' / 'off' force the per-item decision. The
+    # chamber target falls through: per-item override → max(filament-map[loaded
+    # tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
+    # null = no preheat. Default 'inherit' so existing queue items behave
+    # exactly as before the migration.
+    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
+    preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
+    # Dispatch claim (#2615). Set atomically by the scheduler the moment it
+    # begins dispatching this row and cleared when dispatch ends. The row stays
+    # `status='pending'` throughout the (slow) FTP upload, which left a window
+    # where a concurrent PATCH could reassign printer_id mid-upload and split the
+    # queue row from the archive/expected-print/physical command. While this is
+    # set the edit routes reject changes (409) and the scheduler won't re-select
+    # the row. Startup reconciliation clears any left over by a crash mid-dispatch
+    # (no coroutine survives a restart), so a stale claim never wedges an item.
+    dispatching_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Cleared by the per-printer "Resume after failure" action (#1818) so the
     # scheduler's `_check_previous_success` lookback skips this row. Without
     # this, a single `failed` or `aborted` print poisoned every later

+ 3 - 0
backend/app/models/project.py

@@ -30,6 +30,9 @@ class Project(Base):
     target_parts_count: Mapped[int | None] = mapped_column(
         Integer, nullable=True
     )  # Optional target number of parts/objects
+    # Optional copies-per-file target (#1897): every printable file in the
+    # project's linked folders should be printed this many times ("sets").
+    target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
     # Phase 2: Rich text notes (HTML from WYSIWYG editor)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 61 - 0
backend/app/models/slicer_pipeline.py

@@ -0,0 +1,61 @@
+"""Model for a Slicing/Printing Pipeline definition (#1425).
+
+A pipeline bundles the four slot picks a user normally makes in the SliceModal
+(printer / process / filament(s) / bed type) under a named, reusable preset.
+This is PR A — bundle definitions only. Run state and dispatch live in
+``pipeline_runs`` / ``pipeline_jobs`` (PR B + PR C).
+
+The target_* and fanout_strategy columns are materialised now to avoid a
+second migration when PR B / PR C land; PR A's API accepts the defaults and
+the UI doesn't expose them yet.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SlicerPipeline(Base):
+    """A named slicer preset bundle (printer + process + filament[s] + bed)."""
+
+    __tablename__ = "slicer_pipelines"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(200))
+    description: Mapped[str | None] = mapped_column(String(1000))
+
+    # Preset slots. ``*_source`` mirrors PresetRef.source semantics
+    # (orca_cloud / cloud / local / standard); ``*_id`` is the opaque
+    # source-specific id the slicer pipeline uses to resolve content.
+    printer_preset_source: Mapped[str] = mapped_column(String(20))
+    printer_preset_id: Mapped[str] = mapped_column(String(200))
+    process_preset_source: Mapped[str] = mapped_column(String(20))
+    process_preset_id: Mapped[str] = mapped_column(String(200))
+    # JSON array of {"source": ..., "id": ...} entries — one per AMS slot the
+    # source plate is expected to use. Stored as JSON text per Bambuddy's
+    # convention (see LocalPreset.compatible_printers).
+    filament_presets_json: Mapped[str] = mapped_column(Text)
+
+    bed_type: Mapped[str | None] = mapped_column(String(64))
+
+    # Target — PR B+ wiring; PR A treats every pipeline as a bundle without
+    # an active target. Kept materialised so PR B is code-only, not a
+    # migration. ``target_kind`` ∈ {"specific_printer", "printer_class"}.
+    target_kind: Mapped[str] = mapped_column(String(20), default="printer_class")
+    target_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    target_model_class: Mapped[str | None] = mapped_column(String(20))
+
+    # Fanout strategy for PR C multi-copy runs. PR A defaults it; the UI
+    # doesn't expose it yet. Values: max_parallel / fill_one_first / round_robin.
+    fanout_strategy: Mapped[str] = mapped_column(String(20), default="max_parallel")
+
+    # Audit fields. created_by is nullable so pipelines survive user deletes
+    # and so installs without auth enabled (current_user is None) still work.
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 16 - 0
backend/app/models/smart_plug.py

@@ -67,14 +67,30 @@ class SmartPlug(Base):
     rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for power (watts)
     rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")  # Unit conversion for power
     rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for energy data
+    # Energy used *today*, resetting at midnight (kWh after the multiplier).
     rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for energy (kWh)
     rest_energy_multiplier: Mapped[float] = mapped_column(
         Float, server_default="1.0"
     )  # Unit conversion (e.g., 0.001 for Wh→kWh)
+    # Lifetime cumulative counter that never resets (#2539). A Shelly exposes only
+    # this one (`aenergy.total`, in Wh); a Tasmota behind a REST bridge exposes
+    # both. Kept separate from rest_energy_path because a cumulative counter read
+    # as "today" is silently wrong all day, and feeds Yesterday / Total / the
+    # hourly snapshots that the Statistics page's date filters run on.
+    rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
+    rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
 
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
+    # Whether this plug actually feeds the printer's own power (#2629). The
+    # printer link is also used for accessories that merely follow the print
+    # cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
+    # really cuts printer power may mark the printer offline on auto-off;
+    # doing it for an accessory blanks the printer state and stalls the queue.
+    # Defaults to True so existing plugs keep their previous behaviour.
+    controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start

+ 8 - 0
backend/app/models/user.py

@@ -44,6 +44,14 @@ class User(Base):
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
     # "global" or "china"; NULL treated as "global" for legacy rows.
     cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
+    # Set when Bambu answers 401 to a call made with ``cloud_token`` — the token
+    # has expired or been revoked. NULL means "not known to be dead". The token
+    # itself is kept: clearing it would lose the email/region we show on the
+    # re-login form, and a token can only be replaced by signing in again anyway.
+    # Bambu's token is opaque and carries no expiry we can read, and Bambuddy
+    # does not persist the refresh token, so this flag is the *only* record that
+    # a stored credential has stopped working (#2562 follow-up).
+    cloud_token_invalid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
 
     # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
     # with short-lived access tokens (1h) and rotating single-use refresh tokens,

+ 11 - 0
backend/app/schemas/api_key.py

@@ -12,6 +12,11 @@ class APIKeyCreate(BaseModel):
     can_read_status: bool = True
     can_manage_library: bool = True  # Upload / rename / delete own library files + MakerWorld import
     can_manage_inventory: bool = True  # Inventory writes — SpoolBuddy NFC/scale/system, manual stock edits via API
+    can_manage_maintenance: bool = (
+        True  # Log/reset maintenance items, edit intervals, manage type catalog (#1832 follow-up)
+    )
+    can_manage_archives: bool = True  # Create/update/delete print archives — not purge (#1888)
+    can_manage_projects: bool = True  # Create/update/delete projects + membership (add archives) (#1893)
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
     printer_ids: list[int] | None = None  # null = all printers
@@ -27,6 +32,9 @@ class APIKeyUpdate(BaseModel):
     can_read_status: bool | None = None
     can_manage_library: bool | None = None
     can_manage_inventory: bool | None = None
+    can_manage_maintenance: bool | None = None
+    can_manage_archives: bool | None = None
+    can_manage_projects: bool | None = None
     can_access_cloud: bool | None = None
     can_update_energy_cost: bool | None = None
     printer_ids: list[int] | None = None
@@ -46,6 +54,9 @@ class APIKeyResponse(BaseModel):
     can_read_status: bool
     can_manage_library: bool
     can_manage_inventory: bool
+    can_manage_maintenance: bool
+    can_manage_archives: bool
+    can_manage_projects: bool
     can_access_cloud: bool
     can_update_energy_cost: bool
     printer_ids: list[int] | None

+ 3 - 24
backend/app/schemas/archive.py

@@ -55,6 +55,7 @@ class ArchiveResponse(BaseModel):
     object_count: int | None = None
 
     print_name: str | None
+    plate_id: int | None = None  # Selected plate of a multi-plate 3MF (#2603)
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
     # Percentage: 100 = perfect, >100 = faster than estimated
@@ -136,6 +137,8 @@ class ArchiveSlim(BaseModel):
     started_at: datetime | None
     completed_at: datetime | None
     cost: float | None
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
     quantity: int = 1
     created_at: datetime | None
 
@@ -219,27 +222,3 @@ class ProjectPageUpdate(BaseModel):
     copyright: str | None = None
     profile_title: str | None = None
     profile_description: str | None = None
-
-
-class ReprintRequest(BaseModel):
-    """Request body for reprinting an archive."""
-
-    # Plate selection for multi-plate 3MF files
-    # If not specified, auto-detects from file (legacy behavior for single-plate files)
-    plate_id: int | None = None
-    plate_name: str | None = None
-
-    # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
-    # Global tray ID = (ams_id * 4) + slot_id, external = 254
-    ams_mapping: list[int] | None = None
-
-    # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
-    vibration_cali: bool = True
-    layer_inspect: bool = False
-    timelapse: bool = False
-    use_ams: bool = True  # Not exposed in UI, but needed for API
-    cost_center_id: int | None = None
-    estimated_cost: float | None = None
-    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)

+ 24 - 15
backend/app/schemas/auth.py

@@ -360,28 +360,37 @@ def _validate_icon_url(v: str | None) -> str | None:
 
 
 def _validate_issuer_url(v: str | None) -> str | None:
-    """Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
-
-    HTTP is no longer accepted — OIDC providers must be reachable over TLS.
-    Private-network and loopback addresses are rejected to prevent SSRF attacks
-    where an admin-supplied URL could reach internal services.
+    """Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
+
+    An OIDC provider must be reachable over TLS on the public internet, so
+    this uses the public-internet policy: private, loopback and link-local
+    addresses are all rejected.
+
+    Delegates to the runtime guard ``assert_safe_public_https_url`` for the
+    same reason ``_validate_icon_url`` does — no policy drift between the
+    schema layer and the fetcher. The hand-rolled version this replaced
+    checked only ``is_private | is_loopback | is_link_local``, which left
+    numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
+    (``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
+    able to express a target the policy meant to forbid. The guard's
+    docstring already claimed the two were consistent; now they are.
+
+    Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
+    schemas avoid top-level imports from that layer.
     """
-    import ipaddress
-    from urllib.parse import urlparse
-
     if v is None:
         return v
     if not v.startswith("https://"):
         raise ValueError("issuer_url must start with https://")
-    host = urlparse(v).hostname or ""
+    from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+
     try:
-        addr = ipaddress.ip_address(host)
-        if addr.is_private or addr.is_loopback or addr.is_link_local:
-            raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
+        assert_safe_public_https_url(v)
     except ValueError as exc:
-        if "issuer_url" in str(exc):
-            raise
-        # hostname is a domain name, not a bare IP — that's fine
+        # The guard's messages say "icon URL" — rewrite for this field so the
+        # user sees the setting they actually submitted.
+        detail = str(exc).replace("icon URL", "issuer_url")
+        raise ValueError(detail) from exc
     return v
 
 

+ 4 - 0
backend/app/schemas/cloud.py

@@ -38,6 +38,10 @@ class CloudAuthStatus(BaseModel):
     is_authenticated: bool
     email: str | None = None
     region: Region | None = None
+    # True when a token is stored but Bambu no longer accepts it. Both this and
+    # "never signed in" render the login form, but only this one warrants
+    # telling the user why it came back.
+    sign_in_expired: bool = False
 
 
 class CloudTokenRequest(BaseModel):

+ 4 - 29
backend/app/schemas/library.py

@@ -205,6 +205,10 @@ class FileListResponse(BaseModel):
     created_by_id: int | None = None
     created_by_username: str | None = None
     created_at: datetime
+    # Real on-disk modification time (#2680). Populated for external files from
+    # their filesystem mtime; null for managed uploads. The file pane's date sort
+    # and the "Modified" column use ``fs_modified_at ?? created_at``.
+    fs_modified_at: datetime | None = None
 
     # Key metadata fields for display
     print_name: str | None = None
@@ -278,35 +282,6 @@ class FileMoveRequest(BaseModel):
     folder_id: int | None = None  # None = move to root
 
 
-class FilePrintRequest(BaseModel):
-    """Schema for printing a file from the library.
-
-    Note: printer_id is passed as a query parameter, not in the body.
-    """
-
-    # Print options (same as archive reprint)
-    plate_id: int | None = None
-    plate_name: str | None = None
-    ams_mapping: list[int] | None = None
-    bed_levelling: bool = True
-    flow_cali: bool = False
-    vibration_cali: bool = True
-    layer_inspect: bool = False
-    timelapse: bool = False
-    use_ams: bool = True
-    cost_center_id: int | None = None
-    estimated_cost: float | None = None
-    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)
-    # Project to associate the resulting archive with
-    project_id: int | None = None
-    # When true, delete the LibraryFile row + disk file after the archive has
-    # been created and the print has been dispatched. Used by the Printers-page
-    # Direct-Print flow (click / drag-drop a file onto a printer card) so the
-    # transient upload doesn't linger in File Manager. Cleanup is skipped on
-    # external library files.
-    cleanup_library_after_dispatch: bool = False
-
-
 class FileUploadResponse(BaseModel):
     """Schema for file upload response."""
 

+ 4 - 0
backend/app/schemas/makerworld.py

@@ -109,3 +109,7 @@ class MakerWorldStatus(BaseModel):
 
     has_cloud_token: bool = Field(description="Whether the caller's account has a stored Bambu Cloud token")
     can_download: bool = Field(description="Shortcut: has_cloud_token AND it looks valid. Downloads require it.")
+    sign_in_expired: bool = Field(
+        default=False,
+        description="A token is stored but Bambu has rejected it — the user must sign in to Bambu Cloud again.",
+    )

+ 9 - 0
backend/app/schemas/notification.py

@@ -19,6 +19,7 @@ class ProviderType(StrEnum):
     DISCORD = "discord"
     WEBHOOK = "webhook"
     HOMEASSISTANT = "homeassistant"
+    BARK = "bark"
 
 
 class NotificationProviderBase(BaseModel):
@@ -62,6 +63,9 @@ class NotificationProviderBase(BaseModel):
 
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
+    on_plate_clear_required: bool = Field(
+        default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
+    )
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
@@ -146,6 +150,7 @@ class NotificationProviderUpdate(BaseModel):
 
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
+    on_plate_clear_required: bool | None = None
 
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None
@@ -233,6 +238,10 @@ class PushoverConfig(BaseModel):
     user_key: str = Field(..., description="Your Pushover user key")
     app_token: str = Field(..., description="Your Pushover application token")
     priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
+    # Emergency priority (2) only: how often to re-alert and when to stop.
+    # Pushover requires retry >= 30s and expire <= 10800s (3h).
+    retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
+    expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
 
 
 class TelegramConfig(BaseModel):

+ 27 - 35
backend/app/schemas/orca_cloud.py

@@ -1,50 +1,42 @@
-"""Schemas for Orca Cloud auth + profile sync endpoints."""
+"""Schemas for Orca Cloud device-pairing auth + profile sync endpoints."""
 
 from typing import Literal
 
 from pydantic import BaseModel, Field
 
-# The three OAuth providers Orca's sign-in surface offers. Supabase
-# accepts the bare lowercase provider name in the authorize query string.
-OrcaOAuthProvider = Literal["google", "apple", "github"]
 
+class OrcaDeviceStartResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/device/start``. The frontend shows
+    ``user_code`` and a clickable/QR ``verification_uri_complete``; the user
+    approves in their Orca Cloud settings. The ``device_code`` itself is a
+    secret and stays server-side — it is deliberately NOT in this response."""
 
-class OrcaAuthStartRequest(BaseModel):
-    """Body for ``POST /orca-cloud/auth/start``. Provider defaults to
-    ``google`` so existing clients that send an empty body keep working."""
+    user_code: str = Field(..., description="Short code the user confirms on the approval page")
+    verification_uri: str = Field(..., description="Approval page URL")
+    verification_uri_complete: str = Field(..., description="Approval page URL with the code pre-filled")
+    interval: int = Field(..., description="Seconds the frontend should wait between poll calls")
+    expires_in: int = Field(..., description="Seconds until this pairing attempt expires")
 
-    provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in")
 
+# Poll outcomes surfaced to the frontend. ``authorization_pending`` /
+# ``slow_down`` mean keep polling; ``access_denied`` / ``expired_token`` are
+# terminal (restart the flow); ``complete`` means paired.
+OrcaDevicePollStatus = Literal[
+    "authorization_pending",
+    "slow_down",
+    "access_denied",
+    "expired_token",
+    "complete",
+]
 
-class OrcaAuthStartResponse(BaseModel):
-    """Returned by ``POST /orca-cloud/auth/start``. The frontend opens
-    ``auth_url`` in a new tab. After the user signs in to Orca, they copy the
-    redirected URL from their address bar and POST it to
-    ``/orca-cloud/auth/finish`` to complete the handshake."""
 
-    auth_url: str = Field(..., description="URL to open for Orca Cloud sign-in")
+class OrcaDevicePollResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/device/poll`` — one poll attempt."""
 
-
-class OrcaAuthFinishRequest(BaseModel):
-    """Submitted by the frontend after the user pastes the callback URL from
-    their browser. The URL contains a Supabase ``code`` (and our ``state``)
-    that we exchange for tokens."""
-
-    callback_url: str = Field(..., description="The full URL the browser was redirected to after sign-in")
-
-
-class OrcaAuthPasswordRequest(BaseModel):
-    """Body for ``POST /orca-cloud/auth/password``. Whether this succeeds
-    depends on Orca's Supabase project — their desktop client refuses
-    password payloads, but the web sign-in offers email+password as one
-    option. We forward the credentials and surface the server's response.
-    ``email`` is plain ``str`` rather than Pydantic's ``EmailStr`` to avoid
-    pulling in the optional ``email-validator`` dependency — Supabase will
-    reject malformed addresses with a clear error itself, and the existing
-    Bambu Cloud login schema uses the same approach."""
-
-    email: str = Field(..., min_length=1)
-    password: str = Field(..., min_length=1)
+    status: OrcaDevicePollStatus
+    connected: bool = False
+    email: str | None = None
+    user_id: str | None = None
 
 
 class OrcaAuthStatusResponse(BaseModel):

+ 173 - 0
backend/app/schemas/pipeline_run.py

@@ -0,0 +1,173 @@
+"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B + PR C)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class EligibilityIssueResponse(BaseModel):
+    """Single eligibility issue — see ``services/pipeline_eligibility.py`` for
+    the full list of ``kind`` values and what each means."""
+
+    kind: Literal[
+        "printer_not_set",
+        "printer_not_found",
+        "printer_disabled",
+        "printer_offline",
+        "filament_type_mismatch",
+        "filament_color_mismatch",
+        "ams_slot_missing",
+        "filament_unverified",
+        "no_class_matches",  # PR C: target_kind='printer_class' and zero printers in the install match the model
+        "class_not_set",  # PR C: target_kind='printer_class' with no target_model_class
+    ]
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+class PerPrinterReport(BaseModel):
+    """One row of class-targeting eligibility — per matching printer.
+
+    PR C extends the top-level report with this list so the confirmation modal
+    can show ``3 of 5 X1Cs eligible`` plus a per-printer breakdown of why each
+    candidate is or isn't usable.
+    """
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: list[EligibilityIssueResponse] = []
+
+
+class EligibilityReportResponse(BaseModel):
+    """Returned by both ``POST /check-eligibility`` and (on 409) ``POST /run``
+    so the frontend can render the same modal in either flow.
+
+    ``ok`` semantics:
+      - ``target_kind='specific_printer'``: ``ok`` mirrors that single
+        printer's eligibility (no blocking issues).
+      - ``target_kind='printer_class'``: ``ok`` is True iff **at least one**
+        matching printer passes — the run can dispatch even if some
+        candidates in the class are offline / filament-mismatched, because
+        the scheduler will pick any eligible one. The per-printer list lives
+        on ``printer_reports`` so the operator sees the full picture.
+
+    ``issues`` carries class-level issues only (``no_class_matches``,
+    ``class_not_set``) — per-printer detail moves to ``printer_reports``.
+    """
+
+    ok: bool
+    target_kind: Literal["specific_printer", "printer_class"] = "specific_printer"
+    target_printer_id: int | None = None
+    target_printer_name: str | None = None
+    target_model_class: str | None = None
+    issues: list[EligibilityIssueResponse] = []
+    printer_reports: list[PerPrinterReport] = []
+
+
+class CheckEligibilityRequest(BaseModel):
+    """Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
+    be set."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    force: bool = Field(default=False)
+
+    @model_validator(mode="after")
+    def exactly_one_source(self) -> "CheckEligibilityRequest":
+        if (self.source_library_file_id is None) == (self.source_archive_id is None):
+            raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
+        return self
+
+
+class PipelineRunCreateRequest(BaseModel):
+    """``copies`` defaults to 1 (PR B parity). The route handler enforces the
+    ``pipeline_max_copies`` setting on top of the schema's lower bound."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    copies: int = Field(default=1, ge=1, le=1000)
+    force: bool = Field(
+        default=False,
+        description=(
+            "When False (default), the route returns 409 with the eligibility "
+            "report if any blocking issue exists. When True, the run starts "
+            "even when issues exist — recorded on PipelineRun.eligibility_overridden."
+        ),
+    )
+
+    @model_validator(mode="after")
+    def exactly_one_source(self) -> "PipelineRunCreateRequest":
+        if (self.source_library_file_id is None) == (self.source_archive_id is None):
+            raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
+        return self
+
+
+class PipelineJobResponse(BaseModel):
+    id: int
+    pipeline_run_id: int
+    copy_index: int
+    assigned_printer_id: int | None
+    assigned_printer_name: str | None = None
+    queue_entry_id: int | None
+    status: Literal[
+        "pending",
+        "awaiting_printer",
+        "queued",
+        "printing",
+        "completed",
+        "failed",
+        "cancelled",
+    ]
+    error_message: str | None = None
+    dispatched_at: datetime | None = None
+    completed_at: datetime | None = None
+
+
+class PipelineRunResponse(BaseModel):
+    id: int
+    pipeline_id: int | None
+    pipeline_name: str | None = None
+    source_library_file_id: int | None
+    source_archive_id: int | None = None
+    source_filename: str | None = None
+    parent_run_id: int | None = None
+    copies: int
+    # Roll-up counts used by the dashboard's per-row summary. Computed at read
+    # time from the per-job statuses so they always match the live state.
+    copies_completed: int = 0
+    copies_failed: int = 0
+    copies_cancelled: int = 0
+    copies_in_progress: int = 0
+    status: Literal[
+        "queued",
+        "slicing",
+        "dispatching",
+        "in_progress",
+        "completed",
+        "failed",
+        "partial_failure",  # PR C: some copies succeeded, some failed/cancelled
+        "cancelled",
+    ]
+    slice_job_id: int | None
+    sliced_library_file_id: int | None
+    eligibility_overridden: bool
+    error_message: str | None = None
+    created_by: int | None
+    created_at: datetime
+    started_at: datetime | None
+    completed_at: datetime | None
+    jobs: list[PipelineJobResponse] = []
+    # Pipeline target snapshot — copied onto the response so the dashboard
+    # doesn't need a second query to display "Run on X1C class" per row.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
+
+
+class PipelineRunListResponse(BaseModel):
+    runs: list[PipelineRunResponse] = []
+    total: int = 0  # PR C: for the dashboard's paginator

+ 86 - 17
backend/app/schemas/print_queue.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated, Literal
 
-from pydantic import BaseModel, PlainSerializer
+from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
 
 
 # Custom serializer to ensure UTC datetimes have Z suffix
@@ -15,6 +15,33 @@ def serialize_utc_datetime(dt: datetime | None) -> str | None:
 UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
 
 
+def _coerce_tristate(v: object) -> object:
+    """Map legacy on/off booleans onto the tri-state calibration options.
+
+    bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
+    added BambuStudio's third "auto" state (skip if recently done). Rows and API
+    payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
+    coerce them so old clients and un-migrated rows still validate. getValueInt
+    parity: off=0, on=1, auto=2.
+    """
+    if isinstance(v, bool):
+        return "on" if v else "off"
+    if isinstance(v, int):
+        return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
+    if isinstance(v, str):
+        low = v.strip().lower()
+        if low in ("true", "1"):
+            return "on"
+        if low in ("false", "0"):
+            return "off"
+    return v
+
+
+# Tri-state calibration option: "auto" (printer decides / skip if recent),
+# "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
+TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
+
+
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
@@ -28,6 +55,8 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    insert_at_top: bool = False  # Insert ahead of other pending items in the same queue scope
+    insert_position: int | None = None  # 1-indexed insertion position for priority queueing
     # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
     # PrintModal already showed the deficit warning and the user confirmed,
     # so the scheduler does not re-flag this item on the next tick.
@@ -37,17 +66,23 @@ class PrintQueueItemCreate(BaseModel):
     ams_mapping: list[int] | None = None
     # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
     plate_id: int | None = None
-    # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
+    # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
+    # (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
+    # layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
+    bed_levelling: TriState = "auto"
+    flow_cali: TriState = "auto"
     vibration_cali: bool = True
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
-    # Nozzle offset calibration — dual-nozzle printers only (#1682). Default True
-    # matches BambuStudio's default; the MQTT layer ignores the flag on
-    # single-nozzle printers so the wire value stays "skip" there.
-    nozzle_offset_cali: bool = True
+    # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
+    # layer ignores the value on single-nozzle printers so the wire stays "skip".
+    nozzle_offset_cali: TriState = "auto"
+    # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
+    # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
+    # target falls through: this override → max(filament-map[loaded tray]) → 0.
+    preheat_override: Literal["inherit", "on", "off"] = "inherit"
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
@@ -60,6 +95,9 @@ class PrintQueueItemCreate(BaseModel):
     project_id: int | None = None
     cost_center_id: int | None = None
     estimated_cost: float | None = None
+    # Direct printer-card uploads are temporary library files. The scheduler
+    # deletes them after creating the durable archive copy.
+    cleanup_library_after_dispatch: bool = False
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -75,13 +113,15 @@ class PrintQueueItemUpdate(BaseModel):
     ams_mapping: list[int] | None = None
     plate_id: int | None = None
     # Print options
-    bed_levelling: bool | None = None
-    flow_cali: bool | None = None
+    bed_levelling: TriState | None = None
+    flow_cali: TriState | None = None
     vibration_cali: bool | None = None
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
-    nozzle_offset_cali: bool | None = None
+    nozzle_offset_cali: TriState | None = None
+    preheat_override: Literal["inherit", "on", "off"] | None = None
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     cost_center_id: int | None = None
@@ -120,13 +160,15 @@ class PrintQueueItemResponse(BaseModel):
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
+    bed_levelling: TriState = "auto"
+    flow_cali: TriState = "auto"
     vibration_cali: bool = True
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
-    nozzle_offset_cali: bool = True
+    nozzle_offset_cali: TriState = "auto"
+    preheat_override: Literal["inherit", "on", "off"] = "inherit"
+    preheat_chamber_target_override: int | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     completed_at: UTCDatetime
@@ -172,6 +214,7 @@ class PrintQueueItemResponse(BaseModel):
 
     # Auto-print G-code injection
     gcode_injection: bool = False
+    cleanup_library_after_dispatch: bool = False
 
     # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
     # "edit print → choose nozzle" UI; null on every model except O1C2
@@ -190,6 +233,30 @@ class PrintQueueReorderItem(BaseModel):
 class PrintQueueReorder(BaseModel):
     items: list[PrintQueueReorderItem]
 
+    @model_validator(mode="after")
+    def _validate_positions_unique(self) -> "PrintQueueReorder":
+        """Reject reorder requests with duplicate positions in the payload
+        (#1625-followup).
+
+        The /reorder route is the drag-drop renumber path on the queue UI;
+        a well-behaved client sends a contiguous renumbering of a single
+        printer's pending queue. A buggy client that sends two items at
+        the same position would leave the queue in an inconsistent state
+        (scheduler's ORDER BY (printer_id, position) ties get broken by
+        physical row order). Fail closed at the schema boundary so the
+        bug is caught before any DB mutation.
+
+        Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
+        reorders that intentionally share positions across different
+        printer queues are a non-goal of the drag-drop UI, so this is the
+        right scope.
+        """
+        positions = [it.position for it in self.items]
+        if len(positions) != len(set(positions)):
+            duplicates = sorted({p for p in positions if positions.count(p) > 1})
+            raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
+        return self
+
 
 class PrintQueueBulkUpdate(BaseModel):
     """Bulk update multiple queue items with the same values."""
@@ -202,13 +269,15 @@ class PrintQueueBulkUpdate(BaseModel):
     auto_off_after: bool | None = None
     manual_start: bool | None = None
     # Print options
-    bed_levelling: bool | None = None
-    flow_cali: bool | None = None
+    bed_levelling: TriState | None = None
+    flow_cali: TriState | None = None
     vibration_cali: bool | None = None
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
-    nozzle_offset_cali: bool | None = None
+    nozzle_offset_cali: TriState | None = None
+    preheat_override: Literal["inherit", "on", "off"] | None = None
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     cost_center_id: int | None = None

+ 47 - 0
backend/app/schemas/printer.py

@@ -153,6 +153,15 @@ class HMSErrorResponse(BaseModel):
     attr: int = 0  # Attribute value for constructing wiki URL
     module: int
     severity: int  # 1=fatal, 2=serious, 3=common, 4=info
+    actions: list[str] = []  # List of user-facing action keys (e.g. "CHECK_FILAMENT")
+    job_id: str | None = None  # Optional job ID for actions that require it (e.g. "CHECK_ASSISTANT")
+    # Canonical hex identifier the firmware uses to match HMS-related commands.
+    # 16 chars for `hms[]`-array faults (full 64-bit attr+code), 8 chars for
+    # `print_error` faults. The frontend echoes this back as
+    # HmsActionBody.print_error so we send the firmware-recognised key, not the
+    # truncated short_code that historically caused silent command rejection
+    # (#1830, H2D wrong-plate verification).
+    full_code: str = ""
 
 
 class AMSTray(BaseModel):
@@ -172,6 +181,10 @@ class AMSTray(BaseModel):
     drying_temp: int | None = None  # RFID-recommended drying temp
     drying_time: int | None = None  # RFID-recommended drying time (hours)
     state: int | None = None  # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
+    # Firmware's authoritative "spool physically present" bit (from tray_exist_bits).
+    # True for a non-RFID spool the firmware can't identify — the UI shows "?" rather
+    # than "Empty" (#2527). None when the bitmask was unavailable (→ state-based fallback).
+    exists: bool | None = None
 
 
 class AMSUnit(BaseModel):
@@ -216,6 +229,20 @@ class AmsLabelBody(BaseModel):
     ams_serial: str = Field(default="", max_length=50)
 
 
+class HmsActionBody(BaseModel):
+    # Canonical hex identifier (HMSErrorResponse.full_code): 8 chars for
+    # `print_error`-sourced faults, 16 chars for `hms[]`-array faults whose
+    # full 64-bit code is the firmware's matching key. Length-bounded to
+    # those two valid shapes to keep stray input from reaching the dispatcher.
+    print_error: str = Field(..., min_length=8, max_length=16, pattern=r"^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$")
+    # One of the HMSAction enum values. Length-capped to keep stray input from
+    # reaching the dispatcher's `match` statement.
+    action: str = Field(..., min_length=1, max_length=64)
+    # The `subtask_id` snapshot from the HMSError that surfaced this dialog.
+    # Bambu echoes it back in HMS-aware commands. Optional for idle errors.
+    job_id: str | None = Field(default=None, max_length=64)
+
+
 class FilaSwitchResponse(BaseModel):
     """Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
 
@@ -305,6 +332,17 @@ class PrinterStatus(BaseModel):
     fila_switch: FilaSwitchResponse | None = None
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
+    # Runout / filament-replacement guidance (#2587). Populated only while the
+    # print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
+    # AMS-HT, or 254 for external) so the frontend can highlight them with the same
+    # logic it uses for tray_now:
+    #   expected_tray = the slot the firmware now expects filament in (from tray_tar).
+    #                   None when idle, not paused, or the slot can't be resolved
+    #                   (multi-AMS ambiguity) — the UI then says "check the printer".
+    #   previous_tray = the slot loaded before the pause, i.e. the one that ran out
+    #                   (from tray_pre). None when unknown.
+    expected_tray: int | None = None
+    previous_tray: int | None = None
     # AMS status for filament change tracking
     # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
     ams_status_main: int = 0
@@ -322,6 +360,11 @@ class PrinterStatus(BaseModel):
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10).
+    # None = not installed / not reported by this model.
+    left_aux_fan_speed: int | None = None
+    # Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).
+    exhaust_fan_present: bool = False
     # Firmware version (from info.module[name="ota"].sw_ver)
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
@@ -338,6 +381,10 @@ class PrinterStatus(BaseModel):
     # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
     # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
     supports_drying_while_printing: bool = False
+    # The AMS can dry, but only from the printer's own screen (P1 series, #2533).
+    # supports_drying is False on these; the UI keeps the control visible but disabled
+    # and says why, rather than dropping it without explanation.
+    drying_screen_only: bool = False
     # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
     supports_chamber_heater: bool = False
     # Linked archive for the active print (resolved via subtask_id). Frontend uses

+ 20 - 0
backend/app/schemas/project.py

@@ -26,6 +26,7 @@ class ProjectCreate(BaseModel):
     color: str | None = None
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
     status: str | None = None  # active, completed, archived
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None
@@ -108,6 +110,7 @@ class ProjectResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897)
     notes: str | None = None
     attachments: list | None = None
     tags: str | None = None
@@ -129,6 +132,13 @@ class ProjectResponse(BaseModel):
         from_attributes = True
 
 
+class ProjectFileProgress(BaseModel):
+    """Completed-run count for one library file inside a project (#1897)."""
+
+    file_id: int
+    completed_count: int
+
+
 class ArchivePreview(BaseModel):
     """Minimal archive data for project preview."""
 
@@ -150,7 +160,15 @@ class ProjectListResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    target_sets: int | None = None  # Copies-per-file target (#1897); the shared edit dialog needs it
     budget: float | None = None
+    # The edit dialog is shared with the project detail page and seeds its fields
+    # from whichever project object it is handed, so the list payload has to carry
+    # everything the dialog edits — otherwise a save from the list view submits a
+    # blank tags field and a default priority over the stored values (#2536).
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
     created_at: datetime
     # Quick stats
     archive_count: int = 0  # Number of print jobs
@@ -269,6 +287,7 @@ class ProjectExport(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None
+    target_sets: int | None = None
     notes: str | None
     tags: str | None
     due_date: datetime | None
@@ -287,6 +306,7 @@ class ProjectImport(BaseModel):
     status: str = "active"
     target_count: int | None = None
     target_parts_count: int | None = None
+    target_sets: int | None = None
     notes: str | None = None
     tags: str | None = None
     due_date: datetime | None = None

+ 129 - 9
backend/app/schemas/settings.py

@@ -1,6 +1,22 @@
 import json
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, ValidationInfo, field_validator
+
+from backend.app.schemas.print_queue import TriState
+
+# Outbound service URLs validated on save, so a bad value is rejected at
+# configuration time with a clear message rather than failing opaquely at
+# request time. Every one of these services is commonly self-hosted on the same
+# host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
+# RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
+# IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
+# ``_url_safety.assert_safe_lan_service_url``.
+#
+# Module-level rather than a class attribute so the CI backstop in
+# tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
+# cannot drift from it. Any new outbound-URL setting belongs here (or, if it
+# must be reachable on the public internet, on the stricter OIDC guard).
+LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
 
 
 class AppSettings(BaseModel):
@@ -140,6 +156,15 @@ class AppSettings(BaseModel):
     # Default printer for operations
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
 
+    # Slicer Pipelines (#1425 PR C). Cap on the ``copies`` field in the
+    # Run-with-pipeline modal — keeps a misclick from queueing 5000 prints.
+    pipeline_max_copies: int = Field(
+        default=50,
+        ge=1,
+        le=1000,
+        description="Upper bound on the copies an operator can request when running a Slicer Pipeline. Larger fleets / production rigs can raise this; the hard ceiling at 1000 is a sanity guard against fat-fingered input.",
+    )
+
     # Virtual Printer
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
@@ -285,9 +310,10 @@ class AppSettings(BaseModel):
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
     )
 
-    # Default print options
-    default_bed_levelling: bool = Field(default=True, description="Default bed levelling option for new prints")
-    default_flow_cali: bool = Field(default=False, description="Default flow calibration option for new prints")
+    # Default print options. bed_levelling / flow_cali / nozzle_offset_cali are
+    # tri-state (off/on/auto), defaulting to "auto" per BambuStudio.
+    default_bed_levelling: TriState = Field(default="auto", description="Default bed levelling option for new prints")
+    default_flow_cali: TriState = Field(default="auto", description="Default flow calibration option for new prints")
     default_vibration_cali: bool = Field(
         default=True, description="Default vibration calibration option for new prints"
     )
@@ -295,8 +321,8 @@ class AppSettings(BaseModel):
         default=False, description="Default first layer inspection option for new prints"
     )
     default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
-    default_nozzle_offset_cali: bool = Field(
-        default=True,
+    default_nozzle_offset_cali: TriState = Field(
+        default="auto",
         description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
     )
 
@@ -337,6 +363,53 @@ class AppSettings(BaseModel):
         default=False,
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
+    queue_max_concurrent_uploads: int = Field(
+        default=4,
+        ge=1,
+        le=16,
+        description=(
+            "How many printers the queue may upload to at the same time. Printers are independent "
+            "machines, so raising this starts a multi-printer batch proportionally sooner; each "
+            "concurrent upload costs one connection and one thread on the Bambuddy host."
+        ),
+    )
+
+    # Preheat / heat-soak before queued prints (#1468). The scheduler stage runs
+    # BEFORE FTP upload. Three hardware tiers behave differently:
+    #   - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E): M141 → wait for chamber
+    #     sensor to reach target → soak
+    #   - Chamber sensor only (X1C/P2S): M140 only → wait for radiant chamber
+    #     warm-up to reach target OR max-wait timeout → soak
+    #   - No chamber sensor (P1S/P1P/A1/A1 Mini): M140 only → fixed soak timer
+    #     (no way to verify chamber temp; relies entirely on max_wait + soak)
+    # Chamber target derives per-print from the loaded AMS filament types via
+    # preheat_filament_targets (max across loaded slots). A target of 0 skips
+    # the chamber phase but keeps the bed phase + soak. Per-queue-item
+    # `preheat_chamber_target_override` (nullable) bypasses the derivation.
+    preheat_enabled: bool = Field(
+        default=False,
+        description="Master toggle / default for new queue items. Per-item preheat_override can flip the decision per print.",
+    )
+    preheat_filament_targets: str = Field(
+        default="",
+        description=(
+            "JSON map of normalized filament type → chamber target °C. Empty = bundled defaults "
+            "(PLA/PETG/TPU/PVA: 0, PETG-CF: 40, ABS/ASA: 45, PA/PC/PC-FR: 50, PA-CF: 55, default: 0). "
+            "Scheduler picks max across loaded AMS slots; 0 disables chamber phase for that print."
+        ),
+    )
+    preheat_max_wait_seconds: int = Field(
+        default=900,
+        ge=60,
+        le=3600,
+        description="Maximum time to wait for the chamber to reach the target before falling through to the soak phase (radiant heating on X1C/P2S can take 15-30 min).",
+    )
+    preheat_soak_seconds: int = Field(
+        default=300,
+        ge=0,
+        le=1800,
+        description="Additional hold time at temperature after the chamber reaches the target (or after max_wait_seconds elapses). 0 = no soak.",
+    )
 
     # User-configurable presets for the printer-card temperature / fan-speed
     # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
@@ -478,6 +551,7 @@ class AppSettingsUpdate(BaseModel):
     date_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
+    pipeline_max_copies: int | None = None
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
@@ -516,12 +590,12 @@ class AppSettingsUpdate(BaseModel):
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     session_max_hours: int | None = Field(default=None, ge=1, le=720)
     user_notifications_enabled: bool | None = None
-    default_bed_levelling: bool | None = None
-    default_flow_cali: bool | None = None
+    default_bed_levelling: TriState | None = None
+    default_flow_cali: TriState | None = None
     default_vibration_cali: bool | None = None
     default_layer_inspect: bool | None = None
     default_timelapse: bool | None = None
-    default_nozzle_offset_cali: bool | None = None
+    default_nozzle_offset_cali: TriState | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     billing_enabled: bool | None = None
@@ -530,6 +604,11 @@ class AppSettingsUpdate(BaseModel):
     finance_budget_reset_timezone: str | None = None
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
+    queue_max_concurrent_uploads: int | None = Field(default=None, ge=1, le=16)
+    preheat_enabled: bool | None = None
+    preheat_filament_targets: str | None = None
+    preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)
+    preheat_soak_seconds: int | None = Field(default=None, ge=0, le=1800)
     nozzle_temp_presets: str | None = None
     bed_temp_presets: str | None = None
     chamber_temp_presets: str | None = None
@@ -559,6 +638,47 @@ class AppSettingsUpdate(BaseModel):
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
 
+    @field_validator(*LAN_SERVICE_URL_SETTINGS)
+    @classmethod
+    def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
+        """Reject SSRF-unsafe outbound service URLs on save.
+
+        Empty (and whitespace-only) is the documented "not configured / fall
+        back to the env var" value for all four fields and must keep passing.
+
+        Values that are not absolute URLs at all ("192.168.1.10:3333",
+        "localhost:3333") are left alone rather than rejected. Two reasons:
+
+        - They are inert. Every consumer of these four settings goes through
+          httpx, which raises UnsupportedProtocol for a URL with no scheme, so
+          no request is ever issued and there is nothing to guard against.
+        - They were storable before this validator existed, and the settings
+          UI is a plain text input with no scheme enforcement. Newly rejecting
+          them would break saves that have nothing to do with the URL: the
+          Obico panel, for one, sends obico_ml_url with every change and
+          auto-saves, so one legacy value would block toggling detection on or
+          off. A pre-existing misconfiguration should keep failing where it
+          already failed (at request time), not spread to unrelated fields.
+
+        ``urlparse`` is no help in telling the two apart — it reads
+        "localhost:3333" as scheme "localhost" — so the test is the literal
+        "://" that makes a string an absolute URL.
+        """
+        if v is None or not v.strip():
+            return v
+        candidate = v.strip()
+        if "://" not in candidate:
+            return v
+        # Lazy-imported: schemas avoid top-level imports from api/routes,
+        # matching the existing pattern in auth.py's _validate_icon_url.
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+        try:
+            assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
+        except ValueError as exc:
+            raise ValueError(str(exc)) from exc
+        return v
+
     @field_validator("gcode_snippets")
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:

+ 26 - 0
backend/app/schemas/slicer.py

@@ -82,6 +82,32 @@ class SliceRequest(BaseModel):
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
+    design_overrides: list[str] | None = Field(
+        default=None,
+        description=(
+            "3MF only. Process setting keys from the source file's "
+            "``different_settings_to_system`` to carry onto the picked process "
+            "preset (#2622) — the designer's own wall count, infill, first-layer "
+            "height and so on, which ``--load-settings`` would otherwise discard. "
+            "Only keys the source actually lists as changed are applied; anything "
+            "else is ignored. ``None``/empty means a plain profile slice."
+        ),
+    )
+    use_embedded_settings: bool = Field(
+        default=False,
+        description=(
+            "3MF only. Slice using the file's embedded "
+            "``Metadata/project_settings.config`` (the designer's own tweaks — wall "
+            "count, infill, etc.) instead of the picked printer/process/filament "
+            "triplet. This is the 'slice as designed' path: no ``--load-settings`` "
+            "override, so a MakerWorld author's settings survive. Ignored for STL / "
+            "plain-model 3MF (no embedded profile to honour). The preset refs are "
+            "still required by the validator but go unused on this path. Only makes "
+            "sense when the picked printer matches the design's target model — the "
+            "UI gates the toggle on that; there is no cross-printer re-targeting here "
+            "(that is exactly what the profile path is for)."
+        ),
+    )
     bed_type: str | None = Field(
         default=None,
         max_length=64,

+ 83 - 0
backend/app/schemas/slicer_pipeline.py

@@ -0,0 +1,83 @@
+"""Pydantic schemas for the Slicer Pipeline API (#1425, PR A).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks under a
+reusable name. PR A surfaces only the bundle; target_kind / target_printer_id /
+target_model_class / fanout_strategy are persisted but the API treats them as
+opaque defaults — they come alive in PR B (single-target dispatch) and PR C
+(multi-copy + class targeting + fanout).
+"""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from backend.app.schemas.slicer import PresetRef
+
+
+class SlicerPipelineBase(BaseModel):
+    """Fields editable on create + update."""
+
+    name: str = Field(..., min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+
+    printer_preset: PresetRef
+    process_preset: PresetRef
+    filament_presets: list[PresetRef] = Field(
+        ...,
+        min_length=1,
+        description="One PresetRef per AMS slot. Order matches the source plate's filament-slot order.",
+    )
+    bed_type: str | None = Field(default=None, max_length=64)
+
+
+class SlicerPipelineCreate(SlicerPipelineBase):
+    """Payload for POST /slicer-pipelines."""
+
+
+class SlicerPipelineUpdate(BaseModel):
+    """Payload for PUT /slicer-pipelines/{id}. All fields optional; only those
+    present are written. Preset and filament list are replaced wholesale when
+    set (we don't support partial filament-slot edits)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+    printer_preset: PresetRef | None = None
+    process_preset: PresetRef | None = None
+    filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
+    bed_type: str | None = Field(default=None, max_length=64)
+
+    # PR B target binding. ``target_kind='specific_printer'`` requires
+    # ``target_printer_id`` to be set OR cleared in the same payload (route
+    # handler enforces). ``target_kind='printer_class'`` is wired by PR C
+    # together with ``target_model_class`` (a Bambu model code like 'X1C')
+    # and the fanout strategy that distributes copies across matching
+    # printers.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = Field(default=None, max_length=20)
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
+
+
+class SlicerPipelineResponse(SlicerPipelineBase):
+    """A single pipeline as returned by the API."""
+
+    id: int
+    created_by: int | None
+    created_at: datetime
+    updated_at: datetime
+
+    # Echoed for PR B+ readiness; PR A always returns the persisted defaults.
+    target_kind: Literal["specific_printer", "printer_class"] = "printer_class"
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] = "max_parallel"
+
+    model_config = {"from_attributes": True}
+
+
+class SlicerPipelineListResponse(BaseModel):
+    """Wraps the list so the response stays additive when run/job counts get
+    surfaced in PR B+ (e.g. a ``meta`` field for last-run timestamps)."""
+
+    pipelines: list[SlicerPipelineResponse] = []

+ 12 - 0
backend/app/schemas/smart_plug.py

@@ -58,10 +58,18 @@ class SmartPlugBase(BaseModel):
     rest_power_path: str | None = Field(default=None, max_length=200)
     rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
     rest_energy_url: str | None = Field(default=None, max_length=500)
+    # Today's usage, resetting at midnight.
     rest_energy_path: str | None = Field(default=None, max_length=200)
     rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    # Lifetime counter that never resets (#2539) — a Shelly's `aenergy.total`.
+    rest_energy_total_path: str | None = Field(default=None, max_length=200)
+    rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
 
     printer_id: int | None = None
+    # #2629: only a plug that really feeds the printer may mark it offline when
+    # it switches off. Accessory plugs (filter fan, lights) are linked to a
+    # printer purely to follow the print cycle.
+    controls_printer_power: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_off: bool = True
@@ -153,7 +161,11 @@ class SmartPlugUpdate(BaseModel):
     rest_energy_url: str | None = None
     rest_energy_path: str | None = None
     rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_total_path: str | None = None
+    rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
+    # #2629: see SmartPlugBase.controls_printer_power.
+    controls_printer_power: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_off: bool | None = None

+ 77 - 58
backend/app/services/archive.py

@@ -69,13 +69,37 @@ def resolve_display_stem(filename: str) -> str:
     return Path(name).stem
 
 
+def _read_plate_index(plate) -> int | None:
+    """Return the 1-based index of a ``slice_info.config`` ``<plate>`` element, or None.
+
+    Bambu Studio and OrcaSlicer record it as a ``<metadata key="index"
+    value="N"/>`` child — there is no ``plate_idx`` attribute on ``<plate>``
+    itself, so an XPath predicate on one never matches (#2522).
+    """
+    for meta in plate.findall("metadata"):
+        if meta.get("key") == "index":
+            value = meta.get("value")
+            if not value:
+                return None
+            try:
+                return int(value)
+            except ValueError:
+                return None
+    return None
+
+
 def peek_plate_index_in_3mf(file_path: Path) -> int | None:
-    """Return the plate index recorded inside a Bambu 3MF, or None.
+    """Return the plate index a single-plate Bambu 3MF represents, or None.
 
     Reads only ``Metadata/slice_info.config`` to keep this cheap — used by
     the print-start callback to verify that the 3MF we just downloaded over
     FTP actually matches the plate the printer is running (#1204). The full
     ThreeMFParser does much more work and runs later inside ArchiveService.
+
+    An all-plates export carries every plate, so "which plate is this file"
+    has no answer; returning None there keeps the #1204 guard from reading
+    plate 1 out of such a file, declaring a mismatch against the plate that
+    is really running, and discarding a perfectly good 3MF (#2522).
     """
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
@@ -83,20 +107,12 @@ def peek_plate_index_in_3mf(file_path: Path) -> int | None:
                 return None
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
-            plate = root.find(".//plate")
-            if plate is None:
+            plates = root.findall(".//plate")
+            if len(plates) != 1:
                 return None
-            for meta in plate.findall("metadata"):
-                if meta.get("key") == "index":
-                    value = meta.get("value")
-                    if value:
-                        try:
-                            return int(value)
-                        except ValueError:
-                            return None
+            return _read_plate_index(plates[0])
     except Exception:
         return None
-    return None
 
 
 _PLATE_SUFFIX_RE = re.compile(r"^(.*?)(\s*-\s*Plate\s+|_plate_)(\d+)$", re.IGNORECASE)
@@ -375,42 +391,39 @@ class ThreeMFParser:
             pass  # G-code header parsing is best-effort; metadata may come from other sources
 
     def _extract_filament_info(self, data: dict):
-        """Extract filament info, preferring non-support filaments."""
+        """Extract filament info from project settings — includes support
+        materials so a PLA-model / PVA-support project shows both on the
+        archive card badge (#1881).
+
+        Earlier code filtered by ``filament_is_support``; that hid PVA
+        (and any other soluble/breakaway support material) from the card
+        even when the user had explicitly configured it, and made source
+        3MFs look single-material until the print completed. slice_info
+        (parsed separately) is still preferred when present — it lists
+        only filaments the print actually consumes, this fallback only
+        runs on unsliced source 3MFs.
+        """
         try:
             filament_types = data.get("filament_type", [])
             filament_colors = data.get("filament_colour", [])
-            filament_is_support = data.get("filament_is_support", [])
 
             if not filament_types:
                 return
 
-            # Collect all non-support filaments
-            non_support_types = []
-            non_support_colors = []
-
-            for i, ftype in enumerate(filament_types):
-                is_support = filament_is_support[i] if i < len(filament_is_support) else "0"
-                if is_support == "0":
-                    if ftype and ftype not in non_support_types:
-                        non_support_types.append(ftype)
-                    if i < len(filament_colors) and filament_colors[i]:
-                        color = filament_colors[i]
-                        if color not in non_support_colors:
-                            non_support_colors.append(color)
-
-            # Fallback to first filament if all are support
-            if not non_support_types and filament_types:
-                non_support_types = [filament_types[0]]
-            if not non_support_colors and filament_colors:
-                non_support_colors = [filament_colors[0]]
-
-            # Store filament type(s)
-            if non_support_types:
-                self.metadata["filament_type"] = ", ".join(non_support_types)
-
-            # Store all colors as comma-separated (for multi-color display)
-            if non_support_colors:
-                self.metadata["filament_color"] = ",".join(non_support_colors)
+            unique_types: list[str] = []
+            for ftype in filament_types:
+                if ftype and ftype not in unique_types:
+                    unique_types.append(ftype)
+
+            unique_colors: list[str] = []
+            for color in filament_colors:
+                if color and color not in unique_colors:
+                    unique_colors.append(color)
+
+            if unique_types:
+                self.metadata["filament_type"] = ", ".join(unique_types)
+            if unique_colors:
+                self.metadata["filament_color"] = ",".join(unique_colors)
 
         except Exception:
             pass  # Filament info is optional; fall back to slice_info values
@@ -618,26 +631,26 @@ def extract_printable_objects_from_3mf(
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
 
-            # Find the correct plate
-            if plate_number:
-                plate = root.find(f".//plate[@plate_idx='{plate_number}']")
-                if plate is None:
-                    plate = root.find(".//plate")
-            else:
-                plate = root.find(".//plate")
+            plates = root.findall(".//plate")
+            if not plates:
+                return printable_objects
 
+            # Pick the plate that is actually printing. An all-plates export
+            # lists every plate, so without this we offered the objects (and
+            # the marker positions) of plate 1 whatever the printer was
+            # running (#2522). Falling back to the first plate keeps the
+            # single-plate export — the common case — working when the caller
+            # has no plate to give us.
+            plate = None
+            if plate_number is not None:
+                plate = next((p for p in plates if _read_plate_index(p) == plate_number), None)
             if plate is None:
-                return printable_objects
+                plate = plates[0]
 
-            # Get actual plate index from metadata (sliced files only have one plate)
-            plate_idx = plate_number or 1
-            for meta in plate.findall("metadata"):
-                if meta.get("key") == "index":
-                    try:
-                        plate_idx = int(meta.get("value", "1"))
-                    except ValueError:
-                        pass  # Use default plate_idx if value is non-numeric
-                    break
+            # Derive plate_idx from the plate we settled on, never from the
+            # requested one: on a fallback they differ, and plate_idx also
+            # selects the plate_N.json the positions come from.
+            plate_idx = _read_plate_index(plate) or 1
 
             # Load position data from plate_N.json if we need positions
             # Build a lookup by name - use list to handle duplicate names
@@ -1130,6 +1143,8 @@ class ArchiveService:
         cost_center_id: int | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
+        plate_id: int | None = None,
+        library_file_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1142,6 +1157,8 @@ class ArchiveService:
                 stored with UUID names)
             project_id: Project to associate this archive with (optional, set when triggered
                 from the project view)
+            library_file_id: Library file this run was dispatched from (optional,
+                set by the queue scheduler — powers per-file project progress, #1897)
             subtask_id: MQTT-provided task identifier (optional). Used to match an
                 existing archive across a backend restart mid-print so the
                 original row can be resumed instead of cancelled (#972).
@@ -1301,8 +1318,10 @@ class ArchiveService:
             extra_data=metadata,
             created_by_id=created_by_id,
             project_id=project_id,
+            library_file_id=library_file_id,
             cost_center_id=cost_center_id,
             subtask_id=subtask_id,
+            plate_id=plate_id,
         )
 
         self.db.add(archive)

+ 0 - 1157
backend/app/services/background_dispatch.py

@@ -1,1157 +0,0 @@
-"""Background dispatch for print/reprint jobs.
-
-This service is separate from the app's print queue feature. It exists only to
-decouple "send/start print" operations (FTP upload + start command) from API
-request latency so the UI can continue immediately after dispatch.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import time
-import zipfile
-from collections import deque
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any, Literal
-
-from sqlalchemy import select
-
-from backend.app.core.config import settings
-from backend.app.core.database import async_session
-from backend.app.core.tasks import spawn_background_task
-from backend.app.core.websocket import ws_manager
-from backend.app.models.finance import BudgetReservation, CostCenter
-from backend.app.models.library import LibraryFile
-from backend.app.models.printer import Printer
-from backend.app.models.user import User
-from backend.app.services.archive import ArchiveService
-from backend.app.services.bambu_ftp import (
-    cache_3mf_download,
-    delete_file_async,
-    get_ftp_retry_settings,
-    upload_file_async,
-    with_ftp_retry,
-)
-from backend.app.services.finance_budget import create_budget_reservation, release_budget_reservation
-from backend.app.services.printer_manager import printer_manager
-from backend.app.utils.filename import derive_remote_filename
-
-logger = logging.getLogger(__name__)
-
-# Bambu firmware states that mean the project_file has actually been accepted
-# and the printer is now processing / running / paused mid-print. Used by the
-# direct-dispatch verifier (#1370): a transition into one of these states means
-# the print landed, anything else (e.g. FINISH -> IDLE after the user dismisses
-# a post-print prompt) is NOT a valid "command landed" signal even though the
-# state value did change. Mirrors the same constant in print_scheduler.py —
-# kept duplicated rather than imported to avoid coupling the two services and
-# to keep the value at the point of use.
-_ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
-
-
-class DispatchJobCancelled(Exception):
-    """Raised when a dispatch job is cancelled by the user."""
-
-
-class DispatchEnqueueRejected(Exception):
-    """Raised when a dispatch job should not be accepted."""
-
-
-@dataclass(slots=True)
-class PrintDispatchJob:
-    id: int
-    kind: Literal["reprint_archive", "print_library_file"]
-    source_id: int
-    source_name: str
-    printer_id: int
-    printer_name: str
-    options: dict[str, Any] = field(default_factory=dict)
-    requested_by_user_id: int | None = None
-    requested_by_username: str | None = None
-    project_id: int | None = None
-    cleanup_library_after_dispatch: bool = False
-
-
-@dataclass(slots=True)
-class ActiveDispatchState:
-    job: PrintDispatchJob
-    message: str
-    upload_bytes: int | None = None
-    upload_total_bytes: int | None = None
-
-
-class BackgroundDispatchService:
-    def __init__(self):
-        self._queued_jobs: deque[PrintDispatchJob] = deque()
-        self._dispatcher_task: asyncio.Task | None = None
-        self._running_tasks: dict[int, asyncio.Task] = {}
-        self._lock = asyncio.Lock()
-        self._job_event = asyncio.Event()
-        self._next_job_id = 1
-        self._active_jobs: dict[int, ActiveDispatchState] = {}
-        self._cancel_requested_job_ids: set[int] = set()
-
-        # Progress for the current "batch" (since queue became non-empty)
-        self._batch_total = 0
-        self._batch_completed = 0
-        self._batch_failed = 0
-
-    @staticmethod
-    def _printer_is_busy_printing(printer_id: int) -> bool:
-        state = printer_manager.get_status(printer_id)
-        if not state:
-            return False
-        return state.state in ("RUNNING", "PAUSE", "PAUSED") and bool(state.gcode_file)
-
-    async def start(self):
-        async with self._lock:
-            if self._dispatcher_task and not self._dispatcher_task.done():
-                return
-            self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name="background-dispatch-dispatcher")
-            logger.info("Background dispatch dispatcher started")
-
-    async def stop(self):
-        dispatcher: asyncio.Task | None = None
-        running_tasks: list[asyncio.Task] = []
-        async with self._lock:
-            dispatcher = self._dispatcher_task
-            self._dispatcher_task = None
-            running_tasks = list(self._running_tasks.values())
-            jobs_to_release = [*self._queued_jobs, *(state.job for state in self._active_jobs.values())]
-            self._running_tasks.clear()
-            self._active_jobs.clear()
-            self._queued_jobs.clear()
-            self._cancel_requested_job_ids.clear()
-            self._job_event.set()
-
-        for job in jobs_to_release:
-            await self._release_budget_reservation(job, status="released")
-
-        if dispatcher:
-            dispatcher.cancel()
-        for task in running_tasks:
-            task.cancel()
-
-        if dispatcher:
-            try:
-                await dispatcher
-            except asyncio.CancelledError:
-                pass
-
-        if running_tasks:
-            await asyncio.gather(*running_tasks, return_exceptions=True)
-
-        logger.info("Background dispatch dispatcher stopped")
-
-    async def dispatch_reprint_archive(
-        self,
-        *,
-        archive_id: int,
-        archive_name: str,
-        printer_id: int,
-        printer_name: str,
-        options: dict[str, Any],
-        requested_by_user_id: int | None,
-        requested_by_username: str | None,
-    ) -> dict[str, Any]:
-        return await self._dispatch(
-            kind="reprint_archive",
-            source_id=archive_id,
-            source_name=archive_name,
-            printer_id=printer_id,
-            printer_name=printer_name,
-            options=options,
-            requested_by_user_id=requested_by_user_id,
-            requested_by_username=requested_by_username,
-        )
-
-    async def get_state(self) -> dict[str, Any]:
-        """Get current dispatch queue state snapshot for newly connected clients."""
-        async with self._lock:
-            return self._build_state_payload_unlocked()
-
-    async def dispatch_print_library_file(
-        self,
-        *,
-        file_id: int,
-        filename: str,
-        printer_id: int,
-        printer_name: str,
-        options: dict[str, Any],
-        requested_by_user_id: int | None,
-        requested_by_username: str | None,
-        project_id: int | None = None,
-        cleanup_library_after_dispatch: bool = False,
-    ) -> dict[str, Any]:
-        return await self._dispatch(
-            kind="print_library_file",
-            source_id=file_id,
-            source_name=filename,
-            printer_id=printer_id,
-            printer_name=printer_name,
-            options=options,
-            requested_by_user_id=requested_by_user_id,
-            requested_by_username=requested_by_username,
-            project_id=project_id,
-            cleanup_library_after_dispatch=cleanup_library_after_dispatch,
-        )
-
-    async def cancel_job(self, job_id: int) -> dict[str, Any]:
-        """Cancel a queued dispatch job.
-
-        Queued jobs are removed immediately. Active jobs are cancelled
-        cooperatively and will stop at the next cancellation checkpoint.
-        """
-        async with self._lock:
-            # Check active jobs first
-            active_state = self._active_jobs.get(job_id)
-            if active_state is not None:
-                logger.info("Cancel requested for active dispatch job %s", job_id)
-                self._cancel_requested_job_ids.add(job_id)
-                active_job = active_state.job
-                payload = self._build_state_payload_unlocked(
-                    recent_event={
-                        "status": "cancelling",
-                        "job_id": active_job.id,
-                        "source_name": active_job.source_name,
-                        "printer_id": active_job.printer_id,
-                        "printer_name": active_job.printer_name,
-                        "message": "Cancelling current dispatch...",
-                    }
-                )
-                result = {
-                    "cancelled": True,
-                    "pending": True,
-                    "job_id": active_job.id,
-                    "source_name": active_job.source_name,
-                    "printer_id": active_job.printer_id,
-                    "printer_name": active_job.printer_name,
-                }
-                await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-                return result
-
-            # Check queued jobs
-            cancelled_job: PrintDispatchJob | None = None
-            for job in self._queued_jobs:
-                if job.id == job_id:
-                    cancelled_job = job
-                    break
-
-            if not cancelled_job:
-                logger.info("Cancel requested for unknown dispatch job %s", job_id)
-                return {"cancelled": False, "reason": "not_found"}
-
-            self._queued_jobs.remove(cancelled_job)
-            logger.info("Cancelled queued dispatch job %s", cancelled_job.id)
-            self._batch_total = max(0, self._batch_total - 1)
-
-            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
-                self._batch_completed = 0
-                self._batch_failed = 0
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "cancelled",
-                    "job_id": cancelled_job.id,
-                    "source_name": cancelled_job.source_name,
-                    "printer_id": cancelled_job.printer_id,
-                    "printer_name": cancelled_job.printer_name,
-                    "message": "Cancelled from queue",
-                }
-            )
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-        return {
-            "cancelled": True,
-            "pending": False,
-            "job_id": cancelled_job.id,
-            "source_name": cancelled_job.source_name,
-            "printer_id": cancelled_job.printer_id,
-            "printer_name": cancelled_job.printer_name,
-        }
-
-    async def _dispatch(
-        self,
-        *,
-        kind: Literal["reprint_archive", "print_library_file"],
-        source_id: int,
-        source_name: str,
-        printer_id: int,
-        printer_name: str,
-        options: dict[str, Any],
-        requested_by_user_id: int | None,
-        requested_by_username: str | None,
-        project_id: int | None = None,
-        cleanup_library_after_dispatch: bool = False,
-    ) -> dict[str, Any]:
-        async with self._lock:
-            has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
-            has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())
-
-            if has_pending_for_printer or has_active_for_printer:
-                raise DispatchEnqueueRejected(f"Printer {printer_name} already has a background dispatch in progress")
-
-            if self._printer_is_busy_printing(printer_id):
-                raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
-
-            dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
-            job_id = self._next_job_id
-            async with async_session() as db:
-                requested_by = await db.get(User, requested_by_user_id) if requested_by_user_id is not None else None
-                await create_budget_reservation(
-                    db,
-                    cost_center_id=options.get("cost_center_id"),
-                    estimated_cost=options.get("estimated_cost"),
-                    current_user=requested_by,
-                    source_type="background_dispatch",
-                    source_id=job_id,
-                    print_archive_id=source_id if kind == "reprint_archive" else None,
-                )
-                await db.commit()
-
-            job = PrintDispatchJob(
-                id=job_id,
-                kind=kind,
-                source_id=source_id,
-                source_name=source_name,
-                printer_id=printer_id,
-                printer_name=printer_name,
-                options=options,
-                requested_by_user_id=requested_by_user_id,
-                requested_by_username=requested_by_username,
-                project_id=project_id,
-                cleanup_library_after_dispatch=cleanup_library_after_dispatch,
-            )
-            self._next_job_id += 1
-            self._batch_total += 1
-            self._queued_jobs.append(job)
-            self._job_event.set()
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "dispatched",
-                    "job_id": job.id,
-                    "source_name": source_name,
-                    "printer_id": printer_id,
-                    "printer_name": printer_name,
-                    "message": f"Dispatched to {printer_name}",
-                }
-            )
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-        return {
-            "dispatch_job_id": job.id,
-            "dispatch_position": dispatch_position,
-            "status": "dispatched",
-            "printer_id": printer_id,
-            "source_id": source_id,
-            "source_name": source_name,
-        }
-
-    async def _dispatcher_loop(self):
-        while True:
-            await self._job_event.wait()
-            self._job_event.clear()
-
-            while True:
-                payload: dict[str, Any] | None = None
-                job_to_start: PrintDispatchJob | None = None
-                async with self._lock:
-                    busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}
-                    start_index = next(
-                        (
-                            idx
-                            for idx, queued_job in enumerate(self._queued_jobs)
-                            if queued_job.printer_id not in busy_printer_ids
-                        ),
-                        None,
-                    )
-
-                    if start_index is None:
-                        break
-
-                    job_to_start = self._queued_jobs[start_index]
-                    del self._queued_jobs[start_index]
-                    self._active_jobs[job_to_start.id] = ActiveDispatchState(
-                        job=job_to_start,
-                        message="Preparing background dispatch...",
-                    )
-
-                    task = asyncio.create_task(
-                        self._run_active_job(job_to_start), name=f"background-dispatch-job-{job_to_start.id}"
-                    )
-                    self._running_tasks[job_to_start.id] = task
-
-                    payload = self._build_state_payload_unlocked(
-                        recent_event={
-                            "status": "processing",
-                            "job_id": job_to_start.id,
-                            "source_name": job_to_start.source_name,
-                            "printer_id": job_to_start.printer_id,
-                            "printer_name": job_to_start.printer_name,
-                            "message": "Preparing background dispatch...",
-                        }
-                    )
-
-                if payload:
-                    await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    async def _run_active_job(self, job: PrintDispatchJob):
-        try:
-            await self._process_job(job)
-            await self._mark_job_finished(job, failed=False, message="Background dispatch complete")
-        except DispatchJobCancelled:
-            await self._mark_job_cancelled(job)
-        except asyncio.CancelledError:
-            raise
-        except Exception as e:
-            logger.error("Background dispatch job %s failed: %s", job.id, e, exc_info=True)
-            await self._mark_job_finished(job, failed=True, message=str(e))
-        finally:
-            self._job_event.set()
-
-    async def _set_active_message(self, job: PrintDispatchJob, message: str):
-        async with self._lock:
-            active = self._active_jobs.get(job.id)
-            if not active:
-                return
-            active.message = message
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "processing",
-                    "job_id": active.job.id,
-                    "source_name": active.job.source_name,
-                    "printer_id": active.job.printer_id,
-                    "printer_name": active.job.printer_name,
-                    "message": message,
-                }
-            )
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):
-        async with self._lock:
-            active = self._active_jobs.get(job.id)
-            if not active:
-                return
-
-            active.upload_bytes = max(0, int(uploaded))
-            active.upload_total_bytes = max(0, int(total))
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "processing",
-                    "job_id": active.job.id,
-                    "source_name": active.job.source_name,
-                    "printer_id": active.job.printer_id,
-                    "printer_name": active.job.printer_name,
-                    "message": active.message,
-                }
-            )
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
-        if failed:
-            await self._release_budget_reservation(job, status="released")
-
-        async with self._lock:
-            if failed:
-                self._batch_failed += 1
-            else:
-                self._batch_completed += 1
-
-            self._active_jobs.pop(job.id, None)
-            self._running_tasks.pop(job.id, None)
-            self._cancel_requested_job_ids.discard(job.id)
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "failed" if failed else "completed",
-                    "job_id": job.id,
-                    "source_name": job.source_name,
-                    "printer_id": job.printer_id,
-                    "printer_name": job.printer_name,
-                    "message": message,
-                }
-            )
-            should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-        if should_reset_batch:
-            async with self._lock:
-                if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
-                    self._batch_total = 0
-                    self._batch_completed = 0
-                    self._batch_failed = 0
-
-    async def _mark_job_cancelled(self, job: PrintDispatchJob):
-        await self._release_budget_reservation(job, status="released")
-
-        async with self._lock:
-            self._active_jobs.pop(job.id, None)
-            self._running_tasks.pop(job.id, None)
-            self._cancel_requested_job_ids.discard(job.id)
-            self._batch_total = max(0, self._batch_total - 1)
-
-            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
-                self._batch_completed = 0
-                self._batch_failed = 0
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "cancelled",
-                    "job_id": job.id,
-                    "source_name": job.source_name,
-                    "printer_id": job.printer_id,
-                    "printer_name": job.printer_name,
-                    "message": "Cancelled during dispatch",
-                }
-            )
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    def _is_cancel_requested(self, job_id: int) -> bool:
-        return job_id in self._cancel_requested_job_ids
-
-    def _raise_if_cancel_requested(self, job: PrintDispatchJob):
-        if self._is_cancel_requested(job.id):
-            raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
-
-    @staticmethod
-    async def _release_budget_reservation(job: PrintDispatchJob, *, status: str):
-        if job.options.get("cost_center_id") is None:
-            return
-        async with async_session() as db:
-            await release_budget_reservation(
-                db,
-                source_type="background_dispatch",
-                source_id=job.id,
-                status=status,
-            )
-            await db.commit()
-
-    def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
-        processing = len(self._active_jobs)
-        dispatched = len(self._queued_jobs)
-
-        dispatched_jobs = [
-            {
-                "job_id": job.id,
-                "kind": job.kind,
-                "source_id": job.source_id,
-                "source_name": job.source_name,
-                "printer_id": job.printer_id,
-                "printer_name": job.printer_name,
-            }
-            for job in list(self._queued_jobs)
-        ]
-
-        active_jobs: list[dict[str, Any]] = []
-        for active in self._active_jobs.values():
-            upload_progress_pct = None
-            if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:
-                upload_progress_pct = round(
-                    max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1
-                )
-
-            active_jobs.append(
-                {
-                    "job_id": active.job.id,
-                    "kind": active.job.kind,
-                    "source_id": active.job.source_id,
-                    "source_name": active.job.source_name,
-                    "printer_id": active.job.printer_id,
-                    "printer_name": active.job.printer_name,
-                    "message": active.message,
-                    "upload_bytes": active.upload_bytes,
-                    "upload_total_bytes": active.upload_total_bytes,
-                    "upload_progress_pct": upload_progress_pct,
-                }
-            )
-
-        active_jobs.sort(key=lambda item: int(item["job_id"]))
-        active_job = active_jobs[0] if active_jobs else None
-
-        return {
-            "total": self._batch_total,
-            "dispatched": dispatched,
-            "processing": processing,
-            "completed": self._batch_completed,
-            "failed": self._batch_failed,
-            "dispatched_jobs": dispatched_jobs,
-            "active_jobs": active_jobs,
-            "active_job": active_job,
-            "recent_event": recent_event,
-        }
-
-    async def _process_job(self, job: PrintDispatchJob):
-        if job.kind == "reprint_archive":
-            await self._run_reprint_archive(job)
-            return
-        if job.kind == "print_library_file":
-            await self._run_print_library_file(job)
-            return
-        raise RuntimeError(f"Unknown dispatch job kind: {job.kind}")
-
-    async def _run_reprint_archive(self, job: PrintDispatchJob):
-        from backend.app.main import register_expected_print
-
-        async with async_session() as db:
-            service = ArchiveService(db)
-            archive = await service.get_archive(job.source_id)
-            if not archive:
-                raise RuntimeError("Archive not found")
-
-            cost_center_id = job.options.get("cost_center_id")
-            if cost_center_id is not None:
-                cost_center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id))
-                if not cost_center:
-                    raise RuntimeError("Cost center not found")
-
-            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
-            if not printer:
-                raise RuntimeError("Printer not found")
-
-            printer_name = printer.name
-            printer_ip = printer.ip_address
-            printer_access_code = printer.access_code
-            printer_model = printer.model
-            archive_filename = archive.filename
-
-            if not printer_manager.is_connected(job.printer_id):
-                raise RuntimeError("Printer is not connected")
-
-            file_path = settings.base_dir / archive.file_path
-            if not file_path.exists():
-                raise RuntimeError("Archive file not found")
-
-            remote_filename = derive_remote_filename(archive.filename)
-            remote_path = f"/{remote_filename}"
-
-            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-            self._raise_if_cancel_requested(job)
-
-            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
-            await delete_file_async(
-                printer_ip,
-                printer_access_code,
-                remote_path,
-                socket_timeout=ftp_timeout,
-                printer_model=printer_model,
-            )
-
-            self._raise_if_cancel_requested(job)
-
-            try:
-                await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
-                loop = asyncio.get_running_loop()
-                progress_state = {"last_emit": 0.0, "last_bytes": 0}
-
-                def upload_progress_callback(uploaded: int, total: int):
-                    if self._is_cancel_requested(job.id):
-                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
-
-                    now = time.monotonic()
-                    should_emit = (
-                        uploaded >= total
-                        or now - progress_state["last_emit"] >= 0.2
-                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
-                    )
-
-                    if should_emit:
-                        progress_state["last_emit"] = now
-                        progress_state["last_bytes"] = uploaded
-                        loop.call_soon_threadsafe(
-                            lambda u=uploaded, t=total: spawn_background_task(
-                                self._set_active_upload_progress(job, u, t),
-                                name=f"upload-progress-{job.id}",
-                            )
-                        )
-
-                if ftp_retry_enabled:
-                    uploaded = await with_ftp_retry(
-                        upload_file_async,
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                        max_retries=ftp_retry_count,
-                        retry_delay=ftp_retry_delay,
-                        operation_name=f"Upload for reprint to {printer_name}",
-                        non_retry_exceptions=(DispatchJobCancelled,),
-                    )
-                else:
-                    uploaded = await upload_file_async(
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                    )
-
-                if uploaded:
-                    await self._set_active_upload_progress(job, 1, 1)
-
-                if not uploaded:
-                    raise RuntimeError(
-                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
-                    )
-
-                # Resolve plate_id before register so usage tracking can scope the
-                # 3MF parse to the dispatched plate at print-start (#1697). Pure
-                # transform of file_path + options, safe to reorder.
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
-                register_expected_print(
-                    job.printer_id,
-                    remote_filename,
-                    job.source_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    cost_center_id=job.options.get("cost_center_id"),
-                    plate_id=plate_id,
-                )
-
-                self._raise_if_cancel_requested(job)
-
-                effective_timelapse = bool(job.options.get("timelapse", False))
-
-                await self._set_active_message(job, f"Starting print on {printer_name}...")
-                started = printer_manager.start_print(
-                    job.printer_id,
-                    remote_filename,
-                    plate_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=effective_timelapse,
-                    bed_levelling=job.options.get("bed_levelling", True),
-                    flow_cali=job.options.get("flow_cali", False),
-                    vibration_cali=job.options.get("vibration_cali", True),
-                    layer_inspect=job.options.get("layer_inspect", False),
-                    use_ams=job.options.get("use_ams", True),
-                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
-                )
-
-                if not started:
-                    await self._cleanup_sd_card_file(
-                        printer_ip,
-                        printer_access_code,
-                        remote_path,
-                        printer_model,
-                    )
-                    raise RuntimeError("Failed to start print")
-
-                # Register the archive's local 3MF in the cover-cache so the
-                # /cover endpoint can skip FTP — we already have the file on
-                # disk, no need to refetch 36 MB from a printer whose FTP is
-                # busy serving the active print (#1166 follow-up).
-                cache_3mf_download(job.printer_id, remote_filename, file_path)
-
-                # Wait for the printer to actually pick up the command before
-                # marking the dispatch job complete (#1042). MQTT-publish success
-                # only proves the command queued locally; the printer can still
-                # reject it (HMS error pending, half-broken session, SD card
-                # missing) and never transition. Until #1042 this watchdog was
-                # fire-and-forget — the job was reported successful and the
-                # user had no signal that the print never started. The uploaded
-                # file is intentionally left on the printer's SD card on
-                # timeout: the next dispatch will overwrite it via the existing
-                # delete-then-upload step, and the printer may still be in the
-                # middle of reading it if it picked up just past the timeout.
-                pre_status = printer_manager.get_status(job.printer_id)
-                pre_state = getattr(pre_status, "state", None) if pre_status else None
-                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
-                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
-                if pre_state:
-                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
-                    transitioned = await self._verify_print_response(
-                        job.printer_id,
-                        printer_name,
-                        pre_state,
-                        pre_subtask_id=pre_subtask_id,
-                        pre_gcode_file=pre_gcode_file,
-                    )
-                    if not transitioned:
-                        raise RuntimeError(
-                            f"Printer did not acknowledge print command — state still {pre_state}. "
-                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
-                            f"SD card) and try again."
-                        )
-
-                if job.requested_by_user_id and job.requested_by_username:
-                    printer_manager.set_current_print_user(
-                        job.printer_id,
-                        job.requested_by_user_id,
-                        job.requested_by_username,
-                    )
-            except DispatchJobCancelled:
-                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
-                raise
-
-    async def _run_print_library_file(self, job: PrintDispatchJob):
-        from backend.app.main import register_expected_print
-
-        async with async_session() as db:
-            lib_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == job.source_id))
-            if not lib_file:
-                raise RuntimeError("File not found")
-
-            if not self._is_sliced_file(lib_file.filename):
-                raise RuntimeError("Not a sliced file. Only .gcode or .gcode.3mf files can be printed.")
-
-            file_path = Path(settings.base_dir) / lib_file.file_path
-            if not file_path.exists():
-                raise RuntimeError("File not found on disk")
-
-            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
-            if not printer:
-                raise RuntimeError("Printer not found")
-
-            printer_name = printer.name
-            printer_ip = printer.ip_address
-            printer_access_code = printer.access_code
-            printer_model = printer.model
-            library_filename = lib_file.filename
-
-            if not printer_manager.is_connected(job.printer_id):
-                raise RuntimeError("Printer is not connected")
-
-            await self._set_active_message(job, f"Creating archive for {lib_file.filename}...")
-            archive_service = ArchiveService(db)
-            archive = await archive_service.archive_print(
-                printer_id=job.printer_id,
-                source_file=file_path,
-                original_filename=lib_file.filename,
-                project_id=job.project_id,
-                created_by_id=job.requested_by_user_id,
-                cost_center_id=job.options.get("cost_center_id"),
-            )
-            if not archive:
-                raise RuntimeError("Failed to create archive")
-            if job.options.get("cost_center_id") is not None:
-                reservation = await db.scalar(
-                    select(BudgetReservation).where(
-                        BudgetReservation.source_type == "background_dispatch",
-                        BudgetReservation.source_id == job.id,
-                        BudgetReservation.status == "active",
-                    )
-                )
-                if reservation:
-                    reservation.print_archive_id = archive.id
-                    await db.flush()
-
-            remote_filename = derive_remote_filename(lib_file.filename)
-            remote_path = f"/{remote_filename}"
-
-            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-            self._raise_if_cancel_requested(job)
-
-            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
-            await delete_file_async(
-                printer_ip,
-                printer_access_code,
-                remote_path,
-                socket_timeout=ftp_timeout,
-                printer_model=printer_model,
-            )
-
-            self._raise_if_cancel_requested(job)
-
-            try:
-                await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
-                loop = asyncio.get_running_loop()
-                progress_state = {"last_emit": 0.0, "last_bytes": 0}
-
-                def upload_progress_callback(uploaded: int, total: int):
-                    if self._is_cancel_requested(job.id):
-                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
-
-                    now = time.monotonic()
-                    should_emit = (
-                        uploaded >= total
-                        or now - progress_state["last_emit"] >= 0.2
-                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
-                    )
-
-                    if should_emit:
-                        progress_state["last_emit"] = now
-                        progress_state["last_bytes"] = uploaded
-                        loop.call_soon_threadsafe(
-                            lambda u=uploaded, t=total: spawn_background_task(
-                                self._set_active_upload_progress(job, u, t),
-                                name=f"upload-progress-{job.id}",
-                            )
-                        )
-
-                if ftp_retry_enabled:
-                    uploaded = await with_ftp_retry(
-                        upload_file_async,
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                        max_retries=ftp_retry_count,
-                        retry_delay=ftp_retry_delay,
-                        operation_name=f"Upload for print to {printer_name}",
-                        non_retry_exceptions=(DispatchJobCancelled,),
-                    )
-                else:
-                    uploaded = await upload_file_async(
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                    )
-
-                if uploaded:
-                    await self._set_active_upload_progress(job, 1, 1)
-
-                if not uploaded:
-                    await db.rollback()
-                    raise RuntimeError(
-                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
-                    )
-
-                # Resolve plate_id before register so usage tracking can scope the
-                # 3MF parse to the dispatched plate at print-start (#1697).
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
-                register_expected_print(
-                    job.printer_id,
-                    remote_filename,
-                    archive.id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    cost_center_id=job.options.get("cost_center_id"),
-                    plate_id=plate_id,
-                )
-
-                self._raise_if_cancel_requested(job)
-
-                effective_timelapse = bool(job.options.get("timelapse", False))
-
-                await self._set_active_message(job, f"Starting print on {printer_name}...")
-                started = printer_manager.start_print(
-                    job.printer_id,
-                    remote_filename,
-                    plate_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=effective_timelapse,
-                    bed_levelling=job.options.get("bed_levelling", True),
-                    flow_cali=job.options.get("flow_cali", False),
-                    vibration_cali=job.options.get("vibration_cali", True),
-                    layer_inspect=job.options.get("layer_inspect", False),
-                    use_ams=job.options.get("use_ams", True),
-                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
-                )
-
-                if not started:
-                    await self._cleanup_sd_card_file(
-                        printer_ip,
-                        printer_access_code,
-                        remote_path,
-                        printer_model,
-                    )
-                    await db.rollback()
-                    raise RuntimeError("Failed to start print")
-
-                # Same as the archive path: register the library file's local
-                # 3MF in the cover-cache so /cover skips FTP (#1166 follow-up).
-                cache_3mf_download(job.printer_id, remote_filename, file_path)
-
-                # See _run_reprint_archive for rationale (#1042). On timeout
-                # also rolls back the freshly-created archive so the library
-                # flow doesn't leave behind a phantom row for a print that
-                # never started.
-                pre_status = printer_manager.get_status(job.printer_id)
-                pre_state = getattr(pre_status, "state", None) if pre_status else None
-                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
-                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
-                if pre_state:
-                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
-                    transitioned = await self._verify_print_response(
-                        job.printer_id,
-                        printer_name,
-                        pre_state,
-                        pre_subtask_id=pre_subtask_id,
-                        pre_gcode_file=pre_gcode_file,
-                    )
-                    if not transitioned:
-                        await db.rollback()
-                        raise RuntimeError(
-                            f"Printer did not acknowledge print command — state still {pre_state}. "
-                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
-                            f"SD card) and try again."
-                        )
-
-                if job.requested_by_user_id and job.requested_by_username:
-                    printer_manager.set_current_print_user(
-                        job.printer_id,
-                        job.requested_by_user_id,
-                        job.requested_by_username,
-                    )
-
-                # Direct-Print flow only: archive_print copies, so deleting the
-                # transient library row + files here leaves archive intact. Disk
-                # deletes run only after commit so a rollback leaves no orphan.
-                cleanup_disk_paths: list[Path] = []
-                if job.cleanup_library_after_dispatch and not lib_file.is_external:
-                    cleanup_disk_paths.append(file_path)
-                    if lib_file.thumbnail_path:
-                        thumb_path = Path(lib_file.thumbnail_path)
-                        if not thumb_path.is_absolute():
-                            thumb_path = Path(settings.base_dir) / lib_file.thumbnail_path
-                        cleanup_disk_paths.append(thumb_path)
-                    await db.delete(lib_file)
-
-                await db.commit()
-
-                for cleanup_path in cleanup_disk_paths:
-                    try:
-                        if cleanup_path.exists():
-                            cleanup_path.unlink()
-                    except OSError as cleanup_err:
-                        logger.warning("Failed to delete transient library file %s: %s", cleanup_path, cleanup_err)
-            except DispatchJobCancelled:
-                await db.rollback()
-                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
-                raise
-
-    @staticmethod
-    async def _verify_print_response(
-        printer_id: int,
-        printer_name: str,
-        pre_state: str,
-        pre_subtask_id: str | None = None,
-        pre_gcode_file: str | None = None,
-        timeout: float = 90.0,
-        poll_interval: float = 3.0,
-    ) -> bool:
-        """Wait for the printer to acknowledge a print command.
-
-        Returns True if the printer transitioned (state advanced past pre_state
-        or subtask_id advanced past pre_subtask_id). Returns False on timeout —
-        in that case logs a warning and forces an MQTT reconnect, mirroring the
-        queue-side watchdog (`_watchdog_print_start`). Caller is responsible
-        for surfacing the False result to the user (typically by raising so the
-        dispatch job is marked failed).
-
-        Both transition signals are checked because H2D can sit at FINISH for
-        ~50 s after accepting `project_file` before flipping to PREPARE; the
-        printer echoes our per-dispatch identity back as `subtask_id` on
-        `push_status` first, so a subtask_id change is a definitive "command
-        landed" signal even while state is still FINISH (#1078).
-        """
-        deadline = time.monotonic() + timeout
-        last_status = None
-        while time.monotonic() < deadline:
-            await asyncio.sleep(poll_interval)
-            state = printer_manager.get_status(printer_id)
-            if not state:
-                # Printer momentarily not reporting — could be a brief MQTT
-                # disconnect mid-window. Keep polling rather than declaring
-                # failure on the first missed tick; the printer may reconnect
-                # within the remaining timeout and still surface a transition.
-                continue
-            last_status = state
-            if state.state in _ACTIVE_PRINT_STATES:
-                # Printer is actively processing the job. We do NOT accept
-                # arbitrary state transitions: a printer going FINISH -> IDLE
-                # (user dismissed the post-print prompt without accepting our
-                # project_file) would otherwise look like "command landed"
-                # and the dispatch job would be marked successful even though
-                # no print is running (#1370).
-                return True
-            if pre_subtask_id is not None and state.subtask_id is not None and state.subtask_id != pre_subtask_id:
-                # Printer picked up the job (subtask_id advanced). H2D can
-                # sit at FINISH for ~50 s after accepting project_file before
-                # transitioning to PREPARE, but the subtask_id flips to our
-                # submission_id almost immediately (#1078).
-                return True
-        logger.warning(
-            "Printer %s (%d) did not respond to print command within %.0fs "
-            "(state still %s, subtask_id still %s) — printer may need restart",
-            printer_name,
-            printer_id,
-            timeout,
-            pre_state,
-            pre_subtask_id,
-        )
-        # Distinguish #1150 (slow parse) from #887/#936 (half-broken session)
-        # via gcode_file: if the printer is now showing a different file than
-        # before dispatch, the project_file command landed and the printer is
-        # parsing — a forced reconnect mid-parse causes 0500_4003. If
-        # gcode_file is unchanged, the publish was silently swallowed and the
-        # original #936 recovery (force_reconnect → fresh client_id) is what
-        # we want. Caveat: in the rare retry-same-file-after-timeout case the
-        # printer's gcode_file looks identical before and after the publish
-        # lands, so a slow parse on retry-same-file still falls through to the
-        # reconnect (and the original 0500_4003) — accepted to avoid breaking
-        # the half-broken-session recovery path.
-        client = printer_manager.get_client(printer_id)
-        current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
-        publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
-        if publish_landed:
-            logger.warning(
-                "Printer %s (%d) gcode_file changed to %r (was %r) — printer "
-                "received the command and is parsing slowly. Skipping forced "
-                "MQTT reconnect to avoid 0500_4003 mid-parse (#1150).",
-                printer_name,
-                printer_id,
-                current_gcode_file,
-                pre_gcode_file,
-            )
-        elif client and hasattr(client, "force_reconnect_stale_session"):
-            client.force_reconnect_stale_session(
-                f"print command unacknowledged after {timeout:.0f}s "
-                f"(state still {pre_state}, gcode_file {current_gcode_file!r})"
-            )
-        return False
-
-    @staticmethod
-    async def _cleanup_sd_card_file(
-        printer_ip: str,
-        access_code: str,
-        remote_path: str,
-        printer_model: str | None,
-    ):
-        """Best-effort delete of uploaded file from printer SD card."""
-        try:
-            await delete_file_async(printer_ip, access_code, remote_path, printer_model=printer_model)
-        except Exception:
-            pass  # Best-effort — don't fail the error handler
-
-    @staticmethod
-    def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:
-        if requested_plate_id is not None:
-            return requested_plate_id
-
-        plate_id = 1
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                for name in zf.namelist():
-                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
-                        plate_str = name[15:-6]
-                        plate_id = int(plate_str)
-                        break
-        except (ValueError, zipfile.BadZipFile, OSError):
-            pass
-        return plate_id
-
-    @staticmethod
-    def _is_sliced_file(filename: str) -> bool:
-        lower = filename.lower()
-        return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
-
-
-background_dispatch = BackgroundDispatchService()

+ 200 - 0
backend/app/services/backup_path.py

@@ -0,0 +1,200 @@
+"""Why a backup directory is not writable — and what to actually do about it.
+
+Bambuddy's systemd unit runs with ``ProtectSystem=strict``. That mounts the
+entire filesystem read-only inside the service's own mount namespace and carves
+back out only ``ReadWritePaths=<install> <data> <logs>``. A backup output path
+on a NAS mount is therefore read-only *to the service* while the operator's own
+shell writes to it happily. The kernel reports this as ``EROFS``, not
+``EACCES``, so the obvious move — checking folder permissions — turns up nothing
+and the real cause (our own unit file) is the last place anyone looks (#2544).
+
+Docker has the same shape with a different cause: a host path that was never
+bind-mounted into the container is simply not the host path. Worse, it is still
+*writable* — the write lands in the container's ephemeral layer and vanishes on
+the next ``docker compose up``. A backup that silently goes nowhere is the one
+failure mode a backup feature must not have.
+
+So: probe the directory with a real write before trusting it, and when that
+write fails, name which of these it is and hand back the exact command that
+fixes it.
+"""
+
+from __future__ import annotations
+
+import errno
+import logging
+import os
+import re
+import tempfile
+from pathlib import Path
+
+from backend.app.services.discovery import is_running_in_docker
+
+logger = logging.getLogger(__name__)
+
+# Cgroup line for a systemd service, e.g.
+#   0::/system.slice/bambuddy.service
+#   0::/system.slice/system-bambuddy.slice/bambuddy@1.service
+_SERVICE_CGROUP = re.compile(r"/([^/]+\.service)\b")
+
+
+def systemd_unit_name() -> str | None:
+    """Name of the systemd unit we are running as, or None if we are not one.
+
+    ``INVOCATION_ID`` is set by systemd for every unit it starts and by nothing
+    else, so it is the signal that we are a unit at all. The name itself comes
+    from the cgroup path — systemd exports no environment variable for it.
+    """
+    if not os.environ.get("INVOCATION_ID"):
+        return None
+    try:
+        cgroup = Path("/proc/self/cgroup").read_text()
+    except OSError:
+        return "bambuddy.service"
+    match = _SERVICE_CGROUP.search(cgroup)
+    return match.group(1) if match else "bambuddy.service"
+
+
+def _systemd_remedy(unit: str, path: Path) -> str:
+    return (
+        f"sudo systemctl edit {unit}\n"
+        "\n"
+        "Add these two lines to the drop-in, save, then restart:\n"
+        "\n"
+        "[Service]\n"
+        f"ReadWritePaths={path}\n"
+        "\n"
+        f"sudo systemctl restart {unit}"
+    )
+
+
+def _docker_remedy(path: Path) -> str:
+    return f"services:\n  bambuddy:\n    volumes:\n      - {path}:{path}"
+
+
+def classify_backup_dir_error(exc: OSError, backup_dir: Path) -> dict:
+    """Map an OSError raised while writing to ``backup_dir`` onto a diagnosis.
+
+    ``message`` is English and goes to the log and the API. The frontend
+    translates from ``code`` and renders ``remedy`` verbatim as a snippet.
+    """
+    detail = str(exc)
+    unit = systemd_unit_name()
+
+    if exc.errno == errno.EROFS:
+        if unit:
+            return {
+                "writable": False,
+                "path": str(backup_dir),
+                "code": "sandboxed",
+                "detail": detail,
+                "remedy": _systemd_remedy(unit, backup_dir),
+                "message": (
+                    f"{backup_dir} is read-only for the Bambuddy service. Its systemd unit runs with "
+                    "ProtectSystem=strict, which makes every path outside the install, data and log "
+                    f"directories read-only — add ReadWritePaths={backup_dir} to a drop-in "
+                    f"(sudo systemctl edit {unit}) and restart. If the path is on a network share, also "
+                    "confirm the share itself is not mounted read-only."
+                ),
+            }
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "read_only",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} is on a read-only filesystem.",
+        }
+
+    if exc.errno in (errno.EACCES, errno.EPERM):
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "permission_denied",
+            "detail": detail,
+            "remedy": None,
+            "message": f"Bambuddy is not allowed to write to {backup_dir}. Check the directory's owner and mode.",
+        }
+
+    if exc.errno == errno.ENOSPC:
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "no_space",
+            "detail": detail,
+            "remedy": None,
+            "message": f"No space left on the filesystem holding {backup_dir}.",
+        }
+
+    if exc.errno in (errno.ENOTDIR, errno.EEXIST):
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "not_a_directory",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} exists but is not a directory.",
+        }
+
+    if exc.errno == errno.ENOENT:
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "missing",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} does not exist and could not be created.",
+        }
+
+    return {
+        "writable": False,
+        "path": str(backup_dir),
+        "code": "error",
+        "detail": detail,
+        "remedy": None,
+        "message": f"Bambuddy cannot write to {backup_dir}: {exc}",
+    }
+
+
+def _is_container_ephemeral(backup_dir: Path) -> bool:
+    """True if this path lives in the container's own writable layer.
+
+    A bind mount or named volume always sits on a different device than the
+    container root, so a matching ``st_dev`` means nothing was mounted here and
+    the backups die with the container.
+    """
+    try:
+        return backup_dir.stat().st_dev == Path("/").stat().st_dev
+    except OSError:
+        return False
+
+
+def probe_backup_dir(backup_dir: Path) -> dict:
+    """Create the directory and write a throwaway file in it.
+
+    Returns the same shape as :func:`classify_backup_dir_error`, plus a
+    ``warning`` code for a directory that is writable but not persistent.
+    """
+    try:
+        backup_dir.mkdir(parents=True, exist_ok=True)
+        with tempfile.NamedTemporaryFile(dir=backup_dir, prefix=".bambuddy-write-test-") as probe:
+            probe.write(b"bambuddy")
+            probe.flush()
+    except OSError as e:
+        result = classify_backup_dir_error(e, backup_dir)
+        logger.warning("Backup path check failed: %s", result["message"])
+        return {**result, "warning": None}
+
+    warning = None
+    if is_running_in_docker() and _is_container_ephemeral(backup_dir):
+        warning = "container_ephemeral"
+
+    return {
+        "writable": True,
+        "path": str(backup_dir),
+        "code": "ok",
+        "detail": None,
+        "remedy": _docker_remedy(backup_dir) if warning else None,
+        "message": f"{backup_dir} is writable.",
+        "warning": warning,
+    }

+ 305 - 28
backend/app/services/bambu_cloud.py

@@ -4,8 +4,11 @@ Bambu Lab Cloud API Service
 Handles authentication and profile management with Bambu Lab's cloud services.
 """
 
+import hashlib
 import logging
-from datetime import datetime, timedelta, timezone
+import time
+from collections.abc import Awaitable, Callable
+from datetime import datetime, timezone
 
 import httpx
 
@@ -14,6 +17,61 @@ logger = logging.getLogger(__name__)
 BAMBU_API_BASE = "https://api.bambulab.com"
 BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 
+# How long a "Bambu still accepts this token" answer is trusted before we ask
+# again. ``/cloud/status`` is polled by several components, so validating on
+# every call would put a Bambu round-trip behind every settings render; a token
+# does not expire on a five-minute boundary, so caching that long is free.
+_VALIDATION_TTL_SECONDS = 300
+
+# token digest -> (monotonic deadline, accepted?). Keyed by digest so a token
+# never sits in a process-wide dict in the clear.
+_validation_cache: dict[str, tuple[float, bool]] = {}
+
+
+def _token_digest(token: str) -> str:
+    return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def is_expiry_401(response: httpx.Response) -> bool:
+    """Whether a 401 is Bambu's genuine "token expired" signal.
+
+    Bambu answers an expired/revoked token with ``{"code":4,"error":"Please
+    login.","message":""}``. Not every 401 means that: individual endpoints
+    return 401 for resource-, region- or scope-specific reasons, and a working
+    token still draws the occasional transient 401 (Cloudflare edge, a brief
+    backend blip). Treating *any* 401 as a dead credential signs the user out on
+    a single stray rejection — the #2562 follow-up regression. We trust only the
+    documented expiry body, so a benign 401 no longer nukes the whole cloud
+    integration. An unparseable / unsigned 401 is deliberately NOT expiry.
+
+    Shared by the Bambu Cloud and MakerWorld services — both carry the same
+    token and see the same expiry body.
+    """
+    try:
+        body = response.json()
+    except Exception:
+        return False
+    if not isinstance(body, dict):
+        return False
+    if body.get("code") == 4:
+        return True
+    text = f"{body.get('error', '')} {body.get('message', '')}".lower()
+    return "please login" in text
+
+
+def invalidate_validation_cache(token: str | None = None) -> None:
+    """Drop cached validation verdicts.
+
+    Called on login/logout so a fresh token isn't judged by the previous one's
+    cached verdict, and so a re-login clears a cached rejection immediately
+    rather than leaving the user staring at "sign-in expired" for five minutes.
+    """
+    if token is None:
+        _validation_cache.clear()
+    else:
+        _validation_cache.pop(_token_digest(token), None)
+
+
 # Client identity sent to Bambu Lab's cloud services. We identify honestly as
 # Bambuddy — the URL in parens makes the source unambiguous so Bambu can
 # distinguish our traffic from impersonators. This is the opposite of what the
@@ -66,21 +124,30 @@ def _detect_cloudflare_challenge(response) -> str | None:
     return None
 
 
-# The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
-# parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
-# API returns HTTP 400 "field 'version' is not set"; non-matching formats like
-# "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's
-# server accepts ANY value within that format — it doesn't validate against a
-# release manifest. We therefore use a neutral "1.0.0.0" placeholder that does
-# not impersonate any real Bambu Studio release. Our client identity is in the
-# User-Agent header.
+# The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
+# for the list, the singular GET/DELETE for a specific preset by setting_id, and
+# the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
+# format Bambu Studio releases use. Without it the API returns HTTP 400
+# "field 'version' is not set"; non-matching formats like "bambuddy-1.0" return
+# HTTP 422 "Invalid input parameters". However, Bambu's server accepts ANY value
+# within that format — it doesn't validate against a release manifest. We
+# therefore use a neutral "1.0.0.0" placeholder that does not impersonate any
+# real Bambu Studio release. Our client identity is in the User-Agent header.
 _SLICER_API_VERSION = "1.0.0.0"
 
 
 class BambuCloudError(Exception):
-    """Base exception for Bambu Cloud errors."""
+    """Base exception for Bambu Cloud errors.
 
-    pass
+    ``status_code`` carries the upstream HTTP status when the failure came from
+    a response rather than from the transport, so callers can tell an expected
+    "this preset isn't in the catalog" 400 apart from an expired token or a
+    cloud outage. It stays ``None`` for connection-level failures.
+    """
+
+    def __init__(self, message: str, *, status_code: int | None = None):
+        super().__init__(message)
+        self.status_code = status_code
 
 
 class BambuCloudAuthError(BambuCloudError):
@@ -107,11 +174,23 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
 class BambuCloudService:
     """Service for interacting with Bambu Lab Cloud API."""
 
-    def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
+    def __init__(
+        self,
+        region: str = "global",
+        client: httpx.AsyncClient | None = None,
+        on_auth_failure: Callable[[], Awaitable[None]] | None = None,
+    ):
         self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
         self.access_token: str | None = None
         self.refresh_token: str | None = None
         self.token_expiry: datetime | None = None
+        # Fired once when Bambu answers 401 to a call we made with a stored
+        # token — the credential is dead and the caller wants to record that.
+        # ``build_authenticated_cloud`` wires this to the persisted flag, so
+        # every route that builds a service through it gets invalidation for
+        # free rather than each one having to notice 401s for itself.
+        self._on_auth_failure = on_auth_failure
+        self._auth_failure_reported = False
         # Prefer an explicitly-injected client (tests), else fall back to the
         # app-scoped shared client (production), and finally create our own so
         # scripts / tests that skip the lifespan still get a working service.
@@ -127,11 +206,107 @@ class BambuCloudService:
 
     @property
     def is_authenticated(self) -> bool:
-        """Check if we have a valid token."""
+        """Whether a credential is *loaded* — NOT whether Bambu accepts it.
+
+        Bambu's access token is opaque (no JWT claims to read an expiry out
+        of), so the only authority on whether it still works is Bambu. This
+        used to pretend otherwise: ``set_token`` stamped ``token_expiry =
+        now + 30 days`` every time a stored token was loaded, which made the
+        expiry check reset on every request and this property incapable of
+        ever returning False. The UI reported "connected" indefinitely while
+        every cloud call 401'd (#2562 follow-up).
+
+        ``token_expiry`` is now only set when we genuinely know it. Callers
+        that need to know the token still *works* must ask Bambu — see
+        :meth:`validate_token` — or react to the 401 that surfaces.
+        """
         if not self.access_token:
             return False
         return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
 
+    async def _note_response(self, response: httpx.Response) -> bool:
+        """Record Bambu's genuine token-expiry 401 as "this credential is dead".
+
+        Returns ``True`` only for the real expiry signal (see
+        :meth:`_is_expiry_401`); a plain/transient 401 returns ``False`` and is
+        left alone so it can't durably sign the user out. The durable flag is
+        written at most once per service instance so a route making several
+        calls doesn't write it repeatedly.
+        """
+        if response.status_code != 401:
+            return False
+        if not is_expiry_401(response):
+            logger.info(
+                "Bambu Cloud returned 401 without the expiry signature — treating as transient, "
+                "not signing the stored token out"
+            )
+            return False
+        if self._on_auth_failure is None or self._auth_failure_reported:
+            return True
+        self._auth_failure_reported = True
+        if self.access_token:
+            _validation_cache[_token_digest(self.access_token)] = (
+                time.monotonic() + _VALIDATION_TTL_SECONDS,
+                False,
+            )
+        try:
+            await self._on_auth_failure()
+        except Exception:
+            # Recording the failure is best-effort — the caller still needs the
+            # real error (a 401) rather than a bookkeeping exception on top.
+            logger.exception("Failed to record Bambu Cloud auth failure")
+        return True
+
+    async def validate_token(self) -> bool | None:
+        """Ask Bambu whether the loaded token is still accepted.
+
+        ``True`` accepted, ``False`` rejected (401), ``None`` unknown — Bambu
+        was unreachable or answered 5xx.
+
+        ``None`` must never be treated as "invalid": a Bambu outage or a
+        Cloudflare interstitial would otherwise sign every user out of a
+        perfectly good session. Callers report their last known state instead.
+        """
+        if not self.access_token:
+            return False
+
+        digest = _token_digest(self.access_token)
+        cached = _validation_cache.get(digest)
+        if cached and cached[0] > time.monotonic():
+            return cached[1]
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/design-user-service/my/preference",
+                headers=self._get_headers(),
+                timeout=15.0,
+            )
+        except httpx.HTTPError as exc:
+            logger.info("Could not reach Bambu Cloud to validate the stored token: %s", exc)
+            return None
+
+        if response.status_code == 401:
+            # Only a 401 carrying Bambu's expiry signature is a real sign-out.
+            # A signature-less 401 here is transient/edge noise — report unknown
+            # (last-known state) rather than expiring a working session.
+            expired = await self._note_response(response)
+            return False if expired else None
+        if response.status_code >= 500:
+            logger.info(
+                "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
+            )
+            return None
+        if response.status_code != 200:
+            # 4xx that isn't 401 (403, 418 Cloudflare challenge, 429): the token
+            # itself was not rejected, so don't declare it dead.
+            logger.info(
+                "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
+            )
+            return None
+
+        _validation_cache[digest] = (time.monotonic() + _VALIDATION_TTL_SECONDS, True)
+        return True
+
     def _get_headers(self) -> dict:
         """Get headers for authenticated requests."""
         headers = {
@@ -241,6 +416,42 @@ class BambuCloudService:
             logger.error("Email verification failed: %s", e)
             raise BambuCloudAuthError(f"Verification failed: {e}")
 
+    async def _fetch_csrf_token(self, web_origin: str) -> str | None:
+        """Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
+
+        Bambu added double-submit CSRF protection to the ``bambulab.com`` web
+        origin. A POST without the cookie is rejected ``403 {"error": "CSRF
+        error: missing_cookie"}`` before the request body is looked at; with the
+        cookie but no matching header it becomes ``missing_header``. Only
+        ``GET /api/csrf`` mints one — the sign-in *page* sets nothing but
+        Cloudflare's ``__cf_bm``, so landing there first does not help.
+
+        The token is re-fetched per verification rather than cached: the client
+        is process-wide and long-lived, so a stale cookie could otherwise
+        disagree with the header we send.
+        """
+        try:
+            response = await self._client.get(
+                f"{web_origin}/api/csrf",
+                headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
+            )
+        except Exception as e:
+            logger.warning("Failed to fetch Bambu Cloud CSRF token: %s", e)
+            return None
+        # httpx stores the Set-Cookie on the shared jar, which is also what makes
+        # the cookie ride along on the POST below — we only need the value here
+        # to echo it back in the header.
+        try:
+            token = self._client.cookies.get("bbl_csrf_token")
+        except Exception:  # multiple cookies of the same name across domains
+            token = None
+        if not token:
+            logger.warning(
+                "Bambu Cloud CSRF endpoint returned no bbl_csrf_token (status %s)",
+                response.status_code,
+            )
+        return token
+
     async def verify_totp(self, tfa_key: str, code: str) -> dict:
         """
         Complete login with TOTP code from authenticator app.
@@ -258,9 +469,24 @@ class BambuCloudService:
             # expected application-level "Login failed" JSON, no Cloudflare
             # interstitial). Browser-impersonation removed to stay clearly on
             # the right side of Bambu Lab's "no falsified client identity" line.
-            tfa_url = "https://bambulab.com/api/sign-in/tfa"
-            if "bambulab.cn" in self.base_url:
-                tfa_url = "https://bambulab.cn/api/sign-in/tfa"
+            web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
+            tfa_url = f"{web_origin}/api/sign-in/tfa"
+
+            # #2696: the web origin is CSRF-protected (double submit). Without
+            # both halves the endpoint 403s before it ever evaluates the code,
+            # which surfaced to users as a permanent, misleading "Invalid code".
+            # api.bambulab.com — where every other call in this service goes,
+            # including the email-code 2FA path — is not gated, which is why
+            # only TOTP sign-ins broke.
+            csrf_token = await self._fetch_csrf_token(web_origin)
+            if not csrf_token:
+                return {
+                    "success": False,
+                    "message": (
+                        "Could not obtain a security token from Bambu Cloud. "
+                        "Check the server's internet access and try again."
+                    ),
+                }
 
             response = await self._client.post(
                 tfa_url,
@@ -268,6 +494,10 @@ class BambuCloudService:
                     "Content-Type": "application/json",
                     "User-Agent": _USER_AGENT,
                     "Accept": "application/json",
+                    # Echo of the bbl_csrf_token cookie httpx just stored. Both
+                    # halves are required; the cookie alone yields
+                    # "missing_header".
+                    "x-bbl-csrf-token": csrf_token,
                 },
                 json={
                     "tfaKey": tfa_key,
@@ -304,17 +534,34 @@ class BambuCloudService:
             if response.status_code == 200 and access_token:
                 self.access_token = access_token
                 self.refresh_token = data.get("refreshToken")
-                from datetime import datetime, timedelta, timezone
-
-                self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+                # Expiry left unset: Bambu does not tell us when the token dies
+                # and the token is opaque, so any value here would be invented.
+                self.token_expiry = None
+                invalidate_validation_cache(access_token)
                 return {"success": True, "message": "Login successful"}
 
             # Provide helpful error message
             error_msg = data.get("message", "")
+
+            # A CSRF rejection means the code was never evaluated (#2696). It
+            # used to fall through to the generic path below and read as
+            # "Invalid code", which sent the reporter chasing clock drift and
+            # leading-zero parsing for a request Bambu had already refused.
+            csrf_error = data.get("error", "") if isinstance(data.get("error"), str) else ""
+            if "csrf" in csrf_error.lower() or data.get("reason") in ("missing_cookie", "missing_header"):
+                logger.error("Bambu Cloud rejected the TOTP request on CSRF grounds: %s", response.text[:200])
+                return {
+                    "success": False,
+                    "message": (
+                        "Bambu Cloud rejected the sign-in request before checking your code "
+                        "(security-token error). Your code is fine — please try again."
+                    ),
+                }
+
             if "expired" in error_msg.lower():
                 return {"success": False, "message": "TOTP session expired. Please try logging in again."}
             if not error_msg:
-                error_msg = f"TOTP verification failed (status {response.status_code})"
+                error_msg = data.get("error") or f"TOTP verification failed (status {response.status_code})"
 
             return {"success": False, "message": error_msg}
 
@@ -324,16 +571,30 @@ class BambuCloudService:
             return {"success": False, "message": f"TOTP verification error: {e}"}
 
     def _set_tokens(self, data: dict):
-        """Set tokens from login response."""
+        """Set tokens from a login response.
+
+        No expiry is recorded. Bambu's login response carries no expiry, and
+        the access token is opaque, so the old ``now + 30 days`` was a guess
+        that outlived its own accuracy — see :attr:`is_authenticated`.
+        """
         self.access_token = data.get("accessToken")
         self.refresh_token = data.get("refreshToken")
-        # Token typically valid for ~3 months, but we'll refresh more often
-        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+        self.token_expiry = None
+        if self.access_token:
+            invalidate_validation_cache(self.access_token)
 
     def set_token(self, access_token: str):
-        """Set access token directly (for stored tokens)."""
+        """Load a stored access token.
+
+        This used to stamp ``token_expiry = now + 30 days`` — re-derived from
+        *now* on every request, for a token of entirely unknown age. That made
+        ``is_authenticated`` a permanent True and is why Bambuddy went on
+        reporting "connected" long after Bambu had stopped accepting the token.
+        A stored token's remaining life is unknowable from the token alone, so
+        we record no expiry and let Bambu be the authority.
+        """
         self.access_token = access_token
-        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+        self.token_expiry = None
 
     def logout(self):
         """Clear authentication state."""
@@ -382,6 +643,7 @@ class BambuCloudService:
 
             data = response.json()
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return data
 
@@ -397,13 +659,21 @@ class BambuCloudService:
 
         try:
             response = await self._client.get(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers(),
+                params={"version": _SLICER_API_VERSION},
             )
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return response.json()
 
-            raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
+            # Include body so a future contract change is self-diagnostic from logs.
+            body = (response.text or "")[:200]
+            raise BambuCloudError(
+                f"Failed to get setting detail: {response.status_code} {body}",
+                status_code=response.status_code,
+            )
 
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
@@ -448,6 +718,7 @@ class BambuCloudService:
 
             data = response.json()
 
+            await self._note_response(response)
             if response.status_code in (200, 201):
                 return data
 
@@ -533,6 +804,7 @@ class BambuCloudService:
 
             data = response.json()
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return data
 
@@ -557,9 +829,12 @@ class BambuCloudService:
 
         try:
             response = await self._client.delete(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers(),
+                params={"version": _SLICER_API_VERSION},
             )
 
+            await self._note_response(response)
             if response.status_code in (200, 204):
                 return {"success": True, "message": "Setting deleted"}
 
@@ -580,6 +855,7 @@ class BambuCloudService:
                 f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
             )
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return response.json()
 
@@ -608,6 +884,7 @@ class BambuCloudService:
                 params={"device_id": device_id},
             )
 
+            await self._note_response(response)
             if response.status_code == 200:
                 data = response.json()
                 # API wraps response in 'data' field

+ 377 - 25
backend/app/services/bambu_ftp.py

@@ -6,7 +6,9 @@ import socket
 import ssl
 import threading
 import time
+import weakref
 from collections.abc import Awaitable, Callable
+from concurrent.futures import ThreadPoolExecutor
 from enum import Enum
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
@@ -17,6 +19,58 @@ logger = logging.getLogger(__name__)
 
 T = TypeVar("T")
 
+# Every FTP call below is blocking ftplib work handed to a thread. They used to
+# run on asyncio's *default* executor, which is sized min(32, cpu_count + 4) —
+# six threads on a 2-core NAS — and is shared with every other ``to_thread`` /
+# ``run_in_executor`` caller in the app. That was survivable only because the
+# scheduler uploaded to exactly one printer at a time. Dispatching to several
+# printers at once (#2555) would park one thread per in-flight upload for
+# minutes at a stretch (a 41 MB 3MF at the ~150 KB/s a Bambu printer sustains
+# takes ~4 min), starving the default pool and stalling unrelated work.
+#
+# A dedicated pool keeps that blast radius inside the FTP layer: the scheduler's
+# own concurrency cap is what limits parallel uploads, and it can never exhaust
+# the executor everything else depends on. Threads are created lazily, so an
+# idle pool costs nothing.
+#
+# Sized well above `queue_max_concurrent_uploads` (max 16), because uploads are
+# not the only traffic here: SD browsing, timelapse/recording listing, cover
+# downloads, deletes and storage checks all run through this pool too, and on a
+# farm they fan out across every printer at once. The pool's work queue is
+# unbounded, so exceeding it does not fail — it queues. But `asyncio.wait_for`
+# starts its clock at submission, not at thread start, so a task that sits in the
+# queue can burn its whole timeout without ever running, and `list_files_async`
+# reports a timeout as an empty listing — a silent "this printer has no files".
+# Keep the headroom.
+_FTP_MAX_WORKERS = 48
+_ftp_executor = ThreadPoolExecutor(max_workers=_FTP_MAX_WORKERS, thread_name_prefix="bambu-ftp")
+
+# Overall upload deadline (#2529). A flat wall-clock cap punishes big files on
+# slow links rather than catching broken ones: a 96 MB 3MF at the ~75 KB/s an A1
+# sustains over WiFi legitimately needs ~20 minutes, and the old flat 600 s
+# declared it dead at ~70 MB. The deadline is therefore derived from the file
+# size against a deliberately pessimistic floor rate. This is a backstop, not the
+# failure detector — a link that has actually died is caught within
+# ``socket_timeout`` by the blocking ``sendall``, long before this fires.
+_UPLOAD_FLOOR_BYTES_PER_SEC = 25 * 1024
+_UPLOAD_MIN_TIMEOUT = 600.0
+
+# How long to give the worker thread to notice the cancel flag, unwind, and
+# delete its partial file. It checks the flag once per CHUNK_SIZE, so on a link
+# slow enough to have hit the deadline this is one chunk plus the delete.
+_UPLOAD_CANCEL_GRACE = 60.0
+
+
+class UploadCancelled(Exception):
+    """Raised inside the upload worker to abort an in-flight transfer.
+
+    ``upload_file`` treats any exception from its progress callback as "stop
+    now": it breaks out of the send loop, deletes the partial file from the
+    printer, and re-raises. That is the only way to stop a transfer — an
+    executor thread cannot be cancelled from the event loop, so a bare
+    ``asyncio.wait_for`` leaves it streaming (see ``upload_file_async``).
+    """
+
 
 class DeleteResult(Enum):
     """Outcome of an FTP delete attempt.
@@ -64,7 +118,18 @@ class ImplicitFTP_TLS(FTP_TLS):
         self.ssl_context = ssl.create_default_context()
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
+        # ``create_default_context()`` does NOT guarantee a protocol floor: it
+        # leaves ``minimum_version`` at ``MINIMUM_SUPPORTED``, and what that
+        # resolves to is a property of the OpenSSL build, not of this code.
+        # Measured on identical OpenSSL 3.5.6: python:3.13-slim-trixie (our
+        # Docker base) reports TLSv1_2, a bare-metal venv reports
+        # MINIMUM_SUPPORTED. Docker users have therefore always been floored at
+        # 1.2 — every Bambu model is reachable under that floor — while
+        # bare-metal and appliance installs could silently negotiate TLS 1.0.
+        # State the floor rather than inheriting it.
+        self.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
         if cap_tls_v1_2:
+            # With the floor above this pins the connection to exactly TLS 1.2.
             self.ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
     def connect(self, host="", port=990, timeout=-999, source_address=None):
@@ -288,18 +353,43 @@ class BambuFTPClient:
 
         return files
 
-    def download_file(self, remote_path: str) -> bytes | None:
-        """Download a file from the printer."""
+    def download_file(self, remote_path: str, expected_size: int | None = None) -> bytes | None:
+        """Download a file from the printer.
+
+        ``expected_size`` is the byte count the directory listing reported for
+        this file. Pass it whenever a short read must not be mistaken for a
+        successful download: an FTPS data connection that closes early does
+        not always raise, so ``retrbinary`` can hand back a partial buffer that
+        looks like a perfectly good file to everything downstream. That is
+        tolerable when the printer keeps its copy, and not tolerable when the
+        caller goes on to delete the source (#2704).
+
+        A zero-byte result is always treated as a failure, matching
+        :meth:`download_to_file` — no caller has a use for an empty file.
+        """
         if not self._ftp:
             return None
 
         try:
             buffer = BytesIO()
             self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
-            return buffer.getvalue()
+            data = buffer.getvalue()
         except (OSError, ftplib.Error):
             return None
 
+        if not data:
+            logger.warning("FTP download returned 0 bytes for %s", remote_path)
+            return None
+        if expected_size is not None and len(data) != expected_size:
+            logger.warning(
+                "FTP download of %s is short: got %s bytes, listing reported %s — treating as failed",
+                remote_path,
+                len(data),
+                expected_size,
+            )
+            return None
+        return data
+
     def download_to_file(self, remote_path: str, local_path: Path) -> bool:
         """Download a file from the printer to local filesystem."""
         if not self._ftp:
@@ -881,7 +971,7 @@ async def download_file_async(
         done = threading.Event()
         try:
             return await asyncio.wait_for(
-                loop.run_in_executor(None, _download, force_prot_c, completion, done), timeout=timeout
+                loop.run_in_executor(_ftp_executor, _download, force_prot_c, completion, done), timeout=timeout
             )
         except TimeoutError:
             # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without
@@ -894,6 +984,12 @@ async def download_file_async(
             # floor so artificially small test timeouts still give zombies a
             # realistic window to finish.
             grace = max(min(timeout, 30.0), 0.5)
+            # Deliberately the DEFAULT executor, not `_ftp_executor`: this thread
+            # blocks waiting on `_download`, which is itself an `_ftp_executor`
+            # worker. Parking waiters in the same bounded pool as the workers they
+            # wait for is how you build a deadlock — with enough concurrent
+            # timeouts the waiters would occupy every slot and the downloads they
+            # are waiting for could never be scheduled.
             await loop.run_in_executor(None, done.wait, grace)
             if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
                 logger.info(
@@ -938,12 +1034,21 @@ async def download_file_try_paths_async(
     local_path: Path,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 90.0,
 ) -> bool:
     """Try downloading a file from multiple paths using a single connection.
 
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap. The per-socket timeout only bounds an
+            in-flight worker; it does NOT bound how long this coroutine waits
+            for a free slot in the fixed-size ``_ftp_executor``. On a large
+            farm where offline printers keep every worker busy on dead
+            connects, that queue wait is otherwise unbounded — and any caller
+            holding a DB connection while awaiting this would pin it until the
+            pool is exhausted (#2572). The cap converts that into a bounded
+            wait; the orphaned worker finishes and its result is discarded.
     """
     loop = asyncio.get_event_loop()
 
@@ -966,7 +1071,44 @@ async def download_file_try_paths_async(
         finally:
             client.disconnect()
 
-    return await loop.run_in_executor(None, _download)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP download_try_paths exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return False
+
+
+def _upload_deadline(local_path: Path) -> float:
+    """Derive an upload deadline from the file size (#2529).
+
+    See ``_UPLOAD_FLOOR_BYTES_PER_SEC``. An unstat-able file falls back to the
+    floor timeout — ``upload_file`` will fail on the open() anyway.
+    """
+    try:
+        size = local_path.stat().st_size
+    except OSError:
+        return _UPLOAD_MIN_TIMEOUT
+    return max(_UPLOAD_MIN_TIMEOUT, size / _UPLOAD_FLOOR_BYTES_PER_SEC)
+
+
+# One upload at a time per printer. Two concurrent STOR commands for the same
+# remote path leave a corrupt file on the SD card, and the printer reads as
+# flaky rather than busy (#2529). Held for the duration of a transfer, so a
+# second dispatch to the same printer queues behind the first instead of racing
+# it. Keyed per event loop: an asyncio.Lock binds to the loop that first awaits
+# it, and the test suite runs each case on a fresh loop.
+_upload_locks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = (
+    weakref.WeakKeyDictionary()
+)
+
+
+def _upload_lock(loop: asyncio.AbstractEventLoop, ip_address: str) -> asyncio.Lock:
+    per_loop = _upload_locks.setdefault(loop, {})
+    lock = per_loop.get(ip_address)
+    if lock is None:
+        lock = asyncio.Lock()
+        per_loop[ip_address] = lock
+    return lock
 
 
 async def upload_file_async(
@@ -974,7 +1116,7 @@ async def upload_file_async(
     access_code: str,
     local_path: Path,
     remote_path: str,
-    timeout: float = 600.0,
+    timeout: float | None = None,
     progress_callback: Callable[[int, int], None] | None = None,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
@@ -989,19 +1131,31 @@ async def upload_file_async(
         access_code: Printer access code
         local_path: Local file path to upload
         remote_path: Remote path on printer
-        timeout: Overall operation timeout (asyncio)
+        timeout: Overall deadline. ``None`` (the default) derives it from the
+            file size — see ``_upload_deadline``. A caller that passes a number
+            gets exactly that, which is what the tests rely on.
         progress_callback: Optional callback for progress updates
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
     """
     loop = asyncio.get_event_loop()
     is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
+    deadline = _upload_deadline(local_path) if timeout is None else timeout
+
+    # Set when the deadline expires. The worker checks it once per chunk.
+    cancel = threading.Event()
+
+    def _guarded_progress(uploaded: int, total: int) -> None:
+        if cancel.is_set():
+            raise UploadCancelled(f"upload of {remote_path} exceeded its {deadline:.0f}s deadline")
+        if progress_callback:
+            progress_callback(uploaded, total)
 
     def _upload(force_prot_c: bool = False) -> bool:
         mode_str = "prot_c" if force_prot_c else "prot_p"
         logger.info(
             f"FTP connecting to {ip_address} for upload (model={printer_model}, "
-            f"mode={mode_str}, socket_timeout={socket_timeout}s)..."
+            f"mode={mode_str}, socket_timeout={socket_timeout}s, deadline={deadline:.0f}s)..."
         )
         client = BambuFTPClient(
             ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
@@ -1009,7 +1163,7 @@ async def upload_file_async(
         if client.connect():
             logger.info("FTP connected to %s", ip_address)
             try:
-                result = client.upload_file(local_path, remote_path, progress_callback)
+                result = client.upload_file(local_path, remote_path, _guarded_progress)
                 if result:
                     # Cache the working mode
                     BambuFTPClient.cache_mode(ip_address, mode_str)
@@ -1019,32 +1173,80 @@ async def upload_file_async(
         logger.warning("FTP connection failed to %s", ip_address)
         return False
 
-    try:
+    async def _attempt(force_prot_c: bool) -> bool:
+        """Run one upload attempt, and make a timeout actually stop the transfer.
+
+        ``asyncio.wait_for`` cancels the *future*, never the executor thread
+        behind it. Before #2529 a slow-but-healthy upload that overran the
+        deadline left that thread streaming: it kept pushing bytes, kept firing
+        the progress callback, and the retry above put a *second* STOR of the
+        same file onto the same printer. The reporter's 96 MB job ran four
+        concurrent transfers and never landed. So on timeout we signal the
+        worker (it raises ``UploadCancelled`` from the progress callback, which
+        breaks the send loop and deletes the partial file) and wait for it to
+        actually go.
+        """
+        fut = loop.run_in_executor(_ftp_executor, lambda: _upload(force_prot_c))
+        try:
+            return await asyncio.wait_for(asyncio.shield(fut), timeout=deadline)
+        except TimeoutError:
+            cancel.set()
+            logger.warning(
+                "FTP upload of %s exceeded its %.0fs deadline — cancelling the transfer",
+                remote_path,
+                deadline,
+            )
+            try:
+                await asyncio.wait_for(asyncio.shield(fut), timeout=_UPLOAD_CANCEL_GRACE)
+            except UploadCancelled:
+                logger.info("FTP upload of %s cancelled; partial file removed from the printer", remote_path)
+            except TimeoutError:
+                # The thread is wedged somewhere that never reaches the callback
+                # (a blocked sendall, say). Nothing more we can do from here —
+                # but consume the eventual result so asyncio doesn't log the
+                # future's exception as unretrieved when it is garbage-collected.
+                logger.error(
+                    "FTP upload thread for %s did not stop within %.0fs of the cancel signal",
+                    remote_path,
+                    _UPLOAD_CANCEL_GRACE,
+                )
+                fut.add_done_callback(_swallow_future_result)
+            except Exception as e:
+                logger.warning("FTP upload of %s errored while cancelling: %s", remote_path, e)
+            # Raise rather than return False: a deadline expiry means the link
+            # sustained less than the floor rate for the whole transfer, and a
+            # retry would only spend another full deadline finding that out
+            # again — with check_queue serialized, four of those block the
+            # entire print queue for hours. ``with_ftp_retry`` never retries it.
+            raise UploadCancelled(
+                f"Upload of {remote_path} to {ip_address} exceeded its {deadline:.0f}s deadline "
+                f"(link sustained less than {_UPLOAD_FLOOR_BYTES_PER_SEC // 1024} KB/s)"
+            ) from None
+
+    async with _upload_lock(loop, ip_address):
         # Check if we have a cached mode for this printer
         cached_mode = BambuFTPClient._mode_cache.get(ip_address)
 
         if cached_mode:
             # Use cached mode
-            force_prot_c = cached_mode == "prot_c"
-            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)
+            return await _attempt(cached_mode == "prot_c")
 
         # No cached mode - try prot_p first
-        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)
-
-        if result:
+        if await _attempt(False):
             return True
 
         # Upload failed - for A1 models, try prot_c fallback
         if is_a1:
             logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
-            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)
-            return result
+            return await _attempt(True)
 
         return False
 
-    except TimeoutError:
-        logger.warning("FTP upload timed out after %ss for %s", timeout, remote_path)
-        return False
+
+def _swallow_future_result(fut: asyncio.Future) -> None:
+    """Retrieve a future's exception so asyncio doesn't log it as unhandled."""
+    if not fut.cancelled():
+        fut.exception()
 
 
 async def list_files_async(
@@ -1073,7 +1275,7 @@ async def list_files_async(
         return []
 
     try:
-        return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
     except TimeoutError:
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
         return []
@@ -1085,6 +1287,7 @@ async def delete_file_async(
     remote_path: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 60.0,
 ) -> DeleteResult:
     """Async wrapper for deleting a file.
 
@@ -1095,6 +1298,8 @@ async def delete_file_async(
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
+            the caller (and any DB connection it holds) indefinitely (#2572).
     """
     loop = asyncio.get_event_loop()
 
@@ -1107,7 +1312,11 @@ async def delete_file_async(
                 client.disconnect()
         return DeleteResult.FAILED
 
-    return await loop.run_in_executor(None, _delete)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _delete), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP delete_file exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return DeleteResult.FAILED
 
 
 async def download_file_bytes_async(
@@ -1116,12 +1325,23 @@ async def download_file_bytes_async(
     remote_path: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 300.0,
+    expected_size: int | None = None,
 ) -> bytes | None:
     """Async wrapper for downloading file as bytes.
 
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
+            the caller (and any DB connection it holds) indefinitely (#2572).
+            Generous by default because this pulls whole files (timelapse
+            video, gcode) which can legitimately take minutes over slow Wi-Fi —
+            the cap only guards against a permanently-starved pool, not a
+            slow-but-progressing transfer.
+        expected_size: size from the directory listing; a mismatch fails the
+            download instead of returning a truncated file. See
+            :meth:`BambuFTPClient.download_file`.
     """
     loop = asyncio.get_event_loop()
 
@@ -1129,12 +1349,131 @@ async def download_file_bytes_async(
         client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
-                return client.download_file(remote_path)
+                return client.download_file(remote_path, expected_size=expected_size)
             finally:
                 client.disconnect()
         return None
 
-    return await loop.run_in_executor(None, _download)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP download_bytes exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return None
+
+
+async def remote_file_settled(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+    downloaded_bytes: int,
+    *,
+    printer_model: str | None = None,
+) -> bool:
+    """Confirm the printer has finished writing the file we just downloaded.
+
+    Matching the download against the size from the directory listing proves we
+    received what the listing *said*, not that the file was *finished*. The
+    timelapse scan's first look happens seconds after the print ends, which is
+    exactly when the printer is writing the video — so a file still growing can
+    be listed at a partial size, served at that size, and pass the length check
+    as a complete video (#2704).
+
+    That was survivable while the printer kept its copy. It isn't now that a
+    successful attach deletes the source, so re-list afterwards: if the file has
+    grown, what we hold is a prefix and the caller should discard it and try
+    again on the next round.
+
+    Returns True when the remote file can no longer differ from what we hold —
+    the size still matches, or the file is gone from the listing entirely and
+    so cannot grow any further. Returns False when it has changed size, and on
+    a listing failure, because "we could not check" must not read as "safe to
+    delete".
+    """
+    directory, _, name = remote_path.rpartition("/")
+    files = await list_files_async(ip_address, access_code, directory or "/", printer_model=printer_model)
+    if not files:
+        logger.warning("[TIMELAPSE] Could not re-list %s to confirm %s is complete", directory or "/", name)
+        return False
+
+    for f in files:
+        if f.get("name") == name:
+            size = f.get("size")
+            if size == downloaded_bytes:
+                return True
+            logger.info(
+                "[TIMELAPSE] %s is still being written (%s bytes now, %s when downloaded) — will retry",
+                name,
+                size,
+                downloaded_bytes,
+            )
+            return False
+
+    # Vanished between the download and now. Nothing left that could grow, and
+    # nothing left to delete either.
+    logger.debug("[TIMELAPSE] %s is no longer on the printer after download", name)
+    return True
+
+
+async def delete_archived_timelapse(
+    ip_address: str,
+    access_code: str,
+    remote_path: str,
+    *,
+    verified: bool,
+    printer_model: str | None = None,
+    printer_name: str = "",
+) -> bool:
+    """Remove a timelapse from the printer once it is safely in the archive.
+
+    Call this only after the attach succeeded (#2704). Keeping ``/timelapse``
+    down to just the unclaimed videos is what makes the snapshot diff
+    unambiguous rather than merely usually-right, and it stops P1S cards
+    filling with AVIs.
+
+    ``verified`` must say whether the downloaded byte count was checked against
+    the size the directory listing reported. It is required rather than
+    defaulted because this is the one irreversible step in the flow: an FTPS
+    data connection that closes early does not always raise, so an unverified
+    transfer can be a partial file that looks complete, and deleting the source
+    would then destroy the only good copy. The check lives here rather than at
+    each call site so no future caller can omit it.
+
+    Best-effort otherwise: a printer that refuses the delete keeps its copy, the
+    diff still excludes that filename next time because it is attached to an
+    archive, and nothing else in the flow cares. Returns True only on an actual
+    delete or a 550 (already gone).
+    """
+    if not verified:
+        logger.warning(
+            "[TIMELAPSE] Not deleting %s from printer %s: the download was never size-checked",
+            remote_path,
+            printer_name,
+        )
+        return False
+
+    for attempt in range(1, 4):
+        try:
+            result = await delete_file_async(ip_address, access_code, remote_path, printer_model=printer_model)
+        except Exception as e:
+            result = DeleteResult.FAILED
+            logger.warning("[TIMELAPSE] Delete attempt %d/3 raised for %s: %s", attempt, remote_path, e)
+
+        if result == DeleteResult.DELETED:
+            logger.info("[TIMELAPSE] Deleted %s from printer %s after archiving", remote_path, printer_name)
+            return True
+        if result == DeleteResult.NOT_FOUND:
+            # 550 never recovers by waiting — the printer already cleaned up.
+            logger.debug("[TIMELAPSE] %s already gone from printer %s", remote_path, printer_name)
+            return True
+        if attempt < 3:
+            await asyncio.sleep(2)
+
+    logger.warning(
+        "[TIMELAPSE] Could not delete %s from printer %s (it stays on the card; the archive copy is unaffected)",
+        remote_path,
+        printer_name,
+    )
+    return False
 
 
 async def get_storage_info_async(
@@ -1142,12 +1481,15 @@ async def get_storage_info_async(
     access_code: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 60.0,
 ) -> dict | None:
     """Async wrapper for getting storage info.
 
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
+            the caller (and any DB connection it holds) indefinitely (#2572).
     """
     loop = asyncio.get_event_loop()
 
@@ -1160,7 +1502,11 @@ async def get_storage_info_async(
                 client.disconnect()
         return None
 
-    return await loop.run_in_executor(None, _get_storage)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _get_storage), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP get_storage_info exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return None
 
 
 async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
@@ -1202,6 +1548,10 @@ async def with_ftp_retry(
 
     Returns:
         Result of the operation, or None if all attempts fail
+
+    ``UploadCancelled`` is never retried, whatever the caller passes: it means
+    the transfer overran its size-derived deadline, so a retry would spend
+    another full deadline reaching the same conclusion (#2529).
     """
     last_error = None
 
@@ -1216,6 +1566,8 @@ async def with_ftp_retry(
             # Operation returned failure indicator
             if attempt > 0:
                 logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
+        except UploadCancelled:
+            raise
         except Exception as e:
             if non_retry_exceptions and isinstance(e, non_retry_exceptions):
                 raise

File diff suppressed because it is too large
+ 875 - 25
backend/app/services/bambu_mqtt.py


+ 137 - 4
backend/app/services/camera.py

@@ -6,6 +6,7 @@ Supports two camera protocols:
 """
 
 import asyncio
+import functools
 import logging
 import os
 import shutil
@@ -16,6 +17,8 @@ import uuid
 from datetime import datetime
 from pathlib import Path
 
+from backend.app.core.logging_filters import redact_url_credentials
+
 logger = logging.getLogger(__name__)
 
 # JPEG markers
@@ -32,6 +35,26 @@ _rtsp_socket_timeout_flag: str | None = None
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
 
+# In-flight one-shot captures, keyed by printer IP (#2705).
+#
+# Bambu firmware allows exactly one camera connection, and the existing guards
+# (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a
+# capturer from competing with the fan-out BROADCASTER. They do nothing for
+# capturer-vs-capturer with no viewer attached, where every consumer correctly
+# concludes it isn't competing with a viewer and then collides with the others.
+# Eight paths reach capture_camera_frame_bytes() independently — Obico polling,
+# /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate
+# detection, the camera test and the diagnose tool — so the single-flight lives
+# at the bottom of the stack and needs no call-site changes.
+#
+# Keyed by IP rather than printer_id because IP is what the firmware's one-
+# connection limit applies to: two printer rows pointing at the same address
+# still share one camera. (This function never sees a printer_id anyway.) The
+# key deliberately excludes the timeout, or callers that disagree about it —
+# and they all do, from 10s to 30s — would never coalesce, which is exactly
+# the Obico-vs-snapshot pair from the report.
+_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
+
 
 def get_ffmpeg_path() -> str | None:
     """Find the ffmpeg executable path.
@@ -527,6 +550,38 @@ async def capture_camera_frame(
     return False
 
 
+def capture_in_flight(ip_address: str) -> bool:
+    """Return True iff a one-shot capture for this IP is running right now.
+
+    For callers that need to know whether they will JOIN someone else's
+    capture rather than perform their own — currently only the diagnose tool,
+    which reports on what it measured and so must not present a coalesced
+    frame as proof that it opened its own connection (see camera_diagnose).
+
+    Ordinary consumers should ignore this: they want "a recent frame", and
+    capture_camera_frame_bytes() already does the right thing for them.
+    """
+    task = _inflight_captures.get(ip_address)
+    return task is not None and not task.done()
+
+
+def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None:
+    """Done-callback: drop the finished task from the in-flight registry.
+
+    Guarded on identity so a slow task that finishes after a newer capture
+    has registered can't evict its successor.
+
+    Also retrieves the exception, if any. The leader normally awaits the task
+    and would surface it, but a leader whose own caller was cancelled leaves
+    nobody to collect it — and an unretrieved task exception is logged by
+    asyncio as a warning with a traceback at an arbitrary later point.
+    """
+    if _inflight_captures.get(ip_address) is task:
+        del _inflight_captures[ip_address]
+    if not task.cancelled() and task.exception() is not None:
+        logger.debug("In-flight camera capture for %s ended in an exception", ip_address)
+
+
 async def capture_camera_frame_bytes(
     ip_address: str,
     access_code: str,
@@ -535,18 +590,95 @@ async def capture_camera_frame_bytes(
 ) -> bytes | None:
     """Capture a single frame and return as JPEG bytes (no disk write).
 
-    Uses the same protocol selection as capture_camera_frame but returns
-    bytes directly instead of writing to disk.
+    Concurrent callers for the same printer share one capture (#2705): the
+    first opens the connection, everyone arriving while it is in flight awaits
+    the same result. Every consumer here wants "a recent frame" rather than
+    "a frame captured at exactly my timestamp", so handing identical bytes to
+    simultaneous callers is correct — and it is the only way to honour the
+    firmware's one-connection limit without serialising captures behind a lock
+    (which would just turn a collision into a queue).
+
+    This coalesces; it does not cache. A call that arrives after the previous
+    capture finished always captures fresh. Two consumers of these frames —
+    plate detection and the finish-photo path — decide things about a running
+    print from them, and a stale frame there is worse than a slow one: the
+    whole of #1397 was a finish photo taken seconds late showing the bed
+    already lowered.
 
     Args:
         ip_address: Printer IP address
         access_code: Printer access code
         model: Printer model (X1, H2D, P1, A1, etc.)
-        timeout: Timeout in seconds for the capture operation
+        timeout: Timeout in seconds for the capture operation. Applies to this
+            caller's own wait, including when it joins another caller's
+            capture — the call sites disagree about the value (10s for plate
+            detection, 20s for Obico), and a follower must not silently
+            inherit the leader's deadline in either direction.
 
     Returns:
         JPEG bytes if capture was successful, None otherwise
     """
+    # A follower whose leader fails takes a turn of its own rather than
+    # inheriting a failure it never had a chance to avoid — by then the leader
+    # has finished, so there is no socket left to compete with. Bounded at two
+    # rounds: if the capture we joined AND its replacement both failed, a third
+    # connection won't help, and this caller has already spent its patience.
+    for _ in range(2):
+        leader = _inflight_captures.get(ip_address)
+        if leader is None or leader.done():
+            break
+        try:
+            frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
+        except TimeoutError:
+            # shield() keeps the capture running for whoever else is still
+            # waiting on it — giving up is this caller's decision alone.
+            logger.warning(
+                "Gave up waiting %ss on the in-flight camera capture for %s",
+                timeout,
+                ip_address,
+            )
+            return None
+        except asyncio.CancelledError:
+            # Distinguish "the capture I joined was cancelled" from "I was
+            # cancelled". Only the former is ours to recover from.
+            if not leader.cancelled():
+                raise
+            logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address)
+            continue
+        if frame is not None:
+            logger.info(
+                "Reusing in-flight camera capture for %s: %s bytes (no second connection opened)",
+                ip_address,
+                len(frame),
+            )
+            return frame
+        logger.info("In-flight camera capture for %s failed; capturing our own", ip_address)
+    else:
+        return None
+
+    task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout))
+    _inflight_captures[ip_address] = task
+    task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address))
+    # No wait_for here: this caller IS the capture, and the implementation
+    # already enforces `timeout` internally where it can also kill the ffmpeg
+    # process. A second deadline on top would abandon the subprocess instead.
+    # shield() so that a cancelled leader (a client navigating away mid-
+    # snapshot is routine) doesn't take the capture down with it — the
+    # followers already waiting on it still get their frame.
+    return await asyncio.shield(task)
+
+
+async def _capture_camera_frame_bytes_uncoalesced(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    timeout: int = 15,
+) -> bytes | None:
+    """Open a connection and capture one frame. See capture_camera_frame_bytes.
+
+    Callers want that wrapper, not this: it opens a socket unconditionally,
+    which is the collision #2705 is about.
+    """
     # Chamber image models: A1/P1 - returns bytes directly
     if is_chamber_image_model(model):
         logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
@@ -608,7 +740,8 @@ async def capture_camera_frame_bytes(
             logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
             return stdout
         else:
-            stderr_text = stderr.decode() if stderr else "Unknown error"
+            # ffmpeg echoes the RTSP input URL, which carries the access code.
+            stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
             logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
             return None
 

+ 25 - 2
backend/app/services/camera_diagnose.py

@@ -35,6 +35,13 @@ out broadcaster to prevent). When ``is_stream_active`` reports True
 AND a buffered frame is fresh (last 10 s), we short-circuit the test
 with ``live_stream_active`` and report success — the user is
 literally watching the camera right now, no test needed.
+
+The related case is another one-shot capture (Obico polling, the cam
+wall) being in flight when the user hits Diagnose. There the capture
+layer coalesces for us (#2705) and no competing socket is opened, but
+the frame we get back was someone else's — so ``first_frame`` still
+passes and carries a ``coalesced_capture`` code, because a diagnostic
+that reports a connection it didn't open is worse than a slow one.
 """
 
 from __future__ import annotations
@@ -46,6 +53,7 @@ from dataclasses import dataclass, field
 
 from backend.app.services.camera import (
     capture_camera_frame_bytes,
+    capture_in_flight,
     get_camera_port,
     is_chamber_image_model,
 )
@@ -69,8 +77,10 @@ class CameraDiagnoseStage:
     name: str  # "tcp_reachable" | "first_frame" | "live_stream_active"
     status: str  # "ok" | "failed" | "skipped"
     duration_ms: int = 0
-    # Optional machine-readable code for failures so the frontend can
-    # render a stage-specific hint without parsing free-text errors.
+    # Optional machine-readable code so the frontend can render a stage-
+    # specific hint without parsing free-text errors. Usually a failure
+    # reason; "coalesced_capture" qualifies a PASS whose frame came from a
+    # capture already in flight, so duration_ms isn't a connection time.
     code: str | None = None
 
 
@@ -166,6 +176,15 @@ async def _check_first_frame(
     """Stage 2 — capture one frame end-to-end. Combines auth + protocol
     handshake + first keyframe; either it works or it doesn't."""
     started = time.monotonic()
+    # A capture already running for this printer (an Obico poll, the cam wall)
+    # means capture_camera_frame_bytes will hand us THAT capture's frame rather
+    # than opening its own connection (#2705). Good for the printer, but this
+    # stage exists to report what it measured: the frame would be real evidence
+    # the camera works, while duration_ms would be mostly time spent queueing,
+    # and a pass would be claimed for a connection we never opened. So the
+    # stage says so, the same way the live-stream shortcut above declares
+    # itself instead of quietly passing.
+    coalesced = capture_in_flight(ip_address)
     try:
         jpeg = await capture_camera_frame_bytes(
             ip_address=ip_address,
@@ -190,7 +209,11 @@ async def _check_first_frame(
             name="first_frame",
             status="ok",
             duration_ms=int((time.monotonic() - started) * 1000),
+            code="coalesced_capture" if coalesced else None,
         )
+    # No annotation on the failure path: a follower whose leader fails goes on
+    # to capture on its own, so a None here means this stage did get its own
+    # attempt (or watched two consecutive captures fail — same verdict).
     return CameraDiagnoseStage(
         name="first_frame",
         status="failed",

+ 120 - 9
backend/app/services/camera_fanout.py

@@ -26,6 +26,11 @@ logger = logging.getLogger(__name__)
 # on some firmwares and is the very reconnect cost we are trying to avoid).
 _GRACE_SECONDS = 5.0
 
+# Upper bound on how long a new broadcaster waits for a displaced one to finish
+# tearing down before proceeding anyway (#2521). Teardown is normally sub-second
+# (cancel pump + close socket); the cap only guards a wedged upstream close.
+_TEARDOWN_WAIT_SECONDS = 10.0
+
 # Per-subscriber queue depth. Small on purpose: if a viewer can't keep up
 # with the printer's frame rate we drop frames for that viewer rather than
 # blocking the broadcaster. Live video — old frames have no value.
@@ -35,13 +40,20 @@ _SUBSCRIBER_QUEUE_SIZE = 4
 # subscriber's read loop can break out cleanly instead of hanging on get().
 _UPSTREAM_GONE = b""
 
+# How often a subscriber that isn't receiving frames re-checks whether its
+# client is still connected. Only pays a cost when the stream is *not* producing
+# frames — the normal path returns from queue.get() as soon as a frame lands and
+# checks after the yield. Kept short because the subscriber count derived from
+# it is what /camera/stop uses to decide whether to tear the upstream down.
+_DISCONNECT_POLL_SECONDS = 1.0
+
 UpstreamFactory = Callable[[asyncio.Event], AsyncGenerator[bytes, None]]
 
 
 class MjpegBroadcaster:
     """Single upstream MJPEG stream, fanned out to N subscribers."""
 
-    def __init__(self, key: str, factory: UpstreamFactory) -> None:
+    def __init__(self, key: str, factory: UpstreamFactory, predecessor: MjpegBroadcaster | None = None) -> None:
         self._key = key
         self._factory = factory
         self._subscribers: list[asyncio.Queue[bytes]] = []
@@ -52,6 +64,22 @@ class MjpegBroadcaster:
         # stop reconnecting when the last subscriber leaves.
         self._upstream_disconnect = asyncio.Event()
         self._stopped = False
+        # Most recent chunk pumped to subscribers. New (late) subscribers are
+        # primed with it so the browser renders a frame immediately instead of
+        # waiting for the next upstream frame — critical on slow chamber-image
+        # cams where the wait looked like a permanent black screen (#2521).
+        self._last_chunk: bytes | None = None
+        # Set once teardown is fully complete (pump cancelled AND the upstream
+        # socket closed). A successor broadcaster waits on this before dialing
+        # so a single-connection printer never sees two sockets at once — the
+        # overlap stranded frames on an orphaned socket for the ~20 min it took
+        # the printer's TCP keepalive to reap it (#2521).
+        self._teardown_complete = asyncio.Event()
+        # The stopped broadcaster this one replaces, if any. The pump waits for
+        # its socket to close before opening ours. Guarding at the pump (not at
+        # get_or_create) keeps it correct when concurrent viewers race to
+        # replace the same stopped broadcaster — only the single pump dials.
+        self._predecessor = predecessor
 
     @property
     def key(self) -> str:
@@ -79,6 +107,15 @@ class MjpegBroadcaster:
             queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_SIZE)
             self._subscribers.append(queue)
 
+            # Prime a late joiner with the last frame so it renders instantly
+            # (#2521). The very first subscriber has nothing to prime yet — it
+            # starts the pump below.
+            if self._last_chunk is not None:
+                try:
+                    queue.put_nowait(self._last_chunk)
+                except asyncio.QueueFull:  # pragma: no cover — fresh queue
+                    pass
+
             if self._pump_task is None or self._pump_task.done():
                 # Reset the disconnect signal in case a previous pump set it.
                 self._upstream_disconnect = asyncio.Event()
@@ -105,6 +142,18 @@ class MjpegBroadcaster:
         """Tear down immediately, kick all subscribers. Idempotent."""
         pump_task = await self._mark_stopped_locked(notify_subscribers=True)
         await self._await_pump_cancellation(pump_task)
+        # Upstream socket is now closed (pump's finally ran) — release anyone
+        # waiting to open a replacement broadcaster (#2521).
+        self._teardown_complete.set()
+
+    async def wait_until_torn_down(self) -> None:
+        """Block until this broadcaster's upstream socket has fully closed.
+
+        Only meaningful for a stopped broadcaster; on a live one this never
+        returns. get_or_create_broadcaster gates a replacement on it so the
+        old and new upstream sockets never overlap (#2521).
+        """
+        await self._teardown_complete.wait()
 
     async def _grace_then_stop(self) -> None:
         try:
@@ -123,6 +172,8 @@ class MjpegBroadcaster:
             self._grace_task = None
             self._stopped = True
         await self._await_pump_cancellation(pump_task)
+        # Upstream socket is now closed — release any pending replacement (#2521).
+        self._teardown_complete.set()
 
     async def _mark_stopped_locked(self, *, notify_subscribers: bool) -> asyncio.Task | None:
         """Mark the broadcaster stopped and detach the pump task.
@@ -165,10 +216,23 @@ class MjpegBroadcaster:
     async def _pump(self) -> None:
         """Drive the upstream generator and broadcast each chunk."""
         try:
+            # Don't dial the printer until the broadcaster we're replacing has
+            # closed its socket (#2521). Bounded so a wedged teardown degrades
+            # to the old overlap behaviour rather than never producing a frame.
+            predecessor = self._predecessor
+            self._predecessor = None
+            if predecessor is not None:
+                try:
+                    await asyncio.wait_for(predecessor.wait_until_torn_down(), timeout=_TEARDOWN_WAIT_SECONDS)
+                except asyncio.TimeoutError:
+                    logger.warning("Prior broadcaster %r didn't tear down in time; dialing anyway", self._key)
             async for chunk in self._factory(self._upstream_disconnect):
                 # Snapshot subscribers under lock so we don't iterate a list
                 # mutated by subscribe()/unsubscribe() while we are putting.
+                # Remember the frame under the same lock so subscribe() can
+                # prime a late joiner with a consistent last-chunk value (#2521).
                 async with self._lock:
+                    self._last_chunk = chunk
                     targets = list(self._subscribers)
                 for queue in targets:
                     try:
@@ -203,22 +267,49 @@ async def get_or_create_broadcaster(key: str, factory: UpstreamFactory) -> Mjpeg
 
     A broadcaster that has been stopped (force shutdown or grace timeout) is
     replaced with a fresh instance — the caller will subscribe to the new one.
+
+    When replacing a stopped broadcaster, the fresh instance is handed it as a
+    predecessor: its pump waits for the old socket to close before dialing, so
+    a single-connection cam (chamber-image port 6000) never sees two sockets at
+    once. Otherwise the printer keeps feeding the orphaned socket and starves
+    the new one until its TCP keepalive reaps it, ~20 min later (#2521).
     """
     async with _registry_lock:
         existing = _broadcasters.get(key)
         if existing is not None and not existing.stopped:
             return existing
-        new_bc = MjpegBroadcaster(key, factory)
+        # `existing` (if any) is stopped/tearing down — chain the new pump
+        # behind its socket close.
+        new_bc = MjpegBroadcaster(key, factory, predecessor=existing)
         _broadcasters[key] = new_bc
         return new_bc
 
 
 async def shutdown_broadcaster(key: str) -> bool:
-    """Force-shutdown the broadcaster for `key`. Returns True if one was running."""
+    """Force-shutdown the broadcaster for `key`. Returns True if one was running.
+
+    The stopped broadcaster stays in the registry on purpose. It used to be
+    popped *before* ``force_shutdown()`` was awaited, which vacated the slot
+    while the upstream socket was still closing: a ``/camera/stream`` request
+    landing in that window found nothing, minted a broadcaster with
+    ``predecessor=None``, and dialled the printer immediately. That is exactly
+    the two-sockets-at-once overlap the predecessor gate exists to prevent —
+    the gate only engages when the stopped broadcaster is still *findable*, and
+    popping it here bypassed the gate in the one case it was written for. A page
+    reload fires ``/camera/stop`` and the new stream request concurrently, so a
+    single-connection cam (chamber-image port 6000) ended up with an orphaned
+    socket that the printer kept feeding, starving the live viewer until the
+    printer's TCP keepalive reaped it ~20 min later (#2521).
+
+    Leaving it in place is safe: ``get_or_create_broadcaster`` replaces a stopped
+    entry (chaining the successor behind its teardown), ``get_subscriber_count``
+    reports 0 for it, and ``active_broadcaster_keys`` filters it out. There is at
+    most one entry per printer, and it is overwritten by the next viewer.
+    """
     async with _registry_lock:
-        bc = _broadcasters.pop(key, None)
-    if bc is None:
-        return False
+        bc = _broadcasters.get(key)
+        if bc is None or bc.stopped:
+            return False
     await bc.force_shutdown()
     return True
 
@@ -236,6 +327,20 @@ def active_broadcaster_keys() -> list[str]:
     return [k for k, bc in _broadcasters.items() if not bc.stopped]
 
 
+def get_subscriber_count(key: str) -> int:
+    """Return the number of live subscribers attached to ``key``, or 0.
+
+    Used by ``/camera/stop`` to decide whether to force-shutdown the broadcaster
+    or defer to natural cleanup. Other viewers (cam-wall tile, embedded viewer,
+    popup window) all subscribe to the same broadcaster, so a force-shutdown
+    triggered by one leaving viewer would kill the others' streams.
+    """
+    bc = _broadcasters.get(key)
+    if bc is None or bc.stopped:
+        return 0
+    return bc.subscriber_count
+
+
 # ---------------------------------------------------------------------------
 # AsyncGenerator helper — turns a subscriber queue into an async generator
 # that yields MJPEG chunks until the upstream signals it's gone.
@@ -259,10 +364,16 @@ async def iter_subscriber(
     try:
         while True:
             try:
-                chunk = await asyncio.wait_for(queue.get(), timeout=30.0)
+                chunk = await asyncio.wait_for(queue.get(), timeout=_DISCONNECT_POLL_SECONDS)
             except asyncio.TimeoutError:
-                # No frame in 30s — check whether the client is still there.
-                # If yes, keep waiting; if no, bail out.
+                # No frame this tick — is the client still there? This used to
+                # wait 30 s before asking, and the disconnect check after a yield
+                # only fires when frames are actually flowing. So a viewer that
+                # went away while the stream was black stayed *counted* as a
+                # subscriber for up to half a minute — and ``/camera/stop``
+                # trusts that count to decide whether to tear the upstream down,
+                # so a phantom subscriber could make it skip teardown entirely
+                # (#2521). Poll often enough that the count means something.
                 if is_disconnected is not None and await is_disconnected():
                     break
                 continue

+ 193 - 0
backend/app/services/design_settings.py

@@ -0,0 +1,193 @@
+"""Carry a 3MF designer's own process tweaks across a re-slice (#2622).
+
+A MakerWorld model is often published with deliberate deviations from the stock
+Bambu process preset — 5 walls, 100% infill, a 0.1mm first layer. Re-slicing that
+file for a different printer used to drop every one of them: ``--load-settings``
+is authoritative, so the picked process preset wins over the 3MF's embedded
+``Metadata/project_settings.config``.
+
+We do not have to *compute* what the designer changed. BambuStudio already did,
+and wrote the answer into the file:
+
+    different_settings_to_system = [
+        "enable_support;inner_wall_speed;sparse_infill_density;...",   # [0]  process
+        "filament_change_length;filament_prime_volume",                # [1..N] filaments
+        "machine_start_gcode;bed_custom_model;...",                    # [-1] printer
+    ]
+
+The array is ``1 + len(filament_settings_id) + 1`` long — verified against real
+files at 2, 3 and 4 filament slots. Index 0 is exactly the set of process keys
+that differ from the system preset, which is the reporter's step 1 for free: no
+baseline resolution, no shipping BBL profiles into Bambuddy, and no new endpoint
+on the slicer sidecar (which exposes bundled presets by name only, with no way to
+flatten one).
+
+Delivery is the mechanism ``_patch_process_support_settings`` already proved in
+#1881: write the values into the process JSON that goes out as ``--load-settings``.
+For a "standard" preset pick that JSON is a ``{inherits: …}`` stub, so the keys we
+write are the *child* in the inherits chain and win over the flattened parent.
+
+Not every key is safe to carry, though. Real files put ``inner_wall_speed``,
+``outer_wall_speed`` and ``prime_tower_max_speed`` in that list — values tuned for
+the designer's machine that can be plain wrong, or out of range, on the target.
+Those are classified :data:`PRINTER_COUPLED` and offered unticked; the caller
+decides. Nothing is applied that the caller did not ask for by name.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import zipfile
+from io import BytesIO
+from typing import Any, NamedTuple
+
+logger = logging.getLogger(__name__)
+
+_PROJECT_SETTINGS = "Metadata/project_settings.config"
+
+
+class DesignOverride(NamedTuple):
+    """One process setting the designer changed away from the system preset."""
+
+    key: str
+    value: Any
+    printer_coupled: bool
+
+
+# Process keys whose sane value depends on the machine, not on the design intent.
+# The designer picked these for *their* printer's kinematics, chamber and hotend;
+# carrying them onto another model risks a slice that is merely slower/uglier —
+# or a hard range-validation reject from the CLI, which is how the very first
+# slicer spike died. Offered, but never pre-selected.
+#
+# Matching is by exact key OR by suffix/substring rule below, because Bambu's
+# process schema has dozens of per-feature speed keys and an exhaustive literal
+# list would rot on every slicer release.
+_PRINTER_COUPLED_EXACT: frozenset[str] = frozenset(
+    {
+        "default_acceleration",
+        "independent_support_layer_height",
+        "precise_z_height",
+        "travel_acceleration",
+        "enable_wrapping_detection",
+    }
+)
+
+# Substring rules for the families that are always machine-coupled. Kept
+# deliberately narrow: "speed", "acceleration"/"accel" and "jerk" are the
+# kinematic families, "fan"/"temperature" follow the hotend and chamber, and
+# "prime_tower" follows the target's toolchange hardware.
+_PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
+    # Prime-tower geometry (and whether there is one at all) follows the target's
+    # extruder count and bed, not the design — a real file carries five of these.
+    "prime_tower",
+    "_speed",
+    "speed_",
+    "acceleration",
+    "_accel",
+    "jerk",
+    "fan_speed",
+    "_temperature",
+    "temperature_",
+)
+
+
+def is_printer_coupled(key: str) -> bool:
+    """Whether carrying this process key across printer models is risky."""
+    if key in _PRINTER_COUPLED_EXACT:
+        return True
+    lowered = key.lower()
+    return any(token in lowered for token in _PRINTER_COUPLED_SUBSTRINGS)
+
+
+def _split_changed_keys(entry: Any) -> list[str]:
+    """Parse one ``different_settings_to_system`` entry into its key names."""
+    if not isinstance(entry, str):
+        return []
+    return [part.strip() for part in entry.split(";") if part.strip()]
+
+
+def extract_design_process_overrides(zip_bytes: bytes) -> list[DesignOverride]:
+    """Process settings the 3MF's designer changed away from the system preset.
+
+    Returns an empty list for anything that is not a BambuStudio-style 3MF
+    carrying both ``project_settings.config`` and a well-formed
+    ``different_settings_to_system`` — including OrcaSlicer files and older
+    exports that predate the field. Callers treat empty as "nothing to offer",
+    which is the pre-feature behaviour.
+    """
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
+            if _PROJECT_SETTINGS not in zf.namelist():
+                return []
+            config = json.loads(zf.read(_PROJECT_SETTINGS).decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return []
+    return overrides_from_config(config)
+
+
+def overrides_from_config(config: Any) -> list[DesignOverride]:
+    """``extract_design_process_overrides`` on an already-parsed config dict."""
+    if not isinstance(config, dict):
+        return []
+
+    changed = config.get("different_settings_to_system")
+    if not isinstance(changed, list) or not changed:
+        return []
+
+    # Sanity-check the layout before trusting index 0. The array should be
+    # [process, *filaments, printer]; a file whose length disagrees with its own
+    # filament count is one we do not understand, and guessing there could carry
+    # printer G-code into the process slot.
+    filaments = config.get("filament_settings_id")
+    if isinstance(filaments, list) and len(changed) != len(filaments) + 2:
+        logger.debug(
+            "3MF different_settings_to_system has %d entries for %d filaments "
+            "(expected %d) — skipping design-settings carry-over",
+            len(changed),
+            len(filaments),
+            len(filaments) + 2,
+        )
+        return []
+
+    overrides: list[DesignOverride] = []
+    # Index 0 is the process slot — see the layout in the module docstring. The
+    # length check above is what earns the right to index it blindly.
+    for key in _split_changed_keys(changed[0]):
+        if key not in config:
+            # Listed as changed but absent from the flattened config — nothing
+            # to carry. Seen with keys the slicer renamed between versions.
+            continue
+        overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
+
+    overrides.sort(key=lambda o: o.key)
+    return overrides
+
+
+def apply_design_overrides(process_json: str, overrides: list[DesignOverride], selected_keys: list[str]) -> str:
+    """Write the selected designer values into the outgoing process JSON.
+
+    ``selected_keys`` is authoritative — a key the caller did not name is not
+    applied even when it is present in ``overrides``. Returns ``process_json``
+    unchanged when nothing is selected or the JSON is unparseable, so a bad
+    input degrades to a plain profile slice rather than failing it.
+    """
+    if not selected_keys or not overrides:
+        return process_json
+
+    wanted = set(selected_keys)
+    by_key = {o.key: o.value for o in overrides if o.key in wanted}
+    if not by_key:
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    process_cfg.update(by_key)
+    logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
+    return json.dumps(process_cfg)

+ 149 - 28
backend/app/services/external_camera.py

@@ -11,12 +11,14 @@ import asyncio
 import logging
 import re
 import shutil
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from urllib.parse import urlparse
 
 import aiohttp
 
+from backend.app.core.logging_filters import redact_url_credentials
+
 logger = logging.getLogger(__name__)
 
 
@@ -195,9 +197,15 @@ async def capture_frame(
         JPEG bytes or None on failure
     """
     if snapshot_url:
-        logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
+        # Redact before truncating — slicing first can cut the URL short of the
+        # ``@`` the pattern anchors on and leave the password in the log.
+        logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
         return await _capture_snapshot(snapshot_url, timeout)
-    logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
+    logger.debug(
+        "capture_frame called: type=%s, url=%s...",
+        camera_type,
+        redact_url_credentials(url)[:50] if url else "None",
+    )
     if camera_type == "mjpeg":
         return await _capture_mjpeg_frame(url, timeout)
     elif camera_type == "rtsp":
@@ -311,7 +319,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
     """
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
-        logger.error("Invalid MJPEG URL format: %s...", url[:50])
+        logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
         return None
 
     jpeg_start = b"\xff\xd8"
@@ -438,7 +446,8 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         )
 
         if process.returncode != 0:
-            logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
+            # ffmpeg echoes the RTSP input URL, which carries the camera password.
+            logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
             return None
 
         if not stdout or len(stdout) < 100:
@@ -461,6 +470,39 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
             await proxy_server.wait_closed()
 
 
+def _transcode_to_jpeg(data: bytes) -> bytes | None:
+    """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG.
+
+    Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than
+    JPEG. A browser opened directly at the URL renders those fine, but our MJPEG
+    ``multipart/x-mixed-replace`` stream hard-labels every part
+    ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject
+    the frame and drop the whole stream ("connection lost", #1902). Transcoding to
+    JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream
+    (plate detection, Obico, finish photo) working.
+
+    Returns None if the bytes are not a decodable image (e.g. an HTML error page)
+    or if the imaging libraries are unavailable — callers fall back to the raw
+    bytes so behaviour is never worse than before.
+    """
+    try:
+        import cv2
+        import numpy as np
+    except ImportError:
+        return None
+    try:
+        img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
+        if img is None:
+            return None
+        ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
+        if not ok:
+            return None
+        return buf.tobytes()
+    except Exception as e:  # cv2 raises cv2.error (a subclass of Exception) on bad input
+        logger.debug("Snapshot transcode to JPEG failed: %s", e)
+        return None
+
+
 async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     """Fetch snapshot from HTTP URL.
 
@@ -471,7 +513,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
-        logger.error("Invalid snapshot URL format: %s...", url[:50])
+        logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
         return None
 
     try:
@@ -484,14 +526,6 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
                 return None
 
             data = await response.read()
-
-            # Validate it looks like JPEG
-            if not data.startswith(b"\xff\xd8"):
-                logger.warning("Snapshot does not appear to be JPEG")
-                # Still return it - might be valid with different header
-
-            return data
-
     except TimeoutError:
         logger.warning("Snapshot capture timed out after %ss", timeout)
         return None
@@ -499,6 +533,34 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
         logger.error("Snapshot capture failed: %s", e)
         return None
 
+    # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode).
+    if data.startswith(b"\xff\xd8"):
+        return data
+
+    # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so
+    # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of
+    # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is
+    # CPU-bound and this can be polled at up to 15 fps while a camera view is open.
+    transcoded = await asyncio.to_thread(_transcode_to_jpeg, data)
+    if transcoded is not None:
+        logger.debug(
+            "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG",
+            len(data),
+            data[:4].hex(),
+        )
+        return transcoded
+
+    # Couldn't decode it as an image at all — most likely not an image response
+    # (HTML error page, auth redirect, wrong URL). Return the raw bytes as a last
+    # resort (unchanged behaviour) but log enough to debug.
+    logger.warning(
+        "External camera snapshot is not a decodable image "
+        "(%d bytes, header %s) — verify the camera URL returns an image",
+        len(data),
+        data[:4].hex(),
+    )
+    return data
+
 
 async def test_connection(url: str, camera_type: str) -> dict:
     """Test camera connection.
@@ -506,7 +568,7 @@ async def test_connection(url: str, camera_type: str) -> dict:
     Returns:
         Dict with {success: bool, error?: str, resolution?: str}
     """
-    logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
+    logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
     try:
         frame = await capture_frame(url, camera_type, timeout=10)
         logger.info("Capture result: %s bytes", len(frame) if frame else 0)
@@ -539,13 +601,41 @@ async def test_connection(url: str, camera_type: str) -> dict:
         return {"success": False, "error": f"Connection failed: {error_type}"}
 
 
-async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
+async def generate_mjpeg_stream(
+    url: str,
+    camera_type: str,
+    fps: int = 10,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+    on_frame: Callable[[bytes], None] | None = None,
+    stop_event: asyncio.Event | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Generator yielding MJPEG frames for streaming.
 
     Args:
         url: Camera URL or USB device path
         camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
         fps: Target frames per second
+        on_process: Called with the spawned ffmpeg process for the ``usb`` and
+            ``rtsp`` paths so the route layer can register it into the shared
+            stream registries — that's what lets ``/camera/stop`` and the orphan
+            janitor find and kill a leaked ffmpeg that's holding a USB device
+            open (#2675). Without it the process is reachable only from this
+            generator's own ``finally``, which an abrupt client disconnect can
+            skip (same cancellation-timing class as #776).
+        on_frame: Called with each RAW frame, before it is wrapped for the wire,
+            so the route layer can publish it as the printer's buffered frame
+            (#2707). It has to be a callback: what this generator yields is
+            multipart-wrapped, so a consumer of the stream cannot recover the
+            JPEG, and until now nothing populated the buffer for external
+            cameras at all — leaving every one-shot consumer (layer timelapse,
+            finish photo, Obico, plate check) with nothing to reuse and no
+            option but to open a competing handle on a single-reader device.
+            Exceptions are logged and swallowed: buffering must never be able
+            to break the live stream.
+        stop_event: When set, the reconnect loops stop retrying — so an explicit
+            stop (which kills the current ffmpeg) doesn't immediately respawn a
+            new process and reacquire the device.
 
     Yields:
         MJPEG frame data with HTTP multipart boundaries
@@ -553,6 +643,15 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
     frame_interval = 1.0 / max(fps, 1)
     last_frame_time = 0.0
 
+    def _publish(frame: bytes) -> bytes:
+        """Hand the raw frame to on_frame, then format it for the wire."""
+        if on_frame is not None:
+            try:
+                on_frame(frame)
+            except Exception:
+                logger.exception("on_frame callback raised")
+        return _format_mjpeg_frame(frame)
+
     if camera_type == "mjpeg":
         # Proxy MJPEG stream directly, with reconnect on timeout
         max_retries = 3
@@ -563,8 +662,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
                 current_time = asyncio.get_event_loop().time()
                 if current_time - last_frame_time >= frame_interval:
                     last_frame_time = current_time
-                    yield _format_mjpeg_frame(frame)
-            if not frame_yielded or attempt == max_retries:
+                    yield _publish(frame)
+            if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
             logger.warning(
                 "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
@@ -578,10 +677,10 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
         max_retries = 3
         for attempt in range(max_retries + 1):
             frame_yielded = False
-            async for frame in _stream_rtsp(url, fps):
+            async for frame in _stream_rtsp(url, fps, on_process=on_process):
                 frame_yielded = True
-                yield _format_mjpeg_frame(frame)
-            if not frame_yielded or attempt == max_retries:
+                yield _publish(frame)
+            if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
             logger.warning(
                 "External RTSP stream ended, reconnecting (attempt %d/%d)...",
@@ -592,8 +691,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
 
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
-        async for frame in _stream_usb(url, fps):
-            yield _format_mjpeg_frame(frame)
+        async for frame in _stream_usb(url, fps, on_process=on_process):
+            yield _publish(frame)
 
     elif camera_type == "snapshot":
         # Poll snapshot URL at interval
@@ -601,7 +700,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
             try:
                 frame = await _capture_snapshot(url, timeout=10)
                 if frame:
-                    yield _format_mjpeg_frame(frame)
+                    yield _publish(frame)
                 await asyncio.sleep(frame_interval)
             except asyncio.CancelledError:
                 break
@@ -630,7 +729,7 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
     # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
-        logger.error("Invalid MJPEG stream URL: %s...", url[:50])
+        logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
         return
 
     try:
@@ -671,7 +770,12 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
         logger.error("MJPEG stream error: %s", e)
 
 
-async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
+async def _stream_rtsp(
+    url: str,
+    fps: int,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Stream frames from RTSP URL via ffmpeg.
 
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
@@ -752,12 +856,18 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
+        # Register immediately — before the startup probe below — so a process
+        # that hangs on connect (rather than exiting) is still reachable by the
+        # stop endpoint / orphan janitor (#2675).
+        if on_process is not None:
+            on_process(process)
 
         # Brief check for immediate startup failures
         await asyncio.sleep(0.1)
         if process.returncode is not None:
             stderr = await process.stderr.read()
-            logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
+            # ffmpeg echoes the RTSP input URL, which carries the camera password.
+            logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
             return
 
         buffer = b""
@@ -812,7 +922,12 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             await proxy_server.wait_closed()
 
 
-async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
+async def _stream_usb(
+    device: str,
+    fps: int,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Stream frames from USB camera via ffmpeg."""
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
@@ -854,6 +969,12 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
+        # Register immediately — before the startup probe below — so a process
+        # that hangs in open()/ioctl on a still-locked device (rather than
+        # exiting with a "busy" error) is still reachable by the stop endpoint /
+        # orphan janitor (#2675).
+        if on_process is not None:
+            on_process(process)
 
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)

+ 51 - 0
backend/app/services/filament_requirements.py

@@ -102,6 +102,57 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
     return filaments
 
 
+def overrides_for_plate(
+    overrides: list[dict],
+    file_path: Path | None,
+    plate_id: int | None,
+) -> list[dict]:
+    """Drop the filament overrides whose slots this plate never prints.
+
+    Queueing several plates of one 3MF builds a single override list out of every
+    selected plate's filaments and hands that same list to each plate's item. A
+    ``force_color_match`` entry blocks dispatch until the printer has that exact
+    colour loaded, so a single-colour plate ended up waiting on every colour in
+    the batch (#2551). Each item may only demand what its own plate consumes.
+
+    Overrides are kept as-is when the plate's slots cannot be established (whole
+    file selected, source gone, unreadable 3MF, malformed entry): an item that
+    waits on a colour it does not need is visible and fixable, whereas one that
+    silently loses a forced colour can dispatch the print in the wrong filament.
+    """
+    if not overrides or plate_id is None or file_path is None or not file_path.exists():
+        return overrides
+
+    plate_slots = {f["slot_id"] for f in extract_filament_requirements(file_path, plate_id)}
+    if not plate_slots:
+        logger.warning(
+            "Cannot read the filaments of plate %s in %s; keeping all %d filament override(s)",
+            plate_id,
+            file_path.name,
+            len(overrides),
+        )
+        return overrides
+
+    narrowed = []
+    for override in overrides:
+        try:
+            slot_id = int(override["slot_id"])
+        except (KeyError, TypeError, ValueError):
+            narrowed.append(override)
+            continue
+        if slot_id in plate_slots:
+            narrowed.append(override)
+
+    if len(narrowed) != len(overrides):
+        logger.info(
+            "Plate %s: kept %d of %d filament override(s) — the rest belong to other plates",
+            plate_id,
+            len(narrowed),
+            len(overrides),
+        )
+    return narrowed
+
+
 def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
     """Walk every `./filament` child under `parent` and append normalised
     entries to `into`. Skips filaments with `used_g <= 0` (slot present in

+ 28 - 9
backend/app/services/ftp_profiles.py

@@ -34,10 +34,8 @@ class FTPProfile:
 
     # Pin the SSL context's ``maximum_version`` to TLS 1.2.
     #
-    # Python 3.13's default ``ssl.create_default_context()`` negotiates
-    # TLS 1.3 when both peers support it. The Bambuddy Docker image is
-    # ``python:3.13-slim-trixie``, so every Docker user gets 1.3 by
-    # default. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
+    # ``ssl.create_default_context()`` negotiates TLS 1.3 when both peers
+    # support it. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
     # by @iitazz, #1401) implement session reuse on the FTPS data
     # channel against an old vsFTPd build that doesn't tolerate TLS
     # 1.3's asynchronous session-ticket model: the data channel gets
@@ -47,12 +45,18 @@ class FTPProfile:
     # the printer). Capping to TLS 1.2 makes session resumption
     # synchronous and the upload completes normally.
     #
+    # Note this cap only bites on models that *offer* 1.3 in the first
+    # place. Probed directly on :990, an X1C and an H2D both refuse
+    # TLS 1.0, 1.1 and 1.3 with a handshake_failure alert and complete
+    # only on 1.2 — so for those models the cap is a no-op and the
+    # negotiated version was never 1.3. The P2S evidently does offer
+    # 1.3, which is why it alone surfaced the session-reuse bug.
+    # (P1S untested; no claim made either way.)
+    #
     # **Defaults to False** — only applied to printer models where a
-    # reporter has confirmed the symptom. Existing P1S / X1C / H2D
-    # installs that work fine today stay on the negotiated TLS 1.3.
-    # This is deliberately conservative; flipping a printer to the
-    # capped path is a config edit when a new model surfaces the
-    # same bug.
+    # reporter has confirmed the symptom. This is deliberately
+    # conservative; flipping a printer to the capped path is a config
+    # edit when a new model surfaces the same bug.
     cap_tls_v1_2: bool = False
 
 
@@ -87,6 +91,19 @@ _PROFILES: dict[str, FTPProfile] = {
     "X2D": FTPProfile(
         cap_tls_v1_2=True,
     ),
+    # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083) — same H2
+    # generation and same firmware line as P2S, and with no profile it
+    # ran on the Python-default TLS 1.3. Reported symptom is exactly the
+    # one the X2D comment describes: the sliced 3MF intermittently fails
+    # to come off the printer over FTPS, so the print drops to the no-3MF
+    # fallback archive with no slice data — which is why the Print Log
+    # shows no filament and nothing is deducted. Cap to TLS 1.2 by analogy
+    # with P2S (intermittent "sometimes works" points at the session-reuse
+    # variant, not X2D's deterministic handshake failure); if a debug
+    # capture shows a different FTPS variant the entry stays the tuning slot.
+    "H2C": FTPProfile(
+        cap_tls_v1_2=True,
+    ),
 }
 
 # SSDP internal codes that should resolve to a display-name profile.
@@ -94,6 +111,8 @@ _PROFILES: dict[str, FTPProfile] = {
 _MODEL_ALIASES: dict[str, str] = {
     "N7": "P2S",  # P2S internal SSDP code
     "N6": "X2D",  # X2D internal SSDP code
+    "O1C": "H2C",  # H2C internal SSDP code
+    "O1C2": "H2C",  # H2C dual-nozzle variant SSDP code
 }
 
 

+ 13 - 6
backend/app/services/git_providers/gitea.py

@@ -63,14 +63,21 @@ class GiteaBackend(GitHubBackend):
             return tree_node.get("sha")
         return None
 
+    # Gitea/Forgejo can be hosted under a URL path prefix (ROOT_URL like
+    # https://host/gitea), so the repo lives at /<prefix...>/<owner>/<repo>
+    # rather than at the host root (#2642). Capture the scheme+host+prefix as
+    # one group and the final two path segments as owner/repo; the lazy prefix
+    # group is empty for a root-hosted instance. One shared pattern keeps
+    # parse_repo_url() and get_api_base() from drifting.
+    _HTTPS_REPO_RE = re.compile(
+        r"(https?://[\w.\-]+(?::\d+)?(?:/[\w.\-]+)*?)/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$"
+    )
+
     def parse_repo_url(self, url: str) -> tuple[str, str]:
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         if not url or len(url) > 500:
             raise ValueError("Invalid Git URL: URL too long or empty")
-        match = re.match(
-            r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
-            url,
-        )
+        match = self._HTTPS_REPO_RE.match(url)
         if match:
             return match.group(2), match.group(3).removesuffix(".git")
         match = re.match(
@@ -82,8 +89,8 @@ class GiteaBackend(GitHubBackend):
         raise ValueError(f"Cannot parse repository URL: {url}")
 
     def get_api_base(self, repo_url: str) -> str:
-        """Derive API base from the repository URL's scheme and host."""
-        match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
+        """Derive API base from the repository URL's scheme, host and any path prefix."""
+        match = self._HTTPS_REPO_RE.match(repo_url)
         if match:
             return f"{match.group(1)}/api/v1"
         raise ValueError(f"Cannot derive API base from URL: {repo_url}")

+ 75 - 0
backend/app/services/hms_actions.py

@@ -0,0 +1,75 @@
+"""HMS action lookup.
+
+Bambu printers report HMS errors with a fixed catalog of remediation actions
+(resume / stop / check assistant / etc.). The catalog is bundled as JSON, keyed
+by the 3-letter SN prefix (printer model code: 03W = A1, 31B = X1C, etc.) and
+the short error code with no separator.
+
+The action IDs and their string names are derived from BambuStudio's source via
+`scripts/update_hms_actions.py`. The data file itself is fetched from Bambu's
+public `e.bambulab.com/hms/GetActionImage.php` endpoint.
+"""
+
+import json
+from enum import StrEnum
+from pathlib import Path
+
+_DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "hms_actions.json"
+
+# Loaded eagerly at import — the file is ~150KB and only read once. Using an
+# absolute path keeps the load independent of CWD (systemd unit, Docker
+# entrypoint, pytest run from `backend/`).
+with _DATA_FILE.open("r", encoding="utf-8") as _f:
+    _actions: dict[str, dict[str, list[str]]] = json.load(_f)
+
+
+class HMSAction(StrEnum):
+    """Remediation actions a Bambu printer can offer for an HMS error.
+
+    Values intentionally match the constants used in BambuStudio's source so the
+    HMS-data fetcher can map Bambu's integer action IDs straight to these
+    strings. The CANCLE typo is preserved verbatim — it's how BambuStudio spells
+    it, and changing it would break the action lookup against the catalog.
+    """
+
+    RESUME_PRINTING = "RESUME_PRINTING"
+    RESUME_PRINTING_DEFECTS = "RESUME_PRINTING_DEFECTS"
+    RESUME_PRINTING_PROBELM_SOLVED = "RESUME_PRINTING_PROBELM_SOLVED"
+    STOP_PRINTING = "STOP_PRINTING"
+    CHECK_ASSISTANT = "CHECK_ASSISTANT"
+    FILAMENT_EXTRUDED = "FILAMENT_EXTRUDED"
+    RETRY_FILAMENT_EXTRUDED = "RETRY_FILAMENT_EXTRUDED"
+    CONTINUE = "CONTINUE"
+    LOAD_VIRTUAL_TRAY = "LOAD_VIRTUAL_TRAY"
+    OK_BUTTON = "OK_BUTTON"
+    FILAMENT_LOAD_RESUME = "FILAMENT_LOAD_RESUME"
+    JUMP_TO_LIVEVIEW = "JUMP_TO_LIVEVIEW"
+    NO_REMINDER_NEXT_TIME = "NO_REMINDER_NEXT_TIME"
+    REFRESH_NOZZLE = "REFRESH_NOZZLE"
+    IGNORE_NO_REMINDER_NEXT_TIME = "IGNORE_NO_REMINDER_NEXT_TIME"
+    IGNORE_RESUME = "IGNORE_RESUME"
+    PROBLEM_SOLVED_RESUME = "PROBLEM_SOLVED_RESUME"
+    TURN_OFF_FIRE_ALARM = "TURN_OFF_FIRE_ALARM"
+    RETRY_PROBLEM_SOLVED = "RETRY_PROBLEM_SOLVED"
+    STOP_DRYING = "STOP_DRYING"
+    CANCLE = "CANCLE"  # sic — verbatim from BambuStudio
+    REMOVE_CLOSE_BTN = "REMOVE_CLOSE_BTN"
+    PROCEED = "PROCEED"
+    OK_JUMP_RACK = "OK_JUMP_RACK"
+    ABORT = "ABORT"
+    DISABLE_PURIFICATION = "DISABLE_PURIFICATION"
+    DONT_REMIND_NEXT_TIME = "DONT_REMIND_NEXT_TIME"
+    DBL_CHECK_CANCEL = "DBL_CHECK_CANCEL"
+    DBL_CHECK_DONE = "DBL_CHECK_DONE"
+    DBL_CHECK_RETRY = "DBL_CHECK_RETRY"
+    DBL_CHECK_RESUME = "DBL_CHECK_RESUME"
+    DBL_CHECK_OK = "DBL_CHECK_OK"
+
+
+def get_actions_for_error_code(device: str, error_code: str) -> list[str]:
+    """Look up the action list for a printer SN prefix + short error code.
+
+    Returns the empty list if the printer model or the error code is unknown —
+    the modal renders no buttons in that case, which is the correct fallback.
+    """
+    return _actions.get(device, {}).get(error_code, [])

+ 58 - 22
backend/app/services/label_renderer.py

@@ -130,7 +130,13 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
         return b""
     qr = qrcode.QRCode(
         version=None,
-        error_correction=qrcode.constants.ERROR_CORRECT_M,
+        # ERROR_CORRECT_L (7% recovery) rather than M (15%): a label QR only
+        # needs to survive being scanned off clean stock, not physical damage,
+        # and L encodes the same payload in a lower version (fewer, chunkier
+        # modules). That extra module size is what makes the code printable on
+        # low-resolution 203 dpi thermal printers, where M-level density bled
+        # the modules together on small labels (#1870).
+        error_correction=qrcode.constants.ERROR_CORRECT_L,
         box_size=box_size,
         border=border,
     )
@@ -168,6 +174,19 @@ def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, da
     c.rect(x, y, w, h, stroke=1, fill=0)
 
 
+def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
+    """QR edge length (points) for the roomy layout.
+
+    Historically a flat 20% of inner width, which on the narrowest label
+    (box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
+    fell below ~2 dots and the code bled into itself on thermal printers
+    (#1870). A 12 mm floor keeps small labels scannable; the code is still
+    capped by the inner height, an 18 mm absolute max, and ~45% of inner width
+    so it can't crowd out the text column on an ultra-narrow label.
+    """
+    return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
+
+
 def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
     """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
     png = _qr_png_bytes(payload)
@@ -189,7 +208,9 @@ def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, m
     return text + ell if text else ell
 
 
-def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
+def _draw_label(
+    c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData, monochrome: bool = False
+) -> None:
     """Render one label inside the box (x, y, w, h). Origin is bottom-left.
 
     Two layouts, picked by available height:
@@ -219,9 +240,9 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
     is_tight = h < 20 * mm
 
     if is_tight:
-        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
     else:
-        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
 
 
 def _draw_label_tight(
@@ -236,11 +257,17 @@ def _draw_label_tight(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
-    swatch_w = min(inner_h, inner_w * 0.35)
-    swatch_y = inner_y + (inner_h - swatch_w) / 2
-    _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
+    # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
+    # width to the text column (#1870).
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_h, inner_w * 0.35)
+        swatch_y = inner_y + (inner_h - swatch_w) / 2
+        _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
 
     text_x = inner_x + swatch_w + pad
     text_w = inner_w - swatch_w - pad
@@ -296,17 +323,22 @@ def _draw_label_roomy(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     # Swatch: full inner height, ~18% of inner width but capped so we never
-    # eat the text column on extreme aspect ratios.
-    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
-    swatch_h = inner_h
-    _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
-
-    # QR: square, capped at the smaller of (a fraction of width, the inner
-    # height, or 18 mm — beyond that the QR is overkill for the print size).
-    qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
+    # eat the text column on extreme aspect ratios. Omitted entirely in
+    # monochrome mode — on a B&W thermal printer a colour block prints as a
+    # muddy grey that conveys nothing, so we reclaim the space for text and
+    # rely on the hex-code line to carry the colour (#1870, requested by
+    # @Geoff-S). The hex code already renders below whenever rgba is set.
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
+        _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
+
+    qr_size = _roomy_qr_size(inner_w, inner_h)
     qr_x = x + w - pad - qr_size
     qr_y = inner_y + (inner_h - qr_size) / 2
     _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
@@ -394,7 +426,7 @@ _SHEET_TEMPLATES: dict[str, tuple] = {
 }
 
 
-def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
     page_w, page_h = w_mm * mm, h_mm * mm
 
@@ -403,14 +435,14 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData])
     c.setTitle(f"Bambuddy spool labels ({template})")
 
     for data in data_list:
-        _draw_label(c, 0, 0, page_w, page_h, data)
+        _draw_label(c, 0, 0, page_w, page_h, data, monochrome)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
-def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
     page_w, page_h = page_size
 
@@ -433,23 +465,27 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> byt
             col = idx % cols
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
-            _draw_label(c, x, y, label_w, label_h, data)
+            _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
-def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     short-circuit beforehand if that's not desired.
+
+    ``monochrome`` drops the colour swatch (which prints as a useless grey block
+    on black-and-white thermal printers) and reclaims the space for text; the
+    hex-code line still carries the colour. See #1870.
     """
     if template in _SINGLE_LABEL_SIZES_MM:
-        return _render_single_label_pdf(template, data_list)
+        return _render_single_label_pdf(template, data_list, monochrome)
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list)
+        return _render_sheet_pdf(template, data_list, monochrome)
     raise ValueError(f"Unknown label template: {template!r}")
 
 

+ 20 - 1
backend/app/services/layer_timelapse.py

@@ -67,7 +67,26 @@ class TimelapseSession:
         self.last_layer = layer_num
 
         try:
-            frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
+            # Reuse the live view's frame instead of opening a second handle on
+            # a single-reader device (#2707). Unguarded, a print watched from
+            # start to finish recorded zero successful layer captures, and the
+            # stitched video came out empty or badly truncated.
+            from backend.app.api.routes.camera import live_frame_for_capture
+
+            defer, buffered = live_frame_for_capture(self.printer_id)
+            if defer:
+                if not buffered:
+                    # Viewer attached but nothing buffered yet: skip this layer
+                    # rather than compete and kick them off (#1348).
+                    logger.debug(
+                        "Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
+                        layer_num,
+                        self.printer_id,
+                    )
+                    return False
+                frame_data = buffered
+            else:
+                frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
             if frame_data:
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)

+ 6 - 2
backend/app/services/ldap_service.py

@@ -256,10 +256,14 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             return None
 
         info = _extract_user_info(service_conn, config, user_entry, username)
+        # Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
+        # The username + group count is enough to confirm a successful auth; the
+        # support-bundle sanitizer also redacts any DN that slips through (e.g. an
+        # ldap3 exception string), but keeping it out of the log at the source is
+        # the primary hygiene per the "no private data in logs" rule.
         logger.info(
-            "LDAP authentication successful for user: %s (DN: %s, groups: %d)",
+            "LDAP authentication successful for user: %s (groups: %d)",
             info.username,
-            user_dn,
             len(info.groups),
         )
         return info

+ 30 - 34
backend/app/services/local_backup.py

@@ -6,46 +6,22 @@ on a configurable schedule with retention management.
 
 import asyncio
 import logging
-import os
-from datetime import datetime, timedelta, timezone, tzinfo
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
-from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 from sqlalchemy import select
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.models.settings import Settings
+from backend.app.services.backup_path import classify_backup_dir_error, probe_backup_dir
 
-logger = logging.getLogger(__name__)
-
-
-def _local_zone() -> tzinfo:
-    """Resolve the local timezone for scheduled-backup HH:MM interpretation.
-
-    Uses the container's ``TZ`` env var (the same value the support package
-    surfaces); falls back to UTC when unset or unrecognised so a missing TZ
-    keeps the legacy behaviour rather than crashing. See #1602 follow-up.
-
-    On Windows the embedded Python in our installer doesn't carry an IANA
-    tz database, so ``ZoneInfo(...)`` — including ``ZoneInfo("UTC")`` —
-    raises ``ZoneInfoNotFoundError`` unless the ``tzdata`` PyPI package is
-    installed. requirements.txt now pins ``tzdata`` on win32, but to keep
-    this resilient on installs that haven't refreshed deps we fall through
-    to the stdlib ``datetime.timezone.utc`` as a last resort; it satisfies
-    every ``astimezone`` / ``str()`` call site without needing the IANA DB.
-    """
-    tz_name = os.environ.get("TZ", "").strip()
-    if tz_name:
-        try:
-            return ZoneInfo(tz_name)
-        except ZoneInfoNotFoundError:
-            logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
-    try:
-        return ZoneInfo("UTC")
-    except ZoneInfoNotFoundError:
-        return timezone.utc
+# The TZ-env resolution used to live here. It moved to utils/local_time when the
+# smart-plug energy history (#2539) needed the same local day boundary. Re-exported
+# under the old private name so existing importers keep working.
+from backend.app.utils.local_time import local_zone as _local_zone
 
+logger = logging.getLogger(__name__)
 
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
@@ -194,6 +170,15 @@ class LocalBackupService:
             return Path(path_setting.strip())
         return _default_backup_dir()
 
+    def check_path(self, path_setting: str) -> dict:
+        """Probe the configured output directory with a real write.
+
+        Called when the path is saved and when the backup card is opened, so a
+        directory the service cannot write to is caught there and then instead
+        of at 03:00 for a week (#2544).
+        """
+        return probe_backup_dir(self._resolve_backup_dir(path_setting))
+
     async def run_backup(self, settings: dict | None = None) -> dict:
         """Run a backup now. Returns {success, message, filename}."""
         if self._running:
@@ -205,11 +190,22 @@ class LocalBackupService:
                 settings = await self._load_settings()
 
             backup_dir = self._resolve_backup_dir(settings["path"])
-            backup_dir.mkdir(parents=True, exist_ok=True)
 
-            from backend.app.api.routes.settings import create_backup_zip
+            try:
+                backup_dir.mkdir(parents=True, exist_ok=True)
+
+                from backend.app.api.routes.settings import create_backup_zip
 
-            zip_path, filename = await create_backup_zip(output_path=backup_dir)
+                zip_path, filename = await create_backup_zip(output_path=backup_dir)
+            except OSError as e:
+                # A raw "[Errno 30] Read-only file system" sends people off to check
+                # folder permissions, which is exactly where the answer is not (#2544).
+                diagnosis = classify_backup_dir_error(e, backup_dir)
+                self._last_backup_at = datetime.now(timezone.utc).isoformat()
+                self._last_status = "failed"
+                self._last_message = diagnosis["message"]
+                logger.error("Local backup failed: %s (%s)", diagnosis["message"], diagnosis["detail"])
+                return {"success": False, "message": diagnosis["message"], "diagnosis": diagnosis}
 
             # Prune old backups
             retention = max(1, settings["retention"])

+ 24 - 2
backend/app/services/log_reader.py

@@ -14,6 +14,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
+from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -25,6 +26,21 @@ logger = logging.getLogger(__name__)
 # parse it out; the log-health scanner does not.
 LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
 
+# LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
+# (#2681). Match a run of at least two ``attr=value`` RDN components joined by
+# commas, where ``attr`` is a known LDAP attribute type. Requiring two components
+# keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
+# while still catching DNs wherever they surface — the deliberate "auth successful"
+# line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
+# redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
+# a name is not.
+# The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
+# DN value, so an unescaped one marks the end of the DN, not part of it. That stops
+# the final (comma-unbounded) component from greedily swallowing trailing log text
+# such as ``… -> GroupName``.
+_LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
+_LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
+
 
 class LogEntry(BaseModel):
     """A single parsed log entry."""
@@ -153,12 +169,18 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
                 continue  # Skip very short strings to prevent over-redaction
             content = re.sub(re.escape(value), label, content)
 
-    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
-    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
+    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host).
+    # Shares its pattern with the log-pipeline redaction in ``core.logging_filters`` so
+    # the two can't drift; the bundle drops the username too, where the live log keeps
+    # it for diagnosis.
+    content = URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>[CREDENTIALS]@", content)
 
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
+    # Replace LDAP Distinguished Names (#2681) — PII on par with email.
+    content = _LDAP_DN_PATTERN.sub("[DN]", content)
+
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
     content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
 

+ 37 - 8
backend/app/services/long_lived_tokens.py

@@ -22,6 +22,7 @@ tokens — a leaked permanent token would be irrevocable footgun-by-design).
 from __future__ import annotations
 
 import secrets
+from collections.abc import Collection
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 
@@ -35,9 +36,26 @@ from backend.app.models.long_lived_token import LongLivedToken
 # (90 days) and the create route enforces this ceiling.
 MAX_TOKEN_LIFETIME_DAYS = 365
 
-# Only V1 scope. Adding "snapshot" or "control" later means adding a value
-# to this tuple and an `if scope == ...` branch in the route, no schema work.
-ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream"})
+# Every scope is a separate grant, never implied by another. A token minted for
+# one purpose must not silently widen when a later scope is added.
+#
+#   camera_stream — the MJPEG stream / snapshot endpoints and nothing else
+#                   (#1108). What a Home Assistant or Frigate card needs.
+#   camwall       — those same streams *plus* the read-only tile metadata the
+#                   Cam Wall draws: printer names and print state (#2531).
+#                   Strictly wider than camera_stream, so it gets its own scope
+#                   rather than quietly extending tokens already handed out.
+#   overlay       — the streaming overlay (#2613): the camera stream plus the
+#                   single-printer status the /overlay page draws, which unlike
+#                   the Cam Wall *includes the print filename*. A distinct grant
+#                   precisely because it reveals the part name a camwall token
+#                   is trusted never to expose, so folding it into camwall would
+#                   silently widen every wall token already handed out.
+ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
+
+# Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
+# token has to be able to pull the video its own view is showing.
+STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
 
 # Don't write to last_used_at more than once per minute per token. MJPEG
 # streams call verify() at most once per fetch (the browser holds the
@@ -142,23 +160,34 @@ async def create_token(
     return CreatedToken(record=record, plaintext=plaintext)
 
 
-async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
+async def verify_token(
+    db: AsyncSession,
+    token: str,
+    *,
+    scope: str | Collection[str] = "camera_stream",
+) -> LongLivedToken | None:
     """Validate a token. Returns the matching record on success, None otherwise.
 
-    The bcrypt-style verify is the slow step (intentional — pbkdf2 by design),
-    so we pre-filter by the indexed ``lookup_prefix`` to ensure the verify
-    runs against at most one or two candidate rows.
+    ``scope`` accepts a single scope or a collection of acceptable ones — the
+    stream endpoints pass ``STREAM_SCOPES`` because more than one scope may
+    legitimately reach them. The record must carry one of them; a token is
+    never accepted on the strength of a scope it does not hold.
+
+    The pbkdf2 verify is the slow step (intentional), so we pre-filter by the
+    indexed ``lookup_prefix`` to ensure the verify runs against at most one or
+    two candidate rows.
     """
     parsed = _parse_token(token)
     if parsed is None:
         return None
     lookup_prefix, full_token = parsed
+    scopes = (scope,) if isinstance(scope, str) else tuple(scope)
 
     now = datetime.now(timezone.utc)
     result = await db.execute(
         select(LongLivedToken).where(
             LongLivedToken.lookup_prefix == lookup_prefix,
-            LongLivedToken.scope == scope,
+            LongLivedToken.scope.in_(scopes),
             LongLivedToken.revoked_at.is_(None),
         )
     )

Some files were not shown because too many files changed in this diff