Procházet zdrojové kódy

Merge branch 'dev' into feature/oidc-env-config

MartinNYHC před 1 měsícem
rodič
revize
c3448dae91
96 změnil soubory, kde provedl 7412 přidání a 330 odebrání
  1. 9 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 30 11
      backend/app/api/routes/_oidc_helpers.py
  4. 10 56
      backend/app/api/routes/_spoolman_helpers.py
  5. 99 10
      backend/app/api/routes/_url_safety.py
  6. 10 0
      backend/app/api/routes/archives.py
  7. 284 29
      backend/app/api/routes/camera.py
  8. 14 0
      backend/app/api/routes/github_backup.py
  9. 35 2
      backend/app/api/routes/library.py
  10. 39 5
      backend/app/api/routes/printers.py
  11. 100 13
      backend/app/api/routes/settings.py
  12. 36 6
      backend/app/api/routes/support.py
  13. 110 0
      backend/app/core/database.py
  14. 84 15
      backend/app/main.py
  15. 24 15
      backend/app/schemas/auth.py
  16. 5 0
      backend/app/schemas/printer.py
  17. 56 1
      backend/app/schemas/settings.py
  18. 197 17
      backend/app/services/bambu_mqtt.py
  19. 133 3
      backend/app/services/camera.py
  20. 25 2
      backend/app/services/camera_diagnose.py
  21. 24 4
      backend/app/services/external_camera.py
  22. 30 7
      backend/app/services/homeassistant.py
  23. 20 1
      backend/app/services/layer_timelapse.py
  24. 2 0
      backend/app/services/mqtt_relay.py
  25. 94 7
      backend/app/services/notification_service.py
  26. 32 1
      backend/app/services/obico_detection.py
  27. 20 8
      backend/app/services/plate_detection.py
  28. 117 17
      backend/app/services/print_scheduler.py
  29. 2 0
      backend/app/services/printer_manager.py
  30. 35 15
      backend/app/services/rest_smart_plug.py
  31. 15 2
      backend/app/services/slicer_3mf_convert.py
  32. 22 2
      backend/app/services/tasmota.py
  33. 30 0
      backend/app/utils/printer_models.py
  34. 45 0
      backend/app/utils/threemf_tools.py
  35. 362 5
      backend/tests/integration/test_library_slice_api.py
  36. 72 3
      backend/tests/integration/test_printers_api.py
  37. 288 0
      backend/tests/unit/services/test_camera_capture_coalescing.py
  38. 73 0
      backend/tests/unit/services/test_camera_diagnose.py
  39. 39 16
      backend/tests/unit/services/test_notification_service.py
  40. 394 0
      backend/tests/unit/services/test_p2s_accessory_fans.py
  41. 38 6
      backend/tests/unit/services/test_rest_smart_plug.py
  42. 50 0
      backend/tests/unit/services/test_slicer_3mf_convert.py
  43. 255 0
      backend/tests/unit/services/test_total_layers_print_start.py
  44. 118 7
      backend/tests/unit/test_camera_ffmpeg_termination.py
  45. 287 0
      backend/tests/unit/test_camera_stderr_tail.py
  46. 238 0
      backend/tests/unit/test_camera_stream_registry_isolation.py
  47. 4 0
      backend/tests/unit/test_camera_usb_stream_cleanup.py
  48. 29 0
      backend/tests/unit/test_config_env_warnings.py
  49. 144 0
      backend/tests/unit/test_dispatch_claim_recovery.py
  50. 263 0
      backend/tests/unit/test_expected_print_rollback.py
  51. 297 0
      backend/tests/unit/test_external_camera_live_frame_reuse.py
  52. 650 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  53. 2 0
      backend/tests/unit/test_plate_clear_mqtt_notification.py
  54. 166 0
      backend/tests/unit/test_pool_fits_server.py
  55. 2 0
      backend/tests/unit/test_printer_manager_status_broadcast.py
  56. 163 0
      backend/tests/unit/test_spoolman_settings_value_coercion.py
  57. 166 0
      backend/tests/unit/test_support_helpers.py
  58. 107 0
      backend/tests/unit/test_telegram_forum_topic.py
  59. 106 0
      backend/tests/unit/test_threemf_tools.py
  60. 2 0
      frontend/src/App.tsx
  61. 90 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  62. 96 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  63. 185 0
      frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx
  64. 110 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  65. 157 0
      frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx
  66. 4 1
      frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx
  67. 44 1
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  68. 107 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  69. 20 1
      frontend/src/api/client.ts
  70. 22 0
      frontend/src/components/AddNotificationModal.tsx
  71. 8 2
      frontend/src/components/SliceModal.tsx
  72. 47 15
      frontend/src/contexts/SliceJobTrackerContext.tsx
  73. 15 0
      frontend/src/contexts/ThemeContext.tsx
  74. 38 11
      frontend/src/hooks/useFilamentMapping.ts
  75. 162 0
      frontend/src/hooks/usePrintProgressTitle.ts
  76. 3 3
      frontend/src/i18n/index.ts
  77. 8 0
      frontend/src/i18n/locales/de.ts
  78. 8 0
      frontend/src/i18n/locales/en.ts
  79. 8 0
      frontend/src/i18n/locales/es.ts
  80. 8 0
      frontend/src/i18n/locales/fr.ts
  81. 8 0
      frontend/src/i18n/locales/it.ts
  82. 8 0
      frontend/src/i18n/locales/ja.ts
  83. 8 0
      frontend/src/i18n/locales/ko.ts
  84. 8 0
      frontend/src/i18n/locales/pt-BR.ts
  85. 8 0
      frontend/src/i18n/locales/ru.ts
  86. 8 0
      frontend/src/i18n/locales/tr.ts
  87. 8 0
      frontend/src/i18n/locales/uk.ts
  88. 8 0
      frontend/src/i18n/locales/zh-CN.ts
  89. 8 0
      frontend/src/i18n/locales/zh-TW.ts
  90. 18 2
      frontend/src/pages/FileManagerPage.tsx
  91. 56 5
      frontend/src/pages/PrintersPage.tsx
  92. 19 0
      frontend/src/pages/SettingsPage.tsx
  93. 0 0
      static/assets/index-C2LOlVCR.js
  94. 0 1
      static/assets/index-D4bpNaiw.css
  95. 1 0
      static/assets/index-oReXTzKG.css
  96. 2 2
      static/index.html

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 9 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -156,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - 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
 - 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
 - **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)
 - **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)

+ 30 - 11
backend/app/api/routes/_oidc_helpers.py

@@ -1,9 +1,11 @@
 """Pure helper functions for OIDC routes.
 """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
 from __future__ import annotations
@@ -11,15 +13,21 @@ from __future__ import annotations
 import ipaddress
 import ipaddress
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
-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 (
+    CLOUD_METADATA_HOSTNAMES,
+    CLOUD_METADATA_IPS,
+    NUMERIC_IP_RE,
+    unwrap_ipv4_mapped,
+)
 
 
 
 
 def assert_safe_public_https_url(url: str) -> None:
 def assert_safe_public_https_url(url: str) -> None:
     """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
     """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:
     Checks performed:
     - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
     - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
@@ -35,9 +43,12 @@ def assert_safe_public_https_url(url: str) -> None:
     - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
     - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
       check so an attacker can't bypass via IPv6 encoding.
       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 otherwise 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. The
+    fixed cloud-metadata hostnames are the exception: matching them is a
+    literal string comparison, not a resolution.
     """
     """
     parsed = urlparse(url)
     parsed = urlparse(url)
     if parsed.scheme.lower() != "https":
     if parsed.scheme.lower() != "https":
@@ -45,6 +56,14 @@ def assert_safe_public_https_url(url: str) -> None:
 
 
     hostname = (parsed.hostname or "").lower()
     hostname = (parsed.hostname or "").lower()
 
 
+    # "https:///path" parses to an empty hostname; without this it reaches the
+    # ip_address() ValueError branch and is accepted as a symbolic hostname.
+    if not hostname:
+        raise ValueError("icon URL must include a hostname")
+
+    if hostname in CLOUD_METADATA_HOSTNAMES:
+        raise ValueError("icon URL must not point to a cloud metadata endpoint")
+
     if NUMERIC_IP_RE.match(hostname):
     if NUMERIC_IP_RE.match(hostname):
         raise ValueError("icon URL must not use numeric-encoded IP addresses")
         raise ValueError("icon URL must not use numeric-encoded IP addresses")
 
 

+ 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
 from __future__ import annotations
 
 
-import ipaddress
 import json
 import json
 import logging
 import logging
 import math
 import math
 import re
 import re
 from typing import Any
 from typing import Any
-from urllib.parse import urlparse
 
 
 from typing_extensions import TypedDict
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
 
 
 
 
 def assert_safe_spoolman_url(url: str) -> None:
 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}$")
 _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")

+ 99 - 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
 from __future__ import annotations
 
 
 import ipaddress
 import ipaddress
 import re
 import re
+from urllib.parse import urlparse
 
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # targets. Both guards reject these unconditionally.
 # targets. Both guards reject these unconditionally.
@@ -28,6 +40,18 @@ CLOUD_METADATA_IPS = frozenset(
     }
     }
 )
 )
 
 
+# The DNS-name form of the same targets. Neither guard resolves hostnames (see
+# the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
+# literal-string match needs no resolution and costs nothing. These names only
+# resolve inside the respective cloud, so there is no legitimate reason for any
+# Bambuddy integration to point at one.
+CLOUD_METADATA_HOSTNAMES = frozenset(
+    {
+        "metadata.google.internal",  # GCP
+        "metadata.goog",  # GCP short form
+    }
+)
+
 
 
 # libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
 # libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
 # for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
 # for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
@@ -49,3 +73,68 @@ def unwrap_ipv4_mapped(
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
         return addr.ipv4_mapped
         return addr.ipv4_mapped
     return addr
     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 otherwise 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. The one exception is the fixed set of cloud-metadata
+    hostnames, which is a literal-string match and needs no resolution.
+    """
+    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()
+
+    # "http:///path" parses to an empty hostname. Never a valid destination,
+    # and without this it falls through the ip_address() ValueError branch
+    # below and is accepted as if it were a symbolic hostname.
+    if not hostname:
+        raise ValueError(f"{label} must include a hostname")
+
+    if hostname in CLOUD_METADATA_HOSTNAMES:
+        raise ValueError(f"{label} must not point to a cloud metadata endpoint")
+
+    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")

+ 10 - 0
backend/app/api/routes/archives.py

@@ -33,6 +33,7 @@ from backend.app.services.design_settings import overrides_from_config
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
     extract_project_filaments_from_3mf,
@@ -3937,6 +3938,7 @@ async def get_filament_requirements(
     archive_id: int,
     archive_id: int,
     plate_id: int | None = None,
     plate_id: int | None = None,
     request_id: str | None = None,
     request_id: str | None = None,
+    full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
@@ -4046,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.
             # Unsliced project files: see library.py for full rationale.
             # Return the FULL project_settings.config slot list with a
             # Return the FULL project_settings.config slot list with a
             # used_in_plate flag derived from the preview slice; the
             # used_in_plate flag derived from the preview slice; the

+ 284 - 29
backend/app/api/routes/camera.py

@@ -1,10 +1,13 @@
 """Camera streaming API endpoints for Bambu Lab printers."""
 """Camera streaming API endpoints for Bambu Lab printers."""
 
 
 import asyncio
 import asyncio
+import contextlib
 import logging
 import logging
 import os
 import os
 import subprocess
 import subprocess
 import sys
 import sys
+import time
+import uuid
 from collections.abc import AsyncGenerator
 from collections.abc import AsyncGenerator
 
 
 from fastapi import APIRouter, Depends, HTTPException, Request
 from fastapi import APIRouter, Depends, HTTPException, Request
@@ -46,12 +49,25 @@ from backend.app.services.camera_profiles import get_camera_profile
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
 router = APIRouter(prefix="/printers", tags=["camera"])
 
 
-# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580). A killed
-# ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily
-# long to exit — an unbounded post-kill wait() parked the fan-out stream
-# coroutine for 12 hours on a P2S, leaving every viewer attached to a stalled
-# broadcaster. Abandoning the wait is safe: cleanup_orphaned_streams' /proc scan
-# reaps any Bambu ffmpeg not attached to an active stream on its next pass.
+# 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
 _FFMPEG_KILL_TIMEOUT = 2.0
 
 
 # Track active ffmpeg processes for cleanup
 # Track active ffmpeg processes for cleanup
@@ -83,6 +99,14 @@ _disconnect_events: dict[str, asyncio.Event] = {}
 # Track last frame time per stream_id (not just per printer_id) for stale detection
 # Track last frame time per stream_id (not just per printer_id) for stale detection
 _stream_last_frame_times: dict[str, float] = {}
 _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:
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
     """Get the last buffered frame for a printer from an active stream.
@@ -194,8 +218,6 @@ async def generate_chamber_mjpeg_stream(
 
 
             # Save frame to buffer for photo capture and track timestamp
             # Save frame to buffer for photo capture and track timestamp
             if printer_id is not None:
             if printer_id is not None:
-                import time
-
                 _last_frames[printer_id] = frame
                 _last_frames[printer_id] = frame
                 _last_frame_times[printer_id] = time.time()
                 _last_frame_times[printer_id] = time.time()
 
 
@@ -227,10 +249,7 @@ async def generate_chamber_mjpeg_stream(
             _stream_last_frame_times.pop(stream_id, None)
             _stream_last_frame_times.pop(stream_id, None)
 
 
         # Clean up frame buffer and timestamps
         # 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
         # Close the connection
         try:
         try:
@@ -241,14 +260,127 @@ async def generate_chamber_mjpeg_stream(
         logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
         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:
 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:
     if process.returncode is not None:
+        _spawned_ffmpeg_pids.pop(process.pid, None)
         return  # Already dead
         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:
     try:
         process.terminate()
         process.terminate()
         try:
         try:
-            await asyncio.wait_for(process.wait(), timeout=2.0)
+            await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
         except TimeoutError:
         except TimeoutError:
             logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
             logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
             process.kill()
             process.kill()
@@ -257,7 +389,8 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
             except TimeoutError:
             except TimeoutError:
                 # Do NOT keep waiting (#2580): the caller is the stream
                 # Do NOT keep waiting (#2580): the caller is the stream
                 # generator, and blocking here pins the fan-out pump forever.
                 # generator, and blocking here pins the fan-out pump forever.
-                # The orphan janitor reaps the process later.
+                # The orphan janitor reaps the process later. With the pipes
+                # drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
                 logger.error(
                 logger.error(
                     "ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
                     "ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
                     _FFMPEG_KILL_TIMEOUT,
                     _FFMPEG_KILL_TIMEOUT,
@@ -267,7 +400,11 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
         pass  # Already dead
         pass  # Already dead
     except OSError as e:
     except OSError as e:
         logger.warning("Error terminating ffmpeg: %s", 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:
 def _summarize_ffmpeg_stderr(text: str | None) -> str:
@@ -303,6 +440,82 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
     return "\n".join(meaningful[-10:])
     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:
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
     """Read whatever ffmpeg has written to stderr so far (best-effort).
     """Read whatever ffmpeg has written to stderr so far (best-effort).
 
 
@@ -313,8 +526,18 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
     banner + stream-analysis lines ffmpeg already printed. Reading in bounded
     banner + stream-analysis lines ffmpeg already printed. Reading in bounded
     chunks returns the buffered output promptly whether or not ffmpeg has
     chunks returns the buffered output promptly whether or not ffmpeg has
     exited. Returns the content with ffmpeg's boilerplate banner stripped.
     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
         return None
     chunks: list[bytes] = []
     chunks: list[bytes] = []
     total = 0
     total = 0
@@ -435,6 +658,7 @@ async def generate_rtsp_mjpeg_stream(
     jpeg_end = b"\xff\xd9"
     jpeg_end = b"\xff\xd9"
     reconnect_count = 0
     reconnect_count = 0
     process = None
     process = None
+    stderr_tail: _FfmpegStderrTail | None = None
     got_any_frames = False
     got_any_frames = False
 
 
     try:
     try:
@@ -487,6 +711,14 @@ async def generate_rtsp_mjpeg_stream(
                 reconnect_count += 1
                 reconnect_count += 1
                 continue
                 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
             # Read JPEG frames from ffmpeg stdout
             buffer = b""
             buffer = b""
             stream_ended = False
             stream_ended = False
@@ -530,8 +762,6 @@ async def generate_rtsp_mjpeg_stream(
                         got_any_frames = True
                         got_any_frames = True
 
 
                         if printer_id is not None:
                         if printer_id is not None:
-                            import time
-
                             _last_frames[printer_id] = frame
                             _last_frames[printer_id] = frame
                             _last_frame_times[printer_id] = time.time()
                             _last_frame_times[printer_id] = time.time()
                             if stream_id:
                             if stream_id:
@@ -562,6 +792,12 @@ async def generate_rtsp_mjpeg_stream(
 
 
             # Clean up this ffmpeg process before reconnecting or exiting
             # Clean up this ffmpeg process before reconnecting or exiting
             await _terminate_ffmpeg(process, stream_id)
             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
             process = None
 
 
             if client_gone:
             if client_gone:
@@ -604,15 +840,16 @@ async def generate_rtsp_mjpeg_stream(
             _stream_last_frame_times.pop(stream_id, None)
             _stream_last_frame_times.pop(stream_id, None)
 
 
         # Clean up frame buffer and timestamps
         # 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:
         if process:
             await _terminate_ffmpeg(process, stream_id)
             await _terminate_ffmpeg(process, stream_id)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, 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
         # Shut down the TLS proxy
         proxy_server.close()
         proxy_server.close()
         await proxy_server.wait_closed()
         await proxy_server.wait_closed()
@@ -672,9 +909,11 @@ async def camera_stream(
 
 
     # Check for external camera first
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
     if printer.external_camera_enabled and printer.external_camera_url:
-        import time
-        import uuid
-
+        # 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
         from backend.app.services.external_camera import generate_mjpeg_stream
 
 
         # Limit external camera FPS to reduce browser load
         # Limit external camera FPS to reduce browser load
@@ -710,6 +949,18 @@ async def camera_stream(
             _spawned_ffmpeg_pids[proc.pid] = time.time()
             _spawned_ffmpeg_pids[proc.pid] = time.time()
             _stream_last_frame_times[stream_id] = 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():
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             """Wrap external stream to track start/stop and update frame times."""
             try:
             try:
@@ -718,6 +969,7 @@ async def camera_stream(
                     printer.external_camera_type,
                     printer.external_camera_type,
                     fps,
                     fps,
                     on_process=_register_external_process,
                     on_process=_register_external_process,
+                    on_frame=_publish_external_frame,
                     stop_event=stop_event,
                     stop_event=stop_event,
                 ):
                 ):
                     # generate_mjpeg_stream already handles rate limiting;
                     # generate_mjpeg_stream already handles rate limiting;
@@ -738,6 +990,11 @@ async def camera_stream(
                 _disconnect_events.pop(stream_id, None)
                 _disconnect_events.pop(stream_id, None)
                 _stream_last_frame_times.pop(stream_id, None)
                 _stream_last_frame_times.pop(stream_id, None)
                 _active_external_streams.discard(printer_id)
                 _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)
                 logger.info("External camera stream ended for printer %s", printer_id)
 
 
         return StreamingResponse(
         return StreamingResponse(
@@ -769,8 +1026,6 @@ async def camera_stream(
     # attached — otherwise /camera/status would report stream_uptime jumping
     # attached — otherwise /camera/status would report stream_uptime jumping
     # backward whenever a second viewer joins. The upstream generator's
     # backward whenever a second viewer joins. The upstream generator's
     # finally clears this entry when the upstream actually ends.
     # finally clears this entry when the upstream actually ends.
-    import time
-
     _stream_start_times.setdefault(printer_id, time.time())
     _stream_start_times.setdefault(printer_id, time.time())
 
 
     # Fan-out broadcaster (#1089): one upstream connection per printer, shared
     # Fan-out broadcaster (#1089): one upstream connection per printer, shared
@@ -783,7 +1038,7 @@ async def camera_stream(
     # broadcaster. Concurrent viewers share that rate; new viewers after
     # broadcaster. Concurrent viewers share that rate; new viewers after
     # teardown create a fresh broadcaster at their requested fps.
     # teardown create a fresh broadcaster at their requested fps.
     fanout_key = f"printer-{printer_id}"
     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):
     def _factory(disconnect_event: asyncio.Event):
         # Re-bind locals into the closure so the async generator below sees
         # Re-bind locals into the closure so the async generator below sees

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

@@ -49,7 +49,21 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
 
 
     Used by POST and PATCH /config so a backup configuration can never be
     Used by POST and PATCH /config so a backup configuration can never be
     saved against a public repository.
     saved against a public repository.
+
+    The URL is policy-checked first: the Gitea and Forgejo backends derive
+    their API base from this value (``get_api_base``) and then request it with
+    the supplied token, so an unchecked repository_url is an outbound fetch to
+    an operator-supplied host. A self-hosted Gitea on the LAN is the normal
+    case, so the LAN-service tier applies — this only rules out the targets
+    that are wrong under any topology.
     """
     """
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    try:
+        assert_safe_lan_service_url(repo_url, label="Repository URL")
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
     result = await github_backup_service.test_connection(repo_url, token, provider=provider)
     result = await github_backup_service.test_connection(repo_url, token, provider=provider)
     if not result.get("success"):
     if not result.get("success"):
         message = result.get("message") or "Connection test failed"
         message = result.get("message") or "Connection test failed"

+ 35 - 2
backend/app/api/routes/library.py

@@ -73,6 +73,7 @@ from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_miss
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 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.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
     extract_project_filaments_from_3mf,
@@ -3114,6 +3115,7 @@ async def get_library_file_filament_requirements(
     file_id: int,
     file_id: int,
     plate_id: int | None = None,
     plate_id: int | None = None,
     request_id: str | None = None,
     request_id: str | None = None,
+    full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
@@ -3130,6 +3132,10 @@ async def get_library_file_filament_requirements(
     Args:
     Args:
         file_id: The library file ID
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
         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
     import defusedxml.ElementTree as ET
 
 
@@ -3232,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.
             # Unsliced project files: slice_info had no per-plate data.
             # Return the FULL project_settings.config AMS slot list so
             # Return the FULL project_settings.config AMS slot list so
             # the slicer CLI receives a profile for every project slot
             # the slicer CLI receives a profile for every project slot
@@ -3761,10 +3778,26 @@ async def _run_slicer_with_fallback(
     # with printer …" (#2628). Replace unused-slot entries with the
     # with printer …" (#2628). Replace unused-slot entries with the
     # plate's lowest used slot before the real slice so the loaded set is
     # plate's lowest used slot before the real slice so the loaded set is
     # materially homogeneous and printer-correct.
     # materially homogeneous and printer-correct.
-    if is_3mf and request.plate is not None:
+    #
+    # ``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
         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
     # Cross-class slice-all loop (#1493): when the user asks for
     # ``plate=0`` (all plates) AND the source's nozzle class differs from
     # ``plate=0`` (all plates) AND the source's nozzle class differs from

+ 39 - 5
backend/app/api/routes/printers.py

@@ -64,6 +64,7 @@ from backend.app.services.printer_manager import (
 )
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 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.http import build_content_disposition
+from backend.app.utils.printer_models import uses_exhaust_fan_label
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -798,6 +799,8 @@ async def get_printer_status(
         big_fan1_speed=state.big_fan1_speed,
         big_fan1_speed=state.big_fan1_speed,
         big_fan2_speed=state.big_fan2_speed,
         big_fan2_speed=state.big_fan2_speed,
         heatbreak_fan_speed=state.heatbreak_fan_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,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
         developer_mode=state.developer_mode if state else None,
         ams_filament_backup=state.ams_filament_backup if state else None,
         ams_filament_backup=state.ams_filament_backup if state else None,
@@ -3193,16 +3196,28 @@ async def set_chamber_temperature(
 @router.post("/{printer_id}/fan-speed")
 @router.post("/{printer_id}/fan-speed")
 async def set_fan_speed(
 async def set_fan_speed(
     printer_id: int,
     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"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     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)
     fan_id = fan_ids.get(fan)
     if fan_id is None:
     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))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -3213,12 +3228,31 @@ async def set_fan_speed(
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         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)
     pwm_speed = round(speed * 255 / 100)
     success = client.set_fan_speed(fan_id, pwm_speed)
     success = client.set_fan_speed(fan_id, pwm_speed)
     if not success:
     if not success:
         raise HTTPException(500, "Failed to set fan speed")
         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}%"}
     return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
 
 
 
 

+ 100 - 13
backend/app/api/routes/settings.py

@@ -42,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
     return setting.value if setting else 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:
 async def get_external_login_url(db: AsyncSession) -> str:
     """Get the external URL for the login page.
     """Get the external URL for the login page.
 
 
@@ -435,14 +517,20 @@ async def update_spoolman_settings(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     _: 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:
     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)
         await set_setting(db, "spoolman_enabled", new_val)
 
 
         # Switching to Spoolman: clear built-in inventory slot assignments
         # 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
             from backend.app.models.spool_assignment import SpoolAssignment
 
 
             result = await db.execute(delete(SpoolAssignment))
             result = await db.execute(delete(SpoolAssignment))
@@ -452,21 +540,20 @@ async def update_spoolman_settings(
         # spoolman_slot_assignments rows linger and would wrongly count as
         # spoolman_slot_assignments rows linger and would wrongly count as
         # "assigned" in any mode-agnostic check (e.g. the missing-spool-
         # "assigned" in any mode-agnostic check (e.g. the missing-spool-
         # assignment notification, which unions both tables — #1473).
         # 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
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
 
             result = await db.execute(delete(SpoolmanSlotAssignment))
             result = await db.execute(delete(SpoolmanSlotAssignment))
             logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
             logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
     if "spoolman_url" in settings:
     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:
     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
     spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
 
 

+ 36 - 6
backend/app/api/routes/support.py

@@ -1227,6 +1227,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
     return out
     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:
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
     # Collect sensitive strings from DB for redaction
@@ -1300,12 +1329,13 @@ async def generate_support_bundle(
                 "captured_at": datetime.now(timezone.utc).isoformat(),
                 "captured_at": datetime.now(timezone.utc).isoformat(),
                 "raw_data": redacted,
                 "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
         # Add log file
         # Off the event loop: this reads up to 10 MB and then runs one full regex
         # Off the event loop: this reads up to 10 MB and then runs one full regex

+ 110 - 0
backend/app/core/database.py

@@ -27,6 +27,11 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
 # /system/db-pool can report it without re-deriving the dialect defaults.
 # /system/db-pool can report it without re-deriving the dialect defaults.
 _pool_config: dict = {}
 _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:
 def _resolve_pool_kwargs() -> dict:
     """Build the pool kwargs for ``create_async_engine`` (issue #2572).
     """Build the pool kwargs for ``create_async_engine`` (issue #2572).
@@ -151,6 +156,10 @@ def get_pool_status() -> dict:
     return {
     return {
         "dialect": "sqlite" if is_sqlite() else "postgresql",
         "dialect": "sqlite" if is_sqlite() else "postgresql",
         "config": dict(_pool_config),
         "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,
         **gauges,
     }
     }
 
 
@@ -317,6 +326,107 @@ async def init_db():
     await seed_spool_catalog()
     await seed_spool_catalog()
     await seed_color_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
 # B2: Module-level counter exposing the number of rows skipped during the last
 # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
 # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status

+ 84 - 15
backend/app/main.py

@@ -740,6 +740,51 @@ def register_expected_print(
     )
     )
 
 
 
 
+def unregister_expected_print(printer_id: int, filename: str, archive_id: int) -> None:
+    """Undo :func:`register_expected_print` when the print never went out.
+
+    Registration has to happen *before* the MQTT print command, because the
+    printer can report the print before the line after the send executes. So
+    every path that registers and then fails to send — a cancel winning the
+    #1853 CAS race, a ``start_print()`` that returns False, or any exception in
+    between — leaves an expectation for a print that will never arrive.
+
+    The TTL sweep evicts those after two hours, which is far longer than it
+    takes a user to react to a failed dispatch by pressing print again: that
+    reprint would be folded into the *old* archive and take the stale
+    ``ams_mapping`` / ``plate_id`` with it. Hence the explicit inverse.
+
+    Mirrors the sweep's rules, including the one that is easy to get wrong:
+    ``_print_ams_mappings`` / ``_print_plate_ids`` are keyed by archive, not by
+    file, so they may only be dropped once no live key still points at that
+    archive.
+    """
+    keys = [(printer_id, filename)]
+    if filename.endswith(".3mf"):
+        base = filename[:-4]
+        keys.append((printer_id, base))
+        keys.append((printer_id, f"{base}.gcode"))
+
+    removed = False
+    for key in keys:
+        if _expected_prints.pop(key, None) is not None:
+            removed = True
+        _expected_print_creators.pop(key, None)
+        _expected_print_registered_at.pop(key, None)
+
+    if archive_id not in set(_expected_prints.values()):
+        _print_ams_mappings.pop(archive_id, None)
+        _print_plate_ids.pop(archive_id, None)
+
+    if removed:
+        logging.getLogger(__name__).info(
+            "Unregistered expected print: printer=%s, file=%s, archive=%s (print was never sent)",
+            printer_id,
+            filename,
+            archive_id,
+        )
+
+
 def _compute_run_filament_grams(
 def _compute_run_filament_grams(
     status: str,
     status: str,
     archive_filament_used_grams: float | None,
     archive_filament_used_grams: float | None,
@@ -2172,13 +2217,21 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
         # Try external camera first
         # Try external camera first
         if printer.external_camera_enabled and printer.external_camera_url:
         if printer.external_camera_enabled and printer.external_camera_url:
             logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
             logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
+            from backend.app.api.routes.camera import live_frame_for_capture
             from backend.app.services.external_camera import capture_frame
             from backend.app.services.external_camera import capture_frame
 
 
-            frame_data = await capture_frame(
-                printer.external_camera_url,
-                printer.external_camera_type or "mjpeg",
-                snapshot_url=printer.external_camera_snapshot_url,
-            )
+            # An external camera allows one reader, so capturing while a viewer
+            # is attached fails (#2707). A None here falls through to the paths
+            # below exactly as a failed capture did.
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                frame_data = buffered
+            else:
+                frame_data = await capture_frame(
+                    printer.external_camera_url,
+                    printer.external_camera_type or "mjpeg",
+                    snapshot_url=printer.external_camera_snapshot_url,
+                )
             if frame_data and len(frame_data) <= 2_500_000:
             if frame_data and len(frame_data) <= 2_500_000:
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
                 return _apply_camera_rotation(frame_data, printer, logger)
                 return _apply_camera_rotation(frame_data, printer, logger)
@@ -4337,13 +4390,21 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                 )
                 )
 
 
         if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
         if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
+            from backend.app.api.routes.camera import live_frame_for_capture
             from backend.app.services.external_camera import capture_frame
             from backend.app.services.external_camera import capture_frame
 
 
-            frame_bytes = await capture_frame(
-                printer.external_camera_url,
-                printer.external_camera_type or "mjpeg",
-                snapshot_url=printer.external_camera_snapshot_url,
-            )
+            # #2707: this used to collide with the live view and fail, which is
+            # how finish-photo notifications went out with no image attached.
+            # Leaving frame_bytes None keeps the rest of the fallback chain.
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                frame_bytes = buffered
+            else:
+                frame_bytes = await capture_frame(
+                    printer.external_camera_url,
+                    printer.external_camera_type or "mjpeg",
+                    snapshot_url=printer.external_camera_snapshot_url,
+                )
             if frame_bytes:
             if frame_bytes:
                 logger.info(
                 logger.info(
                     "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
                     "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
@@ -5307,13 +5368,21 @@ async def on_print_complete(printer_id: int, data: dict):
             if not photo_filename:
             if not photo_filename:
                 if printer.external_camera_enabled and printer.external_camera_url:
                 if printer.external_camera_enabled and printer.external_camera_url:
                     logger.info("[PHOTO-BG] Using external camera")
                     logger.info("[PHOTO-BG] Using external camera")
+                    from backend.app.api.routes.camera import live_frame_for_capture
                     from backend.app.services.external_camera import capture_frame
                     from backend.app.services.external_camera import capture_frame
 
 
-                    frame_data = await capture_frame(
-                        printer.external_camera_url,
-                        printer.external_camera_type or "mjpeg",
-                        snapshot_url=printer.external_camera_snapshot_url,
-                    )
+                    # #2707: the second half of the finish-photo failure — the
+                    # pre-capture and this fallback both collided with the live
+                    # view. None here continues down the fallback chain.
+                    defer, buffered = live_frame_for_capture(printer_id)
+                    if defer:
+                        frame_data = buffered
+                    else:
+                        frame_data = await capture_frame(
+                            printer.external_camera_url,
+                            printer.external_camera_type or "mjpeg",
+                            snapshot_url=printer.external_camera_snapshot_url,
+                        )
                     if frame_data:
                     if frame_data:
                         photos_dir = archive_dir / "photos"
                         photos_dir = archive_dir / "photos"
                         photos_dir.mkdir(parents=True, exist_ok=True)
                         photos_dir.mkdir(parents=True, exist_ok=True)

+ 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:
 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:
     if v is None:
         return v
         return v
     if not v.startswith("https://"):
     if not v.startswith("https://"):
         raise ValueError("issuer_url must start with 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:
     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:
     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
     return v
 
 
 
 

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

@@ -360,6 +360,11 @@ class PrinterStatus(BaseModel):
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak 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 (from info.module[name="ota"].sw_ver)
     firmware_version: str | None = None
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown

+ 56 - 1
backend/app/schemas/settings.py

@@ -1,9 +1,23 @@
 import json
 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
 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):
 class AppSettings(BaseModel):
     """Application settings schema."""
     """Application settings schema."""
@@ -600,6 +614,47 @@ class AppSettingsUpdate(BaseModel):
     default_sidebar_order: str | None = None
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
     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")
     @field_validator("gcode_snippets")
     @classmethod
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:
     def validate_gcode_snippets(cls, v: str | None) -> str | None:

+ 197 - 17
backend/app/services/bambu_mqtt.py

@@ -472,6 +472,21 @@ class PrinterState:
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Left auxiliary part cooling fan (optional accessory on P2S/X2D). Reported ONLY
+    # via device.airduct.parts (decoded part id 10 = FAN_REMOTE_COOLING_1 in Bambu
+    # Studio's AIR_FUN enum) — the firmware does NOT mirror it into any flat
+    # big_fanX_speed field, which is why it was previously dropped. 0-100 percent.
+    left_aux_fan_speed: int | None = None
+    # Chamber exhaust fan, derived from the airduct parts list containing decoded
+    # id 3. On the P2S this is the External Exhaust Fan kit and a base machine
+    # omits it, which is the case this flag exists to detect.
+    #
+    # NOTE: the flag is not P2S/X2D-specific despite the name. The H2 series
+    # (H2C/H2D/H2S) also reports part 3, so this goes True there too. That is
+    # harmless because only the P2S/X2D badge consults it — those models keep
+    # their unconditional "Chamber Fan" badge — but do not read this as
+    # "an exhaust kit is fitted" without also checking the model.
+    exhaust_fan_present: bool = False
     # Tray change history during current print: [(global_tray_id, layer_num), ...]
     # Tray change history during current print: [(global_tray_id, layer_num), ...]
     # Used by usage tracker to split filament weight on mid-print tray switch
     # Used by usage tracker to split filament weight on mid-print tray switch
     tray_change_log: list = field(default_factory=list)
     tray_change_log: list = field(default_factory=list)
@@ -721,6 +736,12 @@ class BambuMQTTClient:
         # and the FINISH-state fallback don't both fire on the same
         # and the FINISH-state fallback don't both fire on the same
         # print. Reset to False on every print start.
         # print. Reset to False on every print start.
         self._finish_photo_captured: bool = False
         self._finish_photo_captured: bool = False
+        # #2702: one-shot re-request of the layer total. Armed at print start
+        # when the starting frame carried no `total_layer_num`, spent on the
+        # first layer advance that still has no denominator. Bambu firmware
+        # only re-sends *changed* fields, so a total we never received (or
+        # dropped) is only recoverable via a full pushall.
+        self._total_layers_refresh_armed: bool = False
         # #2547 end-of-print telemetry probe state. `_armed` is cleared once the
         # #2547 end-of-print telemetry probe state. `_armed` is cleared once the
         # window has run for a print so a late FINISH re-send can't reopen it.
         # window has run for a print so a late FINISH re-send can't reopen it.
         self._eop_probe_armed: bool = True
         self._eop_probe_armed: bool = True
@@ -2978,8 +2999,49 @@ class BambuMQTTClient:
                     f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
                     f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
                 )
                 )
             self.state.mc_print_sub_stage = new_sub_stage
             self.state.mc_print_sub_stage = new_sub_stage
+        # Positive `total_layer_num` carried by *this* frame, or 0. Read up
+        # front because three places below consult it and they run in an order
+        # that is not the order they read most naturally in: the layer-advance
+        # refresh (#2702) must not fire on a frame that already answers it, the
+        # apply step must ignore firmware-reset 0s (#1771), and the new-print
+        # reset must not discard a total that belongs to the starting print.
+        total_from_this_frame = 0
+        if "total_layer_num" in data:
+            try:
+                total_from_this_frame = max(int(data["total_layer_num"] or 0), 0)
+            except (TypeError, ValueError):
+                # Must not escape. `_on_message` catches only JSONDecodeError
+                # and paho is left at `suppress_exceptions = False`, so an
+                # exception raised here is re-raised on the network thread and
+                # takes the printer connection down over one unusable field.
+                # Treat it as "not reported": the refresh below then recovers
+                # the real total from a pushall.
+                logger.debug(
+                    "[%s] ignoring unusable total_layer_num: %r",
+                    self.serial_number,
+                    data["total_layer_num"],
+                )
+
         if "layer_num" in data:
         if "layer_num" in data:
-            new_layer = int(data["layer_num"])
+            try:
+                new_layer = int(data["layer_num"])
+            except (TypeError, ValueError):
+                # Contained for the same reason as `total_layer_num` above: an
+                # exception raised here escapes `_update_state` and paho
+                # re-raises it on the network thread. Losing this frame would
+                # also lose the print-start and completion detection further
+                # down, which is worse than losing a layer number.
+                #
+                # Held at the last known layer rather than substituted with 0:
+                # a fabricated 0 reads as the firmware's cancel reset, which
+                # would move `_last_valid_layer_num` and show layer 0 in the UI
+                # until the next good frame.
+                logger.debug(
+                    "[%s] ignoring unusable layer_num: %r",
+                    self.serial_number,
+                    data["layer_num"],
+                )
+                new_layer = self.state.layer_num
             old_layer = self.state.layer_num
             old_layer = self.state.layer_num
             # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
             # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
             if old_layer > 0:
             if old_layer > 0:
@@ -2988,6 +3050,25 @@ class BambuMQTTClient:
             # Trigger layer change callback if layer increased
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
             if new_layer > old_layer and self.on_layer_change:
                 self.on_layer_change(new_layer)
                 self.on_layer_change(new_layer)
+            # #2702: the print is demonstrably laying down layers but we still
+            # have no denominator, so the pushall requested at print start
+            # either went unanswered or raced the printer learning the total.
+            # Ask once more — by layer 1 the printer definitely knows it.
+            # One-shot: an unanswered pushall must not turn into a per-layer
+            # retry loop for the rest of the print.
+            if (
+                new_layer > old_layer
+                and self._total_layers_refresh_armed
+                and not self.state.total_layers
+                and not total_from_this_frame
+            ):
+                self._total_layers_refresh_armed = False
+                logger.debug(
+                    "[%s] layer %s with no total_layer_num — re-requesting full status",
+                    self.serial_number,
+                    new_layer,
+                )
+                self._request_push_all()
             # #1867 last-layer finish-photo trigger. A1 Mini (and other
             # #1867 last-layer finish-photo trigger. A1 Mini (and other
             # firmware variants) skips `stg_cur=22`, so the fallback fires
             # firmware variants) skips `stg_cur=22`, so the fallback fires
             # at gcode_state=FINISH — which runs AFTER user End G-code
             # at gcode_state=FINISH — which runs AFTER user End G-code
@@ -3017,15 +3098,12 @@ class BambuMQTTClient:
                         "timelapse_was_active": self._timelapse_during_print,
                         "timelapse_was_active": self._timelapse_during_print,
                     }
                     }
                 )
                 )
-        if "total_layer_num" in data:
-            # Some firmware (P1S observed) resets `total_layer_num` to 0 at
-            # print end — same shape as the `layer_num` reset guarded above.
-            # Preserve the last known good value so the usage-tracker split
-            # path (#1771) has a denominator that survives the reset frame.
-            # Explicit reset to 0 happens on print start (`_handle_print_start`).
-            new_total = int(data["total_layer_num"])
-            if new_total > 0:
-                self.state.total_layers = new_total
+        if total_from_this_frame:
+            # Firmware (P1S observed) resets `total_layer_num` to 0 at print
+            # end — same shape as the `layer_num` reset guarded above. Applying
+            # only positive values preserves the last known good denominator so
+            # the usage-tracker split path (#1771) survives the reset frame.
+            self.state.total_layers = total_from_this_frame
 
 
         # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
         # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
         # Convert to 0-100 percentage for display
         # Convert to 0-100 percentage for display
@@ -3459,6 +3537,83 @@ class BambuMQTTClient:
                             f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}"
                             f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}"
                         )
                         )
                     self.state.airduct_mode = new_mode
                     self.state.airduct_mode = new_mode
+                # Parse individual airduct fan parts (new-protocol models: P2S/X2D/H2*).
+                # Raw part ids are bit-packed — decoded id = raw_id >> 4 (bits 4-11),
+                # mirroring Bambu Studio DevFan::ParseV3_0. Decoded ids follow the
+                # AIR_FUN enum: 1=part cooling, 2=right aux, 3=chamber/exhaust,
+                # 10=left aux (FAN_REMOTE_COOLING_1). The airduct `parts` list only
+                # contains the fans that physically exist, so it doubles as a
+                # presence signal for the two P2S/X2D add-on kits:
+                #   - id 10 (left auxiliary part cooling fan) — reported ONLY here,
+                #     never mirrored into a flat big_fanX_speed field.
+                #   - id 3 (chamber exhaust fan) — its speed is mirrored into
+                #     big_fan2_speed, but the part is only listed when the External
+                #     Exhaust Fan kit (get_version module "eef") is installed.
+                # `state` is already a 0-100 percentage.
+                parts = airduct_data.get("parts")
+                if isinstance(parts, list):
+                    speeds: dict[int, int] = {}
+                    for part in parts:
+                        if not isinstance(part, dict):
+                            continue
+                        try:
+                            # Studio reads the id with get_flag_bits(id, 4, 8),
+                            # so mask after shifting for the same reason `state`
+                            # is masked below. Every id seen in the wild
+                            # (16/32/48/160) decodes identically either way —
+                            # this is consistency, not a live bug.
+                            part_id = (int(part["id"]) >> 4) & 0xFF
+                            # `state` is bit-packed like its sibling `range`
+                            # (end << 16 | start), so take only the low 8 bits —
+                            # the same decode Bambu Studio does with
+                            # get_flag_bits(state, 0, 8). Without the mask a
+                            # packed value would clamp to 100 instead of
+                            # decoding to the real percentage.
+                            part_state = int(part["state"]) & 0xFF
+                        except (KeyError, ValueError, TypeError):
+                            continue
+                        # Ids seen across the support-package archive:
+                        #   1 part cooling, 2 aux, 3 chamber/exhaust,
+                        #   6 (H2 series, unmapped), 10 left aux.
+                        speeds[part_id] = max(0, min(100, part_state))
+
+                    # Absence in this list is what tells us a kit is NOT fitted,
+                    # so it may only be trusted when the list is a full
+                    # inventory rather than a diff frame. `device.airduct` is
+                    # pushed field by field — the `modeCur` handler above exists
+                    # for exactly that reason — and a truncated `parts` read as
+                    # gospel would retract both accessory badges mid-print and
+                    # start rejecting `aux2` on a printer that has the fan.
+                    #
+                    # Every airduct layout in the support-package archive
+                    # (P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 /
+                    # H2C,H2D,H2S 1,2,3,6 — 37 of 37 bundles) contains both the
+                    # part cooling fan and the aux fan, neither of which is
+                    # optional on any machine that reports an airduct at all.
+                    # A list carrying both is therefore a complete inventory; a
+                    # list missing either is a partial frame, and we take its
+                    # speeds without touching presence.
+                    is_full_inventory = 1 in speeds and 2 in speeds
+
+                    left_aux_speed = speeds.get(10)
+                    if left_aux_speed is None and not is_full_inventory:
+                        # Partial frame that didn't mention the left aux fan —
+                        # keep whatever we already knew about it.
+                        left_aux_speed = self.state.left_aux_fan_speed
+                    if left_aux_speed != self.state.left_aux_fan_speed:
+                        logger.debug(
+                            f"[{self.serial_number}] left_aux_fan_speed changed: "
+                            f"{self.state.left_aux_fan_speed} -> {left_aux_speed}"
+                        )
+                    # A FULL parts list without id 10 means the left aux fan is
+                    # not installed — report None so the UI can hide the widget.
+                    self.state.left_aux_fan_speed = left_aux_speed
+                    # id 3 present == chamber exhaust fan installed (base P2S
+                    # omits it). Only ever retracted on a full inventory.
+                    if 3 in speeds:
+                        self.state.exhaust_fan_present = True
+                    elif is_full_inventory:
+                        self.state.exhaust_fan_present = False
                 # Parse chamber temp - may be encoded as (target*65536+current) when > 500
                 # Parse chamber temp - may be encoded as (target*65536+current) when > 500
                 # Check if we recently set the target locally (within 5 seconds)
                 # Check if we recently set the target locally (within 5 seconds)
                 local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0)
                 local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0)
@@ -4084,11 +4239,29 @@ class BambuMQTTClient:
             # Reset layer tracking for new print (needed for layer-based timelapse)
             # Reset layer tracking for new print (needed for layer-based timelapse)
             self.state.layer_num = 0
             self.state.layer_num = 0
             # Reset total_layers so the previous print's value can't bleed into
             # Reset total_layers so the previous print's value can't bleed into
-            # this print's usage-tracker split before the new push_status arrives
-            # with the slicer's total (#1771 follow-on to the preservation guard
-            # above at line ~2135 — the guard now ignores firmware-reset 0s, so
-            # the explicit reset has to happen here instead).
-            self.state.total_layers = 0
+            # this print's usage-tracker split (#1771 follow-on to the
+            # preservation guard at the `total_layer_num` parse above — that
+            # guard ignores firmware-reset 0s, so the explicit reset has to
+            # happen here instead).
+            #
+            # #2702: reset to *this frame's* total, not to 0. The frame that
+            # trips the new-print detection can carry the new print's
+            # `total_layer_num` as well — the parse above has already applied
+            # it, and zeroing unconditionally threw it away. That looked
+            # harmless but is not recoverable: Bambu firmware sends only
+            # changed fields, so the printer never offers the total again, and
+            # the print runs to completion at `n/0` in the UI, in
+            # `{total_layers}` notifications, and as the usage-split
+            # denominator. The value only reappears on the next full pushall
+            # (reconnect / Force Refresh), which is why the symptom looked
+            # random and why a *stable* connection made it worse.
+            self.state.total_layers = total_from_this_frame
+            # If the starting frame brought no total, ask for one. Costs one
+            # MQTT message per print and covers the ordering where the printer
+            # published the total a frame or two before the state flip.
+            self._total_layers_refresh_armed = not total_from_this_frame
+            if self._total_layers_refresh_armed:
+                self._request_push_all()
             # Reset completion tracking for new print
             # Reset completion tracking for new print
             self._was_running = True
             self._was_running = True
             self._completion_triggered = False
             self._completion_triggered = False
@@ -5712,13 +5885,16 @@ class BambuMQTTClient:
         """Set fan speed.
         """Set fan speed.
 
 
         Args:
         Args:
-            fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber)
+            fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber, 10=left auxiliary).
+                Index 10 is the optional left auxiliary part cooling fan on P2S/X2D
+                (airduct part id 10); Bambu's official machine profiles drive it with
+                "M106 P10" in start/layer-change gcode.
             speed: Speed 0-255 (0=off, 255=full)
             speed: Speed 0-255 (0=off, 255=full)
 
 
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
         """
         """
-        if fan not in (1, 2, 3):
+        if fan not in (1, 2, 3, 10):
             logger.warning("[%s] Invalid fan index: %s", self.serial_number, fan)
             logger.warning("[%s] Invalid fan index: %s", self.serial_number, fan)
             return False
             return False
 
 
@@ -5737,6 +5913,10 @@ class BambuMQTTClient:
         """Set chamber fan speed (0-255)."""
         """Set chamber fan speed (0-255)."""
         return self.set_fan_speed(3, speed)
         return self.set_fan_speed(3, speed)
 
 
+    def set_left_aux_fan(self, speed: int) -> bool:
+        """Set left auxiliary part cooling fan speed (0-255). P2S/X2D accessory."""
+        return self.set_fan_speed(10, speed)
+
     def set_airduct_mode(self, mode: str) -> bool:
     def set_airduct_mode(self, mode: str) -> bool:
         """Set air conditioning mode (cooling or heating).
         """Set air conditioning mode (cooling or heating).
 
 

+ 133 - 3
backend/app/services/camera.py

@@ -6,6 +6,7 @@ Supports two camera protocols:
 """
 """
 
 
 import asyncio
 import asyncio
+import functools
 import logging
 import logging
 import os
 import os
 import shutil
 import shutil
@@ -34,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.
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
 _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:
 def get_ffmpeg_path() -> str | None:
     """Find the ffmpeg executable path.
     """Find the ffmpeg executable path.
@@ -529,6 +550,38 @@ async def capture_camera_frame(
     return False
     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(
 async def capture_camera_frame_bytes(
     ip_address: str,
     ip_address: str,
     access_code: str,
     access_code: str,
@@ -537,18 +590,95 @@ async def capture_camera_frame_bytes(
 ) -> bytes | None:
 ) -> bytes | None:
     """Capture a single frame and return as JPEG bytes (no disk write).
     """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:
     Args:
         ip_address: Printer IP address
         ip_address: Printer IP address
         access_code: Printer access code
         access_code: Printer access code
         model: Printer model (X1, H2D, P1, A1, etc.)
         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:
     Returns:
         JPEG bytes if capture was successful, None otherwise
         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
     # Chamber image models: A1/P1 - returns bytes directly
     if is_chamber_image_model(model):
     if is_chamber_image_model(model):
         logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
         logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)

+ 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
 AND a buffered frame is fresh (last 10 s), we short-circuit the test
 with ``live_stream_active`` and report success — the user is
 with ``live_stream_active`` and report success — the user is
 literally watching the camera right now, no test needed.
 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
 from __future__ import annotations
@@ -46,6 +53,7 @@ from dataclasses import dataclass, field
 
 
 from backend.app.services.camera import (
 from backend.app.services.camera import (
     capture_camera_frame_bytes,
     capture_camera_frame_bytes,
+    capture_in_flight,
     get_camera_port,
     get_camera_port,
     is_chamber_image_model,
     is_chamber_image_model,
 )
 )
@@ -69,8 +77,10 @@ class CameraDiagnoseStage:
     name: str  # "tcp_reachable" | "first_frame" | "live_stream_active"
     name: str  # "tcp_reachable" | "first_frame" | "live_stream_active"
     status: str  # "ok" | "failed" | "skipped"
     status: str  # "ok" | "failed" | "skipped"
     duration_ms: int = 0
     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
     code: str | None = None
 
 
 
 
@@ -166,6 +176,15 @@ async def _check_first_frame(
     """Stage 2 — capture one frame end-to-end. Combines auth + protocol
     """Stage 2 — capture one frame end-to-end. Combines auth + protocol
     handshake + first keyframe; either it works or it doesn't."""
     handshake + first keyframe; either it works or it doesn't."""
     started = time.monotonic()
     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:
     try:
         jpeg = await capture_camera_frame_bytes(
         jpeg = await capture_camera_frame_bytes(
             ip_address=ip_address,
             ip_address=ip_address,
@@ -190,7 +209,11 @@ async def _check_first_frame(
             name="first_frame",
             name="first_frame",
             status="ok",
             status="ok",
             duration_ms=int((time.monotonic() - started) * 1000),
             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(
     return CameraDiagnoseStage(
         name="first_frame",
         name="first_frame",
         status="failed",
         status="failed",

+ 24 - 4
backend/app/services/external_camera.py

@@ -607,6 +607,7 @@ async def generate_mjpeg_stream(
     fps: int = 10,
     fps: int = 10,
     *,
     *,
     on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
     on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+    on_frame: Callable[[bytes], None] | None = None,
     stop_event: asyncio.Event | None = None,
     stop_event: asyncio.Event | None = None,
 ) -> AsyncGenerator[bytes, None]:
 ) -> AsyncGenerator[bytes, None]:
     """Generator yielding MJPEG frames for streaming.
     """Generator yielding MJPEG frames for streaming.
@@ -622,6 +623,16 @@ async def generate_mjpeg_stream(
             open (#2675). Without it the process is reachable only from this
             open (#2675). Without it the process is reachable only from this
             generator's own ``finally``, which an abrupt client disconnect can
             generator's own ``finally``, which an abrupt client disconnect can
             skip (same cancellation-timing class as #776).
             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_event: When set, the reconnect loops stop retrying — so an explicit
             stop (which kills the current ffmpeg) doesn't immediately respawn a
             stop (which kills the current ffmpeg) doesn't immediately respawn a
             new process and reacquire the device.
             new process and reacquire the device.
@@ -632,6 +643,15 @@ async def generate_mjpeg_stream(
     frame_interval = 1.0 / max(fps, 1)
     frame_interval = 1.0 / max(fps, 1)
     last_frame_time = 0.0
     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":
     if camera_type == "mjpeg":
         # Proxy MJPEG stream directly, with reconnect on timeout
         # Proxy MJPEG stream directly, with reconnect on timeout
         max_retries = 3
         max_retries = 3
@@ -642,7 +662,7 @@ async def generate_mjpeg_stream(
                 current_time = asyncio.get_event_loop().time()
                 current_time = asyncio.get_event_loop().time()
                 if current_time - last_frame_time >= frame_interval:
                 if current_time - last_frame_time >= frame_interval:
                     last_frame_time = current_time
                     last_frame_time = current_time
-                    yield _format_mjpeg_frame(frame)
+                    yield _publish(frame)
             if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
             if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
                 break
             logger.warning(
             logger.warning(
@@ -659,7 +679,7 @@ async def generate_mjpeg_stream(
             frame_yielded = False
             frame_yielded = False
             async for frame in _stream_rtsp(url, fps, on_process=on_process):
             async for frame in _stream_rtsp(url, fps, on_process=on_process):
                 frame_yielded = True
                 frame_yielded = True
-                yield _format_mjpeg_frame(frame)
+                yield _publish(frame)
             if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
             if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
                 break
             logger.warning(
             logger.warning(
@@ -672,7 +692,7 @@ async def generate_mjpeg_stream(
     elif camera_type == "usb":
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
         # Use ffmpeg to stream from USB camera
         async for frame in _stream_usb(url, fps, on_process=on_process):
         async for frame in _stream_usb(url, fps, on_process=on_process):
-            yield _format_mjpeg_frame(frame)
+            yield _publish(frame)
 
 
     elif camera_type == "snapshot":
     elif camera_type == "snapshot":
         # Poll snapshot URL at interval
         # Poll snapshot URL at interval
@@ -680,7 +700,7 @@ async def generate_mjpeg_stream(
             try:
             try:
                 frame = await _capture_snapshot(url, timeout=10)
                 frame = await _capture_snapshot(url, timeout=10)
                 if frame:
                 if frame:
-                    yield _format_mjpeg_frame(frame)
+                    yield _publish(frame)
                 await asyncio.sleep(frame_interval)
                 await asyncio.sleep(frame_interval)
             except asyncio.CancelledError:
             except asyncio.CancelledError:
                 break
                 break

+ 30 - 7
backend/app/services/homeassistant.py

@@ -187,17 +187,40 @@ class HomeAssistantService:
 
 
     @staticmethod
     @staticmethod
     def _validate_url(url: str) -> str | None:
     def _validate_url(url: str) -> str | None:
-        """Validate HA URL scheme and block dangerous destinations."""
+        """Normalise a caller-supplied HA URL, or return None if it is unsafe.
+
+        The stored ``ha_url`` setting is already validated at the schema layer
+        (``LAN_SERVICE_URL_SETTINGS`` in schemas/settings.py), but
+        ``test_connection`` takes its URL straight from the request body, so
+        the same policy has to be applied here.
+
+        Delegates to ``_url_safety.assert_safe_lan_service_url`` rather than
+        the string blocklist this replaces. That blocklist only knew three
+        literal hostnames plus a ``169.254.`` prefix and never parsed the
+        hostname as an IP, so it let through the Alibaba (100.100.100.200)
+        and AWS-IPv6 (fd00:ec2::254) metadata endpoints, numeric-encoded
+        loopback, multicast, and IPv4-mapped IPv6 encodings of the IMDS
+        address it did know about.
+
+        Loopback and RFC-1918 remain permitted — Home Assistant is a
+        LAN-resident service by design, and the shared guard is documented
+        that way.
+        """
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
         try:
         try:
-            parsed = urlparse(url)
+            assert_safe_lan_service_url(url, label="Home Assistant URL")
         except ValueError:
         except ValueError:
             return None
             return None
-        if parsed.scheme not in ("http", "https") or not parsed.hostname:
-            return None
-        blocked = ("169.254.169.254", "metadata.google.internal", "0.0.0.0")  # nosec B104
-        if parsed.hostname.lower() in blocked or (parsed.hostname or "").startswith("169.254."):
+        # Guard passed, so the scheme is http/https and a hostname is present;
+        # re-parse only to drop query/fragment and normalise the authority.
+        parsed = urlparse(url)
+        if not parsed.hostname:
             return None
             return None
-        return f"{parsed.scheme}://{parsed.hostname}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
+        # urlparse strips the brackets off an IPv6 literal, so they have to go
+        # back on or the rebuilt URL is unparseable ("http://fd00::1:8123").
+        host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
+        return f"{parsed.scheme.lower()}://{host}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
 
 
     async def test_connection(self, url: str, token: str) -> dict:
     async def test_connection(self, url: str, token: str) -> dict:
         """Test connection to Home Assistant.
         """Test connection to Home Assistant.

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

@@ -67,7 +67,26 @@ class TimelapseSession:
         self.last_layer = layer_num
         self.last_layer = layer_num
 
 
         try:
         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:
             if frame_data:
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)

+ 2 - 0
backend/app/services/mqtt_relay.py

@@ -282,6 +282,8 @@ class MQTTRelayService:
             "big_fan1_speed": state.big_fan1_speed,
             "big_fan1_speed": state.big_fan1_speed,
             "big_fan2_speed": state.big_fan2_speed,
             "big_fan2_speed": state.big_fan2_speed,
             "heatbreak_fan_speed": state.heatbreak_fan_speed,
             "heatbreak_fan_speed": state.heatbreak_fan_speed,
+            "left_aux_fan_speed": state.left_aux_fan_speed,
+            "exhaust_fan_present": state.exhaust_fan_present,
             # Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
             # Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
             # Web UI already receives via printer_state_to_dict, so an external
             # Web UI already receives via printer_state_to_dict, so an external
             # automation can tell "finished" from "finished and still waiting for
             # automation can tell "finished" from "finished and still waiting for

+ 94 - 7
backend/app/services/notification_service.py

@@ -64,6 +64,55 @@ def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
     return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
     return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
 
 
 
 
+def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
+    """Validate a provider URL taken from user-supplied config.
+
+    Returns an error message on rejection, or None when the URL is
+    acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
+    than raising, so a message is more useful here than an exception.
+
+    Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
+    receiver on the home LAN is normal and must keep working, so loopback and
+    RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
+    non-HTTP schemes are rejected.
+    """
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    try:
+        assert_safe_lan_service_url(url, label=label)
+    except ValueError as exc:
+        return str(exc)
+    return None
+
+
+def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
+    """Failure message for a provider whose destination host the user supplies.
+
+    The response body is deliberately **not** returned to the caller. Provider
+    URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
+    the default Operators group carries and which does not imply
+    ``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
+    straight from the request body without persisting anything. Echoing the
+    response body there turned an intended "does my webhook work?" check into
+    an authenticated read primitive against any host the Bambuddy process can
+    reach, including services that are not exposed to the network at all.
+
+    Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
+    keep returning the upstream body — there is no trust boundary to cross
+    when the destination cannot be influenced.
+
+    The body is logged at debug level, where it stays available to whoever
+    already administers the host without being handed back over the API.
+    """
+    logger.debug(
+        "%s delivery failed with HTTP %s; body: %s",
+        label,
+        response.status_code,
+        (response.text or "")[:200],
+    )
+    return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
+
+
 class NotificationService:
 class NotificationService:
     """Service for sending notifications through various providers."""
     """Service for sending notifications through various providers."""
 
 
@@ -265,6 +314,10 @@ class NotificationService:
         if not device_key:
         if not device_key:
             return False, "Device key is required"
             return False, "Device key is required"
 
 
+        url_error = _assert_safe_provider_url(server, label="Bark server URL")
+        if url_error:
+            return False, url_error
+
         payload: dict[str, Any] = {
         payload: dict[str, Any] = {
             "device_key": device_key,
             "device_key": device_key,
             "title": title,
             "title": title,
@@ -291,9 +344,13 @@ class NotificationService:
             except ValueError:
             except ValueError:
                 body = None
                 body = None
             if isinstance(body, dict) and body.get("code") not in (200, None):
             if isinstance(body, dict) and body.get("code") not in (200, None):
-                return False, f"Bark error {body.get('code')}: {str(body.get('message'))[:200]}"
+                # Only the numeric code is echoed. A server chosen by the caller
+                # controls this body too, so the free-text message is a (narrow)
+                # read channel of the same kind _opaque_http_failure closes.
+                logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
+                return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
             return True, "Message sent successfully"
             return True, "Message sent successfully"
-        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        return False, _opaque_http_failure(response, label="Bark server")
 
 
     async def _send_ntfy(
     async def _send_ntfy(
         self,
         self,
@@ -311,6 +368,10 @@ class NotificationService:
         if not topic:
         if not topic:
             return False, "Topic is required"
             return False, "Topic is required"
 
 
+        url_error = _assert_safe_provider_url(server, label="ntfy server URL")
+        if url_error:
+            return False, url_error
+
         url = f"{server}/{topic}"
         url = f"{server}/{topic}"
         # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
         # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
         # for str header values, but printer names and filenames can contain
         # for str header values, but printer names and filenames can contain
@@ -363,7 +424,7 @@ class NotificationService:
                 "Fight Mode, or front the server with Cloudflare Access using a "
                 "Fight Mode, or front the server with Cloudflare Access using a "
                 "service token. (#1534)"
                 "service token. (#1534)"
             )
             )
-        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        return False, _opaque_http_failure(response, label="ntfy server")
 
 
     async def _send_pushover(
     async def _send_pushover(
         self, config: dict, title: str, message: str, image_data: bytes | None = None
         self, config: dict, title: str, message: str, image_data: bytes | None = None
@@ -438,6 +499,19 @@ class NotificationService:
         if not bot_token or not chat_id:
         if not bot_token or not chat_id:
             return False, "Bot token and chat ID are required"
             return False, "Bot token and chat ID are required"
 
 
+        # Optional forum topic (#1518).  Telegram expects message_thread_id as an
+        # integer in the JSON sendMessage body — a string 400s there even though
+        # the multipart sendPhoto call below would happily accept one.  Coerce it
+        # once, up front, so both call sites agree and a bad value fails loudly
+        # instead of silently breaking only the text notifications.
+        thread_id_raw = str(config.get("message_thread_id") or "").strip()
+        message_thread_id: int | None = None
+        if thread_id_raw:
+            try:
+                message_thread_id = int(thread_id_raw)
+            except ValueError:
+                return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
+
         # Escape underscores in the message body so Telegram Markdown
         # Escape underscores in the message body so Telegram Markdown
         # parsing doesn't break on job names like "A1_plate_8" or error
         # parsing doesn't break on job names like "A1_plate_8" or error
         # codes like "0300_0001".  The title is already wrapped in *bold*
         # codes like "0300_0001".  The title is already wrapped in *bold*
@@ -452,18 +526,23 @@ class NotificationService:
         if image_data:
         if image_data:
             # Use sendPhoto to attach the thumbnail with the caption
             # Use sendPhoto to attach the thumbnail with the caption
             url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
             url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+            form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
+            if message_thread_id is not None:
+                form["message_thread_id"] = message_thread_id
             response = await client.post(
             response = await client.post(
                 url,
                 url,
-                data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
+                data=form,
                 files={"photo": ("photo.jpg", image_data, "image/jpeg")},
                 files={"photo": ("photo.jpg", image_data, "image/jpeg")},
             )
             )
         else:
         else:
             url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
             url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
-            data = {
+            data: dict[str, Any] = {
                 "chat_id": chat_id,
                 "chat_id": chat_id,
                 "text": message,
                 "text": message,
                 "parse_mode": "Markdown",
                 "parse_mode": "Markdown",
             }
             }
+            if message_thread_id is not None:
+                data["message_thread_id"] = message_thread_id
             response = await client.post(url, json=data)
             response = await client.post(url, json=data)
 
 
         if response.status_code == 200:
         if response.status_code == 200:
@@ -665,6 +744,10 @@ class NotificationService:
         if not webhook_url:
         if not webhook_url:
             return False, "Webhook URL is required"
             return False, "Webhook URL is required"
 
 
+        url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
+        if url_error:
+            return False, url_error
+
         # Build payload based on format
         # Build payload based on format
         if payload_format == "slack":
         if payload_format == "slack":
             # Slack/Mattermost format - just text field
             # Slack/Mattermost format - just text field
@@ -710,7 +793,7 @@ class NotificationService:
             if response.status_code in (200, 201, 202, 204):
             if response.status_code in (200, 201, 202, 204):
                 return True, "Webhook delivered successfully"
                 return True, "Webhook delivered successfully"
             else:
             else:
-                return False, f"HTTP {response.status_code}: {response.text[:200]}"
+                return False, _opaque_http_failure(response, label="webhook endpoint")
         except Exception as e:
         except Exception as e:
             return False, f"Webhook error: {str(e)}"
             return False, f"Webhook error: {str(e)}"
 
 
@@ -811,7 +894,11 @@ class NotificationService:
         elif response.status_code == 401:
         elif response.status_code == 401:
             return False, "Home Assistant authentication failed - check your token"
             return False, "Home Assistant authentication failed - check your token"
         else:
         else:
-            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+            # ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
+            # this is a narrower channel than the per-request provider URLs — but
+            # it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
+            # gets the same treatment.
+            return False, _opaque_http_failure(response, label="Home Assistant endpoint")
 
 
     async def _send_to_provider(
     async def _send_to_provider(
         self,
         self,

+ 32 - 1
backend/app/services/obico_detection.py

@@ -193,6 +193,21 @@ class ObicoDetectionService:
             return None
             return None
 
 
         if printer.external_camera_enabled and printer.external_camera_url:
         if printer.external_camera_enabled and printer.external_camera_url:
+            # Same rule as the built-in branch below, which this used to skip:
+            # an external camera is single-reader too, so polling while a viewer
+            # is attached just fails (#2707).
+            from backend.app.api.routes.camera import live_frame_for_capture
+
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                if buffered:
+                    return buffered
+                logger.info(
+                    "Obico: viewer attached for printer %s but buffer empty; "
+                    "skipping this poll to avoid competing camera handle (#2707)",
+                    printer_id,
+                )
+                return None
             return await capture_external_frame(
             return await capture_external_frame(
                 printer.external_camera_url,
                 printer.external_camera_url,
                 printer.external_camera_type,
                 printer.external_camera_type,
@@ -350,7 +365,23 @@ class ObicoDetectionService:
         }
         }
 
 
     async def test_connection(self, url: str) -> dict:
     async def test_connection(self, url: str) -> dict:
-        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
+        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}.
+
+        The stored ``obico_ml_url`` setting is validated at the schema layer,
+        but this route takes its URL from the request body, so the same
+        LAN-service policy has to be applied here or the guard is trivially
+        sidestepped by testing a URL instead of saving it. The response body
+        is returned to the caller (it is the health signal — the endpoint
+        answers "ok"), which is exactly why the destination must be inside
+        policy before the request is made.
+        """
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+        try:
+            assert_safe_lan_service_url(url, label="Obico ML URL")
+        except ValueError as exc:
+            return {"ok": False, "status_code": None, "body": None, "error": str(exc)}
+
         target = f"{url.rstrip('/')}/hc/"
         target = f"{url.rstrip('/')}/hc/"
         try:
         try:
             async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
             async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:

+ 20 - 8
backend/app/services/plate_detection.py

@@ -604,16 +604,28 @@ async def capture_camera_image(
     # Try external camera first if requested and available
     # Try external camera first if requested and available
     if use_external and external_camera_url and external_camera_type:
     if use_external and external_camera_url and external_camera_type:
         try:
         try:
+            from backend.app.api.routes.camera import live_frame_for_capture
             from backend.app.services.external_camera import capture_frame
             from backend.app.services.external_camera import capture_frame
 
 
-            image_data = await capture_frame(
-                external_camera_url,
-                external_camera_type,
-                snapshot_url=external_camera_snapshot_url,
-            )
-            if image_data:
-                camera_source = "external"
-                logger.debug("Captured frame from external camera for printer %s", printer_id)
+            # What this function's docstring already promised, but only the
+            # built-in fallback below delivered: an external camera is
+            # single-reader too, so capturing while a viewer watches fails
+            # (#2707).
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                if buffered:
+                    image_data = buffered
+                    camera_source = "external (buffered)"
+                    logger.debug("Using buffered external frame for printer %s", printer_id)
+            else:
+                image_data = await capture_frame(
+                    external_camera_url,
+                    external_camera_type,
+                    snapshot_url=external_camera_snapshot_url,
+                )
+                if image_data:
+                    camera_source = "external"
+                    logger.debug("Captured frame from external camera for printer %s", printer_id)
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to capture from external camera: %s", e)
             logger.warning("Failed to capture from external camera: %s", e)
 
 

+ 117 - 17
backend/app/services/print_scheduler.py

@@ -280,17 +280,29 @@ class PrintScheduler:
         # event-loop thread, so this dict needs no lock.
         # event-loop thread, so this dict needs no lock.
         # item_id -> (task, printer_id)
         # item_id -> (task, printer_id)
         self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
         self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
+        # Expected prints registered by `_start_print` that have not yet had a
+        # print command sent. Populated at registration, dropped once
+        # `start_print()` succeeds, and rolled back by `_dispatch_one` on every
+        # other exit. Same threading argument as `_inflight` above: one
+        # sequential caller, callbacks on the same loop, so no lock.
+        # item_id -> (printer_id, remote_filename, archive_id)
+        self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
 
 
     async def run(self):
     async def run(self):
         """Main loop - check queue every interval."""
         """Main loop - check queue every interval."""
         self._running = True
         self._running = True
         logger.info("Print scheduler started")
         logger.info("Print scheduler started")
 
 
-        await self._clear_stale_dispatch_claims()
+        await self._clear_stale_dispatch_claims(at_startup=True)
 
 
         while self._running:
         while self._running:
             dispatched = False
             dispatched = False
             try:
             try:
+                # No-op while any upload is in flight; on a quiet tick it releases
+                # a claim whose best-effort clear failed (e.g. the database was
+                # briefly unreachable), instead of leaving the row wedged until
+                # the next restart.
+                await self._clear_stale_dispatch_claims()
                 dispatched = await self.check_queue()
                 dispatched = await self.check_queue()
             except Exception as e:
             except Exception as e:
                 logger.error("Scheduler error: %s", e)
                 logger.error("Scheduler error: %s", e)
@@ -299,14 +311,29 @@ class PrintScheduler:
             # not stall behind the idle interval; otherwise sleep normally (#2555).
             # not stall behind the idle interval; otherwise sleep normally (#2555).
             await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
             await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
 
 
-    async def _clear_stale_dispatch_claims(self) -> None:
-        """Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
-
-        A claim is only ever held by a live dispatch coroutine, and no coroutine
-        survives a process restart — so every ``dispatching_at`` present at startup
-        is stale. Clearing them lets those still-pending rows be re-selected for a
-        fresh, consistent dispatch instead of being wedged out of the selection
-        query forever. Called once at the top of ``run()``."""
+    async def _clear_stale_dispatch_claims(self, *, at_startup: bool = False) -> None:
+        """Clear dispatch claims with no live dispatch coroutine behind them (#2615).
+
+        A claim is only ever held by a live dispatch coroutine, so when this
+        process has nothing in ``_inflight`` every ``dispatching_at`` in the table
+        is stale. At startup that is trivially true — no coroutine survives a
+        restart. It is equally true on any later tick where no upload is running,
+        which is what makes this safe to repeat rather than only run once.
+
+        Repeating it matters because ``_clear_dispatch_claim`` is best-effort: if
+        the database is briefly unreachable at exactly the moment dispatch ends,
+        the claim survives and the row is wedged out of the selection query. That
+        used to last until the next restart (#2702 follow-up, seen when
+        PostgreSQL refused a connection mid-dispatch).
+
+        ``_inflight`` is populated when the task is spawned, before the coroutine
+        claims its row, and pruned by a done-callback that cannot run before the
+        coroutine's own ``finally`` — so "claim present, nothing in flight" has no
+        race window and needs no age threshold. A size-derived upload deadline
+        (``max(600s, size/25KB/s)``) has no safe fixed bound anyway.
+        """
+        if self._inflight:
+            return
         try:
         try:
             async with async_session() as db:
             async with async_session() as db:
                 res = await db.execute(
                 res = await db.execute(
@@ -314,9 +341,13 @@ class PrintScheduler:
                 )
                 )
                 await db.commit()
                 await db.commit()
                 if res.rowcount:
                 if res.rowcount:
-                    logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
+                    logger.info(
+                        "Cleared %d orphaned dispatch claim(s)%s (#2615)",
+                        res.rowcount,
+                        " at startup" if at_startup else "",
+                    )
         except Exception as exc:
         except Exception as exc:
-            logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
+            logger.error("Failed to clear orphaned dispatch claims: %s", exc)
 
 
     def stop(self):
     def stop(self):
         """Stop the scheduler."""
         """Stop the scheduler."""
@@ -929,6 +960,14 @@ class PrintScheduler:
                     return
                     return
                 await self._start_print(item_db, item)
                 await self._start_print(item_db, item)
             finally:
             finally:
+                # Undo an expected-print registration whose print command never
+                # went out. One choke point covers every way `_start_print` can
+                # end without sending: a raised exception (a DB failure mid-
+                # dispatch is the reported case), an early return, a cancel
+                # winning the #1853 CAS, or `start_print()` returning False.
+                # A confirmed send removes the entry itself, so this is a no-op
+                # on the happy path.
+                self._rollback_unconfirmed_expected_print(item_id)
                 # Release the claim on every exit. Once dispatch has finished the
                 # Release the claim on every exit. Once dispatch has finished the
                 # row's status carries the lock (printing/failed/cancelled are all
                 # row's status carries the lock (printing/failed/cancelled are all
                 # != pending), so the token is only needed for the duration of the
                 # != pending), so the token is only needed for the duration of the
@@ -936,6 +975,30 @@ class PrintScheduler:
                 # dispatchable again on the next tick.
                 # dispatchable again on the next tick.
                 await self._clear_dispatch_claim(item_db, item_id)
                 await self._clear_dispatch_claim(item_db, item_id)
 
 
+    def _rollback_unconfirmed_expected_print(self, item_id: int) -> None:
+        """Drop an expectation for a print command that was never sent.
+
+        Best-effort and never raises: this runs in the ``finally`` of dispatch,
+        where the interesting exception is usually the one already propagating.
+        """
+        pending = self._unconfirmed_expected_print.pop(item_id, None)
+        if pending is None:
+            return
+        printer_id, remote_filename, archive_id = pending
+        try:
+            from backend.app.main import unregister_expected_print
+
+            unregister_expected_print(printer_id, remote_filename, archive_id)
+        except Exception:
+            logger.warning(
+                "Queue item %s: failed to unregister expected print (printer=%s, file=%s, archive=%s)",
+                item_id,
+                printer_id,
+                remote_filename,
+                archive_id,
+                exc_info=True,
+            )
+
     async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
     async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
 
 
@@ -954,12 +1017,39 @@ class PrintScheduler:
 
 
     async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
     async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
         """Clear the dispatch claim (#2615). Best-effort: a failure here must not
         """Clear the dispatch claim (#2615). Best-effort: a failure here must not
-        mask the dispatch outcome, and startup reconciliation clears any leftover."""
-        try:
-            await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
-            await db.commit()
-        except Exception as exc:
-            logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
+        mask the dispatch outcome.
+
+        Retried, because the failure mode in practice is transient and narrow: a
+        database that is momentarily unreachable — PostgreSQL out of connection
+        slots is the observed case — refuses this write for a second or two while
+        the dispatch that just ended is still holding the row out of the selection
+        query. One attempt was enough to wedge the item; a couple of spaced
+        attempts clear it. Each attempt rolls back first, since a failed write
+        leaves the session needing it before it can be reused.
+
+        If every attempt fails, ``_clear_stale_dispatch_claims`` picks the row up
+        on the next quiet tick.
+        """
+        for attempt in range(1, 4):
+            try:
+                await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
+                await db.commit()
+                return
+            except Exception as exc:
+                try:
+                    await db.rollback()
+                except Exception:
+                    pass
+                if attempt == 3:
+                    logger.warning(
+                        "Queue item %s: failed to clear dispatch claim after %d attempts: %s "
+                        "— a later quiet tick will release it",
+                        item_id,
+                        attempt,
+                        exc,
+                    )
+                    return
+                await asyncio.sleep(0.5 * attempt)
 
 
     async def _find_idle_printer_for_model(
     async def _find_idle_printer_for_model(
         self,
         self,
@@ -3432,6 +3522,12 @@ class PrintScheduler:
                 created_by_id=item.created_by_id,
                 created_by_id=item.created_by_id,
                 plate_id=item.plate_id,
                 plate_id=item.plate_id,
             )
             )
+            # Registration happens before the print command by necessity (the
+            # printer can report the print before the send returns), so record
+            # what to undo if we never get as far as sending. `_dispatch_one`
+            # rolls back anything still pending here on every exit — exception,
+            # early return, or cancel winning the CAS below.
+            self._unconfirmed_expected_print[item.id] = (item.printer_id, remote_filename, archive.id)
 
 
         # Propagate the queue item's owner into printer_manager so the
         # Propagate the queue item's owner into printer_manager so the
         # print-complete callback can credit the user in the PrintLogEntry
         # print-complete callback can credit the user in the PrintLogEntry
@@ -3556,6 +3652,10 @@ class PrintScheduler:
         )
         )
 
 
         if started:
         if started:
+            # The command is away, so the expectation is now legitimate and must
+            # survive. Anything still in this dict when _dispatch_one exits gets
+            # rolled back.
+            self._unconfirmed_expected_print.pop(item.id, None)
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # status='processing' from upload start until the printer acked
             # status='processing' from upload start until the printer acked

+ 2 - 0
backend/app/services/printer_manager.py

@@ -1418,6 +1418,8 @@ def printer_state_to_dict(
         "big_fan1_speed": state.big_fan1_speed,
         "big_fan1_speed": state.big_fan1_speed,
         "big_fan2_speed": state.big_fan2_speed,
         "big_fan2_speed": state.big_fan2_speed,
         "heatbreak_fan_speed": state.heatbreak_fan_speed,
         "heatbreak_fan_speed": state.heatbreak_fan_speed,
+        "left_aux_fan_speed": state.left_aux_fan_speed,
+        "exhaust_fan_present": state.exhaust_fan_present,
         # Chamber light state
         # Chamber light state
         "chamber_light": state.chamber_light,
         "chamber_light": state.chamber_light,
         # Active extruder for dual-nozzle printers (0=right, 1=left)
         # Active extruder for dual-nozzle printers (0=right, 1=left)

+ 35 - 15
backend/app/services/rest_smart_plug.py

@@ -1,10 +1,8 @@
 """Service for controlling smart plugs via generic REST/HTTP API."""
 """Service for controlling smart plugs via generic REST/HTTP API."""
 
 
-import ipaddress
 import json
 import json
 import logging
 import logging
 from typing import TYPE_CHECKING, Any
 from typing import TYPE_CHECKING, Any
-from urllib.parse import urlparse
 
 
 import httpx
 import httpx
 
 
@@ -24,18 +22,39 @@ class RESTSmartPlugService:
         self.timeout = timeout
         self.timeout = timeout
 
 
     @staticmethod
     @staticmethod
-    def _validate_url(url: str) -> bool:
-        """Block cloud metadata and link-local IPs."""
+    def _url_error(url: str) -> str | None:
+        """Return why *url* is rejected by the LAN-service policy, else None.
+
+        Split out from ``_validate_url`` so ``test_connection`` can tell the
+        user which rule the URL broke instead of a single fixed sentence.
+        """
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
         try:
         try:
-            parsed = urlparse(url)
-            hostname = parsed.hostname
-            if not hostname:
-                return False
-            addr = ipaddress.ip_address(hostname)
-            return not addr.is_loopback and not addr.is_link_local
-        except ValueError:
-            # Hostname is not an IP (e.g., "openhab.local") — allow it
-            return True
+            assert_safe_lan_service_url(url, label="REST plug URL")
+        except ValueError as exc:
+            return str(exc)
+        return None
+
+    @staticmethod
+    def _validate_url(url: str) -> bool:
+        """Apply the shared LAN-service SSRF policy to a REST plug URL.
+
+        Delegates to ``_url_safety.assert_safe_lan_service_url`` — the same
+        guard Spoolman, the notification providers and the LAN-service
+        settings use — rather than reimplementing a narrower check. The
+        hand-rolled version this replaces got the policy wrong in both
+        directions: it rejected a literal ``127.0.0.1`` (so an openHAB or
+        Node-RED instance on the same host could only be reached by spelling
+        it ``localhost``), while allowing every target the shared policy
+        rejects unconditionally — Alibaba/AWS-IPv6 metadata endpoints,
+        numeric-encoded IPs, multicast and the unspecified address — because
+        anything that wasn't a bare IP literal fell through to ``True``.
+
+        Loopback and RFC-1918 stay permitted on purpose: a REST-controlled
+        plug bridge running next to Bambuddy is the normal topology.
+        """
+        return RESTSmartPlugService._url_error(url) is None
 
 
     def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
     def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
         """Parse JSON string to dict of headers."""
         """Parse JSON string to dict of headers."""
@@ -273,8 +292,9 @@ class RESTSmartPlugService:
             - success: bool
             - success: bool
             - error: error message if failed
             - error: error message if failed
         """
         """
-        if not self._validate_url(url):
-            return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
+        url_error = self._url_error(url)
+        if url_error:
+            return {"success": False, "error": url_error}
 
 
         parsed_headers = self._parse_headers(headers)
         parsed_headers = self._parse_headers(headers)
 
 

+ 15 - 2
backend/app/services/slicer_3mf_convert.py

@@ -264,14 +264,27 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
     doesn't even use.
     doesn't even use.
 
 
     The substitution is a no-op when:
     The substitution is a no-op when:
-    - ``plate_id`` is None (we can't determine which slots are unused),
+    - ``plate_id`` is not a real plate — ``None`` (caller couldn't say) or
+      ``0`` (the slice-all sentinel, where every slot is used by *some*
+      plate so there is nothing unused to substitute). Callers that know
+      "absent means plate 1" must resolve that themselves before calling;
+      this function will not guess, because guessing wrong rewrites a
+      filament the plate actually prints with.
     - the source isn't a valid 3MF / zip,
     - the source isn't a valid 3MF / zip,
     - the source doesn't carry plate-extruder metadata (parse returns
     - the source doesn't carry plate-extruder metadata (parse returns
       empty set — treat as "every slot is used", same fallback the
       empty set — treat as "every slot is used", same fallback the
       SliceModal uses),
       SliceModal uses),
     - ``items`` has fewer than 2 entries (nothing to substitute).
     - ``items`` has fewer than 2 entries (nothing to substitute).
+
+    The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
+    1-indexed, so the geometry lookup for plate 0 matches nothing — but
+    the support-filament slots unioned in below come from the project
+    config and carry no plate scope at all. Without the guard a slice-all
+    of a project with a dedicated support slot would see ``used`` as just
+    that one slot, anchor on it, and rewrite every colour in the project
+    to the support material (#2711).
     """
     """
-    if plate_id is None or len(items) < 2:
+    if plate_id is None or plate_id < 1 or len(items) < 2:
         return items
         return items
     # Local import keeps the bytes->ZipFile boundary in this module and
     # Local import keeps the bytes->ZipFile boundary in this module and
     # avoids dragging zipfile into every caller.
     # avoids dragging zipfile into every caller.

+ 22 - 2
backend/app/services/tasmota.py

@@ -26,12 +26,32 @@ class TasmotaService:
 
 
     @staticmethod
     @staticmethod
     def _validate_ip(ip: str) -> bool:
     def _validate_ip(ip: str) -> bool:
-        """Block cloud metadata and link-local IPs."""
+        """Block cloud metadata, loopback and link-local destinations.
+
+        Deliberately stricter than the shared LAN-service guard, and kept that
+        way: a Tasmota plug is always a separate device on the LAN, so a bare
+        IP literal is the only sensible value. Anything that is not one —
+        including a symbolic hostname — still fails closed here, which is why
+        this does not simply delegate to ``assert_safe_lan_service_url``.
+
+        What it borrows from the shared guard is the destination set that is
+        dangerous under any topology: cloud-metadata endpoints beyond the AWS
+        IPv4 address (Alibaba's 100.100.100.200, AWS's fd00:ec2::254),
+        multicast and unspecified addresses, and IPv4-mapped IPv6 encodings
+        used to smuggle any of the above past the per-class checks.
+        """
+        from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, unwrap_ipv4_mapped
+
         try:
         try:
             addr = ipaddress.ip_address(ip)
             addr = ipaddress.ip_address(ip)
         except ValueError:
         except ValueError:
             return False  # Not a valid IP
             return False  # Not a valid IP
-        return not addr.is_loopback and not addr.is_link_local
+        effective = unwrap_ipv4_mapped(addr)
+        if effective in CLOUD_METADATA_IPS:
+            return False
+        if effective.is_multicast or effective.is_unspecified:
+            return False
+        return not effective.is_loopback and not effective.is_link_local
 
 
     async def _send_command(
     async def _send_command(
         self,
         self,

+ 30 - 0
backend/app/utils/printer_models.py

@@ -212,6 +212,36 @@ DUAL_NOZZLE_MODELS = frozenset(
 )
 )
 
 
 
 
+# Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 /
+# airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's
+# touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it
+# is an add-on kit rather than built-in hardware. Other enclosed models
+# (X1 / P1S / H2 series) keep the "Chamber" naming.
+EXHAUST_FAN_LABEL_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "P2S",
+        "X2D",
+        # Internal codes
+        "N7",  # P2S
+        "N6",  # X2D
+    ]
+)
+
+
+def uses_exhaust_fan_label(model: str | None) -> bool:
+    """Return True if this model calls the big_fan2 enclosure fan "Exhaust".
+
+    P2S/X2D name that fan "Exhaust" in Bambu's firmware/UI; everything else
+    enclosed calls it the chamber fan. Used so the UI badge and the API
+    response message agree on what the user sees.
+    """
+    if not model:
+        return False
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized in EXHAUST_FAN_LABEL_MODELS
+
+
 def has_ethernet(model: str | None) -> bool:
 def has_ethernet(model: str | None) -> bool:
     """Return True if the printer model has an ethernet port."""
     """Return True if the printer model has an ethernet port."""
     if not model:
     if not model:

+ 45 - 0
backend/app/utils/threemf_tools.py

@@ -907,6 +907,51 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     return out
     return out
 
 
 
 
+def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
+    """Widen a used-only filament list to one entry per project slot.
+
+    ``used`` is the slice_info-derived list: only the slots whose G-code
+    actually consumed filament, each carrying real usage figures. That is the
+    right answer for print-time AMS matching, and the wrong one for the slice
+    modal, because the list the modal builds is **positional** — index 0 is
+    slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
+    A source whose only used slot is 4 therefore produced a single dropdown
+    whose pick the CLI bound to slot 1, leaving slot 4 — the one the model
+    prints with — on whatever the source had baked in (#2712).
+
+    Returns the project's slots in slot order, each flagged ``used_in_plate``.
+    Rows present in ``used`` are kept whole, so their usage figures, resolved
+    type/colour and ``tray_info_idx`` survive; the rest come from the project
+    configuration with zero usage. A used slot beyond the project's slot count
+    is appended rather than dropped — the caller asked for a superset, and
+    silently losing the one slot that prints would be the original bug again.
+
+    ``used`` is returned unchanged when the file carries no project settings
+    to widen against: a narrower-than-ideal list still prints correctly, an
+    invented one might not.
+    """
+    project = extract_project_filaments_from_3mf(zf)
+    if not project:
+        return used
+
+    by_slot = {f["slot_id"]: f for f in used}
+    out: list[dict] = []
+    for slot in project:
+        known = by_slot.pop(slot["slot_id"], None)
+        if known is not None:
+            known["used_in_plate"] = True
+            out.append(known)
+        else:
+            slot["used_in_plate"] = False
+            out.append(slot)
+    # Anything slice_info reported that the project doesn't declare.
+    for leftover in by_slot.values():
+        leftover["used_in_plate"] = True
+        out.append(leftover)
+    out.sort(key=lambda f: f["slot_id"])
+    return out
+
+
 def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
 def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
     """Slots referenced by the process settings for support material.
     """Slots referenced by the process settings for support material.
 
 

+ 362 - 5
backend/tests/integration/test_library_slice_api.py

@@ -93,15 +93,24 @@ async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0)
 
 
 
 
 @pytest.fixture
 @pytest.fixture
-async def slice_test_setup(db_session, tmp_path):
-    """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
+async def slice_test_setup(db_session, tmp_path, monkeypatch):
+    """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer.
+
+    ``base_dir`` is patched via ``monkeypatch`` rather than assigned and
+    restored by hand. ``app_settings`` is a process-wide singleton, and the
+    hand-rolled version only restored after the ``yield`` — so anything raising
+    during setup (a commit, a refresh) left ``base_dir`` pointing at a
+    ``tmp_path`` that pytest then deleted, and every later test in that xdist
+    worker which reads it failed. That was the cause of intermittent failures
+    in ``TestLibraryPathHelpers`` and ``TestArchivePlatesDesignOverrides``,
+    which share nothing with this module but land in the same worker.
+    """
     storage_dir = tmp_path / "library" / "files"
     storage_dir = tmp_path / "library" / "files"
     storage_dir.mkdir(parents=True, exist_ok=True)
     storage_dir.mkdir(parents=True, exist_ok=True)
     src_path = storage_dir / "Cube.stl"
     src_path = storage_dir / "Cube.stl"
     src_path.write_bytes(b"solid Cube\nendsolid\n")
     src_path.write_bytes(b"solid Cube\nendsolid\n")
 
 
-    original_base_dir = app_settings.base_dir
-    app_settings.base_dir = tmp_path
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
 
 
     src_file = LibraryFile(
     src_file = LibraryFile(
         filename="Cube.stl",
         filename="Cube.stl",
@@ -137,7 +146,6 @@ async def slice_test_setup(db_session, tmp_path):
         "tmp_path": tmp_path,
         "tmp_path": tmp_path,
     }
     }
 
 
-    app_settings.base_dir = original_base_dir
     slicer_api_module.set_shared_http_client(None)
     slicer_api_module.set_shared_http_client(None)
 
 
 
 
@@ -1618,3 +1626,352 @@ class TestNozzleClassGuard:
         if resp.status_code == 400:
         if resp.status_code == 400:
             detail = resp.json().get("detail", "")
             detail = resp.json().get("detail", "")
             assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
             assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
+
+
+class TestUnusedSlotSubstitutionOnSinglePlateSource:
+    """#2711: a single-plate 3MF must still get its unused slots substituted.
+
+    The SliceModal omits ``plate`` entirely for single-plate and STL sources —
+    it skips the plate picker, so ``selectedPlate`` stays null and the field
+    never reaches the body. The schema documents an absent plate as "plate 1",
+    but the substitution used to read it as "unknown plate" and skip, so every
+    single-plate project reached the CLI with the dropdown values of slots the
+    plate never paints with.
+
+    In the reported case that was a MakerWorld project declaring four filaments
+    while plate 1 paints with one, the other three carrying presets baked into
+    the source for a different printer. The CLI rejected the whole slice with
+    "filament preset ... (slot 1) is not compatible with printer ...", and the
+    modal disables unused rows so there was no way to correct it by hand.
+    """
+
+    @staticmethod
+    def _single_plate_using_only_slot_3() -> bytes:
+        """One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
+        declared by the project but unused. Mirrors the reported file."""
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                "<?xml version='1.0'?>\n<config>"
+                '<object id="1"><metadata key="extruder" value="3"/></object>'
+                '<plate><metadata key="plater_id" value="1"/>'
+                '<model_instance><metadata key="object_id" value="1"/>'
+                '<metadata key="instance_id" value="0"/></model_instance>'
+                "</plate></config>",
+            )
+        return buf.getvalue()
+
+    @staticmethod
+    def _filament_names_sent(body: bytes) -> list[str]:
+        """Pull the ``name`` of each ``filamentProfile`` part, in slot order.
+
+        ``slice_model`` sends one repeated ``filamentProfile`` part per slot as
+        ``filament_N.json``; the parts stay in submission order, so a plain
+        scan preserves the slot mapping.
+        """
+        names: list[str] = []
+        marker = b'name="filamentProfile"; filename="filament_'
+        pos = body.find(marker)
+        while pos != -1:
+            start = body.find(b"{", pos)
+            end = body.find(b"\r\n", start)
+            names.append(json.loads(body[start:end].decode("utf-8"))["name"])
+            pos = body.find(marker, end)
+        return names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unused_slots_are_substituted_when_the_body_omits_plate(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        tmp_path = slice_test_setup["tmp_path"]
+        src = tmp_path / "library" / "files" / "train.3mf"
+        src.write_bytes(self._single_plate_using_only_slot_3())
+        threemf = LibraryFile(
+            filename="train.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(threemf)
+
+        # Four distinguishable filament presets, one per project slot. Only
+        # slot 3's is compatible with the target in the reported scenario.
+        slots = []
+        for i in range(1, 5):
+            p = LocalPreset(
+                name=f"slot{i}",
+                preset_type="filament",
+                source="orcaslicer",
+                setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
+            )
+            db_session.add(p)
+            slots.append(p)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+        for p in slots:
+            await db_session.refresh(p)
+
+        captured: list[list[str]] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured.append(self._filament_names_sent(request.content))
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
+                # No "plate" — exactly what the modal sends for a single-plate
+                # source. This is the whole point of the test.
+            },
+        )
+        assert response.status_code == 202, response.text
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert captured, "sidecar was never called"
+        # Every slot carries slot 3's profile: the array length stays intact
+        # (the source's per-slot references depend on it) while nothing the
+        # plate doesn't print with can fail the CLI's validators.
+        assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
+        """``plate=0`` is the all-plates sentinel, so nothing is unused.
+
+        It reaches the same call site, and plate ids are 1-indexed — the
+        geometry lookup for plate 0 matches nothing. Without an explicit
+        exclusion the project's support-filament slot would be the only
+        member of the used set and would be copied over every colour.
+        """
+        tmp_path = slice_test_setup["tmp_path"]
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps(
+                    {
+                        "enable_support": "1",
+                        "support_filament": "4",
+                        "support_interface_filament": "4",
+                        "filament_type": ["PLA", "PLA", "PLA", "PVA"],
+                    }
+                ),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                "<?xml version='1.0'?>\n<config>"
+                '<object id="1"><metadata key="extruder" value="1"/></object>'
+                '<object id="2"><metadata key="extruder" value="2"/></object>'
+                '<plate><metadata key="plater_id" value="1"/>'
+                '<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
+                '<plate><metadata key="plater_id" value="2"/>'
+                '<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
+                "</config>",
+            )
+        src = tmp_path / "library" / "files" / "multi.3mf"
+        src.write_bytes(buf.getvalue())
+        threemf = LibraryFile(
+            filename="multi.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(threemf)
+
+        slots = []
+        for i in range(1, 5):
+            p = LocalPreset(
+                name=f"slot{i}",
+                preset_type="filament",
+                source="orcaslicer",
+                setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
+            )
+            db_session.add(p)
+            slots.append(p)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+        for p in slots:
+            await db_session.refresh(p)
+
+        captured: list[list[str]] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured.append(self._filament_names_sent(request.content))
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
+                "plate": 0,
+            },
+        )
+        assert response.status_code == 202, response.text
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert captured, "sidecar was never called"
+        assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]
+
+
+class TestFilamentRequirementsFullSlots:
+    """#2712: what the slice modal is handed must be positional.
+
+    The modal's filament list maps index 0 to slot 1, and the backend forwards
+    it in that order as ``filament_1.json``..``filament_N.json``. A MakerWorld
+    source that ships slice_info and paints with slot 4 alone therefore has to
+    present four rows — a one-row list binds the user's pick to slot 1, and
+    slot 4 slices with whatever the source had baked in. Picking PETG produced
+    a PLA print, and the print dialog then correctly refused to match PETG.
+
+    Print-time AMS matching shares this endpoint and needs the opposite: only
+    the slots the plate consumes, so it doesn't demand spools for slots the
+    G-code never touches. Hence the opt-in flag rather than a shape change.
+    """
+
+    @staticmethod
+    def _sliced_source_using_only_slot_4() -> bytes:
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps(
+                    {
+                        "filament_type": ["PLA", "PLA", "PLA", "PLA"],
+                        "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
+                    }
+                ),
+            )
+            # MakerWorld ships slice_info without plate G-code, which is what
+            # sends this file down the "already sliced" branch.
+            zf.writestr(
+                "Metadata/slice_info.config",
+                "<?xml version='1.0'?>\n<config><plate>"
+                "<metadata key='index' value='1'/>"
+                "<filament id='4' tray_info_idx='GFL99' type='PLA' color='#898989'"
+                " used_m='35.51' used_g='105.92'/>"
+                "</plate></config>",
+            )
+        return buf.getvalue()
+
+    async def _make_file(self, db_session, tmp_path) -> int:
+        src = tmp_path / "library" / "files" / "tunnel.3mf"
+        src.write_bytes(self._sliced_source_using_only_slot_4())
+        lib = LibraryFile(
+            filename="tunnel.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(lib)
+        await db_session.commit()
+        await db_session.refresh(lib)
+        return lib.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_slots_returns_one_row_per_project_slot(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
+
+        r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1&full_slots=true")
+        assert r.status_code == 200, r.text
+        filaments = r.json()["filaments"]
+
+        assert [f["slot_id"] for f in filaments] == [1, 2, 3, 4]
+        # Only slot 4 prints, so only its row is selectable in the modal.
+        assert [f["used_in_plate"] for f in filaments] == [False, False, False, True]
+        # The used row keeps what the slice actually reported.
+        assert filaments[3]["used_grams"] == 105.9
+        assert filaments[3]["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_print_path_still_gets_only_the_used_slot(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """Without the flag the response must be byte-for-byte what it was.
+
+        PrintModal drives AMS matching off this; widening it would ask the
+        user to load three spools the print never touches.
+        """
+        file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
+
+        r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1")
+        assert r.status_code == 200, r.text
+        filaments = r.json()["filaments"]
+
+        assert [f["slot_id"] for f in filaments] == [4]
+        assert filaments[0]["used_in_plate"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unsliced_sources_are_unaffected_by_the_flag(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """Those already returned the full project list; the flag must not
+        double-handle them or change what the modal has been getting."""
+        tmp_path = slice_test_setup["tmp_path"]
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"filament_type": ["PLA", "PETG"], "filament_colour": ["#000000", "#FFFFFF"]}),
+            )
+        src = tmp_path / "library" / "files" / "raw.3mf"
+        src.write_bytes(buf.getvalue())
+        lib = LibraryFile(
+            filename="raw.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(lib)
+        await db_session.commit()
+        await db_session.refresh(lib)
+
+        with_flag = await async_client.get(
+            f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1&full_slots=true"
+        )
+        without = await async_client.get(f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1")
+
+        assert with_flag.status_code == 200 and without.status_code == 200
+        assert with_flag.json()["filaments"] == without.json()["filaments"]
+        assert [f["slot_id"] for f in with_flag.json()["filaments"]] == [1, 2]

+ 72 - 3
backend/tests/integration/test_printers_api.py

@@ -3855,8 +3855,10 @@ class TestSetChamberTemperatureAPI:
 class TestSetFanSpeedAPI:
 class TestSetFanSpeedAPI:
     """Integration tests for POST /printers/{id}/fan-speed (#1661).
     """Integration tests for POST /printers/{id}/fan-speed (#1661).
 
 
-    The fan-id mapping (part->1, aux->2, chamber->3) is the critical
-    correctness gate — wrong mapping would target the wrong physical fan.
+    The fan-id mapping (part->1, aux->2, chamber->3, aux2->10) is the
+    critical correctness gate — wrong mapping would target the wrong
+    physical fan. "aux2" (M106 P10) is the optional left auxiliary part
+    cooling fan on P2S/X2D.
     """
     """
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -3879,13 +3881,17 @@ class TestSetFanSpeedAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     @pytest.mark.parametrize(
     @pytest.mark.parametrize(
         "fan_name,expected_fan_id",
         "fan_name,expected_fan_id",
-        [("part", 1), ("aux", 2), ("chamber", 3)],
+        [("part", 1), ("aux", 2), ("chamber", 3), ("aux2", 10)],
     )
     )
     async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
     async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
         """Verify each fan name maps to the correct hardware fan-id."""
         """Verify each fan name maps to the correct hardware fan-id."""
         printer = await printer_factory(name="P", model="X1C")
         printer = await printer_factory(name="P", model="X1C")
         mock_client = MagicMock()
         mock_client = MagicMock()
         mock_client.set_fan_speed.return_value = True
         mock_client.set_fan_speed.return_value = True
+        # aux2 is presence-gated on the printer reporting airduct part 10, so
+        # give the mock a reported speed. Set explicitly rather than leaning on
+        # MagicMock's auto-attribute, which would satisfy the gate by accident.
+        mock_client.state.left_aux_fan_speed = 0
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
         with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
             mock_pm.get_client.return_value = mock_client
             mock_pm.get_client.return_value = mock_client
             response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
@@ -3893,6 +3899,39 @@ class TestSetFanSpeedAPI:
         called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         assert called_fan_id == expected_fan_id
         assert called_fan_id == expected_fan_id
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_aux2_rejected_when_printer_has_no_left_aux_fan(self, async_client: AsyncClient, printer_factory):
+        """A printer that never reports airduct part 10 must not be sent M106 P10.
+
+        Without the gate the endpoint accepted aux2 for every model, so a POST
+        against an A1 would fire a command for hardware that does not exist.
+        """
+        printer = await printer_factory(name="P", model="A1")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        mock_client.state.left_aux_fan_speed = None
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=aux2&speed=50")
+        assert response.status_code == 400
+        assert "left auxiliary fan" in response.json()["detail"]
+        mock_client.set_fan_speed.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_other_fans_unaffected_by_the_aux2_gate(self, async_client: AsyncClient, printer_factory):
+        """The gate is aux2-only — a base P2S can still drive its built-in fans."""
+        printer = await printer_factory(name="P", model="P2S")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        mock_client.state.left_aux_fan_speed = None
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            for fan_name in ("part", "aux", "chamber"):
+                response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=50")
+                assert response.status_code == 200, fan_name
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     @pytest.mark.parametrize(
     @pytest.mark.parametrize(
@@ -3911,6 +3950,36 @@ class TestSetFanSpeedAPI:
         _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         assert called_pwm == expected_pwm
         assert called_pwm == expected_pwm
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "model,expected_label",
+        [
+            ("P2S", "Exhaust fan"),
+            ("X2D", "Exhaust fan"),
+            ("X1C", "Chamber fan"),
+            ("P1S", "Chamber fan"),
+            ("H2D", "Chamber fan"),
+        ],
+    )
+    async def test_chamber_fan_message_matches_model_label(
+        self, async_client: AsyncClient, printer_factory, model, expected_label
+    ):
+        """The success toast must use the same name as the printer card badge.
+
+        On P2S/X2D the big_fan2 fan is labelled "Exhaust"; everywhere else it
+        stays "Chamber". A mismatch means the user clicks "Exhaust" and gets
+        told "Chamber fan set to N%".
+        """
+        printer = await printer_factory(name="P", model=model)
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=chamber&speed=50")
+        assert response.status_code == 200
+        assert response.json()["message"] == f"{expected_label} set to 50%"
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
     async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):

+ 288 - 0
backend/tests/unit/services/test_camera_capture_coalescing.py

@@ -0,0 +1,288 @@
+"""Single-flight coalescing of one-shot camera captures (#2705).
+
+Bambu firmware allows exactly one camera connection. The pre-existing guards
+(``is_stream_active`` / ``try_get_active_buffered_frame``, #1271 + #1348) only
+keep a one-shot capturer from competing with the fan-out broadcaster; nothing
+kept the capturers from competing with EACH OTHER when no viewer was attached,
+so an Obico poll and a ``/camera/snapshot`` 200 ms apart each opened their own
+RTSP socket and knocked the other over.
+
+These tests drive ``capture_camera_frame_bytes`` at the public boundary and
+count how many times the underlying capture ran, since "how many connections
+did we open" is the entire point of the fix.
+"""
+
+import asyncio
+
+import pytest
+
+from backend.app.services import camera as camera_module
+from backend.app.services.camera import capture_camera_frame_bytes, capture_in_flight
+
+FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
+FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
+
+
+@pytest.fixture(autouse=True)
+def _clear_inflight():
+    """The registry is module-global; don't leak tasks between tests."""
+    camera_module._inflight_captures.clear()
+    yield
+    camera_module._inflight_captures.clear()
+
+
+class RecordingCapture:
+    """Stand-in for the real capture, recording each call.
+
+    ``gate`` (when set) holds every capture open until released, which is how
+    these tests create the overlap window that used to produce two sockets.
+    """
+
+    def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None):
+        self.calls: list[tuple[str, int]] = []
+        self._frames = list(frames)
+        self._gate = gate
+        self.started = asyncio.Event()
+
+    async def __call__(self, ip_address, access_code, model, timeout=15):
+        self.calls.append((ip_address, timeout))
+        self.started.set()
+        if self._gate is not None:
+            await self._gate.wait()
+        return self._frames.pop(0) if self._frames else None
+
+    @property
+    def count(self) -> int:
+        return len(self.calls)
+
+
+@pytest.fixture
+def patch_capture(monkeypatch):
+    def _install(capture):
+        monkeypatch.setattr(camera_module, "_capture_camera_frame_bytes_uncoalesced", capture)
+        return capture
+
+    return _install
+
+
+async def _let_leader_start(capture: RecordingCapture) -> None:
+    """Wait until the leader is inside the capture, so the next caller joins it.
+
+    Without this the second caller can reach the registry before the first has
+    even been scheduled, which tests a different (and uninteresting) race.
+    """
+    await asyncio.wait_for(capture.started.wait(), timeout=1)
+
+
+@pytest.mark.asyncio
+async def test_simultaneous_callers_share_one_capture(patch_capture):
+    """The reported collision: two consumers, one connection, two frames."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=15))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert await follower == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_five_callers_one_capture(patch_capture):
+    """Verified on live hardware in the report: 5 callers, 1 connection."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    rest = [asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) for _ in range(4)]
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await asyncio.gather(first, *rest) == [FRAME_A] * 5
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_different_printers_do_not_coalesce(patch_capture):
+    """The one-connection limit is per printer, so the key must be too."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    one = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    two = asyncio.create_task(capture_camera_frame_bytes("10.0.2.44", "code", "P2S"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert {await one, await two} == {FRAME_A, FRAME_B}
+    assert capture.count == 2
+    assert {ip for ip, _ in capture.calls} == {"10.0.2.43", "10.0.2.44"}
+
+
+@pytest.mark.asyncio
+async def test_coalescing_is_not_caching(patch_capture):
+    """Sequential callers each capture fresh.
+
+    Deliberate: plate detection and the finish-photo path decide things about a
+    running print from these frames, and #1397 was a finish photo a few seconds
+    stale showing the bed already lowered.
+    """
+    capture = patch_capture(RecordingCapture())
+
+    assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_A
+    assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_B
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_registry_is_empty_after_a_capture_finishes(patch_capture):
+    """No leak, and nothing left behind for the next caller to join."""
+    patch_capture(RecordingCapture())
+
+    await capture_camera_frame_bytes("10.0.2.43", "code", "P2S")
+    await asyncio.sleep(0)  # let the done-callback run
+
+    assert camera_module._inflight_captures == {}
+    assert capture_in_flight("10.0.2.43") is False
+
+
+@pytest.mark.asyncio
+async def test_failed_leader_does_not_poison_its_followers(patch_capture):
+    """A follower that never got its own attempt gets one when the leader fails.
+
+    Safe by then: the leader has finished, so there is no socket to compete
+    with. This also covers the follower whose timeout is LONGER than the
+    leader's — it isn't cut short by someone else's deadline.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=10))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader is None
+    assert await follower == FRAME_B
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_two_consecutive_failures_give_up(patch_capture):
+    """Bounded retry: a follower doesn't chase failing captures forever.
+
+    Two followers behind a failing leader. The first takes its own turn, the
+    second joins THAT capture, and when it fails too the second gives up rather
+    than opening a third connection.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+    second = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader is None
+    assert await first is None
+    assert await second is None
+    # The leader's capture plus one retry — not one per disappointed caller.
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture):
+    """A follower giving up leaves the capture running for everyone else.
+
+    The call sites disagree about the timeout (10s plate detection, 20s Obico),
+    so a follower must be able to abandon a join without cancelling a capture
+    other callers are still waiting on.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30))
+    await _let_leader_start(capture)
+    impatient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=0.01))
+    patient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30))
+
+    assert await impatient is None  # gave up on its own deadline
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert await patient == FRAME_A  # unaffected by the one that walked away
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_cancelled_leader_still_delivers_to_followers(patch_capture):
+    """Snapshot requests get cancelled routinely (client navigates away).
+
+    The follower must not lose the frame because the caller that happened to
+    open the connection went away.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+
+    leader.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await leader
+    gate.set()
+
+    assert await follower == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture):
+    """The mirror case: the follower's cancellation is its own business."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+
+    follower.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await follower
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_capture_in_flight_reports_the_window(patch_capture):
+    """The predicate the diagnose tool uses to know it will join, not measure."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    assert capture_in_flight("10.0.2.43") is False
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+
+    assert capture_in_flight("10.0.2.43") is True
+    assert capture_in_flight("10.0.2.44") is False  # per printer
+
+    gate.set()
+    await leader
+    await asyncio.sleep(0)
+
+    assert capture_in_flight("10.0.2.43") is False

+ 73 - 0
backend/tests/unit/services/test_camera_diagnose.py

@@ -221,6 +221,79 @@ class TestFirstFrameStage:
         assert result.overall_status == "ok"
         assert result.overall_status == "ok"
         assert result.summary_code == "all_ok"
         assert result.summary_code == "all_ok"
         assert all(s.status == "ok" for s in result.stages)
         assert all(s.status == "ok" for s in result.stages)
+        assert result.stages[1].code is None  # we opened our own connection
+
+    @pytest.mark.asyncio
+    async def test_pass_riding_on_an_inflight_capture_says_so(self):
+        """#2705: a capture already running means we get its frame, not our own.
+
+        The frame is real evidence the camera works, so the stage still passes —
+        but duration_ms is then mostly time spent queueing behind someone
+        else's capture, and claiming a connection we never opened is exactly
+        what a diagnostic must not do."""
+
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_in_flight",
+                return_value=True,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=b"\xff\xd8\xff\xd9",
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "ok"
+        assert result.summary_code == "all_ok"
+        assert result.stages[1].status == "ok"
+        assert result.stages[1].code == "coalesced_capture"
+
+    @pytest.mark.asyncio
+    async def test_failure_is_not_annotated_as_coalesced(self):
+        """A follower whose leader fails goes on to capture on its own, so a
+        failure here was this stage's own attempt — no qualifier needed."""
+
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_in_flight",
+                return_value=True,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "no_frame"
+        assert result.stages[1].code == "no_frame"
 
 
 
 
 class TestResultMetadata:
 class TestResultMetadata:

+ 39 - 16
backend/tests/unit/services/test_notification_service.py

@@ -1168,16 +1168,24 @@ class TestBarkProvider:
         mock_client.post.assert_not_called()
         mock_client.post.assert_not_called()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_send_bark_error_in_200_body(self, service):
-        """bark-server can wrap a failure in HTTP 200; the body code must win."""
+    async def test_send_bark_error_in_200_body(self, service, caplog):
+        """bark-server can wrap a failure in HTTP 200; the body code must win.
+
+        Only the numeric code is returned — the server is caller-supplied
+        (bark is self-hostable), so its free-text message is the same read
+        channel the HTTP-failure path closes. The text goes to the debug log.
+        """
         mock_client = self._client_returning(200, {"code": 400, "message": "device token invalid"})
         mock_client = self._client_returning(200, {"code": 400, "message": "device token invalid"})
 
 
         with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
         with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
             mock_get_client.return_value = mock_client
             mock_get_client.return_value = mock_client
-            success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
+            with caplog.at_level("DEBUG", logger="backend.app.services.notification_service"):
+                success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
 
 
         assert success is False
         assert success is False
-        assert "device token invalid" in message
+        assert "Bark error 400" in message
+        assert "device token invalid" not in message
+        assert "device token invalid" in caplog.text
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_send_bark_http_error(self, service):
     async def test_send_bark_http_error(self, service):
@@ -2476,10 +2484,15 @@ class TestNtfyOutbound:
         assert "<!DOCTYPE" not in detail
         assert "<!DOCTYPE" not in detail
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_ntfy_normal_403_still_surfaces_body(self, service):
-        """A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
-        the original body so the user can debug the real error — we
-        only intercept the Cloudflare-challenge shape."""
+    async def test_ntfy_normal_403_is_not_misread_as_a_cloudflare_challenge(self, service, caplog):
+        """A non-Cloudflare 403 (e.g. ntfy auth fail) must report the real
+        status rather than the Cloudflare-challenge advice — we only intercept
+        the challenge shape.
+
+        The origin's body is no longer returned to the API caller: the ntfy
+        server URL is caller-supplied, so echoing it made this an SSRF read
+        primitive. It goes to the debug log instead.
+        """
         import httpx
         import httpx
 
 
         mock_response = httpx.Response(
         mock_response = httpx.Response(
@@ -2491,7 +2504,10 @@ class TestNtfyOutbound:
         mock_client = AsyncMock()
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
         mock_client.post = AsyncMock(return_value=mock_response)
 
 
-        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+        with (
+            patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
+            caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
+        ):
             ok, detail = await service._send_ntfy(
             ok, detail = await service._send_ntfy(
                 {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
                 {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
                 title="t",
                 title="t",
@@ -2500,16 +2516,19 @@ class TestNtfyOutbound:
 
 
         assert ok is False
         assert ok is False
         assert "Cloudflare" not in detail
         assert "Cloudflare" not in detail
-        assert "invalid auth token" in detail
-        assert detail.startswith("HTTP 403:")
+        assert detail.startswith("HTTP 403")
+        assert "invalid auth token" not in detail
+        assert "invalid auth token" in caplog.text
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
+    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service, caplog):
         """Cloudflare adds Server: cloudflare to EVERY proxied response,
         """Cloudflare adds Server: cloudflare to EVERY proxied response,
         including legitimate origin errors. A real 401 "wrong token"
         including legitimate origin errors. A real 401 "wrong token"
         from an ntfy server that happens to sit behind Cloudflare must
         from an ntfy server that happens to sit behind Cloudflare must
-        still surface the origin's actual error body — we must not flip
+        still be reported as the origin's status — we must not flip
         every CF-fronted 4xx into a "your Cloudflare is blocking" message.
         every CF-fronted 4xx into a "your Cloudflare is blocking" message.
+
+        As above, the origin body reaches the debug log rather than the caller.
         """
         """
         import httpx
         import httpx
 
 
@@ -2527,7 +2546,10 @@ class TestNtfyOutbound:
         mock_client = AsyncMock()
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
         mock_client.post = AsyncMock(return_value=mock_response)
 
 
-        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+        with (
+            patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
+            caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
+        ):
             ok, detail = await service._send_ntfy(
             ok, detail = await service._send_ntfy(
                 {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
                 {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
                 title="t",
                 title="t",
@@ -2536,8 +2558,9 @@ class TestNtfyOutbound:
 
 
         assert ok is False
         assert ok is False
         assert "Cloudflare" not in detail
         assert "Cloudflare" not in detail
-        assert "unauthorized" in detail
-        assert detail.startswith("HTTP 401:")
+        assert detail.startswith("HTTP 401")
+        assert "unauthorized" not in detail
+        assert "unauthorized" in caplog.text
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):
     async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):

+ 394 - 0
backend/tests/unit/services/test_p2s_accessory_fans.py

@@ -0,0 +1,394 @@
+"""Tests for the P2S/X2D left auxiliary part cooling fan (#2576).
+
+The "Auxiliary Part Cooling Fan - Left" (also fits X2D) is reported ONLY as
+device.airduct part with raw id 160 (decoded id = 160 >> 4 = 10,
+AIR_FUN.FAN_REMOTE_COOLING_1 in Bambu Studio) — the firmware does NOT mirror
+it into any flat big_fanX_speed field, which is why it was previously dropped.
+It is controlled with "M106 P10", exactly like Bambu's official P2S machine-
+profile gcode does.
+
+The airduct payloads below are verbatim captures from a live P2S
+(fw 01.02.00.00) with the accessory installed.
+"""
+
+import pytest
+
+
+@pytest.fixture
+def mqtt_client():
+    from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+    return BambuMQTTClient(
+        ip_address="192.168.1.100",
+        serial_number="TESTP2S",
+        access_code="12345678",
+    )
+
+
+def _airduct_device(parts):
+    """Wrap airduct parts in the device envelope as pushed by a P2S."""
+    return {
+        "device": {
+            "airduct": {
+                "modeCur": 0,
+                "modeFunc": 0,
+                "modeList": [
+                    {"ctrl": [16, 32, 160, 48], "modeId": 0, "off": []},
+                    {"ctrl": [16, 32, 48], "modeId": 1, "off": [160]},
+                ],
+                "modeVisable": 7,
+                "parts": parts,
+                "subFunc": 0,
+                "subMode": 0,
+                "subVisable": 7,
+                "version": 1,
+            },
+            "type": 1,
+        }
+    }
+
+
+# Verbatim parts list from a live P2S: part cooling ramping (state 30,
+# target 90), right aux at 40%, left aux OFF, chamber at 70%.
+P2S_PARTS_LEFT_AUX_OFF = [
+    {"func": 0, "id": 16, "range": 6553600, "state": 30, "tar_state": 90},
+    {"func": 6, "id": 32, "range": 6553600, "state": 40, "tar_state": 40},
+    {"func": 5, "id": 160, "range": 6553600, "state": 0, "tar_state": 0},
+    {"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70},
+]
+
+# Same printer later in the print: left aux running at 80%.
+P2S_PARTS_LEFT_AUX_80 = [
+    {"func": 0, "id": 16, "range": 6553600, "state": 60, "tar_state": 60},
+    {"func": 6, "id": 32, "range": 6553600, "state": 100, "tar_state": 100},
+    {"func": 5, "id": 160, "range": 6553600, "state": 80, "tar_state": 80},
+    {"func": 2, "id": 48, "range": 6553600, "state": 80, "tar_state": 80},
+]
+
+
+class TestLeftAuxFanParsing:
+    """device.airduct part id 10 (raw 160) -> state.left_aux_fan_speed."""
+
+    def test_defaults_to_none(self, mqtt_client):
+        assert mqtt_client.state.left_aux_fan_speed is None
+
+    def test_parses_left_aux_running(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+    def test_parses_left_aux_off(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_OFF))
+        assert mqtt_client.state.left_aux_fan_speed == 0
+
+    def test_raw_id_is_bit_unpacked(self, mqtt_client):
+        """Raw id 160 must decode to part id 10 (id >> 4), NOT match on 160."""
+        # A hypothetical raw id of 10 would decode to part id 0 — must not match.
+        parts = [{"func": 5, "id": 10, "range": 6553600, "state": 50, "tar_state": 50}]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.left_aux_fan_speed is None
+
+    def test_parts_without_left_aux_reports_none(self, mqtt_client):
+        """A full parts list without id 10 means the fan is not installed."""
+        mqtt_client.state.left_aux_fan_speed = 80  # previously seen
+        parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 160]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.left_aux_fan_speed is None
+
+    def test_diff_push_without_device_preserves_value(self, mqtt_client):
+        """P-series diff pushes omit device.airduct — value must survive."""
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        mqtt_client._update_state({"nozzle_temper": 250.0})
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+    def test_state_clamped_to_0_100(self, mqtt_client):
+        parts = [{"func": 5, "id": 160, "range": 6553600, "state": 250, "tar_state": 0}]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.left_aux_fan_speed == 100
+
+    def test_packed_state_decodes_from_low_8_bits(self, mqtt_client):
+        """`state` is bit-packed like its sibling `range` (end << 16 | start).
+
+        Bambu Studio decodes it with get_flag_bits(state, 0, 8), so only the low
+        byte carries the percentage. Without the mask a packed value would clamp
+        to 100 instead of decoding to the real speed.
+        """
+        packed = (60 << 16) | 45  # sibling field in the high bits, 45% in the low byte
+        parts = [{"func": 5, "id": 160, "range": 6553600, "state": packed, "tar_state": 0}]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.left_aux_fan_speed == 45
+
+    def test_unpacked_state_is_unaffected_by_the_mask(self, mqtt_client):
+        # Plain 0-100 values (what a P2S actually sends) must round-trip exactly.
+        for speed in (0, 30, 80, 100):
+            parts = [{"func": 5, "id": 160, "range": 6553600, "state": speed, "tar_state": speed}]
+            mqtt_client._update_state(_airduct_device(parts))
+            assert mqtt_client.state.left_aux_fan_speed == speed
+
+    def test_malformed_part_entries_ignored(self, mqtt_client):
+        parts = [
+            "not-a-dict",
+            {"func": 5},  # no id/state
+            {"id": "garbage", "state": 10},
+            {"func": 5, "id": 160, "range": 6553600, "state": 30, "tar_state": 30},
+        ]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.left_aux_fan_speed == 30
+
+    def test_flat_fan_fields_unaffected(self, mqtt_client):
+        """Regression: flat fields keep coming from the flat MQTT keys."""
+        payload = {
+            "cooling_fan_speed": "4",
+            "big_fan1_speed": "6",
+            "big_fan2_speed": "10",
+            "heatbreak_fan_speed": "14",
+            **_airduct_device(P2S_PARTS_LEFT_AUX_OFF),
+        }
+        mqtt_client._update_state(payload)
+        assert mqtt_client.state.cooling_fan_speed == 27  # 4/15
+        assert mqtt_client.state.big_fan1_speed == 40  # 6/15
+        assert mqtt_client.state.big_fan2_speed == 67  # 10/15
+        assert mqtt_client.state.heatbreak_fan_speed == 93  # 14/15
+        assert mqtt_client.state.left_aux_fan_speed == 0
+
+
+class TestExhaustFanPresence:
+    """device.airduct part id 3 (raw 48) presence -> state.exhaust_fan_present.
+
+    The chamber exhaust fan is a P2S/X2D add-on kit (get_version module "eef").
+    Its speed rides on the flat big_fan2_speed field, but the airduct only lists
+    part id 3 when the kit is physically installed — so part-3 presence is the
+    signal the UI uses to show/hide the Exhaust tile.
+    """
+
+    def test_defaults_to_false(self, mqtt_client):
+        assert mqtt_client.state.exhaust_fan_present is False
+
+    def test_present_when_part_3_reported(self, mqtt_client):
+        # Full P2S parts list includes id 48 (>>4 = 3).
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.exhaust_fan_present is True
+
+    def test_absent_when_part_3_missing(self, mqtt_client):
+        mqtt_client.state.exhaust_fan_present = True  # previously seen
+        parts = [p for p in P2S_PARTS_LEFT_AUX_80 if p["id"] != 48]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.exhaust_fan_present is False
+
+    def test_base_p2s_only_part_cooling_and_aux(self, mqtt_client):
+        # A base P2S (no exhaust kit, no left aux kit) lists only ids 1 and 2.
+        parts = [
+            {"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
+            {"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
+        ]
+        mqtt_client._update_state(_airduct_device(parts))
+        assert mqtt_client.state.exhaust_fan_present is False
+        assert mqtt_client.state.left_aux_fan_speed is None
+
+    def test_diff_push_without_device_preserves_value(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        mqtt_client._update_state({"nozzle_temper": 250.0})
+        assert mqtt_client.state.exhaust_fan_present is True
+
+
+class TestPartialPartsFrames:
+    """A `parts` list that is not a full inventory must not retract presence.
+
+    `device.airduct` is pushed field by field — the `modeCur` handler reads it
+    with an `in` check for exactly that reason — so a frame can carry `parts`
+    without carrying every fan. Absence is what tells us a kit is not fitted, so
+    it is only trustworthy on a complete list. Read as gospel, a truncated frame
+    would make both accessory badges vanish mid-print and start rejecting
+    ``fan=aux2`` on a printer that does have the fan.
+
+    Completeness is judged on ids 1 (part cooling) and 2 (aux) being present:
+    neither is optional on any machine that reports an airduct at all, and both
+    appear in every layout in the support-package archive (P2S base 1,2 /
+    P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6).
+    """
+
+    def test_partial_frame_does_not_retract_the_left_aux_fan(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+        # Only the part cooling fan changed — the frame says nothing about the
+        # left aux fan, which is not the same as saying it is gone.
+        mqtt_client._update_state(
+            _airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
+        )
+
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+    def test_partial_frame_does_not_retract_the_exhaust_fan(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.exhaust_fan_present is True
+
+        mqtt_client._update_state(
+            _airduct_device([{"func": 0, "id": 16, "range": 6553600, "state": 70, "tar_state": 70}])
+        )
+
+        assert mqtt_client.state.exhaust_fan_present is True
+
+    def test_a_partial_frame_still_applies_the_speed_it_carries(self, mqtt_client):
+        """Not-authoritative-for-absence is not the same as ignored."""
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+
+        mqtt_client._update_state(
+            _airduct_device([{"func": 5, "id": 160, "range": 6553600, "state": 25, "tar_state": 25}])
+        )
+
+        assert mqtt_client.state.left_aux_fan_speed == 25
+
+    def test_a_partial_frame_can_still_reveal_a_fan(self, mqtt_client):
+        """Presence may always be added — only retraction needs a full list."""
+        mqtt_client._update_state(
+            _airduct_device([{"func": 2, "id": 48, "range": 6553600, "state": 70, "tar_state": 70}])
+        )
+
+        assert mqtt_client.state.exhaust_fan_present is True
+
+    def test_a_full_frame_still_retracts_both(self, mqtt_client):
+        """The kits really can be removed, and a complete list must say so —
+        this is the behaviour the presence gate exists for."""
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        assert mqtt_client.state.left_aux_fan_speed == 80
+        assert mqtt_client.state.exhaust_fan_present is True
+
+        # Base P2S layout: part cooling + aux only.
+        mqtt_client._update_state(
+            _airduct_device(
+                [
+                    {"func": 0, "id": 16, "range": 6553600, "state": 0, "tar_state": 0},
+                    {"func": 6, "id": 32, "range": 6553600, "state": 0, "tar_state": 0},
+                ]
+            )
+        )
+
+        assert mqtt_client.state.left_aux_fan_speed is None
+        assert mqtt_client.state.exhaust_fan_present is False
+
+    def test_an_empty_parts_list_changes_nothing(self, mqtt_client):
+        mqtt_client._update_state(_airduct_device(P2S_PARTS_LEFT_AUX_80))
+        mqtt_client._update_state(_airduct_device([]))
+
+        assert mqtt_client.state.left_aux_fan_speed == 80
+        assert mqtt_client.state.exhaust_fan_present is True
+
+
+class TestLeftAuxFanCommand:
+    """set_fan_speed must accept index 10 and emit M106 P10."""
+
+    def test_set_fan_speed_10_sends_m106_p10(self, mqtt_client, monkeypatch):
+        sent = []
+        monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
+        assert mqtt_client.set_fan_speed(10, 204) is True
+        assert sent == ["M106 P10 S204"]
+
+    def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch):
+        sent = []
+        monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
+        assert mqtt_client.set_left_aux_fan(255) is True
+        assert sent == ["M106 P10 S255"]
+
+    def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
+        sent = []
+        monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
+        mqtt_client.set_left_aux_fan(999)
+        assert sent == ["M106 P10 S255"]
+
+    def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):
+        sent = []
+        monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
+        assert mqtt_client.set_fan_speed(4, 100) is False
+        assert mqtt_client.set_fan_speed(11, 100) is False
+        assert sent == []
+
+    def test_existing_fan_indexes_still_accepted(self, mqtt_client, monkeypatch):
+        sent = []
+        monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
+        for idx in (1, 2, 3):
+            assert mqtt_client.set_fan_speed(idx, 128) is True
+        assert sent == ["M106 P1 S128", "M106 P2 S128", "M106 P3 S128"]
+
+
+class TestExhaustFanLabelModels:
+    """P2S/X2D call the big_fan2 enclosure fan "Exhaust"; others say "Chamber"."""
+
+    def test_p2s_and_x2d_use_exhaust_label(self):
+        from backend.app.utils.printer_models import uses_exhaust_fan_label
+
+        for model in ("P2S", "X2D", "p2s", " P2S ", "N7", "N6"):
+            assert uses_exhaust_fan_label(model) is True, model
+
+    def test_other_enclosed_models_keep_chamber_label(self):
+        from backend.app.utils.printer_models import uses_exhaust_fan_label
+
+        for model in ("X1C", "X1", "X1E", "P1S", "H2D", "H2C", "H2S", "A1"):
+            assert uses_exhaust_fan_label(model) is False, model
+
+    def test_unknown_or_missing_model_defaults_to_chamber(self):
+        from backend.app.utils.printer_models import uses_exhaust_fan_label
+
+        assert uses_exhaust_fan_label(None) is False
+        assert uses_exhaust_fan_label("") is False
+        assert uses_exhaust_fan_label("SomeFutureModel") is False
+
+
+class TestExhaustLabelModelListsAgree:
+    """The exhaust-label model list is duplicated across the stack.
+
+    The backend keeps ``EXHAUST_FAN_LABEL_MODELS`` (display names plus the N7/N6
+    internal codes, since the API can be handed either) and the frontend keeps
+    ``MODELS_WITH_EXHAUST_LABEL`` in PrintersPage.tsx (display names only —
+    ``printer.model`` is always a display name by the time it reaches the card).
+    Both are correct as written, but nothing stopped them drifting apart: adding
+    a model to one and forgetting the other silently produces a card labelled
+    "Exhaust" whose control toast says "Chamber fan", or vice versa.
+    """
+
+    def _frontend_models(self) -> set[str]:
+        import re
+        from pathlib import Path
+
+        import pytest
+
+        # Walk up rather than hard-coding a parent depth, so the test survives
+        # the file being moved and works whatever directory pytest runs from.
+        relative = Path("frontend") / "src" / "pages" / "PrintersPage.tsx"
+        source = next(
+            (candidate for parent in Path(__file__).resolve().parents if (candidate := parent / relative).is_file()),
+            None,
+        )
+        if source is None:
+            pytest.skip("frontend sources not present in this checkout")
+        text = source.read_text(encoding="utf-8")
+        match = re.search(
+            r"const MODELS_WITH_EXHAUST_LABEL:[^=]*=\s*new Set\(\[(.*?)\]\)",
+            text,
+            re.DOTALL,
+        )
+        assert match, "MODELS_WITH_EXHAUST_LABEL not found in PrintersPage.tsx"
+        return set(re.findall(r"['\"]([^'\"]+)['\"]", match.group(1)))
+
+    def test_frontend_list_is_the_display_name_subset_of_the_backend_list(self):
+        from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
+
+        frontend = self._frontend_models()
+        assert frontend, "frontend list parsed as empty"
+        missing = frontend - set(EXHAUST_FAN_LABEL_MODELS)
+        assert not missing, (
+            f"models {sorted(missing)} label the fan 'Exhaust' in the UI but the backend "
+            f"would report 'Chamber fan' — add them to EXHAUST_FAN_LABEL_MODELS"
+        )
+
+    def test_every_backend_display_name_is_handled_by_the_frontend(self):
+        from backend.app.utils.printer_models import EXHAUST_FAN_LABEL_MODELS
+
+        # N7/N6 are internal codes that never reach the card, so exclude them.
+        internal_codes = {"N7", "N6"}
+        backend_display = set(EXHAUST_FAN_LABEL_MODELS) - internal_codes
+        missing = backend_display - self._frontend_models()
+        assert not missing, (
+            f"models {sorted(missing)} say 'Exhaust fan' in the API response but the card "
+            f"would still show 'Chamber Fan' — add them to MODELS_WITH_EXHAUST_LABEL"
+        )

+ 38 - 6
backend/tests/unit/services/test_rest_smart_plug.py

@@ -49,11 +49,39 @@ class TestURLValidation:
     def test_hostname_url(self, service):
     def test_hostname_url(self, service):
         assert service._validate_url("http://openhab.local:8080/api") is True
         assert service._validate_url("http://openhab.local:8080/api") is True
 
 
-    def test_loopback_blocked(self, service):
-        assert service._validate_url("http://127.0.0.1/api") is False
+    def test_loopback_allowed(self, service):
+        """Deliberate change: the LAN-service policy permits loopback, because
+        an openHAB/Node-RED bridge on the same host is the normal topology.
 
 
-    def test_link_local_blocked(self, service):
-        assert service._validate_url("http://169.254.1.1/api") is False
+        The previous check rejected a literal 127.0.0.1 while accepting the
+        equivalent "localhost", so the same target was configurable one way and
+        not the other. See test_outbound_url_ssrf_guards.py for the policy.
+        """
+        assert service._validate_url("http://127.0.0.1/api") is True
+
+    def test_link_local_allowed(self, service):
+        """Also deliberate: a generic APIPA address is a LAN host like any
+        other. The cloud-metadata address inside that range is blocked by
+        name, not by rejecting the whole /16 — see test_metadata_blocked."""
+        assert service._validate_url("http://169.254.1.1/api") is True
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://169.254.169.254/latest/meta-data/",
+            "http://100.100.100.200/",
+            "http://[fd00:ec2::254]/",
+            "http://metadata.google.internal/",
+            "http://[::ffff:169.254.169.254]/",
+            "http://2130706433/",
+            "http://0.0.0.0/",
+        ],
+    )
+    def test_metadata_and_encoded_targets_blocked(self, service, url):
+        """The gap the previous hand-rolled check left: anything that was not a
+        bare IP literal fell through to True, and the literals it did parse were
+        only tested for loopback/link-local."""
+        assert service._validate_url(url) is False
 
 
     def test_empty_hostname(self, service):
     def test_empty_hostname(self, service):
         assert service._validate_url("http:///api") is False
         assert service._validate_url("http:///api") is False
@@ -405,6 +433,10 @@ class TestTestConnection:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_connection_invalid_url(self, service):
     async def test_connection_invalid_url(self, service):
-        result = await service.test_connection("http://127.0.0.1/api")
+        """127.0.0.1 is now permitted (see TestURLValidation), so the rejection
+        case here is a target that is out of policy under any topology. The
+        error is the guard's own message rather than a fixed sentence, so the
+        user learns which rule the URL broke."""
+        result = await service.test_connection("http://169.254.169.254/latest/meta-data/")
         assert result["success"] is False
         assert result["success"] is False
-        assert "blocked" in result["error"].lower()
+        assert "cloud metadata" in result["error"].lower()

+ 50 - 0
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -443,3 +443,53 @@ class TestSubstituteUnusedPlateFilaments:
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
 
 
         assert result == ["pla.json", "pla.json", "pva.json"]
         assert result == ["pla.json", "pla.json", "pva.json"]
+
+    # ---- #2711: plate 0 is the slice-all sentinel, not a plate ----------
+
+    def test_no_op_for_the_slice_all_sentinel(self):
+        """``plate=0`` means every plate, so every slot is used by something."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
+        items = ["pla_white.json", "pla_red.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
+
+        assert result == items
+
+    def test_slice_all_is_not_collapsed_onto_the_support_filament(self):
+        """The guard that makes the plate-0 no-op load-bearing.
+
+        Geometry lookup for plate 0 matches nothing (plates are 1-indexed),
+        but the support-filament union reads project settings and has no
+        plate scope — so it survives as the *only* member of the used set.
+        Anchored on it, a slice-all would rewrite every colour in the
+        project to the support material and print the whole thing in PVA.
+        """
+        model_settings = self._model_settings_xml([(1, [1]), (2, [2]), (3, [3])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "4",
+                "support_interface_filament": "4",
+                "filament_type": ["PLA", "PLA", "PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["white.json", "red.json", "blue.json", "pva.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
+
+        assert result == items, "slice-all collapsed the project onto the support filament"
+
+    def test_negative_plate_id_is_a_no_op(self):
+        """Not reachable through the schema (``ge=0``), but the function is the
+        thing that must not guess — a caller resolving a plate wrongly should
+        get the user's picks back, not a rewrite anchored on nothing."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
+        items = ["a.json", "b.json"]
+
+        assert substitute_unused_plate_filaments(zip_bytes, plate_id=-1, items=items) == items

+ 255 - 0
backend/tests/unit/services/test_total_layers_print_start.py

@@ -0,0 +1,255 @@
+"""The layer total must survive the print-start reset (#2702).
+
+`_update_state` applies `total_layer_num` early and, further down, resets
+`total_layers` when it detects a new print (added by #1771 so the previous
+print's total can't bleed into the next one's usage split). Those two run in
+the same function on the same frame, so a frame that carried both the new
+print's total *and* the transition into RUNNING had its total applied and then
+zeroed.
+
+That is unrecoverable rather than merely late: Bambu firmware sends only
+changed fields, so the printer never re-sends a total it already published.
+The value reappears only in a full pushall — i.e. on reconnect or a manual
+Force Refresh — which is why the reporter saw `n/0` for nine minutes on a
+flawless connection, why it looked random, and why a *stable* link made it
+worse.
+
+Frames here are trimmed to the fields the code under test reads. No printer is
+needed: the fix is a property of how one function orders its own writes.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+
+@pytest.fixture
+def client():
+    """A client with a recording stand-in for the MQTT connection."""
+    from unittest.mock import MagicMock
+
+    from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+    c = BambuMQTTClient(
+        ip_address="192.168.1.100",
+        serial_number="TEST123",
+        access_code="12345678",
+    )
+    c._client = MagicMock()
+    # A new print is only detected once a previous state has been observed
+    # (#1304 guard), so give every test a plausible pre-print history.
+    c._previous_gcode_state = "IDLE"
+    c._previous_gcode_file = None
+    c._was_running = False
+    return c
+
+
+def pushalls(client) -> list[dict]:
+    """Every pushall published on this client, decoded."""
+    sent = []
+    for call in client._client.publish.call_args_list:
+        payload = json.loads(call.args[1])
+        if payload.get("pushing", {}).get("command") == "pushall":
+            sent.append(payload)
+    return sent
+
+
+def running_frame(**extra) -> dict:
+    """A frame that flips the printer into RUNNING with a file — a new print."""
+    return {"gcode_state": "RUNNING", "gcode_file": "widget.3mf", "subtask_name": "widget", **extra}
+
+
+# ---------------------------------------------------------------------------
+# The regression
+# ---------------------------------------------------------------------------
+
+
+def test_total_arriving_with_the_start_frame_survives(client):
+    """The reported bug: total and transition in one frame lost the total."""
+    client._update_state(running_frame(total_layer_num=33, layer_num=0))
+
+    assert client.state.total_layers == 33
+
+
+def test_total_arriving_with_the_start_frame_needs_no_pushall(client):
+    """We already have the denominator, so don't spend a round-trip on it."""
+    client._update_state(running_frame(total_layer_num=33))
+
+    assert pushalls(client) == []
+    assert client._total_layers_refresh_armed is False
+
+
+def test_previous_prints_total_still_cannot_bleed_through(client):
+    """#1771's reason for the reset — preserved exactly.
+
+    A start frame with no total of its own must land on 0, never on the
+    finished print's denominator.
+    """
+    client.state.total_layers = 120  # left over from the print that just ended
+
+    client._update_state(running_frame())
+
+    assert client.state.total_layers == 0
+
+
+def test_start_frame_without_a_total_asks_the_printer_for_one(client):
+    """Covers the ordering where the total was published a frame or two early.
+
+    Re-applying this frame's own value can't help there — the value was
+    already consumed and zeroed — so recovery has to come from a pushall,
+    the only message that re-sends unchanged fields.
+    """
+    client._update_state(running_frame())
+
+    assert len(pushalls(client)) == 1
+    assert client._total_layers_refresh_armed is True
+
+
+# ---------------------------------------------------------------------------
+# The one-shot re-request
+# ---------------------------------------------------------------------------
+
+
+def test_first_layer_advance_without_a_total_re_requests_once(client):
+    client._update_state(running_frame())
+    assert len(pushalls(client)) == 1  # from print start
+
+    client._update_state({"layer_num": 1})
+
+    assert len(pushalls(client)) == 2
+    assert client._total_layers_refresh_armed is False
+
+
+def test_later_layer_advances_do_not_keep_re_requesting(client):
+    """An unanswered pushall must not become a per-layer retry loop."""
+    client._update_state(running_frame())
+    client._update_state({"layer_num": 1})
+    before = len(pushalls(client))
+
+    for layer in range(2, 12):
+        client._update_state({"layer_num": layer})
+
+    assert len(pushalls(client)) == before
+
+
+def test_no_re_request_once_the_total_is_known(client):
+    """The pushall answered: layers advance without further traffic."""
+    client._update_state(running_frame())
+    client._update_state({"total_layer_num": 33})  # the pushall's answer
+    before = len(pushalls(client))
+
+    client._update_state({"layer_num": 1})
+    client._update_state({"layer_num": 2})
+
+    assert client.state.total_layers == 33
+    assert len(pushalls(client)) == before
+
+
+def test_the_recovered_total_is_what_downstream_reads(client):
+    """End-to-end on the reporter's sequence, minus the 9-minute wait.
+
+    Start with no total, layers advance at `n/0`, the pushall answers, and
+    from then on the UI, `{total_layers}` notifications and the usage-split
+    denominator all see 33 — they read this one field.
+    """
+    client._update_state(running_frame())
+    client._update_state({"layer_num": 1})
+    assert client.state.total_layers == 0  # the symptom in the screenshot
+
+    client._update_state({"layer_num": 2, "total_layer_num": 33})
+
+    assert (client.state.layer_num, client.state.total_layers) == (2, 33)
+
+
+def test_the_pushall_answer_does_not_re_trigger_the_reset(client):
+    """Loop safety: the answer is a *full* frame, gcode_state and file included.
+
+    If that re-tripped the new-print detection it would reset the total it just
+    delivered and request another pushall, once per round-trip, forever.
+    """
+    client._update_state(running_frame())
+    assert len(pushalls(client)) == 1
+
+    client._update_state(running_frame(total_layer_num=33, layer_num=1, mc_percent=3))
+
+    assert client.state.total_layers == 33
+    assert len(pushalls(client)) == 1
+
+
+# ---------------------------------------------------------------------------
+# Interaction with the pre-existing firmware-reset guard
+# ---------------------------------------------------------------------------
+
+
+def test_firmware_reset_to_zero_mid_print_is_still_ignored(client):
+    """P1S zeroes total_layer_num at print end; #1771's guard keeps the total."""
+    client._update_state(running_frame(total_layer_num=33))
+
+    client._update_state({"layer_num": 33, "total_layer_num": 0})
+
+    assert client.state.total_layers == 33
+
+
+@pytest.mark.parametrize("value", [None, "", 0, "0", -1, "abc", "33.7", [], {}, 3.9])
+def test_unusable_totals_do_not_break_ingest(client, value):
+    """A bad total must not escape `_update_state`.
+
+    The old parse did a bare ``int(data["total_layer_num"])``. `_on_message`
+    catches only `JSONDecodeError` and paho is left at
+    ``suppress_exceptions = False``, so anything this raised was re-raised on
+    the network thread and took the printer connection down over one field.
+    `None`, `[]` and `{}` all did exactly that.
+    """
+    client._update_state(running_frame(total_layer_num=value))
+
+    assert client.state.total_layers in (0, 3)  # 3.9 truncates; the rest are 0
+    assert client.state.gcode_file == "widget.3mf"  # the rest of the frame landed
+
+
+def test_an_unusable_total_does_not_stop_the_layer_counter(client):
+    """The read happens before the layer block, so it must not be able to raise.
+
+    Otherwise a firmware sending a malformed total would freeze `layer_num` for
+    the whole print — the frame would abort before reaching it.
+    """
+    client._update_state(running_frame())
+
+    client._update_state({"layer_num": 7, "total_layer_num": "not-a-number"})
+
+    assert client.state.layer_num == 7
+
+
+def test_a_string_total_is_accepted(client):
+    """Bambu ships numbers as strings in plenty of other fields."""
+    client._update_state(running_frame(total_layer_num="33"))
+
+    assert client.state.total_layers == 33
+
+
+def test_a_zero_total_on_the_start_frame_counts_as_no_total(client):
+    """`total_layer_num: 0` is the firmware's "don't know yet", not a value."""
+    client.state.total_layers = 120
+
+    client._update_state(running_frame(total_layer_num=0))
+
+    assert client.state.total_layers == 0
+    assert len(pushalls(client)) == 1
+
+
+# ---------------------------------------------------------------------------
+# A restarted print (file change while RUNNING) takes the same path
+# ---------------------------------------------------------------------------
+
+
+def test_file_change_while_running_also_keeps_its_own_total(client):
+    """`is_file_change` shares the reset, so it needs the same treatment."""
+    client._update_state(running_frame(total_layer_num=33))
+    client._was_running = True
+
+    client._update_state(
+        {"gcode_state": "RUNNING", "gcode_file": "other.3mf", "subtask_name": "other", "total_layer_num": 77}
+    )
+
+    assert client.state.total_layers == 77

+ 118 - 7
backend/tests/unit/test_camera_ffmpeg_termination.py

@@ -1,20 +1,33 @@
-"""Bounded post-kill ffmpeg cleanup (#2580, fix shape from PR #2581 by @ronaldheft).
+"""ffmpeg teardown: draining the pipes, and the bounded waits behind it.
 
 
-A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take
-arbitrarily long to be reaped. The cleanup paths used to ``await process.wait()``
-unbounded after ``kill()`` — on a P2S RTSP read timeout this parked the fan-out
-stream coroutine for 12 hours, leaving every viewer attached to a stalled
-broadcaster while snapshots/diagnostics (fresh connections) kept working.
+Originally #2580 (fix shape from PR #2581 by @ronaldheft): the cleanup paths
+``await process.wait()``-ed unbounded after ``kill()``, which on a P2S RTSP read
+timeout parked the fan-out stream coroutine for 12 hours, leaving every viewer
+attached to a stalled broadcaster while snapshots (fresh connections) kept
+working. Three places had it, all bounded now:
 
 
-The same unbounded wait existed in THREE places, all bounded now:
 1. ``_terminate_ffmpeg`` — the stream generator's cleanup (the reported hang).
 1. ``_terminate_ffmpeg`` — the stream generator's cleanup (the reported hang).
 2. ``stop_camera`` — hung the very request a user makes to recover.
 2. ``stop_camera`` — hung the very request a user makes to recover.
 3. ``cleanup_orphaned_streams`` — hung the janitor that is the safety net.
 3. ``cleanup_orphaned_streams`` — hung the janitor that is the safety net.
+
+That diagnosis — "a SIGKILLed ffmpeg stuck in uninterruptible I/O" — turned out
+to be wrong, and the bound was capping a deadlock of our own making. ffmpeg was
+blocked writing to a stdout pipe nobody was reading, which makes SIGTERM
+unactionable, and ``wait()`` cannot observe an exit while a pipe transport is
+still undrained. So the abandon path fired on every camera close, costing 4s of
+the printer's single camera connection each time. The pipes are drained now; the
+bounds remain as backstops, and the tests for them stay valid.
+
+The draining tests below drive a REAL subprocess, because the failure is in
+asyncio's pipe/transport bookkeeping — a fake process object cannot reproduce
+it and would happily pass against the broken code.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import logging
+import sys
 import time
 import time
 from contextlib import suppress
 from contextlib import suppress
 
 
@@ -24,6 +37,46 @@ from backend.app.api.routes import camera
 
 
 pytestmark = pytest.mark.asyncio
 pytestmark = pytest.mark.asyncio
 
 
+# Stands in for ffmpeg: floods stdout, and handles SIGTERM the way ffmpeg does
+# — a handler that sets a flag which only the main loop checks, so a process
+# blocked in write() never acts on it until something drains the pipe.
+_FFMPEG_LIKE = """
+import signal, sys
+stop = False
+def _handler(*_a):
+    global stop
+    stop = True
+signal.signal(signal.SIGTERM, _handler)
+sys.stderr.write("x" * 4096)
+sys.stderr.flush()
+while not stop:
+    sys.stdout.buffer.write(b"x" * 65536)
+    sys.stdout.buffer.flush()
+"""
+
+# Same, but SIGTERM is ignored outright — forces the SIGKILL branch.
+_SIGTERM_PROOF = """
+import signal, sys
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+while True:
+    sys.stdout.buffer.write(b"x" * 65536)
+    sys.stdout.buffer.flush()
+"""
+
+
+async def _spawn(program: str) -> asyncio.subprocess.Process:
+    """Start the stand-in and let it fill its stdout pipe, as the cancel path
+    leaves a real ffmpeg."""
+    process = await asyncio.create_subprocess_exec(
+        sys.executable,
+        "-c",
+        program,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    await asyncio.sleep(0.4)
+    return process
+
 
 
 class _FakeServer:
 class _FakeServer:
     def close(self) -> None:
     def close(self) -> None:
@@ -106,6 +159,64 @@ class _FrameProcess:
         return self.returncode
         return self.returncode
 
 
 
 
+# ---------------------------------------------------------------------------
+# 0. _terminate_ffmpeg drains the pipes — against a real subprocess
+# ---------------------------------------------------------------------------
+
+
+async def test_terminate_drains_stdout_so_sigterm_works(caplog):
+    """A process blocked writing to a full pipe still shuts down on SIGTERM.
+
+    Undrained, this took the full grace period plus the SIGKILL bound (4s
+    measured) and ended in the abandon error. Drained, SIGTERM lands.
+    """
+    process = await _spawn(_FFMPEG_LIKE)
+    camera._spawned_ffmpeg_pids[process.pid] = time.time()
+
+    with caplog.at_level(logging.WARNING, logger=camera.logger.name):
+        started = time.monotonic()
+        await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-drain"), timeout=5.0)
+        elapsed = time.monotonic() - started
+
+    assert process.returncode is not None, "wait() must observe the exit"
+    # Comfortably under the 2.0s grace period: proves SIGTERM was acted on
+    # rather than timing out into the kill branch.
+    assert elapsed < 1.5, f"teardown took {elapsed:.2f}s — pipes likely not drained"
+    assert "didn't terminate gracefully" not in caplog.text
+    assert "abandoning wait" not in caplog.text
+    assert process.pid not in camera._spawned_ffmpeg_pids
+
+
+async def test_terminate_observes_kill_of_a_sigterm_proof_process(monkeypatch, caplog):
+    """Even when SIGTERM is genuinely ignored, wait() must see the SIGKILL.
+
+    This is the case the abandon error was invented for. With the pipes drained
+    the exit is observable, so it must not fire.
+    """
+    monkeypatch.setattr(camera, "_FFMPEG_TERM_TIMEOUT", 0.3)
+    process = await _spawn(_SIGTERM_PROOF)
+    camera._spawned_ffmpeg_pids[process.pid] = time.time()
+
+    with caplog.at_level(logging.WARNING, logger=camera.logger.name):
+        await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-kill"), timeout=5.0)
+
+    assert process.returncode == -9, "SIGKILLed exit must be observed, not abandoned"
+    assert "didn't terminate gracefully" in caplog.text  # SIGTERM really was ignored
+    assert "abandoning wait" not in caplog.text
+    assert process.pid not in camera._spawned_ffmpeg_pids
+
+
+async def test_terminate_is_a_noop_for_an_already_dead_process():
+    """The early return must still drop the pid from the tracking dict."""
+    process = await asyncio.create_subprocess_exec(sys.executable, "-c", "pass")
+    await process.wait()
+    camera._spawned_ffmpeg_pids[process.pid] = time.time()
+
+    await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-dead"), timeout=2.0)
+
+    assert process.pid not in camera._spawned_ffmpeg_pids
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # 1. _terminate_ffmpeg — the helper itself is bounded
 # 1. _terminate_ffmpeg — the helper itself is bounded
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

+ 287 - 0
backend/tests/unit/test_camera_stderr_tail.py

@@ -0,0 +1,287 @@
+"""Continuous stderr draining for streaming ffmpeg (_FfmpegStderrTail).
+
+ffmpeg is spawned with stderr=PIPE, and stderr used to be read only when
+something had already gone wrong — so for the life of a stream nobody read that
+pipe. ffmpeg writes its banner, the input analysis and then a progress line at a
+steady rate, so a 64 KiB pipe fills eventually, ffmpeg blocks writing to it,
+frames stop, and the stream's own read timeout fires with nothing in the log
+explaining that we starved it.
+
+How long that takes is unmeasured and evidently long — one H2D upstream ran
+21m36s without stalling — so this is a bounded resource being treated as
+unbounded rather than an observed failure. These tests pin the four properties
+that matter: the pipe is always drained, the retained tail is bounded, the tail
+is what the error paths report, and it goes through the same redaction funnel as
+every other stderr log in this module.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from backend.app.api.routes import camera
+
+pytestmark = pytest.mark.asyncio
+
+
+class _Reader:
+    """Feeds queued chunks, then blocks like a live-but-quiet ffmpeg."""
+
+    def __init__(self, chunks: list[bytes], then_block: bool = True) -> None:
+        self._chunks = list(chunks)
+        self._then_block = then_block
+        self.reads = 0
+
+    async def read(self, _size: int = -1) -> bytes:
+        self.reads += 1
+        if self._chunks:
+            return self._chunks.pop(0)
+        if self._then_block:
+            await asyncio.Event().wait()  # never returns, never EOFs
+        return b""
+
+
+class _Proc:
+    def __init__(self, reader, pid: int = 88010) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = None
+        self.stderr = reader
+
+
+@pytest.fixture(autouse=True)
+def _no_leaked_tails():
+    yield
+    # Last-resort teardown only — this fixture is sync, so it cancels without
+    # awaiting. Tests are expected to aclose() their own tails.
+    for tail in list(camera._stderr_tails.values()):
+        if tail._task is not None:
+            tail._task.cancel()
+    camera._stderr_tails.clear()
+
+
+async def _settle() -> None:
+    """Let the pump task run."""
+    for _ in range(5):
+        await asyncio.sleep(0)
+
+
+async def test_it_keeps_draining_a_stream_that_never_closes_stderr():
+    """The whole point: the pipe is read continuously, not on demand."""
+    reader = _Reader([b"first\n", b"second\n"])
+    tail = camera._FfmpegStderrTail(_Proc(reader))
+
+    await _settle()
+
+    assert reader.reads >= 3, "pump stopped reading instead of following the pipe"
+    assert "second" in (tail.text() or "")
+    await tail.aclose()
+
+
+async def test_the_retained_tail_is_bounded():
+    """A long-running stream must not turn the pipe into unbounded memory."""
+    oversized = b"x" * (camera._FFMPEG_STDERR_TAIL_BYTES * 3)
+    tail = camera._FfmpegStderrTail(_Proc(_Reader([oversized])))
+
+    await _settle()
+
+    assert len(tail._buffer) == camera._FFMPEG_STDERR_TAIL_BYTES
+    await tail.aclose()
+
+
+async def test_the_tail_keeps_the_newest_output():
+    """Recent output is what explains a failure; the banner gets stripped anyway."""
+    filler = b"stale-line\n" * 4000
+    tail = camera._FfmpegStderrTail(_Proc(_Reader([filler, b"Connection timed out\n"])))
+
+    await _settle()
+
+    text = tail.text() or ""
+    assert "Connection timed out" in text
+    assert len(tail._buffer) <= camera._FFMPEG_STDERR_TAIL_BYTES
+    await tail.aclose()
+
+
+async def test_read_ffmpeg_stderr_defers_to_the_collector():
+    """Two readers on one StreamReader raise, so the on-demand read must not
+    touch a pipe the collector owns."""
+    reader = _Reader([b"Server returned 401 Unauthorized\n"])
+    process = _Proc(reader)
+    tail = camera._FfmpegStderrTail(process)
+    await _settle()
+    reads_before = reader.reads
+
+    result = await camera._read_ffmpeg_stderr(process)
+
+    assert "401 Unauthorized" in (result or "")
+    assert reader.reads == reads_before, "on-demand read raced the collector"
+    await tail.aclose()
+
+
+async def test_read_ffmpeg_stderr_still_reads_the_pipe_without_a_collector():
+    """An immediately-failed ffmpeg has no collector; that path must still work."""
+    process = _Proc(_Reader([b"Server returned 404 Not Found\n"], then_block=False))
+
+    result = await camera._read_ffmpeg_stderr(process)
+
+    assert "404 Not Found" in (result or "")
+
+
+async def test_the_tail_redacts_the_access_code():
+    """ffmpeg echoes its input URL, which carries the printer's access code.
+
+    This is a new stderr-to-log path, so it gets the same guarantee as the rest:
+    everything goes through _summarize_ffmpeg_stderr.
+    """
+    secret = "12345678"
+    leaky = f"[rtsp @ 0x55] Failed to resolve rtsp://bblp:{secret}@127.0.0.1:8554/streaming/live/1\n"
+    tail = camera._FfmpegStderrTail(_Proc(_Reader([leaky.encode()])))
+
+    await _settle()
+    text = tail.text() or ""
+
+    assert secret not in text, "access code leaked into a log line"
+    # Assert the line SURVIVED with the credential masked, not that it was
+    # dropped — otherwise this passes whenever the summariser happens to filter
+    # the line out, and proves nothing about redaction.
+    assert "Failed to resolve" in text, "line was filtered, so redaction is untested"
+    assert "[REDACTED]" in text
+    await tail.aclose()
+
+
+async def test_close_releases_ownership_and_is_idempotent():
+    process = _Proc(_Reader([b"line\n"]))
+    tail = camera._FfmpegStderrTail(process)
+    await _settle()
+    assert camera._stderr_tails.get(process.pid) is tail
+
+    await tail.aclose()
+    await tail.aclose()  # must not raise
+
+    assert process.pid not in camera._stderr_tails
+
+
+async def test_a_process_without_stderr_is_handled():
+    """Fakes and some spawn paths pass stderr=None; must not register or crash."""
+    process = _Proc(None, pid=88099)
+
+    tail = camera._FfmpegStderrTail(process)
+
+    assert tail.text() is None
+    assert process.pid not in camera._stderr_tails
+    await tail.aclose()
+
+
+async def test_the_stream_generator_owns_then_releases_the_collector(monkeypatch):
+    """Lifecycle inside the real generator: registered while streaming, gone after.
+
+    The other generator tests use fakes with stderr=None, so they never build a
+    collector at all — this is the one that would catch a missing close() or a
+    reader race between the collector and teardown.
+    """
+    printer_id = 8842
+    stream_id = f"{printer_id}-fanout-stderrtail"
+
+    class _FrameThenBlock:
+        def __init__(self) -> None:
+            self._sent = False
+
+        async def read(self, _size: int = -1) -> bytes:
+            if self._sent:
+                await asyncio.Event().wait()  # stay alive, don't trigger reconnect
+            self._sent = True
+            return b"\xff\xd8frame\xff\xd9"
+
+    class _Proc2:
+        def __init__(self) -> None:
+            self.pid = 88042
+            self.returncode = None
+            self.stdout = _FrameThenBlock()
+            self.stderr = _Reader([b"Stream #0:0: Video: h264\n"])
+
+        def terminate(self) -> None:
+            self.returncode = 0
+
+        def kill(self) -> None:
+            self.returncode = -9
+
+        async def wait(self) -> int:
+            if self.returncode is None:
+                self.returncode = 0
+            return self.returncode
+
+    process = _Proc2()
+
+    class _FakeServer:
+        def close(self) -> None:
+            pass
+
+        async def wait_closed(self) -> None:
+            pass
+
+    async def _fake_exec(*_a, **_kw):
+        return process
+
+    async def _fake_proxy(_ip, _port):
+        return 48777, _FakeServer()
+
+    monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
+    monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
+    monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
+
+    stream = camera.generate_rtsp_mjpeg_stream(
+        ip_address="192.0.2.44",
+        access_code="c",
+        model="P2S",
+        fps=15,
+        stream_id=stream_id,
+        disconnect_event=asyncio.Event(),
+        printer_id=printer_id,
+    )
+    try:
+        chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
+        assert b"frame" in chunk
+        assert process.pid in camera._stderr_tails, "generator did not take stderr ownership"
+
+        await asyncio.wait_for(stream.aclose(), timeout=5.0)
+
+        assert process.pid not in camera._stderr_tails, "collector outlived its stream"
+    finally:
+        camera._active_streams.pop(stream_id, None)
+        camera._disconnect_events.pop(stream_id, None)
+        camera._stream_last_frame_times.pop(stream_id, None)
+        camera._last_frames.pop(printer_id, None)
+        camera._last_frame_times.pop(printer_id, None)
+        camera._stream_start_times.pop(printer_id, None)
+        camera._spawned_ffmpeg_pids.pop(process.pid, None)
+
+
+async def test_terminate_skips_stderr_while_a_collector_owns_it():
+    """_terminate_ffmpeg must not add a second reader to an owned pipe."""
+    reader = _Reader([b"tearing down\n"])
+
+    class _Killable(_Proc):
+        def terminate(self):
+            self.returncode = 0
+
+        def kill(self):
+            self.returncode = -9
+
+        async def wait(self):
+            if self.returncode is None:
+                self.returncode = 0
+            return self.returncode
+
+    process = _Killable(reader, pid=88020)
+    tail = camera._FfmpegStderrTail(process)
+    await _settle()
+    reads_before = reader.reads
+
+    await asyncio.wait_for(camera._terminate_ffmpeg(process, "88020-fanout-abcd"), timeout=2.0)
+
+    # The collector, not _terminate_ffmpeg, is the only reader that advanced.
+    assert reader.reads >= reads_before
+    assert tail.text() is not None
+    await tail.aclose()

+ 238 - 0
backend/tests/unit/test_camera_stream_registry_isolation.py

@@ -0,0 +1,238 @@
+"""A departing camera stream must not clean up its successor's state.
+
+The fan-out stream id used to be ``f"{printer_id}-fanout"`` — constant per
+printer, so every successive stream shared one registry key — and the
+generator's ``finally`` popped the per-printer frame buffer unconditionally.
+Teardown taking ~4s (the undrained-pipe deadlock, fixed separately) made the
+overlap wide enough to hit by closing and reopening the camera:
+
+    12.221  stream A cancelled, begins teardown
+    12.324  new viewer attaches
+    16.223  A finishes killing
+    16.224  new generator registers _active_streams["1-fanout"]
+            ...then A's finally pops that very entry
+
+The damage is not cosmetic. ``is_stream_active()`` is what the #1348 / #1271
+guards consult before deciding whether opening a second camera connection is
+safe, so a printer with a viewer attached looked idle; the janitor's /proc scan
+reaps any ffmpeg missing from ``_active_streams``, so it killed the live stream;
+and ``/camera/stop`` reported ``Stopped 0``.
+
+The external-camera path already solved this with a per-instance id (#2675).
+These tests pin the same property for the fan-out path.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import suppress
+
+import pytest
+
+from backend.app.api.routes import camera
+
+pytestmark = pytest.mark.asyncio
+
+PRINTER_ID = 7701
+
+
+@pytest.fixture(autouse=True)
+def _clean_registries():
+    """These registries are module-global; leave them as we found them."""
+
+    def _purge():
+        for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
+            camera._active_streams.pop(sid, None)
+        for sid in [k for k in camera._active_chamber_streams if k.startswith(f"{PRINTER_ID}-")]:
+            camera._active_chamber_streams.pop(sid, None)
+        for sid in [k for k in camera._stream_last_frame_times if k.startswith(f"{PRINTER_ID}-")]:
+            camera._stream_last_frame_times.pop(sid, None)
+        for sid in [k for k in camera._disconnect_events if k.startswith(f"{PRINTER_ID}-")]:
+            camera._disconnect_events.pop(sid, None)
+        camera._last_frames.pop(PRINTER_ID, None)
+        camera._last_frame_times.pop(PRINTER_ID, None)
+        camera._stream_start_times.pop(PRINTER_ID, None)
+
+    _purge()
+    yield
+    _purge()
+
+
+def _seed_frame_state() -> None:
+    camera._last_frames[PRINTER_ID] = b"\xff\xd8live\xff\xd9"
+    camera._last_frame_times[PRINTER_ID] = time.time()
+    camera._stream_start_times[PRINTER_ID] = time.time()
+
+
+# ---------------------------------------------------------------------------
+# _new_fanout_stream_id — one key per stream, not per printer
+# ---------------------------------------------------------------------------
+
+
+async def test_fanout_stream_ids_are_unique_per_stream():
+    """Two streams for one printer must never collide in the registries."""
+    ids = {camera._new_fanout_stream_id(PRINTER_ID) for _ in range(50)}
+
+    assert len(ids) == 50, "ids collide, so one stream can clean up another's entry"
+
+
+async def test_camera_stream_has_no_function_local_module_imports():
+    """A local ``import x`` anywhere in camera_stream shadows x for the WHOLE
+    function, including branches that never reach the import.
+
+    This is not hypothetical: an ``import uuid`` inside the external-camera
+    branch meant building the fan-out id on the RTSP path raised
+    UnboundLocalError, so the camera would not start on any printer without an
+    external camera configured. ``time`` and ``uuid`` are module-level now;
+    keep them that way.
+    """
+    import ast
+    import inspect
+
+    tree = ast.parse(inspect.getsource(camera.camera_stream))
+    local_imports = [alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names]
+
+    assert local_imports == [], f"function-local imports shadow the whole function: {local_imports}"
+
+
+async def test_fanout_stream_id_keeps_the_printer_prefix():
+    """is_stream_active / stop_camera_stream / camera-status all scan for it."""
+    stream_id = camera._new_fanout_stream_id(PRINTER_ID)
+
+    assert stream_id.startswith(f"{PRINTER_ID}-")
+    camera._active_streams[stream_id] = object()
+    assert camera.is_stream_active(PRINTER_ID) is True
+
+
+# ---------------------------------------------------------------------------
+# _release_printer_frame_state — the ownership check itself
+# ---------------------------------------------------------------------------
+
+
+async def test_frame_state_survives_when_another_rtsp_stream_is_live():
+    _seed_frame_state()
+    camera._active_streams[f"{PRINTER_ID}-fanout-successor"] = object()
+
+    camera._release_printer_frame_state(PRINTER_ID)
+
+    assert PRINTER_ID in camera._last_frames, "successor's buffered frame was wiped"
+    assert PRINTER_ID in camera._last_frame_times
+    assert PRINTER_ID in camera._stream_start_times
+
+
+async def test_frame_state_survives_when_a_chamber_stream_is_live():
+    """A1/P1 models register in a different dict; ownership spans both."""
+    _seed_frame_state()
+    camera._active_chamber_streams[f"{PRINTER_ID}-fanout-successor"] = (None, None)
+
+    camera._release_printer_frame_state(PRINTER_ID)
+
+    assert PRINTER_ID in camera._last_frames
+
+
+async def test_last_stream_out_releases_the_frame_state():
+    """The other half: with nothing left running, stale state must not linger."""
+    _seed_frame_state()
+
+    camera._release_printer_frame_state(PRINTER_ID)
+
+    assert PRINTER_ID not in camera._last_frames
+    assert PRINTER_ID not in camera._last_frame_times
+    assert PRINTER_ID not in camera._stream_start_times
+
+
+async def test_release_is_a_noop_without_a_printer_id():
+    _seed_frame_state()
+
+    camera._release_printer_frame_state(None)
+
+    assert PRINTER_ID in camera._last_frames
+
+
+# ---------------------------------------------------------------------------
+# The whole generator cleanup path, with a successor already registered
+# ---------------------------------------------------------------------------
+
+
+class _FakeServer:
+    def close(self) -> None:
+        pass
+
+    async def wait_closed(self) -> None:
+        pass
+
+
+class _OneFrameThenEOF:
+    def __init__(self) -> None:
+        self._sent = False
+
+    async def read(self, _size: int = -1) -> bytes:
+        if self._sent:
+            return b""
+        self._sent = True
+        return b"\xff\xd8predecessor\xff\xd9"
+
+
+class _Proc:
+    def __init__(self, pid: int = 77010) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = _OneFrameThenEOF()
+        self.stderr = None
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        if self.returncode is None:
+            self.returncode = 0
+        return self.returncode
+
+
+async def test_departing_generator_leaves_its_successors_registry_entry_alone(monkeypatch):
+    """End of the real cleanup path, with a second stream already registered."""
+
+    async def _fake_exec(*_args, **_kwargs):
+        return _Proc()
+
+    async def _fake_proxy(_ip: str, _port: int):
+        return 48999, _FakeServer()
+
+    monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
+    monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
+    monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
+
+    predecessor_id = f"{PRINTER_ID}-fanout-aaaaaaaa"
+    successor_id = f"{PRINTER_ID}-fanout-bbbbbbbb"
+
+    stream = camera.generate_rtsp_mjpeg_stream(
+        ip_address="192.0.2.31",
+        access_code="test-code",
+        model="P2S",
+        fps=15,
+        stream_id=predecessor_id,
+        disconnect_event=asyncio.Event(),
+        printer_id=PRINTER_ID,
+    )
+
+    # Drive it far enough to buffer a frame, as a real viewer would.
+    chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
+    assert b"predecessor" in chunk
+    assert camera._last_frames[PRINTER_ID].endswith(b"predecessor\xff\xd9")
+
+    # A viewer reopens the camera mid-teardown: a fresh stream registers under
+    # its own id and republishes the buffered frame.
+    camera._active_streams[successor_id] = object()
+    camera._last_frames[PRINTER_ID] = b"\xff\xd8successor\xff\xd9"
+
+    with suppress(Exception):
+        await asyncio.wait_for(stream.aclose(), timeout=5.0)
+
+    assert successor_id in camera._active_streams, "predecessor removed its successor's entry"
+    assert camera.is_stream_active(PRINTER_ID) is True, "a viewer is attached; guards must see it"
+    assert camera._last_frames[PRINTER_ID].endswith(b"successor\xff\xd9"), "successor's frame was wiped"
+    assert predecessor_id not in camera._active_streams, "predecessor must still clean up after itself"

+ 4 - 0
backend/tests/unit/test_camera_usb_stream_cleanup.py

@@ -33,6 +33,10 @@ class _CleanProc:
     def __init__(self, pid: int) -> None:
     def __init__(self, pid: int) -> None:
         self.pid = pid
         self.pid = pid
         self.returncode = None
         self.returncode = None
+        # Real Process objects always expose these (None when not piped), and
+        # _terminate_ffmpeg drains them so a full pipe can't wedge the exit.
+        self.stdout = None
+        self.stderr = None
 
 
     def terminate(self) -> None:
     def terminate(self) -> None:
         self.returncode = 0
         self.returncode = 0

+ 29 - 0
backend/tests/unit/test_config_env_warnings.py

@@ -9,6 +9,35 @@ import logging
 import pytest
 import pytest
 
 
 
 
+@pytest.fixture(autouse=True)
+def _restore_config_module():
+    """Undo the ``importlib.reload`` these tests depend on.
+
+    Reloading ``backend.app.core.config`` re-executes it, so ``settings`` becomes
+    a *new* object built from the environment as it stands mid-test. Nothing put
+    the old one back. ``monkeypatch`` unwinds the env vars, not the reload.
+
+    The result is two live ``Settings`` instances in one process: every module
+    that did ``from ... config import settings`` at import time keeps the
+    original, while anything resolving ``config.settings`` afterwards gets the
+    replacement — and under xdist that split persisted for every later test in
+    the same worker. It surfaced as unrelated path assertions failing with a
+    ``base_dir`` from *this* module's tmp_path (``TestLibraryPathHelpers``,
+    ``TestUploadSourceThreeMF``, ``TestArchivePlatesDesignOverrides``,
+    ``TestSystemHealthAPI``), which is why it looked like a random flake and
+    moved between runs as the work distribution changed.
+
+    Snapshotting the whole module dict rather than just ``settings`` restores
+    object *identity*, which is what the two views have to agree on.
+    """
+    import backend.app.core.config as cfg_mod
+
+    saved = dict(cfg_mod.__dict__)
+    yield
+    cfg_mod.__dict__.clear()
+    cfg_mod.__dict__.update(saved)
+
+
 @pytest.mark.unit
 @pytest.mark.unit
 def test_unknown_mfa_env_var_logs_info(monkeypatch, caplog):
 def test_unknown_mfa_env_var_logs_info(monkeypatch, caplog):
     """A typo'd MFA_* env var must be logged at INFO so operators see it."""
     """A typo'd MFA_* env var must be logged at INFO so operators see it."""

+ 144 - 0
backend/tests/unit/test_dispatch_claim_recovery.py

@@ -0,0 +1,144 @@
+"""A dispatch claim must not survive the dispatch that held it (#2615, #2702).
+
+``dispatching_at`` holds a queue row out of the selection query for the duration
+of an upload. Clearing it is best-effort, and the observed failure was narrow:
+PostgreSQL refused a connection for a second or two at exactly the moment
+dispatch ended, the single clear attempt failed, and the row stayed invisible to
+the scheduler until the process restarted.
+
+Two independent recoveries, tested here: the clear retries, and a later tick
+releases any claim with no dispatch behind it.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture
+def scheduler():
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    return PrintScheduler()
+
+
+def _session(fail_times: int) -> MagicMock:
+    """A session whose execute() fails `fail_times` times, then succeeds."""
+    db = MagicMock()
+    calls = {"n": 0}
+
+    async def execute(*_a, **_k):
+        calls["n"] += 1
+        if calls["n"] <= fail_times:
+            raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
+        return MagicMock(rowcount=1)
+
+    db.execute = AsyncMock(side_effect=execute)
+    db.commit = AsyncMock()
+    db.rollback = AsyncMock()
+    db._calls = calls
+    return db
+
+
+# ---------------------------------------------------------------------------
+# The retry
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_a_transient_failure_is_retried_and_the_claim_clears(scheduler):
+    """The reported case: one failed attempt used to wedge the row."""
+    db = _session(fail_times=1)
+
+    with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
+        await scheduler._clear_dispatch_claim(db, 597)
+
+    assert db._calls["n"] == 2
+    assert db.commit.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_session_is_rolled_back_between_attempts(scheduler):
+    """A failed write leaves the session needing a rollback before reuse."""
+    db = _session(fail_times=1)
+
+    with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
+        await scheduler._clear_dispatch_claim(db, 597)
+
+    assert db.rollback.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_retries_are_bounded_and_never_raise(scheduler):
+    """Dispatch's outcome must not be masked by this cleanup failing."""
+    db = _session(fail_times=99)
+
+    with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
+        await scheduler._clear_dispatch_claim(db, 597)  # must not raise
+
+    assert db._calls["n"] == 3
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_no_retry_when_the_first_attempt_works(scheduler):
+    """The happy path must not pay for the retry."""
+    db = _session(fail_times=0)
+
+    await scheduler._clear_dispatch_claim(db, 597)
+
+    assert db._calls["n"] == 1
+
+
+# ---------------------------------------------------------------------------
+# The quiet-tick sweep
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_sweep_does_nothing_while_an_upload_is_in_flight(scheduler):
+    """An in-flight dispatch owns its claim — clearing it would let a second
+    dispatch pick up the same row mid-upload, which is what #2615 prevents."""
+    scheduler._inflight[597] = (MagicMock(), 1)
+
+    with patch("backend.app.services.print_scheduler.async_session") as sess:
+        await scheduler._clear_stale_dispatch_claims()
+
+    sess.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_sweep_releases_a_claim_with_nothing_in_flight(scheduler):
+    """`_inflight` is populated before the coroutine claims its row, and pruned
+    after its `finally` — so "claim present, nothing in flight" is orphaned."""
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=MagicMock(rowcount=1))
+    db.commit = AsyncMock()
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
+        await scheduler._clear_stale_dispatch_claims()
+
+    assert db.execute.await_count == 1
+    assert db.commit.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_sweep_survives_a_database_that_is_still_down(scheduler):
+    """It runs every tick; a failure must not break the scheduler loop."""
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("still refusing connections"))
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
+        await scheduler._clear_stale_dispatch_claims()  # must not raise

+ 263 - 0
backend/tests/unit/test_expected_print_rollback.py

@@ -0,0 +1,263 @@
+"""A dispatch that never sends the print command must leave no expectation.
+
+``register_expected_print`` has to run *before* the MQTT command, because the
+printer can report the print before the send returns. So any path that
+registers and then fails to send leaves Bambuddy expecting a print that will
+never arrive: a cancel winning the #1853 CAS race, ``start_print()`` returning
+False, or an exception in between — a PostgreSQL connection failure mid-dispatch
+is the case that surfaced this (#2702 follow-up).
+
+The two-hour TTL sweep does eventually evict such an entry, but two hours is far
+longer than it takes someone to react to a failed dispatch by pressing print
+again. That reprint would be folded into the *old* archive and inherit its
+``ams_mapping`` and ``plate_id`` instead of creating a fresh one.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture
+def expected_print_tables():
+    """The module-level registries, emptied around each test."""
+    from backend.app import main
+
+    names = (
+        "_expected_prints",
+        "_expected_print_creators",
+        "_expected_print_registered_at",
+        "_print_ams_mappings",
+        "_print_plate_ids",
+    )
+    saved = {n: dict(getattr(main, n)) for n in names}
+    for n in names:
+        getattr(main, n).clear()
+    yield main
+    for n in names:
+        getattr(main, n).clear()
+        getattr(main, n).update(saved[n])
+
+
+# ---------------------------------------------------------------------------
+# unregister_expected_print is the exact inverse of register_expected_print
+# ---------------------------------------------------------------------------
+
+
+def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
+    """The strongest form: register then unregister is a round trip to empty."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1)
+    assert main._expected_prints, "nothing registered — the test proves nothing"
+
+    main.unregister_expected_print(1, "widget.3mf", 298)
+
+    assert main._expected_prints == {}
+    assert main._expected_print_creators == {}
+    assert main._expected_print_registered_at == {}
+    assert main._print_ams_mappings == {}
+    assert main._print_plate_ids == {}
+
+
+def test_unregister_clears_the_filename_variants_too(expected_print_tables):
+    """Registration stores the name three ways; a partial undo still matches."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298)
+    main.unregister_expected_print(1, "widget.3mf", 298)
+
+    for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
+        assert key not in main._expected_prints, f"{key} survived"
+
+
+def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298)
+    main.register_expected_print(2, "widget.3mf", 299)
+
+    main.unregister_expected_print(1, "widget.3mf", 298)
+
+    assert main._expected_prints[(2, "widget.3mf")] == 299
+
+
+def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
+    expected_print_tables,
+):
+    """Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
+
+    ``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
+    by file. Two files can be registered against one archive, so dropping them
+    on the first unregister would strip usage-tracking data from a print that is
+    still expected.
+    """
+    main = expected_print_tables
+
+    main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
+    main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
+
+    main.unregister_expected_print(1, "plate1.3mf", 298)
+
+    assert main._print_ams_mappings.get(298) == [3]
+    assert 298 in main._print_plate_ids
+
+
+def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
+    """Runs from a ``finally``, so it must tolerate having nothing to do."""
+    main = expected_print_tables
+
+    main.unregister_expected_print(99, "never-registered.3mf", 1234)
+
+    assert main._expected_prints == {}
+
+
+# ---------------------------------------------------------------------------
+# The scheduler's rollback hook
+# ---------------------------------------------------------------------------
+
+
+def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
+    sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+
+    sched._rollback_unconfirmed_expected_print(597)
+
+    assert main._expected_prints == {}
+    assert sched._unconfirmed_expected_print == {}
+
+
+def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
+    """A sent print's expectation must survive — the callback needs it."""
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
+    sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+    # What `_start_print` does once start_print() returns True.
+    sched._unconfirmed_expected_print.pop(597, None)
+
+    sched._rollback_unconfirmed_expected_print(597)
+
+    assert main._expected_prints[(1, "widget.3mf")] == 298
+    assert main._print_ams_mappings[298] == [3, 6]
+
+
+def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
+    """It runs in the ``finally`` of dispatch, usually with an exception already
+    propagating — it must not replace it with one of its own."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+    monkeypatch.setattr(
+        expected_print_tables,
+        "unregister_expected_print",
+        lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
+    )
+
+    sched._rollback_unconfirmed_expected_print(597)  # must not raise
+
+    assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
+    """End to end through `_dispatch_one`, on the reported failure.
+
+    A database error inside `_start_print` must leave no expectation behind, must
+    still release the claim, and must not be swallowed — the background-task
+    runner logs it, and hiding it here would turn a loud failure into a silent
+    one.
+    """
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+
+    async def fake_start_print(db, item):
+        # What `_start_print` does before the point the real one died.
+        main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
+        sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+        raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
+
+    db = MagicMock()
+    db.get = AsyncMock(return_value=MagicMock(id=597))
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with (
+        patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
+        patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
+        patch.object(sched, "_start_print", side_effect=fake_start_print),
+        patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
+        pytest.raises(RuntimeError),
+    ):
+        await sched._dispatch_one(597)
+
+    assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
+    assert main._print_ams_mappings == {}
+    assert main._print_plate_ids == {}
+    assert sched._unconfirmed_expected_print == {}
+    clear.assert_awaited_once_with(db, 597)
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
+    """The mirror image: a confirmed send must survive dispatch teardown, or the
+    print-complete callback would create a duplicate archive."""
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+
+    async def fake_start_print(db, item):
+        main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
+        sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+        sched._unconfirmed_expected_print.pop(597, None)  # start_print() returned True
+
+    db = MagicMock()
+    db.get = AsyncMock(return_value=MagicMock(id=597))
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with (
+        patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
+        patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
+        patch.object(sched, "_start_print", side_effect=fake_start_print),
+        patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
+    ):
+        await sched._dispatch_one(597)
+
+    assert main._expected_prints[(1, "widget.3mf")] == 298
+    assert main._print_ams_mappings[298] == [3, 6]
+
+
+def test_rollback_entries_are_per_item(expected_print_tables):
+    """Two dispatches in flight must not roll back each other's registration."""
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    main.register_expected_print(1, "a.3mf", 1)
+    main.register_expected_print(2, "b.3mf", 2)
+    sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
+    sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
+
+    sched._rollback_unconfirmed_expected_print(10)
+
+    assert (1, "a.3mf") not in main._expected_prints
+    assert main._expected_prints[(2, "b.3mf")] == 2

+ 297 - 0
backend/tests/unit/test_external_camera_live_frame_reuse.py

@@ -0,0 +1,297 @@
+"""External-camera captures must reuse the live view's frame (#2707).
+
+A USB camera allows exactly one V4L2 handle, so a one-shot capture taken while
+somebody is watching the live view doesn't degrade — it fails. The reporter
+measured 0 of 87 and 0 of 105 layer-timelapse captures on prints watched from
+start to finish, and finish-photo notifications going out with no image.
+
+The guards for the built-in camera (#1348, #1271) were never extended to the
+external paths, and the deeper reason they couldn't be: ``_last_frames`` was
+only ever populated by the built-in paths. ``generate_mjpeg_stream`` yields
+multipart-wrapped chunks, so the route layer had no way to recover the JPEG —
+hence the ``on_frame`` callback, and hence a guard alone would have found an
+empty buffer and skipped every time.
+
+These tests cover the plumbing (raw frames reach the callback) and each consumer
+that used to compete: layer timelapse, Obico polling, and plate detection.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes import camera
+from backend.app.services import external_camera, layer_timelapse
+from backend.app.services.obico_detection import ObicoDetectionService
+
+pytestmark = pytest.mark.asyncio
+
+LIVE_FRAME = b"\xff\xd8live-viewer-frame\xff\xd9"
+FRESH_FRAME = b"\xff\xd8fresh-capture\xff\xd9"
+PRINTER_ID = 9310
+
+
+@pytest.fixture(autouse=True)
+def _clean_registries():
+    def _purge():
+        for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
+            camera._active_streams.pop(sid, None)
+        camera._last_frames.pop(PRINTER_ID, None)
+        camera._last_frame_times.pop(PRINTER_ID, None)
+        camera._stream_start_times.pop(PRINTER_ID, None)
+
+    _purge()
+    yield
+    _purge()
+
+
+def _attach_viewer(frame: bytes | None = LIVE_FRAME) -> None:
+    """Register a live external stream, as the stream route does."""
+    camera._active_streams[f"{PRINTER_ID}-ext-deadbeef"] = object()
+    if frame is not None:
+        camera._last_frames[PRINTER_ID] = frame
+
+
+# ---------------------------------------------------------------------------
+# live_frame_for_capture — the shared decision
+# ---------------------------------------------------------------------------
+
+
+async def test_no_viewer_means_capture_normally():
+    defer, frame = camera.live_frame_for_capture(PRINTER_ID)
+
+    assert defer is False
+    assert frame is None
+
+
+async def test_viewer_with_a_buffered_frame_is_reused():
+    _attach_viewer()
+
+    defer, frame = camera.live_frame_for_capture(PRINTER_ID)
+
+    assert defer is True
+    assert frame == LIVE_FRAME
+
+
+async def test_viewer_with_an_empty_buffer_means_skip_not_capture():
+    """#1348: competing for the device is worse than missing one frame."""
+    _attach_viewer(frame=None)
+
+    defer, frame = camera.live_frame_for_capture(PRINTER_ID)
+
+    assert defer is True
+    assert frame is None
+
+
+# ---------------------------------------------------------------------------
+# on_frame plumbing — without this the buffer is always empty
+# ---------------------------------------------------------------------------
+
+
+async def test_on_frame_receives_the_raw_jpeg_not_the_multipart_chunk():
+    """The consumers want a JPEG; the stream yields multipart. Hence a callback."""
+    captured: list[bytes] = []
+
+    async def _fake_usb(_url, _fps, on_process=None):
+        yield FRESH_FRAME
+
+    with patch.object(external_camera, "_stream_usb", _fake_usb):
+        chunks = [
+            chunk
+            async for chunk in external_camera.generate_mjpeg_stream(
+                "/dev/video0", "usb", fps=15, on_frame=captured.append
+            )
+        ]
+
+    assert captured == [FRESH_FRAME], "callback did not get the raw frame"
+    assert b"--frame" in chunks[0], "wire format should still be multipart"
+    assert b"--frame" not in captured[0]
+
+
+async def test_a_raising_on_frame_callback_cannot_break_the_stream():
+    """Buffering is a side effect; it must never take the live view down."""
+
+    async def _fake_usb(_url, _fps, on_process=None):
+        yield FRESH_FRAME
+        yield FRESH_FRAME
+
+    def _boom(_frame: bytes) -> None:
+        raise RuntimeError("buffering blew up")
+
+    with patch.object(external_camera, "_stream_usb", _fake_usb):
+        chunks = [
+            chunk async for chunk in external_camera.generate_mjpeg_stream("/dev/video0", "usb", fps=15, on_frame=_boom)
+        ]
+
+    assert len(chunks) == 2, "stream stopped because the callback raised"
+
+
+# ---------------------------------------------------------------------------
+# Layer timelapse — the 0-of-87 case
+# ---------------------------------------------------------------------------
+
+
+def _session(tmp_path) -> layer_timelapse.TimelapseSession:
+    with patch.object(layer_timelapse.settings, "base_dir", tmp_path):
+        return layer_timelapse.TimelapseSession(
+            printer_id=PRINTER_ID,
+            archive_id=None,
+            camera_url="/dev/video0",
+            camera_type="usb",
+        )
+
+
+async def test_timelapse_uses_the_live_frame_instead_of_competing(tmp_path):
+    session = _session(tmp_path)
+    _attach_viewer()
+
+    with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
+        captured = await session.capture_layer(1)
+
+    assert captured is True, "layer capture failed with a viewer attached"
+    # Would have opened a competing handle on a single-reader device.
+    mock_capture.assert_not_called()
+    written = sorted(session.frames_dir.glob("layer_*.jpg"))
+    assert len(written) == 1
+    assert written[0].read_bytes() == LIVE_FRAME
+
+
+async def test_timelapse_skips_a_layer_rather_than_competing_on_an_empty_buffer(tmp_path):
+    session = _session(tmp_path)
+    _attach_viewer(frame=None)
+
+    with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
+        captured = await session.capture_layer(1)
+
+    assert captured is False
+    mock_capture.assert_not_called()
+    assert sorted(session.frames_dir.glob("layer_*.jpg")) == []
+
+
+async def test_timelapse_captures_normally_with_no_viewer(tmp_path):
+    """The unwatched path must be untouched — this is the common case."""
+    session = _session(tmp_path)
+
+    with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
+        captured = await session.capture_layer(1)
+
+    assert captured is True
+    mock_capture.assert_awaited_once()
+    written = sorted(session.frames_dir.glob("layer_*.jpg"))
+    assert written[0].read_bytes() == FRESH_FRAME
+
+
+# ---------------------------------------------------------------------------
+# Obico polling — external branch, mirroring the built-in one
+# ---------------------------------------------------------------------------
+
+
+def _external_printer() -> MagicMock:
+    return MagicMock(
+        external_camera_enabled=True,
+        external_camera_url="/dev/video0",
+        external_camera_type="usb",
+        external_camera_snapshot_url=None,
+        ip_address="192.168.1.10",
+        access_code="12345678",
+        model="A1",
+    )
+
+
+def _db_returning(printer) -> MagicMock:
+    session = MagicMock()
+    session.get = AsyncMock(return_value=printer)
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=session)
+    ctx.__aexit__ = AsyncMock(return_value=None)
+    return ctx
+
+
+async def test_obico_reuses_the_live_external_frame():
+    _attach_viewer()
+    svc = ObicoDetectionService()
+
+    with (
+        patch(
+            "backend.app.services.obico_detection.async_session",
+            return_value=_db_returning(_external_printer()),
+        ),
+        patch(
+            "backend.app.services.external_camera.capture_frame",
+            new=AsyncMock(return_value=FRESH_FRAME),
+        ) as mock_capture,
+    ):
+        result = await svc._capture_frame(printer_id=PRINTER_ID)
+
+    assert result == LIVE_FRAME
+    mock_capture.assert_not_called()
+
+
+async def test_obico_skips_the_poll_when_the_external_buffer_is_empty():
+    _attach_viewer(frame=None)
+    svc = ObicoDetectionService()
+
+    with (
+        patch(
+            "backend.app.services.obico_detection.async_session",
+            return_value=_db_returning(_external_printer()),
+        ),
+        patch(
+            "backend.app.services.external_camera.capture_frame",
+            new=AsyncMock(return_value=FRESH_FRAME),
+        ) as mock_capture,
+    ):
+        result = await svc._capture_frame(printer_id=PRINTER_ID)
+
+    assert result is None
+    mock_capture.assert_not_called()
+
+
+async def test_obico_still_captures_when_nobody_is_watching():
+    svc = ObicoDetectionService()
+
+    with (
+        patch(
+            "backend.app.services.obico_detection.async_session",
+            return_value=_db_returning(_external_printer()),
+        ),
+        patch(
+            "backend.app.services.external_camera.capture_frame",
+            new=AsyncMock(return_value=FRESH_FRAME),
+        ) as mock_capture,
+    ):
+        result = await svc._capture_frame(printer_id=PRINTER_ID)
+
+    assert result == FRESH_FRAME
+    mock_capture.assert_awaited_once()
+
+
+# ---------------------------------------------------------------------------
+# Plate detection — its docstring already promised this
+# ---------------------------------------------------------------------------
+
+
+async def test_plate_detection_reuses_the_live_external_frame():
+    from backend.app.services import plate_detection
+
+    _attach_viewer()
+
+    with patch(
+        "backend.app.services.external_camera.capture_frame",
+        new=AsyncMock(return_value=FRESH_FRAME),
+    ) as mock_capture:
+        image, source = await plate_detection.capture_camera_image(
+            printer_id=PRINTER_ID,
+            ip_address="192.168.1.10",
+            access_code="12345678",
+            model="A1",
+            external_camera_url="/dev/video0",
+            external_camera_type="usb",
+            use_external=True,
+        )
+
+    assert image == LIVE_FRAME
+    assert source == "external (buffered)"
+    mock_capture.assert_not_called()

+ 650 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -0,0 +1,650 @@
+"""Outbound-URL SSRF policy: two tiers, applied consistently.
+
+Bambuddy makes outbound HTTP requests to hosts the operator configures. Which
+policy applies is a property of the *service*, not the caller:
+
+- LAN-service (Spoolman, ntfy, Bark, webhooks, Home Assistant, Obico ML, the
+  slicer sidecars) — loopback and RFC-1918 MUST stay reachable, because
+  self-hosting those next to Bambuddy is the normal topology. Blocking them
+  would break most installs, which is why a blanket private-IP blocklist is
+  the wrong fix here.
+- Public-internet (OIDC issuer and icon URLs) — a private address cannot be a
+  real IdP, so it is a probe.
+
+Both tiers reject what is dangerous under any topology: non-HTTP schemes,
+numeric-encoded IPs, cloud-metadata endpoints, multicast/unspecified, and
+IPv4-mapped IPv6 encodings of the above.
+
+The separate concern covered here is *response-body echo*. Notification
+provider URLs are writable by anyone holding ``NOTIFICATIONS_CREATE`` — which
+the default Operators group carries and which does NOT imply
+``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` takes the URL
+from the request body without persisting it. Returning the upstream body there
+made an intended reachability check into an authenticated read primitive
+against anything the process can reach. Providers whose host Bambuddy pins
+(Pushover, Telegram, CallMeBot, Discord) may still echo, since the caller
+cannot influence the destination.
+"""
+
+from __future__ import annotations
+
+import inspect
+import re
+
+import httpx
+import pytest
+
+from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+from backend.app.schemas.auth import OIDCProviderCreate, OIDCProviderUpdate
+from backend.app.schemas.settings import LAN_SERVICE_URL_SETTINGS, AppSettingsUpdate
+from backend.app.services import notification_service as ns
+from backend.app.services.homeassistant import HomeAssistantService
+from backend.app.services.rest_smart_plug import RESTSmartPlugService
+from backend.app.services.tasmota import TasmotaService
+
+# Dangerous under any topology — both tiers must reject all of these.
+UNIVERSALLY_BLOCKED = [
+    "file:///etc/passwd",
+    "gopher://127.0.0.1:6379/_INFO",
+    "ftp://internal.example.com/",
+    "http://169.254.169.254/latest/meta-data/",
+    "http://100.100.100.200/",
+    "http://[fd00:ec2::254]/",
+    "http://2130706433/",
+    "http://0x7f000001/",
+    "http://[::ffff:169.254.169.254]/",
+    "http://0.0.0.0/",
+    "http://239.255.255.250/",
+    # The DNS-name form of the same target. Neither tier resolves hostnames,
+    # but these are a fixed literal set, so matching them costs no lookup.
+    "http://metadata.google.internal/",
+    "http://METADATA.GOOGLE.INTERNAL/computeMetadata/v1/",
+    "http://metadata.goog/",
+]
+
+# The normal self-hosted topology — the LAN tier must permit all of these.
+LAN_ALLOWED = [
+    "http://127.0.0.1:7912/",
+    "http://localhost:3003",
+    "http://192.168.1.50:8123",
+    "http://10.0.0.7:3333",
+    "http://172.16.4.9:8080",
+    "https://ntfy.example.com/",
+    "http://spoolman.lan:7912",
+]
+
+
+# ---------------------------------------------------------------------------
+# The LAN-service tier
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_lan_tier_rejects_universally_dangerous_targets(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_lan_service_url(url, label="Test URL")
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_lan_tier_permits_the_normal_self_hosted_topology(url: str):
+    """A blanket private-IP block here would break most real installs."""
+    assert_safe_lan_service_url(url, label="Test URL")
+
+
+def test_lan_tier_names_the_field_in_its_error():
+    with pytest.raises(ValueError, match="ntfy server URL"):
+        assert_safe_lan_service_url("file:///etc/passwd", label="ntfy server URL")
+
+
+def test_spoolman_wrapper_keeps_its_user_facing_wording():
+    """The wording is asserted by pre-existing tests; delegation must not change it."""
+    with pytest.raises(ValueError, match="^Spoolman URL must use http or https$"):
+        assert_safe_spoolman_url("file:///etc/passwd")
+    with pytest.raises(ValueError, match="^Spoolman URL must not point to a cloud metadata endpoint$"):
+        assert_safe_spoolman_url("http://169.254.169.254/")
+
+
+# ---------------------------------------------------------------------------
+# The public-internet tier
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_public_tier_rejects_universally_dangerous_targets(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_public_https_url(url)
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "https://127.0.0.1/",
+        "https://192.168.1.5/",
+        "https://10.1.2.3/",
+        "https://[fe80::1]/",
+        "https://[::ffff:127.0.0.1]/",
+        "http://accounts.google.com/",  # scheme must be https
+    ],
+)
+def test_public_tier_additionally_rejects_private_and_plain_http(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_public_https_url(url)
+
+
+# ---------------------------------------------------------------------------
+# OIDC issuer_url — the encoding bypasses the hand-rolled validator missed
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "https://2130706433/",  # decimal-encoded 127.0.0.1
+        "https://0x7f000001/",  # hex-encoded 127.0.0.1
+        "https://[::ffff:127.0.0.1]/",  # IPv4-mapped loopback
+        "https://[::ffff:169.254.169.254]/",  # IPv4-mapped IMDS
+        "https://0.0.0.0/",
+        "https://239.255.255.250/",
+        "https://169.254.169.254/",
+        "https://127.0.0.1/",
+        "https://192.168.1.5/",
+        "http://idp.example.com/",
+    ],
+)
+def test_issuer_url_rejects_encoded_and_private_targets(url: str):
+    with pytest.raises(ValueError):
+        OIDCProviderCreate(
+            name="SSO",
+            issuer_url=url,
+            client_id="cid",
+            client_secret="secret",
+        )
+
+
+def test_issuer_url_update_is_guarded_too():
+    """The update path matters most: it can change the issuer while the stored
+    client_secret stays, which is the shape that would exfiltrate a real secret."""
+    with pytest.raises(ValueError):
+        OIDCProviderUpdate(issuer_url="https://[::ffff:127.0.0.1]/")
+
+
+def test_issuer_url_error_names_the_field_not_the_icon():
+    with pytest.raises(ValueError, match="issuer_url"):
+        OIDCProviderUpdate(issuer_url="https://127.0.0.1/")
+
+
+def test_a_real_idp_still_validates():
+    provider = OIDCProviderCreate(
+        name="SSO",
+        issuer_url="https://accounts.google.com",
+        client_id="cid",
+        client_secret="secret",
+    )
+    assert provider.issuer_url == "https://accounts.google.com"
+
+
+# ---------------------------------------------------------------------------
+# Settings URLs
+# ---------------------------------------------------------------------------
+
+# Imported from the schema rather than duplicated, so the backstop below cannot
+# silently disagree with what is actually validated.
+LAN_SERVICE_SETTINGS = LAN_SERVICE_URL_SETTINGS
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_settings_urls_reject_dangerous_targets(field: str, url: str):
+    with pytest.raises(ValueError):
+        AppSettingsUpdate(**{field: url})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_settings_urls_permit_lan_hosts(field: str, url: str):
+    assert AppSettingsUpdate(**{field: url})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("empty", ["", "   "])
+def test_settings_urls_accept_empty_meaning_not_configured(field: str, empty: str):
+    """Empty is the documented "fall back to the env var" value for all four."""
+    assert AppSettingsUpdate(**{field: empty})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize(
+    "legacy",
+    [
+        "192.168.1.10:3333",  # urlparse: scheme='', netloc='', hostname=None
+        "localhost:3003",  # urlparse: scheme='localhost' (!), hostname=None
+        "obico.local:3333",  # same trap, with dots
+        "192.168.1.10",
+    ],
+)
+def test_settings_urls_do_not_newly_reject_scheme_less_legacy_values(field: str, legacy: str):
+    """Compatibility guard, not an endorsement.
+
+    The settings inputs are plain text with no scheme enforcement, so values
+    like these are already in the wild. They are inert — httpx raises
+    UnsupportedProtocol, so no request is issued — and they were storable
+    before the validator existed. Rejecting them now would block saves of
+    unrelated fields bundled in the same request (the Obico panel auto-saves
+    obico_ml_url alongside every other Obico setting).
+    """
+    assert AppSettingsUpdate(**{field: legacy})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+def test_settings_urls_still_reject_a_real_non_http_scheme(field: str):
+    """The leniency above is scoped to strings that are not URLs at all."""
+    with pytest.raises(ValueError):
+        AppSettingsUpdate(**{field: "file:///etc/passwd"})
+
+
+def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
+    """CI backstop: a new outbound-URL setting can't land unvalidated.
+
+    Any new ``*_url`` field on AppSettingsUpdate must be added to the
+    validator's field tuple or listed as exempt here with a reason. This
+    catches the failure mode the original report correctly identified — guards
+    added per-incident rather than to the whole class of fields.
+    """
+    exempt = {
+        # Bambuddy's own public address, not a destination it requests. It is
+        # rendered into notification bodies and OIDC redirect URIs, and handed
+        # to Obico's ML server as the `img` parameter for that server to fetch
+        # (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
+        # {nonce}`). Pointing it at a private address only breaks Bambuddy's own
+        # links; it cannot make Bambuddy request anything it otherwise wouldn't.
+        "external_url",
+        # Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
+        # location_service.py, inventory.py, spoolbuddy.py,
+        # spoolman_inventory.py) rather than in the schema, keeping its
+        # established user-facing "Spoolman URL ..." error wording.
+        "spoolman_url",
+        # Not an HTTP URL: ldap:// or ldaps://, handed to an LDAP client, never
+        # to httpx. The LAN-service guard requires http/https and would reject
+        # every valid value. It also cannot reach a cloud-metadata endpoint,
+        # since IMDS only speaks HTTP.
+        "ldap_server_url",
+    }
+    url_fields = {name for name in AppSettingsUpdate.model_fields if name.endswith("_url")}
+    unguarded = url_fields - set(LAN_SERVICE_SETTINGS) - exempt
+    assert not unguarded, (
+        f"New outbound URL setting(s) {sorted(unguarded)} are not covered by a "
+        f"SSRF guard. Add them to AppSettingsUpdate._LAN_SERVICE_URL_FIELDS (or "
+        f"the public-internet guard), or add them to `exempt` above with a reason."
+    )
+
+
+# ---------------------------------------------------------------------------
+# Notification providers: URL guard + no response-body echo
+# ---------------------------------------------------------------------------
+
+
+def _response(status: int = 500, body: str = "root:x:0:0:root:/root:/bin/bash") -> httpx.Response:
+    return httpx.Response(status_code=status, text=body, request=httpx.Request("POST", "http://10.0.0.1/"))
+
+
+SECRET_BODY = "root:x:0:0:root:/root:/bin/bash"
+
+
+def test_opaque_failure_does_not_return_the_response_body():
+    message = ns._opaque_http_failure(_response(), label="webhook endpoint")
+
+    assert SECRET_BODY not in message
+    assert "500" in message, "the status code is still useful and is not sensitive"
+    assert "webhook endpoint" in message
+
+
+def test_opaque_failure_logs_the_body_for_the_operator(caplog):
+    """The body stays available to whoever administers the host — via logs,
+    not via the API response."""
+    with caplog.at_level("DEBUG", logger=ns.__name__):
+        ns._opaque_http_failure(_response(), label="ntfy server")
+
+    assert SECRET_BODY in caplog.text
+
+
+@pytest.mark.parametrize(
+    "provider_label",
+    ["ntfy server", "Bark server", "webhook endpoint", "Home Assistant endpoint"],
+)
+def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
+    """Guards the mapping itself: each user-supplied-host provider must route
+    its HTTP failure through _opaque_http_failure rather than formatting the
+    body inline."""
+    src = inspect.getsource(ns)
+    assert f'_opaque_http_failure(response, label="{provider_label}")' in src
+
+
+def test_no_user_supplied_host_provider_formats_the_body_inline():
+    """Any remaining ``response.text[:200]`` must belong to a host-pinned provider.
+
+    Pushover/Telegram/CallMeBot/Discord all target hardcoded hosts (Discord via
+    a webhook-prefix allowlist), so there is no trust boundary to cross.
+    """
+    src = inspect.getsource(ns).split("\n")
+    host_pinned = {"_send_callmebot", "_send_pushover", "_send_telegram", "_send_discord"}
+
+    current = None
+    offenders = []
+    for line in src:
+        match = re.match(r"\s+async def (_send_\w+)", line)
+        if match:
+            current = match.group(1)
+        if "response.text[:200]" in line and current not in host_pinned:
+            offenders.append(current)
+
+    assert not offenders, (
+        f"{offenders} echo the upstream response body but do not target a "
+        f"hardcoded host. Route the failure through _opaque_http_failure."
+    )
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_provider_url_guard_rejects_dangerous_targets(url: str):
+    assert ns._assert_safe_provider_url(url, label="Webhook URL") is not None
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_provider_url_guard_permits_self_hosted_servers(url: str):
+    assert ns._assert_safe_provider_url(url, label="ntfy server URL") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("provider_type", "config"),
+    [
+        ("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
+        ("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
+        ("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
+    ],
+)
+async def test_test_config_refuses_metadata_targets_without_a_request(provider_type: str, config: dict, monkeypatch):
+    """The end-to-end shape of the reported attack: an unsaved config aimed at
+    IMDS via the test endpoint. It must be refused before any HTTP call."""
+    called = False
+
+    async def _fail_if_called(*_a, **_kw):
+        nonlocal called
+        called = True
+        raise AssertionError("outbound request should not have been attempted")
+
+    service = ns.NotificationService()
+    monkeypatch.setattr(service, "_get_client", _fail_if_called)
+
+    success, message = await service.send_test_notification(provider_type, config)
+
+    assert success is False
+    assert called is False
+    assert "cloud metadata" in message
+
+
+# ---------------------------------------------------------------------------
+# Smart plugs: the same request-body-URL shape as the notification test endpoint
+# ---------------------------------------------------------------------------
+#
+# POST /smart-plugs/{ha,rest}/test-connection take their URL from the request
+# body and never persist it, so the schema-layer validator on ``ha_url`` does
+# not apply. Both are reachable with only ``SMART_PLUGS_CONTROL``, which the
+# default Operators group carries and which does NOT imply ``SETTINGS_UPDATE``
+# — identical to the notification case above.
+#
+# Both previously used hand-rolled checks that got the policy wrong in both
+# directions: the REST one rejected a literal ``127.0.0.1`` while allowing
+# every non-literal hostname, and the HA one matched three literal strings and
+# never parsed the hostname as an IP at all.
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_rest_plug_guard_rejects_dangerous_targets(url: str):
+    assert RESTSmartPlugService._validate_url(url) is False
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_rest_plug_guard_permits_the_normal_self_hosted_topology(url: str):
+    """Includes literal 127.0.0.1, which the previous implementation rejected
+    while accepting the equivalent "localhost" — a plug bridge on the same
+    host could only be configured by spelling it one particular way."""
+    assert RESTSmartPlugService._validate_url(url) is True
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_ha_guard_rejects_dangerous_targets(url: str):
+    assert HomeAssistantService._validate_url(url) is None
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_ha_guard_permits_the_normal_self_hosted_topology(url: str):
+    assert HomeAssistantService._validate_url(url) is not None
+
+
+def test_ha_guard_still_normalises_the_url_it_returns():
+    """Delegating the policy must not change what the caller gets back:
+    scheme+host+port+path, with query and fragment dropped."""
+    assert HomeAssistantService._validate_url("http://192.168.1.5:8123/base?x=1#f") == "http://192.168.1.5:8123/base"
+    assert HomeAssistantService._validate_url("http://ha.lan") == "http://ha.lan"
+
+
+def test_ha_guard_keeps_ipv6_literals_bracketed():
+    """urlparse strips the brackets off an IPv6 host; re-emitting it without
+    them yields an unparseable URL that httpx cannot dial."""
+    assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
+
+
+@pytest.mark.parametrize("ip", ["169.254.169.254", "100.100.100.200", "fd00:ec2::254", "0.0.0.0", "239.255.255.250"])
+def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
+    """Tasmota keeps its own stricter rule (bare IP literals only, loopback
+    rejected — a plug is always a separate LAN device), but must not miss the
+    destinations that are dangerous regardless of topology."""
+    assert TasmotaService._validate_ip(ip) is False
+
+
+@pytest.mark.parametrize("ip", ["::ffff:169.254.169.254", "::ffff:100.100.100.200"])
+def test_tasmota_guard_unwraps_ipv4_mapped_ipv6(ip: str):
+    assert TasmotaService._validate_ip(ip) is False
+
+
+@pytest.mark.parametrize("ip", ["192.168.1.50", "10.0.0.7", "172.16.4.9"])
+def test_tasmota_guard_still_permits_a_normal_lan_plug(ip: str):
+    assert TasmotaService._validate_ip(ip) is True
+
+
+@pytest.mark.parametrize("ip", ["127.0.0.1", "tasmota.local", "not-an-ip"])
+def test_tasmota_guard_keeps_failing_closed_on_non_lan_device_values(ip: str):
+    """Deliberately stricter than the shared LAN guard, and unchanged here."""
+    assert TasmotaService._validate_ip(ip) is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "target",
+    ["http://169.254.169.254/", "http://100.100.100.200/", "http://metadata.google.internal/"],
+)
+async def test_rest_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
+    """End-to-end shape of the reported attack, mirroring the notification
+    test above: an unsaved URL aimed at IMDS via the test endpoint must be
+    refused before any HTTP call is made."""
+
+    def _fail_if_called(*_a, **_kw):
+        raise AssertionError("outbound request should not have been attempted")
+
+    monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
+    result = await RESTSmartPlugService().test_connection(target, "GET", None)
+
+    assert result["success"] is False
+    assert "cloud metadata" in result["error"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "target",
+    ["http://169.254.169.254", "http://100.100.100.200", "http://metadata.google.internal"],
+)
+async def test_ha_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
+    def _fail_if_called(*_a, **_kw):
+        raise AssertionError("outbound request should not have been attempted")
+
+    monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
+    result = await HomeAssistantService().test_connection(target, "token")
+
+    assert result["success"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("target", ["http://169.254.169.254", "http://metadata.google.internal"])
+async def test_obico_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
+    """Same shape again: obico_ml_url is guarded when saved via settings, but
+    this route takes the URL from the request body and echoes the response."""
+    from backend.app.services.obico_detection import ObicoDetectionService
+
+    def _fail_if_called(*_a, **_kw):
+        raise AssertionError("outbound request should not have been attempted")
+
+    monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
+    result = await ObicoDetectionService().test_connection(target)
+
+    assert result["ok"] is False
+    assert result["body"] is None
+    assert "cloud metadata" in result["error"]
+
+
+# ---------------------------------------------------------------------------
+# Drift backstop, part 2: URLs that arrive in a request body
+# ---------------------------------------------------------------------------
+#
+# `test_every_url_setting_is_either_guarded_or_explicitly_exempt` above only
+# walks `AppSettingsUpdate`. That is why the notification test endpoint, and
+# then the two smart-plug test endpoints, each had to be found by hand: a URL
+# that arrives in a request body and is never persisted is not a settings
+# field, so nothing enumerated it. This walks the live route table instead.
+
+
+def _request_body_url_fields() -> set[tuple[str, str]]:
+    """Every (model, field) pair on a mutating route whose body carries a URL."""
+    from fastapi.routing import APIRoute
+    from pydantic import BaseModel
+
+    from backend.app.main import app
+
+    found: set[tuple[str, str]] = set()
+    for route in app.routes:
+        if not isinstance(route, APIRoute) or not ({"POST", "PUT", "PATCH"} & set(route.methods or ())):
+            continue
+        for param in route.dependant.body_params:
+            # FastAPI moved the resolved annotation from `type_` onto
+            # `field_info.annotation`; read both so this can't silently
+            # enumerate nothing (which would make the assertions vacuous).
+            annotation = getattr(param, "type_", None)
+            if annotation is None:
+                annotation = getattr(getattr(param, "field_info", None), "annotation", None)
+            if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
+                continue
+            for field in annotation.model_fields:
+                if field == "url" or field.endswith("_url"):
+                    found.add((annotation.__name__, field))
+    return found
+
+
+# Guarded: the handler (or the service it calls) puts the value through one of
+# the two tiers before any request is issued.
+GUARDED_BODY_URLS = {
+    ("AppSettingsUpdate", "bambu_studio_api_url"),
+    ("AppSettingsUpdate", "ha_url"),
+    ("AppSettingsUpdate", "obico_ml_url"),
+    ("AppSettingsUpdate", "orcaslicer_api_url"),
+    ("AppSettingsUpdate", "spoolman_url"),  # assert_safe_spoolman_url at each consumer
+    ("HATestConnectionRequest", "url"),  # homeassistant._validate_url
+    ("RESTTestConnectionRequest", "url"),  # rest_smart_plug._validate_url
+    ("TestConnectionRequest", "url"),  # obico_detection.test_connection
+    ("OIDCProviderCreate", "issuer_url"),  # public tier, via schemas.auth
+    ("OIDCProviderCreate", "icon_url"),
+    ("OIDCProviderUpdate", "issuer_url"),
+    ("OIDCProviderUpdate", "icon_url"),
+    # Gitea/Forgejo derive their API base from this and request it with the
+    # stored token, so it is a real fetch target — guarded in
+    # github_backup._enforce_private_repo, which both POST and PATCH funnel through.
+    ("GitHubBackupConfigCreate", "repository_url"),
+    ("GitHubBackupConfigUpdate", "repository_url"),
+    # SmartPlug{Create,Update} persist these; every read goes back out through
+    # RESTSmartPlugService._send_request, which applies the same guard.
+    ("SmartPlugCreate", "rest_on_url"),
+    ("SmartPlugCreate", "rest_off_url"),
+    ("SmartPlugCreate", "rest_status_url"),
+    ("SmartPlugCreate", "rest_power_url"),
+    ("SmartPlugCreate", "rest_energy_url"),
+    ("SmartPlugUpdate", "rest_on_url"),
+    ("SmartPlugUpdate", "rest_off_url"),
+    ("SmartPlugUpdate", "rest_status_url"),
+    ("SmartPlugUpdate", "rest_power_url"),
+    ("SmartPlugUpdate", "rest_energy_url"),
+}
+
+# Not a destination Bambuddy requests — no guard applies.
+NOT_A_FETCH_TARGET = {
+    ("AppSettingsUpdate", "external_url"),  # Bambuddy's own address (see exempt list above)
+    ("AppSettingsUpdate", "ldap_server_url"),  # ldap://, handed to an LDAP client
+    ("ProjectCreate", "url"),  # stored link, rendered in the UI, never fetched
+    ("ProjectUpdate", "url"),
+    ("BOMItemCreate", "sourcing_url"),  # stored supplier link, never fetched
+    ("BOMItemUpdate", "sourcing_url"),
+    ("MakerWorldResolveRequest", "url"),  # parsed for a model id; fetches go to a pinned CDN allowlist
+    ("DeviceRegisterRequest", "backend_url"),  # the device's view of Bambuddy's own address
+    ("HeartbeatRequest", "backend_url"),
+    ("SystemConfigRequest", "backend_url"),
+    ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
+    ("ExternalLinkUpdate", "url"),
+    ("MaintenanceTypeCreate", "wiki_url"),  # documentation link surfaced in the UI/notifications
+    ("MaintenanceTypeUpdate", "wiki_url"),
+    ("ArchiveUpdate", "external_url"),  # stored source link for the model, never fetched
+}
+
+# Genuinely unguarded, and deliberately recorded rather than quietly exempted.
+# These reach `external_camera.capture_frame`, which dials rtsp:// as well as
+# http(s):// — the LAN-service guard rejects any non-HTTP scheme, so wiring it
+# up as-is would break every RTSP camera. Closing these needs a scheme-aware
+# variant of the guard, not a one-line delegation.
+KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD = {
+    ("PrinterCreate", "external_camera_url"),
+    ("PrinterCreate", "external_camera_snapshot_url"),
+    ("PrinterUpdate", "external_camera_url"),
+    ("PrinterUpdate", "external_camera_snapshot_url"),
+}
+
+
+def test_the_route_walk_actually_finds_something():
+    """Guards the guard. If FastAPI's internals move again and the walk starts
+    returning nothing, both assertions below pass vacuously and the backstop
+    silently stops working — which is the exact failure it exists to prevent."""
+    found = _request_body_url_fields()
+    assert ("RESTTestConnectionRequest", "url") in found
+    assert ("HATestConnectionRequest", "url") in found
+    assert len(found) > 20
+
+
+def test_every_request_body_url_is_classified():
+    """A new URL-bearing request field can't land without a decision.
+
+    Add it to GUARDED_BODY_URLS once the handler runs it through a guard, or
+    to NOT_A_FETCH_TARGET with the reason it is never requested. Do not add
+    anything to KNOWN_UNGUARDED_* without also raising it.
+    """
+    classified = GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD
+    unclassified = _request_body_url_fields() - classified
+    assert not unclassified, (
+        f"Unclassified request-body URL field(s): {sorted(unclassified)}. Route the value "
+        f"through a guard and list it in GUARDED_BODY_URLS, or list it in NOT_A_FETCH_TARGET "
+        f"with the reason it is never fetched."
+    )
+
+
+def test_classification_lists_do_not_drift_from_the_routes():
+    """The reverse direction: a stale entry means a route was renamed or
+    removed and the list was not updated, which would hide the next one."""
+    actual = _request_body_url_fields()
+    stale = (GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD) - actual
+    assert not stale, f"Classification entries no longer match any route: {sorted(stale)}"

+ 2 - 0
backend/tests/unit/test_plate_clear_mqtt_notification.py

@@ -44,6 +44,8 @@ def _state() -> SimpleNamespace:
         big_fan1_speed=0,
         big_fan1_speed=0,
         big_fan2_speed=0,
         big_fan2_speed=0,
         heatbreak_fan_speed=0,
         heatbreak_fan_speed=0,
+        left_aux_fan_speed=None,
+        exhaust_fan_present=False,
     )
     )
 
 
 
 

+ 166 - 0
backend/tests/unit/test_pool_fits_server.py

@@ -0,0 +1,166 @@
+"""The pool must not silently be allowed to outgrow the PostgreSQL server.
+
+``pool_size + max_overflow`` is the most connections one worker will open. When
+that exceeds what the server permits, the pool never hits its own limit and so
+never queues — it asks the server, which refuses with
+``TooManyConnectionsError`` at whatever happened to need a connection next. In
+the report behind this, that was the middle of a queue dispatch.
+
+The check is diagnostic, not corrective: pool sizes are fixed at engine creation
+(import time, before any connection exists to ask with), and the right ceiling
+depends on the worker count and on other clients sharing the server. So the
+contract under test is "says something accurate and loud, and never breaks
+startup".
+"""
+
+from __future__ import annotations
+
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+def _engine_reporting(max_conn: int, reserved: int, in_use: int | None = 0) -> MagicMock:
+    """An engine whose connection answers the three probe queries in order.
+
+    ``in_use=None`` makes the third query fail, standing in for PostgreSQL < 10
+    where ``pg_stat_activity.backend_type`` does not exist.
+    """
+    conn = MagicMock()
+    conn.execute = AsyncMock(
+        side_effect=[
+            MagicMock(scalar_one=MagicMock(return_value=max_conn)),
+            MagicMock(scalar_one=MagicMock(return_value=reserved)),
+            (
+                MagicMock(scalar_one=MagicMock(return_value=in_use))
+                if in_use is not None
+                else RuntimeError('column "backend_type" does not exist')
+            ),
+        ]
+    )
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=conn)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    engine = MagicMock()
+    engine.connect = MagicMock(return_value=ctx)
+    return engine
+
+
+async def _run_check(*, pool_size, max_overflow, max_conn, reserved, in_use=0, sqlite=False):
+    from backend.app.core import database
+
+    with (
+        patch.object(database, "is_sqlite", return_value=sqlite),
+        patch.object(database, "_pool_config", {"pool_size": pool_size, "max_overflow": max_overflow}),
+        patch.object(database, "engine", _engine_reporting(max_conn, reserved, in_use)),
+        patch.object(database, "_server_connection_limits", None),
+    ):
+        await database.check_pool_fits_server()
+        return database._server_connection_limits
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_warns_when_the_ceiling_exceeds_what_the_server_allows(caplog):
+    """Bambuddy's own PostgreSQL default against a stock server: 100 vs 100-3."""
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3)
+
+    assert any(r.levelno == logging.WARNING for r in caplog.records)
+    msg = caplog.text
+    # The numbers an operator needs, and the knobs to change.
+    for expected in ("100", "97", "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "max_connections"):
+        assert expected in msg, f"warning omits {expected!r}"
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_silent_when_the_pool_fits(caplog):
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        await _run_check(pool_size=20, max_overflow=80, max_conn=500, reserved=3)
+
+    assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_reserved_slots_count_against_the_budget(caplog):
+    """Exactly at max_connections is still too many — reserved slots are not ours."""
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        await _run_check(pool_size=10, max_overflow=90, max_conn=100, reserved=3)
+
+    assert [r for r in caplog.records if r.levelno == logging.WARNING]
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_both_sides_are_recorded_for_the_support_bundle():
+    limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=41)
+
+    assert limits == {
+        "max_connections": 100,
+        "superuser_reserved_connections": 3,
+        "available_to_bambuddy": 97,
+        "client_backends_at_startup": 41,
+        "pool_ceiling_per_worker": 100,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_sqlite_is_skipped_entirely():
+    """No such concept, and the probe SQL is PostgreSQL-only."""
+    limits = await _run_check(pool_size=20, max_overflow=200, max_conn=0, reserved=0, sqlite=True)
+
+    assert limits is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_a_probe_failure_cannot_break_startup(caplog):
+    """A restricted role or an older server may refuse these queries."""
+    from backend.app.core import database
+
+    engine = MagicMock()
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("permission denied"))
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    engine.connect = MagicMock(return_value=ctx)
+
+    with (
+        patch.object(database, "is_sqlite", return_value=False),
+        patch.object(database, "_pool_config", {"pool_size": 20, "max_overflow": 80}),
+        patch.object(database, "engine", engine),
+        patch.object(database, "_server_connection_limits", None),
+        caplog.at_level(logging.WARNING, logger="backend.app.core.database"),
+    ):
+        await database.check_pool_fits_server()  # must not raise
+
+        assert database._server_connection_limits is None
+    assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_an_old_server_without_backend_type_still_gets_the_warning(caplog):
+    """`pg_stat_activity.backend_type` is PostgreSQL 10+; the docs recommend 14+
+    but asyncpg reaches back to 9.5. Losing that count must not cost the
+    warning, which only needs the two settings."""
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=None)
+
+    assert [r for r in caplog.records if r.levelno == logging.WARNING], "warning was lost with the count"
+    assert "100" in caplog.text and "97" in caplog.text
+    # The sentence about other clients is dropped rather than rendered as None.
+    assert "None client" not in caplog.text
+    assert limits["client_backends_at_startup"] is None
+    assert limits["max_connections"] == 100
+
+
+@pytest.mark.unit
+def test_get_pool_status_exposes_the_server_limits_key():
+    """The support bundle reads this; the key must exist even on SQLite."""
+    from backend.app.core.database import get_pool_status
+
+    assert "server_limits" in get_pool_status()

+ 2 - 0
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -90,6 +90,8 @@ def _fake_state(**overrides):
         "firmware_version": None,
         "firmware_version": None,
         "gcode_file": None,
         "gcode_file": None,
         "heatbreak_fan_speed": None,
         "heatbreak_fan_speed": None,
+        "left_aux_fan_speed": None,
+        "exhaust_fan_present": False,
         "layer_num": None,
         "layer_num": None,
         "remaining_time": None,
         "remaining_time": None,
         "speed_level": None,
         "speed_level": None,

+ 163 - 0
backend/tests/unit/test_spoolman_settings_value_coercion.py

@@ -0,0 +1,163 @@
+"""PUT /settings/spoolman must not 500 on a JSON boolean.
+
+The endpoint takes a free-form ``dict`` body, and settings are persisted in a
+VARCHAR column that every reader compares as a string. Sending the natural JSON
+form — ``{"spoolman_enabled": true}`` — used to fail twice over:
+
+- ``bool.lower()`` raised AttributeError while deciding whether the mode had
+  changed, surfacing as an opaque 500;
+- the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
+  to 1/0 while asyncpg rejects it — so the stored representation depended on
+  the deployment's database.
+
+The shipped UI sends strings, so this was reachable only through the REST API
+(scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
+boolean is the obvious thing to send.
+
+These tests cover the normalisers directly. They are pure functions, so the
+matrix stays readable and the endpoint keeps a single code path per field.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.api.routes.settings import (
+    normalize_bool_setting,
+    normalize_str_setting,
+    setting_is_true,
+)
+
+# ---------------------------------------------------------------------------
+# The reported crash
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
+def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
+    """The exact input that used to 500."""
+    assert normalize_bool_setting("spoolman_enabled", value) == expected
+
+
+@pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
+def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
+    assert normalize_bool_setting("spoolman_enabled", value) == expected
+
+
+# ---------------------------------------------------------------------------
+# String spellings — generous on purpose, this is a documented REST surface
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
+def test_truthy_spellings(value: str):
+    assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
+
+
+@pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
+def test_falsy_spellings(value: str):
+    assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
+
+
+def test_python_style_capitalised_true_is_normalised_lowercase():
+    """The frontend compares with a case-sensitive ``=== 'true'``.
+
+    A client sending "True" previously had it stored verbatim, so the UI
+    rendered the setting as OFF while every backend reader (which all use
+    ``.lower()``) treated it as ON.
+    """
+    assert normalize_bool_setting("spoolman_enabled", "True") == "true"
+
+
+# ---------------------------------------------------------------------------
+# Empty means "use the default" — deliberately NOT normalised to "false"
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["", "   "])
+def test_empty_is_preserved_not_turned_into_false(value: str):
+    """get_spoolman_settings reads these with ``or "<default>"``.
+
+    spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
+    coercing a blank submission to "false" would silently switch them off.
+    Whitespace-only collapses to "" so it takes the same path rather than
+    being stored as a truthy-but-meaningless "   ".
+    """
+    assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
+
+
+# ---------------------------------------------------------------------------
+# Values with no sensible reading get a 400 naming the field, not a 500
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
+def test_uninterpretable_values_raise_400_naming_the_field(value: object):
+    with pytest.raises(HTTPException) as exc:
+        normalize_bool_setting("spoolman_enabled", value)
+
+    assert exc.value.status_code == 400
+    assert "spoolman_enabled" in str(exc.value.detail)
+
+
+# ---------------------------------------------------------------------------
+# String settings
+# ---------------------------------------------------------------------------
+
+
+def test_str_setting_passes_strings_through_untouched():
+    assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
+
+
+def test_str_setting_stringifies_numbers():
+    """An unquoted host or port is a plausible client slip, not a hard error."""
+    assert normalize_str_setting("spoolman_url", 7912) == "7912"
+
+
+def test_str_setting_maps_null_to_empty():
+    assert normalize_str_setting("spoolman_url", None) == ""
+
+
+@pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
+def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
+    with pytest.raises(HTTPException) as exc:
+        normalize_str_setting("spoolman_url", value)
+
+    assert exc.value.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# setting_is_true — used for the mode-switch comparison
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    ("stored", "expected"),
+    [
+        ("true", True),
+        ("True", True),
+        ("TRUE", True),
+        (" true ", True),
+        ("false", False),
+        ("", False),
+        ("banana", False),
+        (None, False),  # setting absent from the table
+        (True, True),  # legacy row: SQLite coerced a raw bool into the column
+        (False, False),
+    ],
+)
+def test_setting_is_true(stored: object, expected: bool):
+    assert setting_is_true(stored) is expected
+
+
+@pytest.mark.parametrize("stored", ["1", "on", "yes"])
+def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
+    """Reading must agree with the rest of the codebase, which only accepts "true".
+
+    normalize_bool_setting is generous about what clients may *send*; every
+    reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
+    main) compares ``.lower() == "true"``. Accepting more here would make the
+    mode-switch check disagree with them about a legacy row.
+    """
+    assert setting_is_true(stored) is False

+ 166 - 0
backend/tests/unit/test_support_helpers.py

@@ -1285,3 +1285,169 @@ class TestRedactRawPushStatus:
         assert _redact_raw_push_status(None) == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status(None) == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status([]) == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status([]) == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status("") == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status("") == {}  # type: ignore[arg-type]
+
+
+class TestSanitizePushStatusValues:
+    """The bundled push_status snapshot must stay parseable JSON.
+
+    Sanitization used to run over the *serialised* snapshot. The generic
+    Bambu-serial regex in ``log_reader`` (``0[0-3][A-Z0-9][A-Z0-9]{9,13}``)
+    matches the decimal expansion of a float as readily as a serial, so an AMS
+    ``k`` flow factor came out as ``0.[SERIAL]`` and the whole file stopped
+    parsing — found in a real bundle while diagnosing #2702, which is exactly
+    the case the snapshot was added to serve.
+    """
+
+    def test_float_that_matches_the_serial_regex_survives(self):
+        """The observed reproducer, verbatim."""
+        import json
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"ams": [{"tray": [{"k": 0.0199999995529652}]}]}
+
+        out = _sanitize_push_status_values(raw, {})
+
+        assert json.loads(json.dumps(out)) == raw
+
+    def test_output_always_parses(self):
+        """Whatever it does to values, the result must be valid JSON."""
+        import json
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {
+            "k_values": [0.0199999995529652, 0.019999999552965164, 0.02],
+            "home_flag": 7554487,
+            "sdcard": True,
+            "resolution": "",
+            "nozzle": None,
+        }
+
+        json.loads(json.dumps(_sanitize_push_status_values(raw, {})))
+
+    def test_still_redacts_strings(self):
+        """The point of the pass is not lost — string values are sanitized."""
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"tag_uid": "0123456789ABCDEF", "name": "Martin's P1S", "ip": "192.168.1.50"}
+
+        out = _sanitize_push_status_values(raw, {"Martin's P1S": "[PRINTER]"})
+
+        assert out["tag_uid"] == "[SERIAL]"
+        assert out["name"] == "[PRINTER]"
+        assert out["ip"] == "[IP]"
+
+    def test_walks_nested_containers(self):
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"ams": [{"tray": [{"tray_uuid": "0123456789ABCDEF"}]}]}
+
+        out = _sanitize_push_status_values(raw, {})
+
+        assert out["ams"][0]["tray"][0]["tray_uuid"] == "[SERIAL]"
+
+    def test_keys_are_left_alone(self):
+        """Keys are structural — renaming one would break the schema."""
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"0123456789ABCDEF": 1}
+
+        assert list(_sanitize_push_status_values(raw, {})) == ["0123456789ABCDEF"]
+
+    def test_non_json_scalars_are_sanitized_not_smuggled(self):
+        """``json.dumps(default=str)`` runs after this pass, so do it here."""
+        from datetime import datetime, timezone
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"seen_at": datetime(2026, 7, 29, 23, 12, 40, tzinfo=timezone.utc), "who": object()}
+
+        out = _sanitize_push_status_values(raw, {"2026-07-29": "[WHEN]"})
+
+        assert out["seen_at"].startswith("[WHEN]")
+        assert isinstance(out["who"], str)
+
+    def test_bools_stay_bools(self):
+        """`isinstance(True, int)` — a bool must not fall through to str()."""
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        out = _sanitize_push_status_values({"sdcard": True, "force_upgrade": False}, {})
+
+        assert out["sdcard"] is True
+        assert out["force_upgrade"] is False
+
+    def test_the_full_bundle_chain_on_a_real_p1s_payload(self):
+        """The route's transform, end to end, on the shape from the #2702 bundle.
+
+        `_redact_raw_push_status` then `_sanitize_push_status_values` then
+        `json.dumps(default=str)` — the composition the bundle writer applies.
+        The bundle that exposed this had five `k` values corrupted, so the
+        snapshot could not be read at all; the field the report was about
+        (`total_layer_num`) was sitting in it, intact and unreachable.
+        """
+        import json
+
+        from backend.app.api.routes.support import (
+            _redact_raw_push_status,
+            _sanitize_push_status_values,
+        )
+
+        raw = {
+            "gcode_file": "AMS_Filament_Clip_3MF.3mf",
+            "layer_num": 2,
+            "total_layer_num": 33,
+            "home_flag": 7554487,
+            "sdcard": True,
+            "net": {"info": [{"ip": "192.168.1.50", "mask": 0}]},
+            "ams": {
+                "ams": [
+                    {
+                        "id": "0",
+                        "humidity": "5",
+                        "tray": [
+                            {"id": "0", "k": 0.0199999995529652, "tag_uid": "0123456789ABCDEF"},
+                            {"id": "1", "k": 0.0209999997168779, "tag_uid": "44F782D000000100"},
+                        ],
+                    }
+                ]
+            },
+        }
+
+        snapshot = {
+            "model": "P1S",
+            "firmware_version": "01.10.00.00",
+            "raw_data": _redact_raw_push_status(raw),
+        }
+        text = json.dumps(_sanitize_push_status_values(snapshot, {}), indent=2, default=str)
+
+        parsed = json.loads(text)  # used to raise "Expecting ',' delimiter"
+        trays = parsed["raw_data"]["ams"]["ams"][0]["tray"]
+        assert [t["k"] for t in trays] == [0.0199999995529652, 0.0209999997168779]
+        assert parsed["raw_data"]["total_layer_num"] == 33
+        # Redaction still did its job on both fronts.
+        assert "gcode_file" not in parsed["raw_data"]
+        assert trays[0]["tag_uid"] == "[SERIAL]"
+        # The structural pass replaces the printer's LAN address with the
+        # sentinel 0.0.0.0, which is itself an IPv4 literal, so the value pass
+        # then masks it to [IP]. Harmless — the real address is already gone —
+        # and matches what shipped in the bundle behind #2702.
+        assert parsed["raw_data"]["net"]["info"][0]["ip"] == "[IP]"
+
+    def test_does_not_mutate_the_live_snapshot(self):
+        """`state.raw_data` is read by the dispatcher on every tick.
+
+        The bundle writer passes a redacted copy, but a walker that mutated in
+        place would still be one refactor away from redacting the live state.
+        """
+        import copy
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"tag_uid": "0123456789ABCDEF", "ams": [{"tray": [{"k": 0.02, "n": "0123456789ABCDEF"}]}]}
+        before = copy.deepcopy(raw)
+
+        out = _sanitize_push_status_values(raw, {})
+
+        assert raw == before, "input was mutated"
+        assert out["tag_uid"] == "[SERIAL]"  # and the copy really was redacted

+ 107 - 0
backend/tests/unit/test_telegram_forum_topic.py

@@ -0,0 +1,107 @@
+"""Tests for optional Telegram forum-topic delivery via message_thread_id (#1518).
+
+Telegram forum groups route messages to a topic by ``message_thread_id``.  The
+field is optional: when it is absent, Telegram posts to the group's General
+topic, which is the behaviour every existing install already relies on.
+
+The subtlety worth pinning is the type.  ``sendMessage`` is posted as JSON, and
+Telegram rejects a *string* thread id there, while the multipart ``sendPhoto``
+call would accept one.  A string passed straight through would therefore work
+for notifications carrying a thumbnail and 400 for plain-text ones — so these
+tests assert an ``int`` reaches both call sites.
+"""
+
+import httpx
+import pytest
+
+from backend.app.services.notification_service import NotificationService
+
+
+class _CaptureClient:
+    """Stand-in for httpx.AsyncClient recording the JSON body and form data."""
+
+    def __init__(self):
+        self.is_closed = False
+        self.calls: list[dict] = []
+
+    async def post(self, url, data=None, files=None, json=None):
+        self.calls.append({"url": url, "data": data, "files": files, "json": json})
+        return httpx.Response(200, json={"ok": True, "result": {}})
+
+
+@pytest.fixture
+def service_with_capture():
+    service = NotificationService()
+    client = _CaptureClient()
+    service._http_client = client  # bypass real HTTP
+    return service, client
+
+
+BASE_CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
+PNG = b"\x89PNG\r\n\x1a\n"
+
+
+@pytest.mark.asyncio
+async def test_thread_id_omitted_when_unset(service_with_capture):
+    """Default config must produce exactly the pre-#1518 payload."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram(BASE_CONFIG, "*T*\nbody")
+    assert ok
+    assert "message_thread_id" not in client.calls[0]["json"]
+
+
+@pytest.mark.asyncio
+async def test_blank_thread_id_is_treated_as_unset(service_with_capture):
+    """An emptied-out form field must not turn into a bogus topic."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "   "}, "*T*\nbody")
+    assert ok
+    assert "message_thread_id" not in client.calls[0]["json"]
+
+
+@pytest.mark.asyncio
+async def test_sendmessage_carries_thread_id_as_int(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody")
+    assert ok
+    body = client.calls[0]["json"]
+    assert body["message_thread_id"] == 25
+    assert isinstance(body["message_thread_id"], int), "Telegram 400s on a string thread id in JSON"
+
+
+@pytest.mark.asyncio
+async def test_sendphoto_carries_thread_id(service_with_capture):
+    """Thumbnail notifications take the multipart path and must route too."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody", image_data=PNG)
+    assert ok
+    call = client.calls[0]
+    assert call["url"].endswith("/sendPhoto")
+    assert call["data"]["message_thread_id"] == 25
+
+
+@pytest.mark.asyncio
+async def test_thread_id_accepts_native_int(service_with_capture):
+    """config is a JSON blob — the value may already deserialise as an int."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": 25}, "*T*\nbody")
+    assert ok
+    assert client.calls[0]["json"]["message_thread_id"] == 25
+
+
+@pytest.mark.asyncio
+async def test_non_numeric_thread_id_fails_without_sending(service_with_capture):
+    """Reject locally rather than let Telegram answer with an opaque 400."""
+    service, client = service_with_capture
+    ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "General"}, "*T*\nbody")
+    assert not ok
+    assert "not a number" in error
+    assert client.calls == []
+
+
+@pytest.mark.asyncio
+async def test_error_message_does_not_leak_bot_token(service_with_capture):
+    service, _ = service_with_capture
+    ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "oops"}, "*T*\nbody")
+    assert not ok
+    assert "AAbbCC" not in error

+ 106 - 0
backend/tests/unit/test_threemf_tools.py

@@ -10,6 +10,7 @@ import math
 import zipfile
 import zipfile
 
 
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_bed_type_from_3mf,
     extract_bed_type_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_filament_usage_from_3mf,
     extract_filament_usage_from_3mf,
@@ -480,6 +481,111 @@ class TestExtractProjectFilamentsFrom3mf:
             assert extract_project_filaments_from_3mf(zf) == []
             assert extract_project_filaments_from_3mf(zf) == []
 
 
 
 
+# ---------------------------------------------------------------------------
+# Tests for expand_to_project_slots — #2712
+# ---------------------------------------------------------------------------
+
+
+class TestExpandToProjectSlots:
+    """The slice modal's filament list is positional: index 0 is slot 1, all
+    the way through to the ``filament_N.json`` parts handed to the slicer.
+
+    A MakerWorld source that carries slice_info but paints with slot 4 alone
+    used to yield a one-row list, so the user's single pick was bound to slot
+    1 and slot 4 — the slot that actually prints — kept the source's embedded
+    default. Picking PETG produced a PLA print.
+    """
+
+    PROJECT = json.dumps(
+        {
+            "filament_type": ["PLA", "PLA", "PLA", "PLA"],
+            "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
+        }
+    )
+
+    def test_a_single_used_slot_still_produces_a_full_positional_list(self):
+        """The reported file: four project slots, only slot 4 printed."""
+        used = [
+            {
+                "slot_id": 4,
+                "type": "PLA",
+                "color": "#898989",
+                "used_grams": 105.9,
+                "used_meters": 35.51,
+                "tray_info_idx": "GFL99",
+                "used_in_plate": True,
+            }
+        ]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert [f["slot_id"] for f in out] == [1, 2, 3, 4]
+        assert [f["used_in_plate"] for f in out] == [False, False, False, True]
+
+    def test_the_used_row_keeps_its_usage_figures(self):
+        """The modal shows the real weight and colour, and the print path
+        downstream reads ``tray_info_idx`` — none of it may be flattened into
+        a zeroed project row."""
+        used = [
+            {
+                "slot_id": 4,
+                "type": "PETG",
+                "color": "#FF0000",
+                "used_grams": 105.9,
+                "used_meters": 35.51,
+                "tray_info_idx": "GFL99",
+                "used_in_plate": True,
+            }
+        ]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        slot4 = out[3]
+        assert slot4["used_grams"] == 105.9
+        assert slot4["tray_info_idx"] == "GFL99"
+        # Resolved from the slice, not the project's stale PLA/#898989.
+        assert (slot4["type"], slot4["color"]) == ("PETG", "#FF0000")
+
+    def test_padding_rows_carry_the_project_type_and_colour(self):
+        """They drive the modal's pre-pick for the disabled rows."""
+        used = [{"slot_id": 4, "type": "PLA", "color": "#898989", "used_grams": 1.0, "used_meters": 1.0}]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert (out[0]["type"], out[0]["color"]) == ("PLA", "#38CC0A")
+        assert out[0]["used_grams"] == 0
+
+    def test_every_slot_used_is_a_shape_change_only(self):
+        used = [
+            {"slot_id": i, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0, "used_in_plate": True}
+            for i in (1, 2, 3, 4)
+        ]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert len(out) == 4
+        assert all(f["used_in_plate"] for f in out)
+        assert all(f["used_grams"] == 5.0 for f in out)
+
+    def test_a_used_slot_beyond_the_project_list_is_kept(self):
+        """Dropping it would recreate the original bug on a file whose
+        project settings and slice_info disagree — the one slot that prints
+        would vanish from the list entirely."""
+        used = [{"slot_id": 9, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert [f["slot_id"] for f in out] == [1, 2, 3, 4, 9]
+        assert out[-1]["used_in_plate"] is True
+
+    def test_returns_the_input_unchanged_without_project_settings(self):
+        """Nothing to widen against — a narrow list still prints correctly,
+        an invented one might not."""
+        used = [{"slot_id": 4, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert expand_to_project_slots(zf, used) == used
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
 # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
 # object top-level extruder, per-part extruder, painted-face quadtree leaves.
 # object top-level extruder, per-part extruder, painted-face quadtree leaves.

+ 2 - 0
frontend/src/App.tsx

@@ -26,6 +26,7 @@ import { SetupPage } from './pages/SetupPage';
 import { NotificationsPage } from './pages/NotificationsPage';
 import { NotificationsPage } from './pages/NotificationsPage';
 import { GCodeViewerPage } from './pages/GCodeViewerPage';
 import { GCodeViewerPage } from './pages/GCodeViewerPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
+import { usePrintProgressTitle } from './hooks/usePrintProgressTitle';
 import { useStreamTokenSync } from './hooks/useCameraStreamToken';
 import { useStreamTokenSync } from './hooks/useCameraStreamToken';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -89,6 +90,7 @@ function StreamTokenSync() {
 
 
 function WebSocketProvider({ children }: { children: React.ReactNode }) {
 function WebSocketProvider({ children }: { children: React.ReactNode }) {
   useWebSocket();
   useWebSocket();
+  usePrintProgressTitle();
   return <>{children}</>;
   return <>{children}</>;
 }
 }
 
 

+ 90 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -571,3 +571,93 @@ describe('AddNotificationModal — Bark provider (#1495)', () => {
     });
     });
   });
   });
 });
 });
+
+describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
+  const telegramProvider = (config: Record<string, unknown> = { bot_token: 'x', chat_id: '-100123' }) =>
+    buildProvider({ provider_type: 'telegram', config });
+
+  it('offers the Forum Topic ID field as optional for telegram', async () => {
+    render(<AddNotificationModal provider={telegramProvider()} onClose={() => undefined} />);
+
+    const label = await screen.findByText(/forum topic id/i);
+    // Required fields are marked with a trailing asterisk — this one must not be.
+    expect(label.textContent).not.toContain('*');
+    expect(screen.getByText(/leave empty for the general topic/i)).toBeInTheDocument();
+  });
+
+  it('does not offer the field for other providers', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    await screen.findByDisplayValue('My ntfy');
+    expect(screen.queryByText(/forum topic id/i)).not.toBeInTheDocument();
+  });
+
+  it('round-trips the topic id into config on save', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
+
+    await user.type(await screen.findByPlaceholderText('123'), '25');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).not.toBeNull();
+    expect(captured!.config).toMatchObject({ chat_id: '-100123', message_thread_id: '25' });
+  });
+
+  it('keeps the config free of the key when the field is left empty', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
+
+    await screen.findByPlaceholderText('123');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured!.config).not.toHaveProperty('message_thread_id');
+  });
+
+  it('blocks save on a non-numeric topic id', async () => {
+    // Reaches the form via a config written by the API rather than the picker —
+    // the number input itself already filters most junk out.
+    let patched = false;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async () => {
+        patched = true;
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(
+      <AddNotificationModal
+        provider={telegramProvider({ bot_token: 'x', chat_id: '-100123', message_thread_id: 'General' })}
+        onClose={onClose}
+      />,
+    );
+
+    await screen.findByText(/forum topic id/i);
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    expect(await screen.findByText(/forum topic id must be a number/i)).toBeInTheDocument();
+    expect(patched).toBe(false);
+    expect(onClose).not.toHaveBeenCalled();
+  });
+});

+ 96 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -1242,6 +1242,102 @@ describe('SliceModal', () => {
     });
     });
   });
   });
 
 
+  // #2712: the filament list is positional the whole way down — index 0 is
+  // slot 1, and the backend forwards it as filament_1.json..filament_N.json.
+  // A MakerWorld source that ships slice_info and paints with slot 4 alone
+  // used to yield a one-row list, so the user's only pick was bound to slot 1
+  // and slot 4 sliced with the source's embedded default: picking PETG gave a
+  // PLA print. The modal now asks for every project slot so the positions
+  // line up.
+  it('requests every project slot, not just the ones the plate prints with', async () => {
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(mockApi.getLibraryFileFilamentRequirements).toHaveBeenCalled());
+    const [, plateArg, , fullSlots] = mockApi.getLibraryFileFilamentRequirements.mock.calls[0];
+    expect(plateArg).toBe(1);
+    expect(fullSlots).toBe(true);
+  });
+
+  it('sends the pick for a high-numbered slot at its own index', async () => {
+    // Four project slots, only slot 4 printed. The pick must arrive as the
+    // FOURTH entry; anywhere else and the slicer binds it to the wrong slot.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Tunnel.3mf',
+      is_multi_plate: false,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: ['Tunnel'],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 1200,
+          filament_used_grams: 105,
+          filaments: [],
+        },
+      ],
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Tunnel.3mf',
+      plate_id: 1,
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#38CC0A', used_grams: 0, used_meters: 0, used_in_plate: false },
+        { slot_id: 2, type: 'PLA', color: '#161616', used_grams: 0, used_meters: 0, used_in_plate: false },
+        { slot_id: 3, type: 'PLA', color: '#898989', used_grams: 0, used_meters: 0, used_in_plate: false },
+        { slot_id: 4, type: 'PLA', color: '#898989', used_grams: 105, used_meters: 35, used_in_plate: true },
+      ],
+    });
+    mockApi.getSlicerPresets.mockResolvedValue({
+      cloud: {
+        printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
+        process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
+        filament: [
+          { id: 'F-PLA', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#898989' },
+          { id: 'F-PETG', name: 'Cloud PETG', source: 'cloud', filament_type: 'PETG', filament_colour: '#00FF00' },
+        ],
+      },
+      local: { printer: [], process: [], filament: [] },
+      standard: { printer: [], process: [], filament: [] },
+      cloud_status: 'ok',
+      orca_cloud: { printer: [], process: [], filament: [] },
+      orca_cloud_status: 'ok',
+    });
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 51,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/51',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+
+    // 1 printer + 1 process + 1 bed-type + 4 filament rows.
+    const selects = presetSelects();
+    expect(selects).toHaveLength(7);
+    // Only slot 4 is selectable — the other three are the padding.
+    expect([selects[3].disabled, selects[4].disabled, selects[5].disabled]).toEqual([true, true, true]);
+    expect(selects[6].disabled).toBe(false);
+
+    const user = userEvent.setup();
+    await user.selectOptions(selects[6], 'cloud:F-PETG');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.filament_presets).toHaveLength(4);
+      expect(body.filament_presets[3]).toEqual({ source: 'cloud', id: 'F-PETG' });
+    });
+  });
+
   // ------------------------------------------------------------------
   // ------------------------------------------------------------------
   // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
   // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
   // ------------------------------------------------------------------
   // ------------------------------------------------------------------

+ 185 - 0
frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx

@@ -482,3 +482,188 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => {
     expect(screen.queryByText(/%/)).toBeNull();
     expect(screen.queryByText(/%/)).toBeNull();
   });
   });
 });
 });
+
+describe('SliceJobTrackerProvider — one completion per job', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  /** A QueryClient whose invalidateQueries is counted. completeJob calls it
+   * twice (library-files + archives), so the counter divided by two is the
+   * number of times the terminal state was handled — a direct count that
+   * doesn't depend on how long a transient toast happens to stay on screen. */
+  function countingWrapper(counter: { n: number }) {
+    return function Counting({ children }: { children: ReactNode }) {
+      const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+      const original = queryClient.invalidateQueries.bind(queryClient);
+      queryClient.invalidateQueries = ((...args: Parameters<typeof original>) => {
+        counter.n += 1;
+        return original(...args);
+      }) as typeof queryClient.invalidateQueries;
+      return (
+        <QueryClientProvider client={queryClient}>
+          <ToastProvider>
+            <SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
+          </ToastProvider>
+        </QueryClientProvider>
+      );
+    };
+  }
+
+  it('handles completion once when the backend stalls for many poll intervals', async () => {
+    // The reported symptom: a single slice produced a stream of "Sliced X"
+    // toasts, more than ten of them, arriving one poll interval apart.
+    //
+    // setInterval does not await an async callback. Slicing a large project
+    // blocks the backend for seconds (zip parsing and output assembly are
+    // synchronous), so poll ticks piled up behind one stalled request —
+    // each holding a snapshot taken while the job was still active. When
+    // the backend recovered they all resolved 'completed' at once and each
+    // one ran the completion path. A 20s stall against the 1.5s interval
+    // stacks ~14 of them, which is the observed magnitude.
+    const STALL_MS = 20_000;
+    let polls = 0;
+    mockApi.getSliceJob.mockImplementation(async () => {
+      polls += 1;
+      await new Promise((resolve) => setTimeout(resolve, STALL_MS));
+      return {
+        job_id: 30,
+        status: 'completed',
+        kind: 'library_file',
+        source_id: 300,
+        source_name: 'Stalled.3mf',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={30} name="Stalled.3mf" />
+      </Counting>,
+    );
+
+    act(() => {
+      screen.getByText('track-30').click();
+    });
+
+    // Well past the stall, so every tick that could have piled up has had
+    // its chance to resolve.
+    for (let i = 0; i < 200; i += 1) {
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+      await act(async () => {
+        await Promise.resolve();
+        await Promise.resolve();
+      });
+    }
+
+    expect(counter.n / 2).toBe(1);
+    // The in-flight guard also has to stop the pile-up itself, not just its
+    // visible consequence: a stalled backend must not be handed a fresh
+    // request every 1.5s. One poll starts, one more can start after it
+    // resolves and the job is already gone.
+    expect(polls).toBeLessThanOrEqual(2);
+  });
+
+  it('handles the slower of two jobs once when the first one restarts the poller', async () => {
+    // The in-flight guard alone does not cover this. Two jobs are tracked;
+    // the first completes while the second's request is still open. That
+    // completion changes the tracked count, so the polling effect tears
+    // down and starts a fresh interval — with its own in-flight flag —
+    // while the previous round is still parked on the second job's await.
+    // Both rounds then see 'completed' for it, and the job reports itself
+    // twice unless the abandoned round notices it was cancelled or the
+    // completion path refuses the repeat. Either guard alone closes this;
+    // both are kept, so removing one still passes and removing both fails.
+    mockApi.getSliceJob.mockImplementation(async (id: number) => {
+      const base = {
+        kind: 'library_file' as const,
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+      if (id === 40) {
+        return { ...base, job_id: 40, status: 'completed', source_id: 400, source_name: 'Fast.3mf' };
+      }
+      await new Promise((resolve) => setTimeout(resolve, 6000));
+      return { ...base, job_id: 41, status: 'completed', source_id: 401, source_name: 'Slow.3mf' };
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={40} name="Fast.3mf" />
+        <TrackTrigger id={41} name="Slow.3mf" />
+      </Counting>,
+    );
+
+    act(() => {
+      screen.getByText('track-40').click();
+      screen.getByText('track-41').click();
+    });
+
+    for (let i = 0; i < 100; i += 1) {
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+      await act(async () => {
+        await Promise.resolve();
+        await Promise.resolve();
+      });
+    }
+
+    // Exactly two completions: one per job, neither repeated.
+    expect(counter.n / 2).toBe(2);
+  });
+
+  it('still completes a job tracked again under the same id', async () => {
+    // The finished-id set must not turn into a permanent block list: a
+    // re-tracked id has to reach the completion path again.
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 32,
+      status: 'completed',
+      kind: 'library_file',
+      source_id: 302,
+      source_name: 'Again.3mf',
+      created_at: new Date().toISOString(),
+      started_at: new Date().toISOString(),
+      completed_at: new Date().toISOString(),
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={32} name="Again.3mf" />
+      </Counting>,
+    );
+
+    for (let round = 0; round < 2; round += 1) {
+      act(() => {
+        screen.getByText('track-32').click();
+      });
+      for (let i = 0; i < 3; i += 1) {
+        await act(async () => {
+          vi.advanceTimersByTime(1500);
+        });
+        await act(async () => {
+          await Promise.resolve();
+          await Promise.resolve();
+        });
+      }
+    }
+
+    expect(counter.n / 2).toBe(2);
+  });
+});

+ 110 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -1287,3 +1287,113 @@ describe('useFilamentMapping — no [-1] mapping during a status-load race (#258
     expect(result.current.hasTypeMismatch).toBe(true);
     expect(result.current.hasTypeMismatch).toBe(true);
   });
   });
 });
 });
+
+describe('colour verdict is independent of how the tray was found (#2687)', () => {
+  // tray_info_idx names the filament *variant*, not an individual spool:
+  // GFA00 = PLA Basic, GFA01 = PLA Matte, GFA17 = PLA Translucent. A user with
+  // exactly one Matte spool loaded therefore idx-matches every Matte
+  // requirement no matter what colour it is.
+  const MATTE_DARK_GREEN = createPrinterStatus([
+    { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }] },
+  ]);
+  const wantRedMatte = {
+    filaments: [
+      { slot_id: 1, type: 'PLA', color: '#9D432C', used_grams: 31, tray_info_idx: 'GFA01' },
+    ],
+  };
+
+  it('reports a unique-idx tray of the wrong colour as type_only, not match', () => {
+    const [item] = buildFilamentComparison(
+      wantRedMatte,
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+
+    // The tray is still selected — it is the right variant (#2650) ...
+    expect(item.loaded?.globalTrayId).toBe(0);
+    // ... but red-on-dark-green is not a colour match.
+    expect(item.colorMatch).toBe(false);
+    expect(item.status).toBe('type_only');
+  });
+
+  it('auto and manual agree on the same tray', () => {
+    const loaded = buildLoadedFilaments(MATTE_DARK_GREEN);
+    const auto = buildFilamentComparison(wantRedMatte, loaded, {})[0];
+    const manual = buildFilamentComparison(wantRedMatte, loaded, { 1: 0 })[0];
+
+    // The original report: auto said "match", manually picking that very tray
+    // said "mismatch". Both paths must now reach the same verdict.
+    expect(manual.isManual).toBe(true);
+    expect(auto.status).toBe(manual.status);
+    expect(auto.colorMatch).toBe(manual.colorMatch);
+  });
+
+  it('surfaces the mismatch through the hook so the panel stops saying Ready', () => {
+    const { result } = renderHook(() => useFilamentMapping(wantRedMatte, MATTE_DARK_GREEN, {}));
+    // hasColorMismatch drives the yellow "(Color mismatch)" header; the tray is
+    // still mapped, so this is not a type mismatch.
+    expect(result.current.hasColorMismatch).toBe(true);
+    expect(result.current.hasTypeMismatch).toBe(false);
+    expect(result.current.amsMapping).toEqual([0]);
+  });
+
+  it('still reports a match when the unique-idx tray does carry the right colour', () => {
+    const [item] = buildFilamentComparison(
+      { filaments: [{ slot_id: 1, type: 'PLA', color: '#004225', used_grams: 31, tray_info_idx: 'GFA01' }] },
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+    expect(item.status).toBe('match');
+    expect(item.colorMatch).toBe(true);
+  });
+
+  it('accepts a near-enough shade on the idx path', () => {
+    // Within colorsAreSimilar's per-channel tolerance — the printer reporting a
+    // spool a shade off must not become a mismatch.
+    const [item] = buildFilamentComparison(
+      { filaments: [{ slot_id: 1, type: 'PLA', color: '#0A4A2A', used_grams: 31, tray_info_idx: 'GFA01' }] },
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+    expect(item.status).toBe('match');
+  });
+
+  it('treats a colourless requirement as satisfied by any colour', () => {
+    // 3MFs that omit the colour parse to "" (filament_requirements.py); there is
+    // nothing to disagree with, so this must not read as a colour mismatch.
+    const [item] = buildFilamentComparison(
+      { filaments: [{ slot_id: 1, type: 'PLA', color: '', used_grams: 31, tray_info_idx: 'GFA01' }] },
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+    expect(item.status).toBe('match');
+    expect(item.colorMatch).toBe(true);
+  });
+
+  it('keeps the multi-idx path intact — same idx, several colours picks the right one', () => {
+    // Two Matte spools: the branch that already compared colours must be
+    // unaffected, and the exact-colour tray still wins.
+    const twoMatte = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' },
+          { id: 1, tray_type: 'PLA', tray_color: '9D432C', tray_info_idx: 'GFA01' },
+        ],
+      },
+    ]);
+    const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(twoMatte), {});
+    expect(item.loaded?.globalTrayId).toBe(1);
+    expect(item.status).toBe('match');
+  });
+
+  it('a type-only fallback with no idx candidate is still type_only', () => {
+    // Regression guard: the pre-existing "type matches, colour does not" path.
+    const basicOnly = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA00' }] },
+    ]);
+    const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(basicOnly), {});
+    expect(item.status).toBe('type_only');
+    expect(item.colorMatch).toBe(false);
+  });
+});

+ 157 - 0
frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx

@@ -0,0 +1,157 @@
+import type { ReactNode } from 'react';
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+// Mock the theme pref and the API the hook reads, so the effect can be exercised
+// without the real providers. `theme.value` is swapped per test.
+const h = vi.hoisted(() => ({
+  theme: {
+    value: {
+      progressInTitle: false,
+      resolvedMode: 'dark',
+      darkAccent: 'green',
+      lightAccent: 'green',
+    } as { progressInTitle: boolean; resolvedMode: string; darkAccent: string; lightAccent: string },
+  },
+  getPrinters: vi.fn(),
+  getPrinterStatus: vi.fn(),
+}));
+
+vi.mock('../../contexts/ThemeContext', () => ({ useTheme: () => h.theme.value }));
+vi.mock('../../api/client', () => ({
+  api: { getPrinters: h.getPrinters, getPrinterStatus: h.getPrinterStatus },
+}));
+
+import { pickActivePrint, usePrintProgressTitle, type ProgressStatus } from '../../hooks/usePrintProgressTitle';
+
+const running = (progress: number, remaining_time: number | null): ProgressStatus => ({
+  state: 'RUNNING',
+  progress,
+  remaining_time,
+});
+
+describe('pickActivePrint', () => {
+  it('returns null when nothing is printing', () => {
+    expect(pickActivePrint([])).toBeNull();
+    expect(pickActivePrint([undefined])).toBeNull();
+    expect(pickActivePrint([{ state: 'IDLE', progress: 0, remaining_time: null }])).toBeNull();
+    // The real paused state is 'PAUSE', not 'PAUSED'.
+    expect(pickActivePrint([{ state: 'PAUSE', progress: 40, remaining_time: 10 }])).toBeNull();
+  });
+
+  it('ignores RUNNING prints with no progress value', () => {
+    expect(pickActivePrint([{ state: 'RUNNING', progress: null, remaining_time: 5 }])).toBeNull();
+  });
+
+  it('picks the soonest-finishing print among several running', () => {
+    const soonest = running(20, 12);
+    expect(pickActivePrint([running(80, 45), soonest, running(50, 30)])).toBe(soonest);
+  });
+
+  it('tie-breaks equal ETAs by highest progress', () => {
+    const further = running(70, 15);
+    expect(pickActivePrint([running(30, 15), further])).toBe(further);
+  });
+
+  it('treats a null remaining_time as furthest away', () => {
+    const withEta = running(10, 60);
+    expect(pickActivePrint([running(90, null), withEta])).toBe(withEta);
+  });
+
+  it('treats remaining_time <= 0 as unknown — a just-started print must not win', () => {
+    // The backend serialises "ETA not known yet" as 0 (not null). A printer that
+    // just started (0) must not steal the tab from one that is nearly done.
+    const almostDone = running(95, 180);
+    expect(pickActivePrint([running(2, 0), almostDone])).toBe(almostDone);
+    expect(pickActivePrint([running(2, -1), almostDone])).toBe(almostDone);
+  });
+});
+
+function wrapper() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={qc}>{children}</QueryClientProvider>
+  );
+}
+
+// jsdom has no canvas backend — calling getContext('2d') logs a "Not implemented"
+// jsdomError (with a full React stack) into the suite output on every run, and
+// leaves the favicon path untested because it bails on the null context. Stub
+// both canvas calls so the ring code actually executes and the swap is assertable.
+const RING_URL = 'data:image/png;base64,ring';
+const fakeCtx = {
+  beginPath: vi.fn(),
+  arc: vi.fn(),
+  stroke: vi.fn(),
+  lineWidth: 0,
+  strokeStyle: '',
+  lineCap: 'butt',
+} as unknown as CanvasRenderingContext2D;
+
+const realGetContext = HTMLCanvasElement.prototype.getContext;
+const realToDataURL = HTMLCanvasElement.prototype.toDataURL;
+
+function faviconHref(): string {
+  return document.querySelector<HTMLLinkElement>('link[rel~="icon"]')!.href;
+}
+
+describe('usePrintProgressTitle effect', () => {
+  beforeEach(() => {
+    h.getPrinters.mockReset();
+    h.getPrinterStatus.mockReset();
+
+    HTMLCanvasElement.prototype.getContext = (() =>
+      fakeCtx) as typeof HTMLCanvasElement.prototype.getContext;
+    HTMLCanvasElement.prototype.toDataURL = (() =>
+      RING_URL) as typeof HTMLCanvasElement.prototype.toDataURL;
+
+    // Replaces <title> too, so set the title after wiring the head up — the hook
+    // captures document.title at mount.
+    document.head.innerHTML = '<link rel="icon" href="/favicon.svg">';
+    document.title = 'Bambuddy';
+  });
+  afterEach(() => {
+    cleanup();
+    HTMLCanvasElement.prototype.getContext = realGetContext;
+    HTMLCanvasElement.prototype.toDataURL = realToDataURL;
+  });
+
+  it('is inert while the pref is off — never touches the tab title', async () => {
+    h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    document.title = 'Something Else';
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await new Promise((r) => setTimeout(r, 20));
+    expect(document.title).toBe('Something Else');
+    expect(faviconHref()).toContain('/favicon.svg');
+    expect(h.getPrinters).not.toHaveBeenCalled();
+  });
+
+  it('shows the active print percentage in the title and swaps the favicon', async () => {
+    h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }]);
+    h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
+    expect(faviconHref()).toBe(RING_URL);
+  });
+
+  it('restores the original title and favicon when the pref is switched off', async () => {
+    h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }]);
+    h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
+
+    const { rerender } = renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+    await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
+
+    h.theme.value = { ...h.theme.value, progressInTitle: false };
+    rerender();
+
+    await waitFor(() => expect(document.title).toBe('Bambuddy'));
+    expect(faviconHref()).toContain('/favicon.svg');
+  });
+});

+ 4 - 1
frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx

@@ -71,7 +71,10 @@ function mockAuthUser(permissions: string[]) {
 }
 }
 
 
 async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
 async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
-  const row = screen.getByText(folderName).parentElement!;
+  // Walk up to the row itself rather than assuming the name is its direct
+  // child — the name sits in a wrapper that also holds the optional
+  // last-activity line (#2680).
+  const row = screen.getByText(folderName).closest('div.group')!;
   const buttons = within(row).getAllByRole('button');
   const buttons = within(row).getAllByRole('button');
   // The kebab (MoreVertical) menu toggle is the last button in the row
   // The kebab (MoreVertical) menu toggle is the last button in the row
   await user.click(buttons[buttons.length - 1]);
   await user.click(buttons[buttons.length - 1]);

+ 44 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the FileManagerPage component.
  * Tests for the FileManagerPage component.
  */
  */
 
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
@@ -21,6 +21,9 @@ const mockFolders = [
     archive_id: null,
     archive_id: null,
     project_name: null,
     project_name: null,
     archive_name: null,
     archive_name: null,
+    // #2680: distinctive year so the folder-pane display test can assert on it
+    // without colliding with the file mtimes below.
+    latest_activity_at: '2031-04-05T10:00:00Z',
     children: [
     children: [
       {
       {
         id: 2,
         id: 2,
@@ -31,6 +34,7 @@ const mockFolders = [
         archive_id: null,
         archive_id: null,
         project_name: null,
         project_name: null,
         archive_name: null,
         archive_name: null,
+        latest_activity_at: '2032-06-07T10:00:00Z',
         children: [],
         children: [],
       },
       },
     ],
     ],
@@ -44,6 +48,9 @@ const mockFolders = [
     archive_id: null,
     archive_id: null,
     project_name: 'My Art Project',
     project_name: 'My Art Project',
     archive_name: null,
     archive_name: null,
+    // No activity timestamp — must render no date line rather than an
+    // "Invalid Date" placeholder.
+    latest_activity_at: null,
     children: [],
     children: [],
   },
   },
 ];
 ];
@@ -880,6 +887,13 @@ describe('FileManagerPage', () => {
       setItemMock.mockReset();
       setItemMock.mockReset();
     });
     });
 
 
+    // The mock is module-global, so an implementation left behind here would
+    // silently change every later describe (e.g. collapsing the folder tree).
+    afterEach(() => {
+      getItemMock.mockReset();
+      setItemMock.mockReset();
+    });
+
     it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
     it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
       getItemMock.mockReturnValue(null);
       getItemMock.mockReturnValue(null);
       render(<FileManagerPage />);
       render(<FileManagerPage />);
@@ -1111,5 +1125,34 @@ describe('FileManagerPage', () => {
         expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
         expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
       });
       });
     });
     });
+
+    it('the same toggle reveals latest activity on folder rows, including nested ones', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
+
+      await user.click(screen.getByTitle('Show modified dates'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/2031/)).toBeInTheDocument();
+      });
+      // Nested folders get it too — the prop must survive the recursion.
+      expect(screen.getByText(/2032/)).toBeInTheDocument();
+
+      // A folder with no activity timestamp renders nothing rather than an
+      // "Invalid Date" string.
+      const artRow = screen.getByText('Art Projects').closest('div.group')!;
+      expect(artRow.textContent).not.toMatch(/Invalid/);
+
+      await user.click(screen.getByTitle('Hide modified dates'));
+      await waitFor(() => {
+        expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
+      });
+    });
   });
   });
 });
 });

+ 107 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -310,6 +310,113 @@ describe('PrintersPage', () => {
         expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument();
         expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument();
       });
       });
     });
     });
+
+    // P2S/X2D left auxiliary part cooling fan (airduct part id 10) — optional
+    // hardware, so the badge must only appear when the firmware reports it.
+    const renderWithStatus = (
+      printer: typeof mockPrinters[number],
+      status: Record<string, unknown>,
+    ) => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+      );
+      render(<PrintersPage />);
+    };
+
+    it('shows the exhaust tile labeled "Exhaust" on P2S when the kit is present', async () => {
+      // Exhaust fan is an add-on kit; the tile appears only when the printer
+      // reports it (airduct part id 3 -> exhaust_fan_present).
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'P2S' },
+        { ...statusWithFans, exhaust_fan_present: true },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument();
+      });
+      expect(screen.getByTitle('Exhaust')).toBeInTheDocument();
+      expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument();
+    });
+
+    it('hides the exhaust tile on a base P2S without the kit', async () => {
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'P2S' },
+        { ...statusWithFans, exhaust_fan_present: false },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument();
+      });
+      expect(screen.queryByTitle('Exhaust')).not.toBeInTheDocument();
+      expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument();
+    });
+
+    it('keeps the always-on "Chamber Fan" tile on X1C regardless of exhaust_fan_present', async () => {
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'X1C' },
+        { ...statusWithFans, exhaust_fan_present: false },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument();
+      });
+      expect(screen.queryByTitle('Exhaust')).not.toBeInTheDocument();
+    });
+
+    it('hides the left aux badge when the accessory is not reported', async () => {
+      renderWithStatus({ ...mockPrinters[0], model: 'P2S' }, statusWithFans);
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument();
+      });
+      expect(screen.queryByTitle('Left Auxiliary Fan')).not.toBeInTheDocument();
+    });
+
+    it('orders the fan badges left-to-right: part, left aux, aux, exhaust', async () => {
+      // The two aux badges should read in the same order as the physical
+      // hardware, so the left fan sits before the right one.
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'P2S' },
+        { ...statusWithFans, left_aux_fan_speed: 80, exhaust_fan_present: true },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Left Auxiliary Fan')).toBeInTheDocument();
+      });
+
+      const order = ['Part Cooling Fan', 'Left Auxiliary Fan', 'Auxiliary Fan', 'Exhaust'].map(
+        (title) => screen.getByTitle(title),
+      );
+      for (let i = 1; i < order.length; i++) {
+        // Node.compareDocumentPosition returns FOLLOWING (4) when the argument
+        // comes after the reference node in document order.
+        expect(order[i - 1].compareDocumentPosition(order[i])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+      }
+    });
+
+    it('shows left aux fan badge when the accessory is installed (P2S)', async () => {
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'P2S' },
+        { ...statusWithFans, left_aux_fan_speed: 80 },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Left Auxiliary Fan')).toBeInTheDocument();
+      });
+    });
+
+    it('shows left aux fan badge even at 0% while installed', async () => {
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'P2S' },
+        { ...statusWithFans, left_aux_fan_speed: 0 },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Left Auxiliary Fan')).toBeInTheDocument();
+      });
+    });
+
   });
   });
 
 
   describe('empty state', () => {
   describe('empty state', () => {

+ 20 - 1
frontend/src/api/client.ts

@@ -565,6 +565,11 @@ export interface PrinterStatus {
   big_fan1_speed: number | null;     // Auxiliary fan
   big_fan1_speed: number | null;     // Auxiliary fan
   big_fan2_speed: number | null;     // Chamber/exhaust fan
   big_fan2_speed: number | null;     // Chamber/exhaust fan
   heatbreak_fan_speed: number | null; // Hotend heatbreak fan
   heatbreak_fan_speed: number | null; // Hotend heatbreak fan
+  // Left auxiliary part cooling fan (optional P2S/X2D accessory, M106 P10).
+  // null = not installed / not reported by this model.
+  left_aux_fan_speed: number | null;
+  // Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit, airduct part 3).
+  exhaust_fan_present: boolean;
   firmware_version: string | null;   // Firmware version from MQTT
   firmware_version: string | null;   // Firmware version from MQTT
   // Developer LAN mode: true = enabled, false = disabled, null = unknown
   // Developer LAN mode: true = enabled, false = disabled, null = unknown
   developer_mode: boolean | null;
   developer_mode: boolean | null;
@@ -3939,7 +3944,7 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
-  setFanSpeed: (printerId: number, fan: 'part' | 'aux' | 'chamber', speed: number) =>
+  setFanSpeed: (printerId: number, fan: 'part' | 'aux' | 'aux2' | 'chamber', speed: number) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/fan-speed?fan=${fan}&speed=${speed}`, {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/fan-speed?fan=${fan}&speed=${speed}`, {
       method: 'POST',
       method: 'POST',
     }),
     }),
@@ -4693,10 +4698,17 @@ export const api = {
     archiveId: number,
     archiveId: number,
     plateId?: number,
     plateId?: number,
     requestId?: string,
     requestId?: string,
+    /** Ask for one entry per project slot instead of only the slots this
+     * plate consumes. The slice modal needs it: its filament list is
+     * positional, so a source whose only used slot is 4 must still present
+     * four rows or the user's pick is bound to slot 1 (#2712). Print-time
+     * AMS matching must NOT set this — it wants the used-only list. */
+    fullSlots?: boolean,
   ) => {
   ) => {
     const qs = new URLSearchParams();
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
     if (requestId) qs.set('request_id', requestId);
+    if (fullSlots) qs.set('full_slots', 'true');
     return request<{
     return request<{
       archive_id: number;
       archive_id: number;
       filename: string;
       filename: string;
@@ -6397,10 +6409,17 @@ export const api = {
     fileId: number,
     fileId: number,
     plateId?: number,
     plateId?: number,
     requestId?: string,
     requestId?: string,
+    /** Ask for one entry per project slot instead of only the slots this
+     * plate consumes. The slice modal needs it: its filament list is
+     * positional, so a source whose only used slot is 4 must still present
+     * four rows or the user's pick is bound to slot 1 (#2712). Print-time
+     * AMS matching must NOT set this — it wants the used-only list. */
+    fullSlots?: boolean,
   ) => {
   ) => {
     const qs = new URLSearchParams();
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
     if (requestId) qs.set('request_id', requestId);
+    if (fullSlots) qs.set('full_slots', 'true');
     return request<{
     return request<{
       file_id: number;
       file_id: number;
       filename: string;
       filename: string;

+ 22 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -160,6 +160,15 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       }
       }
     }
     }
 
 
+    // Telegram forum topic must be a plain integer (#1518) — type="number"
+    // still lets "1e5" and "-" through, and Telegram would 400 on those.
+    if (providerType === 'telegram' && config.message_thread_id?.trim()) {
+      if (!/^\d+$/.test(config.message_thread_id.trim())) {
+        setError(t('notifications.telegramThreadIdInvalid'));
+        return;
+      }
+    }
+
     const finalConfig: Record<string, unknown> =
     const finalConfig: Record<string, unknown> =
       providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
       providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
         ? { ...config, event_priorities: eventPriorities }
         ? { ...config, event_priorities: eventPriorities }
@@ -245,6 +254,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         return [
         return [
           { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
           { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
           { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
           { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
+          // Optional forum topic (#1518). Left empty, Telegram posts to the
+          // group's General topic exactly as before.
+          {
+            key: 'message_thread_id',
+            label: t('notifications.telegramThreadId'),
+            placeholder: '123',
+            type: 'number',
+            required: false,
+            help: t('notifications.telegramThreadIdHelp'),
+          },
         ];
         ];
       case 'email':
       case 'email':
         return [
         return [
@@ -423,6 +442,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
                   />
                 )}
                 )}
+                {'help' in field && (field as { help?: string }).help && (
+                  <p className="text-xs text-bambu-gray mt-1">{(field as { help?: string }).help}</p>
+                )}
               </div>
               </div>
             ))}
             ))}
           </div>
           </div>

+ 8 - 2
frontend/src/components/SliceModal.tsx

@@ -316,10 +316,16 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   const filamentReqsQuery = useQuery({
   const filamentReqsQuery = useQuery({
     queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
     queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
     queryFn: async () => {
     queryFn: async () => {
+      // `fullSlots`: one row per project slot, not only the ones this plate
+      // prints with. The list below is positional all the way to the CLI's
+      // filament_N.json parts, so a source whose only used slot is 4 has to
+      // present four rows — otherwise the single pick binds to slot 1 and
+      // slot 4 slices with whatever the source had baked in (#2712). The
+      // unused rows stay disabled exactly as before.
       if (source.kind === 'libraryFile') {
       if (source.kind === 'libraryFile') {
-        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
+        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
       }
       }
-      return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
+      return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
     },
     },
     enabled: !needsPlatePicker,
     enabled: !needsPlatePicker,
     staleTime: 60_000,
     staleTime: 60_000,

+ 47 - 15
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -85,6 +85,12 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
   const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
   const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
   const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
 
 
+  // Job ids whose terminal state has already been handled. `completeJob`
+  // shows a toast and invalidates two query keys, so it has to be exactly
+  // once per job no matter how many callers reach it — see the poll loop
+  // below for how more than one used to.
+  const finishedRef = useRef<Set<number>>(new Set());
+
   const renderProgressToast = useCallback(
   const renderProgressToast = useCallback(
     (job: TrackedJob) => {
     (job: TrackedJob) => {
       const startedAt = startedAtRef.current.get(job.id);
       const startedAt = startedAtRef.current.get(job.id);
@@ -151,6 +157,10 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const trackJob = useCallback(
   const trackJob = useCallback(
     (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
     (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
       setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
       setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
+      // Re-tracking an id re-arms it. Ids come from a database sequence so
+      // this can't collide in practice; clearing here is what keeps the set
+      // from being a permanent record of every job the session ever saw.
+      finishedRef.current.delete(id);
       startedAtRef.current.set(id, Date.now());
       startedAtRef.current.set(id, Date.now());
       phaseRef.current.set(id, 'pending');
       phaseRef.current.set(id, 'pending');
       progressRef.current.set(id, null);
       progressRef.current.set(id, null);
@@ -163,6 +173,11 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
 
 
   const completeJob = useCallback(
   const completeJob = useCallback(
     (job: TrackedJob, state: SliceJobState) => {
     (job: TrackedJob, state: SliceJobState) => {
+      // Guard, not an optimisation: everything below is a side effect the
+      // user sees, and a second call would repeat all of it.
+      if (finishedRef.current.has(job.id)) return;
+      finishedRef.current.add(job.id);
+
       setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
       setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
       startedAtRef.current.delete(job.id);
       startedAtRef.current.delete(job.id);
       phaseRef.current.delete(job.id);
       phaseRef.current.delete(job.id);
@@ -201,24 +216,41 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   useEffect(() => {
   useEffect(() => {
     if (activeJobs.length === 0) return;
     if (activeJobs.length === 0) return;
     let cancelled = false;
     let cancelled = false;
+    // setInterval does not await an async callback, so a tick fires whether
+    // or not the previous one came back. Slicing a large project blocks the
+    // backend for seconds at a time (zip parsing and output assembly are
+    // synchronous), and every tick that piled up during the stall had
+    // already captured a snapshot naming the job as active. They all
+    // resolved `completed` together and each called completeJob, which is
+    // how one slice produced a stream of a dozen "Sliced X" toasts. Letting
+    // only one poll round be in flight fixes that at the source, and stops
+    // queueing requests against a backend that is already saturated.
+    let polling = false;
     const interval = setInterval(async () => {
     const interval = setInterval(async () => {
-      if (cancelled) return;
-      const snapshot = [...activeJobsRef.current];
-      for (const job of snapshot) {
-        try {
-          const state = await api.getSliceJob(job.id);
-          phaseRef.current.set(job.id, state.status);
-          // Capture the latest progress snapshot if the sidecar fed
-          // one through. The 1s tick re-renders the toast off this ref.
-          if (state.progress) {
-            progressRef.current.set(job.id, state.progress);
-          }
-          if (state.status === 'completed' || state.status === 'failed') {
-            completeJob(job, state);
+      if (cancelled || polling) return;
+      polling = true;
+      try {
+        const snapshot = [...activeJobsRef.current];
+        for (const job of snapshot) {
+          try {
+            const state = await api.getSliceJob(job.id);
+            // The tracker may have been torn down while this was in flight.
+            if (cancelled) return;
+            phaseRef.current.set(job.id, state.status);
+            // Capture the latest progress snapshot if the sidecar fed
+            // one through. The 1s tick re-renders the toast off this ref.
+            if (state.progress) {
+              progressRef.current.set(job.id, state.progress);
+            }
+            if (state.status === 'completed' || state.status === 'failed') {
+              completeJob(job, state);
+            }
+          } catch {
+            // Transient poll failure — stay tracked, retry next tick.
           }
           }
-        } catch {
-          // Transient poll failure — stay tracked, retry next tick.
         }
         }
+      } finally {
+        polling = false;
       }
       }
     }, POLL_INTERVAL_MS);
     }, POLL_INTERVAL_MS);
     return () => {
     return () => {

+ 15 - 0
frontend/src/contexts/ThemeContext.tsx

@@ -19,6 +19,9 @@ interface ThemeContextType {
   lightStyle: ThemeStyle;
   lightStyle: ThemeStyle;
   lightBackground: LightBackground;
   lightBackground: LightBackground;
   lightAccent: ThemeAccent;
   lightAccent: ThemeAccent;
+  // Show live print progress (% + accent-coloured ring favicon) in the browser tab
+  progressInTitle: boolean;
+  setProgressInTitle: (v: boolean) => void;
   // Actions
   // Actions
   toggleMode: () => void;
   toggleMode: () => void;
   setMode: (mode: ThemeMode) => void;
   setMode: (mode: ThemeMode) => void;
@@ -87,6 +90,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
     return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
     return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
   });
   });
 
 
+  // Client-only pref (localStorage), no api.updateSettings sync — the tab
+  // title/favicon is per-browser behaviour. Move to server settings if it
+  // ever needs to follow the user across devices. Default off.
+  const [progressInTitle, setProgressInTitleState] = useState<boolean>(() => {
+    return localStorage.getItem('progress-in-title') === 'true';
+  });
+  const setProgressInTitle = (v: boolean) => {
+    setProgressInTitleState(v);
+    localStorage.setItem('progress-in-title', String(v));
+  };
+
   // Sync from API once auth state is known. Same gate shape as
   // Sync from API once auth state is known. Same gate shape as
   // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
   // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
   // settle, then only fetch when we can actually expect a 200 (auth
   // settle, then only fetch when we can actually expect a 200 (auth
@@ -202,6 +216,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
       resolvedMode,
       resolvedMode,
       darkStyle, darkBackground, darkAccent,
       darkStyle, darkBackground, darkAccent,
       lightStyle, lightBackground, lightAccent,
       lightStyle, lightBackground, lightAccent,
+      progressInTitle, setProgressInTitle,
       toggleMode, setMode,
       toggleMode, setMode,
       setDarkStyle, setDarkBackground, setDarkAccent,
       setDarkStyle, setDarkBackground, setDarkAccent,
       setLightStyle, setLightBackground, setLightAccent,
       setLightStyle, setLightBackground, setLightAccent,

+ 38 - 11
frontend/src/hooks/useFilamentMapping.ts

@@ -203,6 +203,27 @@ export function useLoadedFilaments(
   }, [printerStatus]);
   }, [printerStatus]);
 }
 }
 
 
+/**
+ * Does the tray we picked actually carry the colour the slice asked for?
+ *
+ * Shared by the manual and auto branches below so the two can never disagree
+ * about the same tray again (#2687). Exact hex first, then the perceptual
+ * tolerance, so a spool the printer reports one shade off still reads as a
+ * match.
+ *
+ * A requirement with no colour at all is not a mismatch — the 3MF simply
+ * didn't ask for one (`filament_requirements.py` defaults it to `""`), so any
+ * loaded colour satisfies it. Loaded trays always have a colour: buildLoaded-
+ * Filaments falls back to grey when MQTT reports none.
+ */
+function coloursMatch(loadedColor: string | undefined, requiredColor: string | undefined): boolean {
+  const required = normalizeColorForCompare(requiredColor);
+  if (!required) return true;
+  return (
+    normalizeColorForCompare(loadedColor) === required || colorsAreSimilar(loadedColor, requiredColor)
+  );
+}
+
 /**
 /**
  * Compare required filaments with loaded filaments (non-hook version).
  * Compare required filaments with loaded filaments (non-hook version).
  *
  *
@@ -236,9 +257,7 @@ export function buildFilamentComparison(
 
 
       if (manualLoaded) {
       if (manualLoaded) {
         const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
         const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-        const colorMatch =
-          normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-          colorsAreSimilar(manualLoaded.color, req.color);
+        const colorMatch = coloursMatch(manualLoaded.color, req.color);
 
 
         let status: FilamentStatus;
         let status: FilamentStatus;
         if (typeMatch && colorMatch) {
         if (typeMatch && colorMatch) {
@@ -358,17 +377,25 @@ export function buildFilamentComparison(
 
 
     const hasFilament = !!loaded;
     const hasFilament = !!loaded;
     const typeMatch = hasFilament;
     const typeMatch = hasFilament;
-    // idxMatch is always considered a color match (same spool = same color)
-    const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
-
-    // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
+    // #2687: judge the colour on the tray we actually picked, never on which
+    // branch found it. tray_info_idx identifies the filament *variant* — GFA00
+    // is PLA Basic, GFA01 PLA Matte, GFA17 PLA Translucent — not an individual
+    // spool, so one Matte spool idx-matches every Matte requirement whatever
+    // colour it is. The old rule ("same spool = same color") therefore reported
+    // red-required-on-green-loaded as a match, while manually picking that same
+    // tray reported the mismatch honestly. Variant still decides *selection*
+    // (#2650: Basic is not Matte) — it just no longer decides the verdict.
+    const colorMatch = hasFilament && coloursMatch(loaded.color, req.color);
+
+    // No tray of the required type at all is a type mismatch; otherwise the
+    // colour decides between a full match and type-only.
     let status: FilamentStatus;
     let status: FilamentStatus;
-    if (idxMatch || exactMatch || similarMatch) {
+    if (!hasFilament) {
+      status = 'mismatch';
+    } else if (colorMatch) {
       status = 'match';
       status = 'match';
-    } else if (typeOnlyMatch) {
-      status = 'type_only';
     } else {
     } else {
-      status = 'mismatch';
+      status = 'type_only';
     }
     }
 
 
     return {
     return {

+ 162 - 0
frontend/src/hooks/usePrintProgressTitle.ts

@@ -0,0 +1,162 @@
+import { useQueries, useQuery } from '@tanstack/react-query';
+import { useEffect, useRef } from 'react';
+import { api } from '../api/client';
+import { useTheme } from '../contexts/ThemeContext';
+
+const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
+
+// A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0,
+// not null), so treat it as unknown rather than "finishes now".
+const eta = (t: number | null): number => (t != null && t > 0 ? t : Infinity);
+
+// Only the fields we need — keeps pickActivePrint decoupled from the full
+// PrinterStatus type so the test can pass plain objects.
+export interface ProgressStatus {
+  state: string | null;
+  progress: number | null;
+  remaining_time: number | null;
+}
+
+/**
+ * Of all connected printers, pick the RUNNING print to surface in the tab:
+ * the one finishing soonest (smallest remaining_time), tie-broken by highest
+ * progress. Returns null when nothing is actively printing.
+ */
+export function pickActivePrint<T extends ProgressStatus>(statuses: (T | undefined)[]): T | null {
+  let best: T | null = null;
+  for (const s of statuses) {
+    if (!s || s.state !== 'RUNNING' || s.progress == null) continue;
+    if (best === null) {
+      best = s;
+      continue;
+    }
+    const sr = eta(s.remaining_time);
+    const br = eta(best.remaining_time);
+    if (sr < br || (sr === br && (s.progress ?? 0) > (best.progress ?? 0))) {
+      best = s;
+    }
+  }
+  return best;
+}
+
+// Draw a 32x32 progress ring in the current theme accent colour, return a PNG
+// data URL (or null if the browser has no 2d canvas, e.g. under jsdom — the
+// caller then falls back to updating the title only).
+function drawProgressFavicon(pct: number): string | null {
+  const canvas = document.createElement('canvas');
+  canvas.width = 32;
+  canvas.height = 32;
+  const ctx = canvas.getContext('2d');
+  if (!ctx) return null;
+
+  const accent =
+    getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
+    FALLBACK_ACCENT;
+
+  const cx = 16;
+  const cy = 16;
+  const r = 13;
+  const frac = Math.max(0, Math.min(100, pct)) / 100;
+  const start = -Math.PI / 2; // 12 o'clock
+
+  ctx.lineWidth = 4;
+  // Track
+  ctx.beginPath();
+  ctx.arc(cx, cy, r, 0, Math.PI * 2);
+  ctx.strokeStyle = 'rgba(128,128,128,0.3)';
+  ctx.stroke();
+  // Progress arc, clockwise from the top
+  ctx.beginPath();
+  ctx.arc(cx, cy, r, start, start + frac * Math.PI * 2);
+  ctx.strokeStyle = accent;
+  ctx.lineCap = 'round';
+  ctx.stroke();
+
+  return canvas.toDataURL('image/png');
+}
+
+// Point the <link rel="icon"> tags at the ring (remembering originals), or
+// restore them when dataUrl is null. apple-touch-icon is a different rel token
+// so the `rel~="icon"` selector leaves it alone.
+function setFavicon(dataUrl: string | null, originals: Map<HTMLLinkElement, string>) {
+  const links = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
+  links.forEach((link) => {
+    if (dataUrl) {
+      if (!originals.has(link)) originals.set(link, link.href);
+      link.href = dataUrl;
+    } else {
+      const orig = originals.get(link);
+      if (orig !== undefined) link.href = orig;
+    }
+  });
+  if (!dataUrl) originals.clear();
+}
+
+/**
+ * When the "progress in tab" preference is on, reflect the soonest-finishing
+ * print's percentage in document.title and draw a progress ring favicon in the
+ * theme accent colour. Stays fully inert until enabled, and hands the tab back
+ * to its defaults once disabled, idle, or unmounted.
+ * Mounted once, globally, inside WebSocketProvider.
+ */
+export function usePrintProgressTitle() {
+  const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme();
+  // Re-draw the ring when the active accent changes.
+  const accent = resolvedMode === 'dark' ? darkAccent : lightAccent;
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+    enabled: progressInTitle,
+  });
+
+  // No refetchInterval here on purpose. This hook is mounted globally, so a
+  // poll would add one request per printer every interval on every page — the
+  // Printers page already runs its own 30s fallback on this exact key. The
+  // WebSocket writes ['printerStatus', id] directly (useWebSocket), which keeps
+  // the tab live; a cosmetic title going stale during a WS outage is fine.
+  const statusQueries = useQueries({
+    queries: (progressInTitle ? printers ?? [] : []).map((p) => ({
+      queryKey: ['printerStatus', p.id],
+      queryFn: () => api.getPrinterStatus(p.id),
+    })),
+  });
+
+  const originalsRef = useRef<Map<HTMLLinkElement, string>>(new Map());
+  // The tab's own title, captured before we ever touch it, so restoring doesn't
+  // depend on a constant matching index.html.
+  const defaultTitleRef = useRef(document.title);
+  // Whether we currently own the tab title/favicon. Lets us stay inert while
+  // off (never touch the tab) yet still restore once if we ever took it over.
+  const ownsRef = useRef(false);
+
+  const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null;
+  const pct = active && active.progress != null ? Math.round(active.progress) : null;
+
+  useEffect(() => {
+    if (progressInTitle && pct != null) {
+      document.title = `${pct}% · ${defaultTitleRef.current}`;
+      setFavicon(drawProgressFavicon(pct), originalsRef.current);
+      ownsRef.current = true;
+    } else if (ownsRef.current) {
+      // Disabled or idle after having taken over — hand the tab back.
+      document.title = defaultTitleRef.current;
+      setFavicon(null, originalsRef.current);
+      ownsRef.current = false;
+    }
+    // else: never owned the tab → leave it entirely alone.
+  }, [progressInTitle, pct, accent]);
+
+  // Restore the tab to defaults on unmount, but only if we own it.
+  useEffect(() => {
+    const originals = originalsRef.current;
+    const owns = ownsRef;
+    const defaultTitle = defaultTitleRef.current;
+    return () => {
+      if (owns.current) {
+        document.title = defaultTitle;
+        setFavicon(null, originals);
+      }
+    };
+  }, []);
+}

+ 3 - 3
frontend/src/i18n/index.ts

@@ -14,8 +14,8 @@ import ptBR from './locales/pt-BR';
 import zhCN from './locales/zh-CN';
 import zhCN from './locales/zh-CN';
 import zhTW from './locales/zh-TW';
 import zhTW from './locales/zh-TW';
 import tr from './locales/tr';
 import tr from './locales/tr';
-import uk from './locales/uk';
 import ru from './locales/ru';
 import ru from './locales/ru';
+import uk from './locales/uk';
 
 
 const resources = {
 const resources = {
   en: { translation: en },
   en: { translation: en },
@@ -29,8 +29,8 @@ const resources = {
   'zh-CN': { translation: zhCN },
   'zh-CN': { translation: zhCN },
   'zh-TW': { translation: zhTW },
   'zh-TW': { translation: zhTW },
   tr: { translation: tr },
   tr: { translation: tr },
-  uk: { translation: uk },
   ru: { translation: ru },
   ru: { translation: ru },
+  uk: { translation: uk },
 };
 };
 
 
 const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'uk', 'zh-CN', 'zh-TW'];
 const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'uk', 'zh-CN', 'zh-TW'];
@@ -111,6 +111,6 @@ export const availableLanguages = [
   { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
   { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
   { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
   { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
   { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
   { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
-  { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
   { code: 'ru', name: 'Russian', nativeName: 'Русский' },
   { code: 'ru', name: 'Russian', nativeName: 'Русский' },
+  { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
 ];
 ];

+ 8 - 0
frontend/src/i18n/locales/de.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Bauteilkühlung',
       partCooling: 'Bauteilkühlung',
       auxiliary: 'Hilfsventilator',
       auxiliary: 'Hilfsventilator',
+      leftAuxiliary: 'Linker Hilfsventilator',
+      exhaust: 'Abluft',
       chamber: 'Kammerventilator',
       chamber: 'Kammerventilator',
     },
     },
     // HMS errors
     // HMS errors
@@ -2436,6 +2438,8 @@ export default {
     styleGlow: 'Leuchtend',
     styleGlow: 'Leuchtend',
     styleVibrant: 'Lebendig',
     styleVibrant: 'Lebendig',
     themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
     themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
+    progressInTitle: 'Druckfortschritt im Tab',
+    progressInTitleDescription: 'Zeigt den Prozentsatz des aktiven Drucks und einen Fortschrittsring im Browser-Tab an.',
     // Archive
     // Archive
     autoArchivePrints: 'Drucke automatisch archivieren',
     autoArchivePrints: 'Drucke automatisch archivieren',
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
@@ -3668,6 +3672,7 @@ export default {
     showModified: 'Änderungsdatum anzeigen',
     showModified: 'Änderungsdatum anzeigen',
     hideModified: 'Änderungsdatum ausblenden',
     hideModified: 'Änderungsdatum ausblenden',
     lastModified: 'Zuletzt geändert',
     lastModified: 'Zuletzt geändert',
+    lastActivity: 'Letzte Aktivität',
     resultsCount: '{{showing}} von {{total}} Dateien',
     resultsCount: '{{showing}} von {{total}} Dateien',
     selectAll: 'Alle auswählen',
     selectAll: 'Alle auswählen',
     deselectAll: 'Auswahl aufheben',
     deselectAll: 'Auswahl aufheben',
@@ -5615,6 +5620,9 @@ export default {
     pushoverExpire: 'Notfall-Ablauf (s)',
     pushoverExpire: 'Notfall-Ablauf (s)',
     botToken: 'Bot-Token',
     botToken: 'Bot-Token',
     chatId: 'Chat-ID',
     chatId: 'Chat-ID',
+    telegramThreadId: 'Forum-Themen-ID',
+    telegramThreadIdHelp: 'Optional. Sendet in ein einzelnes Thema einer Forum-Gruppe — die letzte Zahl im Themen-Link (t.me/c/.../25). Leer lassen für das allgemeine Thema.',
+    telegramThreadIdInvalid: 'Die Forum-Themen-ID muss eine Zahl sein.',
     smtpServer: 'SMTP-Server',
     smtpServer: 'SMTP-Server',
     smtpPort: 'SMTP-Port',
     smtpPort: 'SMTP-Port',
     security: 'Sicherheit',
     security: 'Sicherheit',

+ 8 - 0
frontend/src/i18n/locales/en.ts

@@ -672,6 +672,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Part Cooling Fan',
       partCooling: 'Part Cooling Fan',
       auxiliary: 'Auxiliary Fan',
       auxiliary: 'Auxiliary Fan',
+      leftAuxiliary: 'Left Auxiliary Fan',
+      exhaust: 'Exhaust',
       chamber: 'Chamber Fan',
       chamber: 'Chamber Fan',
     },
     },
     // HMS errors
     // HMS errors
@@ -2455,6 +2457,8 @@ export default {
     styleGlow: 'Glow',
     styleGlow: 'Glow',
     styleVibrant: 'Vibrant',
     styleVibrant: 'Vibrant',
     themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
     themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
+    progressInTitle: 'Print progress in tab',
+    progressInTitleDescription: 'Show the active print\'s percentage and a progress ring in the browser tab.',
     // Archive
     // Archive
     autoArchivePrints: 'Auto-archive prints',
     autoArchivePrints: 'Auto-archive prints',
     autoArchiveDescription: 'Automatically save 3MF files when prints complete',
     autoArchiveDescription: 'Automatically save 3MF files when prints complete',
@@ -3697,6 +3701,7 @@ export default {
     showModified: 'Show modified dates',
     showModified: 'Show modified dates',
     hideModified: 'Hide modified dates',
     hideModified: 'Hide modified dates',
     lastModified: 'Last modified',
     lastModified: 'Last modified',
+    lastActivity: 'Last activity',
     resultsCount: '{{showing}} of {{total}} files',
     resultsCount: '{{showing}} of {{total}} files',
     selectAll: 'Select All',
     selectAll: 'Select All',
     deselectAll: 'Deselect All',
     deselectAll: 'Deselect All',
@@ -5659,6 +5664,9 @@ export default {
     pushoverExpire: 'Emergency Expire (s)',
     pushoverExpire: 'Emergency Expire (s)',
     botToken: 'Bot Token',
     botToken: 'Bot Token',
     chatId: 'Chat ID',
     chatId: 'Chat ID',
+    telegramThreadId: 'Forum Topic ID',
+    telegramThreadIdHelp: 'Optional. Posts into a single topic of a forum group — the last number in the topic link (t.me/c/.../25). Leave empty for the General topic.',
+    telegramThreadIdInvalid: 'Forum Topic ID must be a number.',
     smtpServer: 'SMTP Server',
     smtpServer: 'SMTP Server',
     smtpPort: 'SMTP Port',
     smtpPort: 'SMTP Port',
     security: 'Security',
     security: 'Security',

+ 8 - 0
frontend/src/i18n/locales/es.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Ventilador de refrigeración de piezas',
       partCooling: 'Ventilador de refrigeración de piezas',
       auxiliary: 'Ventilador auxiliar',
       auxiliary: 'Ventilador auxiliar',
+      leftAuxiliary: 'Ventilador auxiliar izquierdo',
+      exhaust: 'Extracción',
       chamber: 'Ventilador de la cámara',
       chamber: 'Ventilador de la cámara',
     },
     },
     // HMS errors
     // HMS errors
@@ -2439,6 +2441,8 @@ export default {
     styleGlow: 'Resplandor',
     styleGlow: 'Resplandor',
     styleVibrant: 'Vibrante',
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
     themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
+    progressInTitle: 'Progreso en la pestaña',
+    progressInTitleDescription: 'Muestra el porcentaje de la impresión activa y un anillo de progreso en la pestaña del navegador.',
     // Archive
     // Archive
     autoArchivePrints: 'Archivar impresiones automáticamente',
     autoArchivePrints: 'Archivar impresiones automáticamente',
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
@@ -3671,6 +3675,7 @@ export default {
     showModified: 'Mostrar fechas de modificación',
     showModified: 'Mostrar fechas de modificación',
     hideModified: 'Ocultar fechas de modificación',
     hideModified: 'Ocultar fechas de modificación',
     lastModified: 'Última modificación',
     lastModified: 'Última modificación',
+    lastActivity: 'Última actividad',
     resultsCount: '{{showing}} de {{total}} archivos',
     resultsCount: '{{showing}} de {{total}} archivos',
     selectAll: 'Seleccionar todo',
     selectAll: 'Seleccionar todo',
     deselectAll: 'Deseleccionar todo',
     deselectAll: 'Deseleccionar todo',
@@ -5624,6 +5629,9 @@ export default {
     pushoverExpire: 'Expiración de emergencia (s)',
     pushoverExpire: 'Expiración de emergencia (s)',
     botToken: 'Token del bot',
     botToken: 'Token del bot',
     chatId: 'ID del chat',
     chatId: 'ID del chat',
+    telegramThreadId: 'ID del tema del foro',
+    telegramThreadIdHelp: 'Opcional. Envía a un único tema de un grupo de foro: el último número del enlace del tema (t.me/c/.../25). Déjalo vacío para el tema General.',
+    telegramThreadIdInvalid: 'El ID del tema del foro debe ser un número.',
     smtpServer: 'Servidor SMTP',
     smtpServer: 'Servidor SMTP',
     smtpPort: 'Puerto SMTP',
     smtpPort: 'Puerto SMTP',
     security: 'Seguridad',
     security: 'Seguridad',

+ 8 - 0
frontend/src/i18n/locales/fr.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Ventilateur pièce',
       partCooling: 'Ventilateur pièce',
       auxiliary: 'Ventilateur auxiliaire',
       auxiliary: 'Ventilateur auxiliaire',
+      leftAuxiliary: 'Ventilateur auxiliaire gauche',
+      exhaust: 'Extraction',
       chamber: 'Ventilateur chambre',
       chamber: 'Ventilateur chambre',
     },
     },
     // HMS errors
     // HMS errors
@@ -2391,6 +2393,8 @@ export default {
     styleGlow: 'Lumineux',
     styleGlow: 'Lumineux',
     styleVibrant: 'Vif',
     styleVibrant: 'Vif',
     themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
     themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
+    progressInTitle: 'Progression dans l\'onglet',
+    progressInTitleDescription: 'Affiche le pourcentage de l\'impression en cours et un anneau de progression dans l\'onglet du navigateur.',
     autoArchivePrints: 'Archiver automatiquement les impressions',
     autoArchivePrints: 'Archiver automatiquement les impressions',
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
     saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
     saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
@@ -3657,6 +3661,7 @@ export default {
     showModified: 'Afficher les dates de modification',
     showModified: 'Afficher les dates de modification',
     hideModified: 'Masquer les dates de modification',
     hideModified: 'Masquer les dates de modification',
     lastModified: 'Dernière modification',
     lastModified: 'Dernière modification',
+    lastActivity: 'Dernière activité',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     selectAll: 'Tout sélectionner',
     selectAll: 'Tout sélectionner',
     deselectAll: 'Tout désélectionner',
     deselectAll: 'Tout désélectionner',
@@ -5605,6 +5610,9 @@ export default {
     pushoverExpire: 'Expiration urgence (s)',
     pushoverExpire: 'Expiration urgence (s)',
     botToken: 'Jeton du bot',
     botToken: 'Jeton du bot',
     chatId: 'ID du chat',
     chatId: 'ID du chat',
+    telegramThreadId: 'ID du sujet de forum',
+    telegramThreadIdHelp: 'Facultatif. Envoie dans un seul sujet d\'un groupe forum : le dernier nombre du lien du sujet (t.me/c/.../25). Laisser vide pour le sujet General.',
+    telegramThreadIdInvalid: 'L\'ID du sujet de forum doit être un nombre.',
     smtpServer: 'Serveur SMTP',
     smtpServer: 'Serveur SMTP',
     smtpPort: 'Port SMTP',
     smtpPort: 'Port SMTP',
     security: 'Sécurité',
     security: 'Sécurité',

+ 8 - 0
frontend/src/i18n/locales/it.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Ventola raffreddamento parte',
       partCooling: 'Ventola raffreddamento parte',
       auxiliary: 'Ventola ausiliaria',
       auxiliary: 'Ventola ausiliaria',
+      leftAuxiliary: 'Ventola ausiliaria sinistra',
+      exhaust: 'Estrazione',
       chamber: 'Ventola camera',
       chamber: 'Ventola camera',
     },
     },
     // HMS errors
     // HMS errors
@@ -2390,6 +2392,8 @@ export default {
     styleGlow: 'Luminoso',
     styleGlow: 'Luminoso',
     styleVibrant: 'Vibrante',
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
     themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
+    progressInTitle: 'Avanzamento nella scheda',
+    progressInTitleDescription: 'Mostra la percentuale della stampa attiva e un anello di avanzamento nella scheda del browser.',
     autoArchivePrints: 'Archiviazione automatica stampe',
     autoArchivePrints: 'Archiviazione automatica stampe',
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
     saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
     saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
@@ -3656,6 +3660,7 @@ export default {
     showModified: 'Mostra date di modifica',
     showModified: 'Mostra date di modifica',
     hideModified: 'Nascondi date di modifica',
     hideModified: 'Nascondi date di modifica',
     lastModified: 'Ultima modifica',
     lastModified: 'Ultima modifica',
+    lastActivity: 'Ultima attività',
     resultsCount: '{{showing}} di {{total}} file',
     resultsCount: '{{showing}} di {{total}} file',
     selectAll: 'Seleziona tutto',
     selectAll: 'Seleziona tutto',
     deselectAll: 'Deseleziona tutto',
     deselectAll: 'Deseleziona tutto',
@@ -5604,6 +5609,9 @@ export default {
     pushoverExpire: 'Scadenza emergenza (s)',
     pushoverExpire: 'Scadenza emergenza (s)',
     botToken: 'Token del bot',
     botToken: 'Token del bot',
     chatId: 'ID chat',
     chatId: 'ID chat',
+    telegramThreadId: 'ID argomento forum',
+    telegramThreadIdHelp: 'Opzionale. Invia in un singolo argomento di un gruppo forum: l\'ultimo numero nel link dell\'argomento (t.me/c/.../25). Lascia vuoto per l\'argomento Generale.',
+    telegramThreadIdInvalid: 'L\'ID argomento forum deve essere un numero.',
     smtpServer: 'Server SMTP',
     smtpServer: 'Server SMTP',
     smtpPort: 'Porta SMTP',
     smtpPort: 'Porta SMTP',
     security: 'Sicurezza',
     security: 'Sicurezza',

+ 8 - 0
frontend/src/i18n/locales/ja.ts

@@ -667,6 +667,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'パーツ冷却ファン',
       partCooling: 'パーツ冷却ファン',
       auxiliary: '補助ファン',
       auxiliary: '補助ファン',
+      leftAuxiliary: '左補助ファン',
+      exhaust: '排気',
       chamber: 'チャンバーファン',
       chamber: 'チャンバーファン',
     },
     },
     // HMS errors
     // HMS errors
@@ -2435,6 +2437,8 @@ export default {
     styleGlow: 'グロー',
     styleGlow: 'グロー',
     styleVibrant: 'ビビッド',
     styleVibrant: 'ビビッド',
     themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
     themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
+    progressInTitle: 'タブに印刷の進捗を表示',
+    progressInTitleDescription: 'ブラウザのタブに進行中の印刷の進捗率と進捗リングを表示します。',
     // Archive
     // Archive
     autoArchivePrints: '印刷を自動アーカイブ',
     autoArchivePrints: '印刷を自動アーカイブ',
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
@@ -3668,6 +3672,7 @@ export default {
     showModified: '更新日時を表示',
     showModified: '更新日時を表示',
     hideModified: '更新日時を非表示',
     hideModified: '更新日時を非表示',
     lastModified: '最終更新',
     lastModified: '最終更新',
+    lastActivity: '最終アクティビティ',
     resultsCount: '{{total}}件中{{showing}}件',
     resultsCount: '{{total}}件中{{showing}}件',
     selectAll: 'すべて選択',
     selectAll: 'すべて選択',
     deselectAll: 'すべて選択解除',
     deselectAll: 'すべて選択解除',
@@ -5616,6 +5621,9 @@ export default {
     pushoverExpire: '緊急有効期限 (秒)',
     pushoverExpire: '緊急有効期限 (秒)',
     botToken: 'ボットトークン',
     botToken: 'ボットトークン',
     chatId: 'チャットID',
     chatId: 'チャットID',
+    telegramThreadId: 'フォーラムトピック ID',
+    telegramThreadIdHelp: '任意。フォーラムグループ内の特定のトピックに送信します。トピックリンクの末尾の数字です (t.me/c/.../25)。空欄の場合は General トピックに送信されます。',
+    telegramThreadIdInvalid: 'フォーラムトピック ID は数値で入力してください。',
     smtpServer: 'SMTPサーバー',
     smtpServer: 'SMTPサーバー',
     smtpPort: 'SMTPポート',
     smtpPort: 'SMTPポート',
     security: 'セキュリティ',
     security: 'セキュリティ',

+ 8 - 0
frontend/src/i18n/locales/ko.ts

@@ -625,6 +625,8 @@ export default {
     fans: {
     fans: {
       partCooling: '파트 냉각 팬',
       partCooling: '파트 냉각 팬',
       auxiliary: '보조 팬',
       auxiliary: '보조 팬',
+      leftAuxiliary: '왼쪽 보조 팬',
+      exhaust: '배기',
       chamber: '챔버 팬'
       chamber: '챔버 팬'
     },
     },
     clickToViewHmsErrors: 'HMS 오류 보기 클릭',
     clickToViewHmsErrors: 'HMS 오류 보기 클릭',
@@ -2306,6 +2308,8 @@ export default {
     styleGlow: '글로우',
     styleGlow: '글로우',
     styleVibrant: '비브런트',
     styleVibrant: '비브런트',
     themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.',
     themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.',
+    progressInTitle: '탭에 인쇄 진행률 표시',
+    progressInTitleDescription: '브라우저 탭에 진행 중인 인쇄의 백분율과 진행 링을 표시합니다.',
     autoArchivePrints: '인쇄 자동 아카이브',
     autoArchivePrints: '인쇄 자동 아카이브',
     autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
     autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
     saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
     saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
@@ -3480,6 +3484,7 @@ export default {
     showModified: '수정 날짜 표시',
     showModified: '수정 날짜 표시',
     hideModified: '수정 날짜 숨기기',
     hideModified: '수정 날짜 숨기기',
     lastModified: '마지막 수정',
     lastModified: '마지막 수정',
+    lastActivity: '마지막 활동',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     selectAll: '모두 선택',
     selectAll: '모두 선택',
     deselectAll: '모두 선택 해제',
     deselectAll: '모두 선택 해제',
@@ -5328,6 +5333,9 @@ export default {
     pushoverExpire: '긴급 만료 (초)',
     pushoverExpire: '긴급 만료 (초)',
     botToken: '봇 토큰',
     botToken: '봇 토큰',
     chatId: '채팅 ID',
     chatId: '채팅 ID',
+    telegramThreadId: '포럼 주제 ID',
+    telegramThreadIdHelp: '선택 사항. 포럼 그룹의 특정 주제로 전송합니다. 주제 링크의 마지막 숫자입니다 (t.me/c/.../25). 비워 두면 General 주제로 전송됩니다.',
+    telegramThreadIdInvalid: '포럼 주제 ID는 숫자여야 합니다.',
     smtpServer: 'SMTP 서버',
     smtpServer: 'SMTP 서버',
     smtpPort: 'SMTP 포트',
     smtpPort: 'SMTP 포트',
     security: '보안',
     security: '보안',

+ 8 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Ventilador de resfriamento da peça',
       partCooling: 'Ventilador de resfriamento da peça',
       auxiliary: 'Ventilador auxiliar',
       auxiliary: 'Ventilador auxiliar',
+      leftAuxiliary: 'Ventilador auxiliar esquerdo',
+      exhaust: 'Exaustão',
       chamber: 'Ventilador da câmara',
       chamber: 'Ventilador da câmara',
     },
     },
     // HMS errors
     // HMS errors
@@ -2390,6 +2392,8 @@ export default {
     styleGlow: 'Brilhante',
     styleGlow: 'Brilhante',
     styleVibrant: 'Vibrante',
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
     themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
+    progressInTitle: 'Progresso na aba',
+    progressInTitleDescription: 'Mostra a porcentagem da impressão ativa e um anel de progresso na aba do navegador.',
     autoArchivePrints: 'Arquivar impressões automaticamente',
     autoArchivePrints: 'Arquivar impressões automaticamente',
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
     saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
     saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
@@ -3656,6 +3660,7 @@ export default {
     showModified: 'Mostrar datas de modificação',
     showModified: 'Mostrar datas de modificação',
     hideModified: 'Ocultar datas de modificação',
     hideModified: 'Ocultar datas de modificação',
     lastModified: 'Última modificação',
     lastModified: 'Última modificação',
+    lastActivity: 'Última atividade',
     resultsCount: '{{showing}} de {{total}} arquivos',
     resultsCount: '{{showing}} de {{total}} arquivos',
     selectAll: 'Selecionar tudo',
     selectAll: 'Selecionar tudo',
     deselectAll: 'Desmarcar tudo',
     deselectAll: 'Desmarcar tudo',
@@ -5604,6 +5609,9 @@ export default {
     pushoverExpire: 'Expiração de emergência (s)',
     pushoverExpire: 'Expiração de emergência (s)',
     botToken: 'Token do Bot',
     botToken: 'Token do Bot',
     chatId: 'ID do Chat',
     chatId: 'ID do Chat',
+    telegramThreadId: 'ID do tópico do fórum',
+    telegramThreadIdHelp: 'Opcional. Envia para um único tópico de um grupo de fórum: o último número no link do tópico (t.me/c/.../25). Deixe vazio para o tópico Geral.',
+    telegramThreadIdInvalid: 'O ID do tópico do fórum deve ser um número.',
     smtpServer: 'Servidor SMTP',
     smtpServer: 'Servidor SMTP',
     smtpPort: 'Porta SMTP',
     smtpPort: 'Porta SMTP',
     security: 'Segurança',
     security: 'Segurança',

+ 8 - 0
frontend/src/i18n/locales/ru.ts

@@ -630,6 +630,8 @@ export default {
     fans: {
     fans: {
       partCooling: "Вентилятор обдува модели",
       partCooling: "Вентилятор обдува модели",
       auxiliary: "Дополнительный вентилятор",
       auxiliary: "Дополнительный вентилятор",
+      leftAuxiliary: "Левый дополнительный вентилятор",
+      exhaust: "Вытяжка",
       chamber: "Вентилятор камеры",
       chamber: "Вентилятор камеры",
     },
     },
     clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",
     clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",
@@ -2307,6 +2309,8 @@ export default {
     styleGlow: "Свечение",
     styleGlow: "Свечение",
     styleVibrant: "Насыщенный",
     styleVibrant: "Насыщенный",
     themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
     themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
+    progressInTitle: "Прогресс во вкладке",
+    progressInTitleDescription: "Показывает процент текущей печати и кольцо прогресса во вкладке браузера.",
     autoArchivePrints: "Автоматически архивировать печать",
     autoArchivePrints: "Автоматически архивировать печать",
     autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
     autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
     saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
     saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
@@ -3472,6 +3476,7 @@ export default {
     showModified: "Показать даты изменения",
     showModified: "Показать даты изменения",
     hideModified: "Скрыть даты изменения",
     hideModified: "Скрыть даты изменения",
     lastModified: "Изменено",
     lastModified: "Изменено",
+    lastActivity: "Последняя активность",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     selectAll: "Выбрать всё",
     selectAll: "Выбрать всё",
     deselectAll: "Снять выделение",
     deselectAll: "Снять выделение",
@@ -5315,6 +5320,9 @@ export default {
     pushoverExpire: "Срок действия экстренного уведомления (с)",
     pushoverExpire: "Срок действия экстренного уведомления (с)",
     botToken: "Токен бота",
     botToken: "Токен бота",
     chatId: "ID чата",
     chatId: "ID чата",
+    telegramThreadId: "ID темы форума",
+    telegramThreadIdHelp: "Необязательно. Отправляет в конкретную тему форум-группы — последнее число в ссылке на тему (t.me/c/.../25). Оставьте пустым для темы General.",
+    telegramThreadIdInvalid: "ID темы форума должен быть числом.",
     smtpServer: "SMTP-сервер",
     smtpServer: "SMTP-сервер",
     smtpPort: "Порт SMTP",
     smtpPort: "Порт SMTP",
     security: "Защита соединения",
     security: "Защита соединения",

+ 8 - 0
frontend/src/i18n/locales/tr.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: 'Parça Soğutma Fanı',
       partCooling: 'Parça Soğutma Fanı',
       auxiliary: 'Yardımcı Fan',
       auxiliary: 'Yardımcı Fan',
+      leftAuxiliary: 'Sol Yardımcı Fan',
+      exhaust: 'Egzoz',
       chamber: 'Hazne Fanı',
       chamber: 'Hazne Fanı',
     },
     },
     // HMS hataları
     // HMS hataları
@@ -2440,6 +2442,8 @@ export default {
     styleGlow: 'Parıltı',
     styleGlow: 'Parıltı',
     styleVibrant: 'Canlı',
     styleVibrant: 'Canlı',
     themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.',
     themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.',
+    progressInTitle: 'Sekmede baskı ilerlemesi',
+    progressInTitleDescription: 'Tarayıcı sekmesinde etkin baskının yüzdesini ve bir ilerleme halkası gösterir.',
     // Arşiv
     // Arşiv
     autoArchivePrints: 'Baskıları otomatik arşivle',
     autoArchivePrints: 'Baskıları otomatik arşivle',
     autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
     autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
@@ -3664,6 +3668,7 @@ export default {
     showModified: 'Değiştirme tarihlerini göster',
     showModified: 'Değiştirme tarihlerini göster',
     hideModified: 'Değiştirme tarihlerini gizle',
     hideModified: 'Değiştirme tarihlerini gizle',
     lastModified: 'Son değiştirme',
     lastModified: 'Son değiştirme',
+    lastActivity: 'Son etkinlik',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     selectAll: 'Tümünü Seç',
     selectAll: 'Tümünü Seç',
     deselectAll: 'Seçimi Kaldır',
     deselectAll: 'Seçimi Kaldır',
@@ -5564,6 +5569,9 @@ export default {
     pushoverExpire: 'Acil sona erme (sn)',
     pushoverExpire: 'Acil sona erme (sn)',
     botToken: 'Bot Belirteci',
     botToken: 'Bot Belirteci',
     chatId: 'Sohbet ID',
     chatId: 'Sohbet ID',
+    telegramThreadId: 'Forum Konu Kimliği',
+    telegramThreadIdHelp: 'İsteğe bağlı. Forum grubundaki tek bir konuya gönderir: konu bağlantısındaki son sayı (t.me/c/.../25). Genel konu için boş bırakın.',
+    telegramThreadIdInvalid: 'Forum konu kimliği bir sayı olmalıdır.',
     smtpServer: 'SMTP Sunucusu',
     smtpServer: 'SMTP Sunucusu',
     smtpPort: 'SMTP Portu',
     smtpPort: 'SMTP Portu',
     security: 'Güvenlik',
     security: 'Güvenlik',

+ 8 - 0
frontend/src/i18n/locales/uk.ts

@@ -672,6 +672,8 @@ export default {
     fans: {
     fans: {
       partCooling: "Вентилятор охолодження моделі",
       partCooling: "Вентилятор охолодження моделі",
       auxiliary: "Допоміжний вентилятор",
       auxiliary: "Допоміжний вентилятор",
+      leftAuxiliary: "Лівий допоміжний вентилятор",
+      exhaust: "Витяжка",
       chamber: "Камерний вентилятор",
       chamber: "Камерний вентилятор",
     },
     },
     // HMS errors
     // HMS errors
@@ -2455,6 +2457,8 @@ export default {
     styleGlow: "Світіння",
     styleGlow: "Світіння",
     styleVibrant: "Яскравий",
     styleVibrant: "Яскравий",
     themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
     themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
+    progressInTitle: "Прогрес у вкладці",
+    progressInTitleDescription: "Показує відсоток активного друку та кільце прогресу на вкладці браузера.",
     // Archive
     // Archive
     autoArchivePrints: "Автоматично архівувати друки",
     autoArchivePrints: "Автоматично архівувати друки",
     autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
     autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
@@ -3697,6 +3701,7 @@ export default {
     showModified: "Показати змінені дати",
     showModified: "Показати змінені дати",
     hideModified: "Приховати змінені дати",
     hideModified: "Приховати змінені дати",
     lastModified: "Востаннє змінено",
     lastModified: "Востаннє змінено",
+    lastActivity: "Остання активність",
     resultsCount: "{{showing}} з {{total}} файлів",
     resultsCount: "{{showing}} з {{total}} файлів",
     selectAll: "Вибрати усі",
     selectAll: "Вибрати усі",
     deselectAll: "Зняти вибір із усіх",
     deselectAll: "Зняти вибір із усіх",
@@ -5659,6 +5664,9 @@ export default {
     pushoverExpire: "Термін дії екстреного сповіщення (с)",
     pushoverExpire: "Термін дії екстреного сповіщення (с)",
     botToken: "Токен бота",
     botToken: "Токен бота",
     chatId: "Ідентифікатор чату",
     chatId: "Ідентифікатор чату",
+    telegramThreadId: "ID теми форуму",
+    telegramThreadIdHelp: "Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.",
+    telegramThreadIdInvalid: "ID теми форуму має бути числом.",
     smtpServer: "Сервер SMTP",
     smtpServer: "Сервер SMTP",
     smtpPort: "Порт SMTP",
     smtpPort: "Порт SMTP",
     security: "Безпека",
     security: "Безпека",

+ 8 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: '零件冷却风扇',
       partCooling: '零件冷却风扇',
       auxiliary: '辅助风扇',
       auxiliary: '辅助风扇',
+      leftAuxiliary: '左辅助风扇',
+      exhaust: '排气',
       chamber: '腔室风扇',
       chamber: '腔室风扇',
     },
     },
     // HMS errors
     // HMS errors
@@ -2435,6 +2437,8 @@ export default {
     styleGlow: '发光',
     styleGlow: '发光',
     styleVibrant: '鲜艳',
     styleVibrant: '鲜艳',
     themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
     themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
+    progressInTitle: '在标签页显示打印进度',
+    progressInTitleDescription: '在浏览器标签页中显示当前打印的百分比和进度环。',
     autoArchivePrints: '自动归档打印',
     autoArchivePrints: '自动归档打印',
     autoArchiveDescription: '打印完成时自动保存3MF文件',
     autoArchiveDescription: '打印完成时自动保存3MF文件',
     saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
     saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
@@ -3656,6 +3660,7 @@ export default {
     showModified: '显示修改日期',
     showModified: '显示修改日期',
     hideModified: '隐藏修改日期',
     hideModified: '隐藏修改日期',
     lastModified: '最后修改',
     lastModified: '最后修改',
+    lastActivity: '最近活动',
     resultsCount: '{{showing}} / {{total}} 个文件',
     resultsCount: '{{showing}} / {{total}} 个文件',
     selectAll: '全选',
     selectAll: '全选',
     deselectAll: '取消全选',
     deselectAll: '取消全选',
@@ -5604,6 +5609,9 @@ export default {
     pushoverExpire: '紧急过期 (秒)',
     pushoverExpire: '紧急过期 (秒)',
     botToken: '机器人令牌',
     botToken: '机器人令牌',
     chatId: '聊天 ID',
     chatId: '聊天 ID',
+    telegramThreadId: '论坛话题 ID',
+    telegramThreadIdHelp: '可选。发送到论坛群组中的指定话题,即话题链接末尾的数字 (t.me/c/.../25)。留空则发送到常规话题。',
+    telegramThreadIdInvalid: '论坛话题 ID 必须是数字。',
     smtpServer: 'SMTP 服务器',
     smtpServer: 'SMTP 服务器',
     smtpPort: 'SMTP 端口',
     smtpPort: 'SMTP 端口',
     security: '安全',
     security: '安全',

+ 8 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -668,6 +668,8 @@ export default {
     fans: {
     fans: {
       partCooling: '零件冷卻風扇',
       partCooling: '零件冷卻風扇',
       auxiliary: '輔助風扇',
       auxiliary: '輔助風扇',
+      leftAuxiliary: '左輔助風扇',
+      exhaust: '排氣',
       chamber: '腔室風扇',
       chamber: '腔室風扇',
     },
     },
     // HMS errors
     // HMS errors
@@ -2435,6 +2437,8 @@ export default {
     styleGlow: '發光',
     styleGlow: '發光',
     styleVibrant: '鮮豔',
     styleVibrant: '鮮豔',
     themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
     themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
+    progressInTitle: '在分頁顯示列印進度',
+    progressInTitleDescription: '在瀏覽器分頁中顯示目前列印的百分比和進度環。',
     autoArchivePrints: '自動歸檔列印',
     autoArchivePrints: '自動歸檔列印',
     autoArchiveDescription: '列印完成時自動儲存3MF檔案',
     autoArchiveDescription: '列印完成時自動儲存3MF檔案',
     saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
     saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
@@ -3656,6 +3660,7 @@ export default {
     showModified: '顯示修改日期',
     showModified: '顯示修改日期',
     hideModified: '隱藏修改日期',
     hideModified: '隱藏修改日期',
     lastModified: '最後修改',
     lastModified: '最後修改',
+    lastActivity: '最近活動',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     selectAll: '全選',
     selectAll: '全選',
     deselectAll: '取消全選',
     deselectAll: '取消全選',
@@ -5604,6 +5609,9 @@ export default {
     pushoverExpire: '緊急逾時 (秒)',
     pushoverExpire: '緊急逾時 (秒)',
     botToken: '機器人權杖',
     botToken: '機器人權杖',
     chatId: '聊天 ID',
     chatId: '聊天 ID',
+    telegramThreadId: '論壇主題 ID',
+    telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
+    telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
     smtpServer: 'SMTP 伺服器',
     smtpServer: 'SMTP 伺服器',
     smtpPort: 'SMTP 連接埠',
     smtpPort: 'SMTP 連接埠',
     security: '安全',
     security: '安全',

+ 18 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -558,11 +558,12 @@ interface FolderTreeItemProps {
   depth?: number;
   depth?: number;
   wrapNames?: boolean;
   wrapNames?: boolean;
   defaultExpanded?: boolean;
   defaultExpanded?: boolean;
+  showModified?: boolean;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(defaultExpanded);
   const [expanded, setExpanded] = useState(defaultExpanded);
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
@@ -609,7 +610,20 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
         ) : (
         ) : (
           <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
           <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
         )}
         )}
-        <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
+        <div className="flex-1 min-w-0">
+          <span className={`block text-sm ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
+          {/* #2680 follow-up: the same toolbar toggle that shows dates on file
+              cards also shows them here. This is `latest_activity_at` — the
+              newest timestamp among the folder itself, its files and its
+              subfolders (the value "sort by recent activity" orders on) — not
+              the folder's own on-disk mtime, hence the distinct label. */}
+          {showModified && folder.latest_activity_at && (
+            <span className="mt-0.5 flex items-center gap-1 text-xs text-bambu-gray" title={t('fileManager.lastActivity')}>
+              <CalendarClock className="w-3 h-3 flex-shrink-0" />
+              <span className="truncate">{formatDate(folder.latest_activity_at)}</span>
+            </span>
+          )}
+        </div>
         {/* Link indicator - clickable to change link */}
         {/* Link indicator - clickable to change link */}
         {isLinked && (
         {isLinked && (
           <button
           <button
@@ -709,6 +723,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               depth={depth + 1}
               depth={depth + 1}
               wrapNames={wrapNames}
               wrapNames={wrapNames}
               defaultExpanded={defaultExpanded}
               defaultExpanded={defaultExpanded}
+              showModified={showModified}
               hasPermission={hasPermission}
               hasPermission={hasPermission}
               t={t}
               t={t}
             />
             />
@@ -1957,6 +1972,7 @@ export function FileManagerPage() {
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
                 wrapNames={wrapFolderNames}
                 defaultExpanded={!collapseFoldersByDefault}
                 defaultExpanded={!collapseFoldersByDefault}
+                showModified={showModified}
                 hasPermission={hasPermission}
                 hasPermission={hasPermission}
                 t={t}
                 t={t}
               />
               />

+ 56 - 5
frontend/src/pages/PrintersPage.tsx

@@ -1490,6 +1490,17 @@ const MODELS_WITH_CHAMBER_FAN: ReadonlySet<string> = new Set([
   'H2S',
   'H2S',
 ]);
 ]);
 
 
+// On the P2S/X2D, the enclosure fan (big_fan2 / airduct part id 3) is a
+// dedicated chamber EXHAUST fan: it's its own control and stays the same
+// regardless of cooling/heating mode (unlike the aux fan, id 2, which a flap
+// re-tasks between part-cooling and chamber-filter recirculation). Bambu's own
+// firmware/UI and Bambu Studio (FAN_CHAMBER_0_IDX -> "Exhaust") label it
+// "Exhaust" on these models. Other enclosed models (X1/P1S/H2*) keep "Chamber".
+const MODELS_WITH_EXHAUST_LABEL: ReadonlySet<string> = new Set([
+  'P2S',
+  'X2D',
+]);
+
 // Map SSDP model codes to display names
 // Map SSDP model codes to display names
 function mapModelCode(ssdpModel: string | null): string {
 function mapModelCode(ssdpModel: string | null): string {
   if (!ssdpModel) return '';
   if (!ssdpModel) return '';
@@ -2449,7 +2460,7 @@ function PrinterCard({
   });
   });
 
 
   const fanSpeedMutation = useMutation({
   const fanSpeedMutation = useMutation({
-    mutationFn: ({ fan, speed }: { fan: 'part' | 'aux' | 'chamber'; speed: number }) =>
+    mutationFn: ({ fan, speed }: { fan: 'part' | 'aux' | 'aux2' | 'chamber'; speed: number }) =>
       api.setFanSpeed(printer.id, fan, speed),
       api.setFanSpeed(printer.id, fan, speed),
     onMutate: async ({ fan, speed }) => {
     onMutate: async ({ fan, speed }) => {
       await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
       await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
@@ -2457,6 +2468,7 @@ function PrinterCard({
       const fanField = {
       const fanField = {
         part: 'cooling_fan_speed',
         part: 'cooling_fan_speed',
         aux: 'big_fan1_speed',
         aux: 'big_fan1_speed',
+        aux2: 'left_aux_fan_speed',
         chamber: 'big_fan2_speed',
         chamber: 'big_fan2_speed',
       }[fan];
       }[fan];
       queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) =>
       queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) =>
@@ -3886,7 +3898,30 @@ function PrinterCard({
               // control that does nothing. Mirrors the enclosure-door badge
               // control that does nothing. Mirrors the enclosure-door badge
               // gate above.
               // gate above.
               const hasChamberFan = MODELS_WITH_CHAMBER_FAN.has(printer.model ?? '');
               const hasChamberFan = MODELS_WITH_CHAMBER_FAN.has(printer.model ?? '');
-              const fanItems = [
+              // On P2S/X2D the big_fan2 fan is the dedicated chamber EXHAUST fan
+              // ("Exhaust" in Bambu's naming) and is an add-on kit, not preinstalled:
+              // show it only when the printer actually reports it (airduct part id 3,
+              // surfaced as exhaust_fan_present). Other enclosed models (X1/P1S/H2*)
+              // have a built-in chamber fan that's always present, so they keep the
+              // existing model-list gate and the "Chamber Fan" label.
+              const isExhaustModel = MODELS_WITH_EXHAUST_LABEL.has(printer.model ?? '');
+              const chamberFanLabel = isExhaustModel
+                ? t('printers.fans.exhaust')
+                : t('printers.fans.chamber');
+              // Composed rather than either/or so both lists stay live for
+              // P2S/X2D: the model must have an enclosure fan at all, AND —
+              // where that fan is an add-on kit — actually report it. Written
+              // as `isExhaustModel ? exhaust_fan_present : hasChamberFan` the
+              // P2S/X2D entries in MODELS_WITH_CHAMBER_FAN became unreachable,
+              // which reads as if removing them were safe.
+              const showChamberFan = hasChamberFan && (!isExhaustModel || status.exhaust_fan_present);
+              const fanItems: {
+                key: string;
+                label: string;
+                value: number;
+                Icon: typeof Fan;
+                activeClass: string;
+              }[] = [
                 {
                 {
                   key: 'part',
                   key: 'part',
                   label: t('printers.fans.partCooling'),
                   label: t('printers.fans.partCooling'),
@@ -3894,6 +3929,22 @@ function PrinterCard({
                   Icon: Fan,
                   Icon: Fan,
                   activeClass: 'text-cyan-600 dark:text-cyan-400',
                   activeClass: 'text-cyan-600 dark:text-cyan-400',
                 },
                 },
+                // Left auxiliary part cooling fan (optional P2S/X2D accessory).
+                // Only reported (non-null) when the firmware lists airduct part
+                // id 10, i.e. when the fan is physically installed. Placed
+                // before the right-hand auxiliary fan so the two aux badges read
+                // left-to-right in the same order as the physical hardware.
+                ...(status.left_aux_fan_speed != null
+                  ? [
+                      {
+                        key: 'aux2',
+                        label: t('printers.fans.leftAuxiliary'),
+                        value: status.left_aux_fan_speed,
+                        Icon: Wind,
+                        activeClass: 'text-indigo-600 dark:text-indigo-400',
+                      },
+                    ]
+                  : []),
                 {
                 {
                   key: 'aux',
                   key: 'aux',
                   label: t('printers.fans.auxiliary'),
                   label: t('printers.fans.auxiliary'),
@@ -3901,11 +3952,11 @@ function PrinterCard({
                   Icon: Wind,
                   Icon: Wind,
                   activeClass: 'text-blue-600 dark:text-blue-400',
                   activeClass: 'text-blue-600 dark:text-blue-400',
                 },
                 },
-                ...(hasChamberFan
+                ...(showChamberFan
                   ? [
                   ? [
                       {
                       {
                         key: 'chamber',
                         key: 'chamber',
-                        label: t('printers.fans.chamber'),
+                        label: chamberFanLabel,
                         value: status.big_fan2_speed ?? 0,
                         value: status.big_fan2_speed ?? 0,
                         Icon: AirVent,
                         Icon: AirVent,
                         activeClass: 'text-green-600 dark:text-green-400',
                         activeClass: 'text-green-600 dark:text-green-400',
@@ -4157,7 +4208,7 @@ function PrinterCard({
                               isPending={fanSpeedMutation.isPending}
                               isPending={fanSpeedMutation.isPending}
                               options={buildPresetOptions(fanSpeedPresets, '%')}
                               options={buildPresetOptions(fanSpeedPresets, '%')}
                               onClose={() => setStatusControlMenu(null)}
                               onClose={() => setStatusControlMenu(null)}
-                              onSubmit={(speed) => fanSpeedMutation.mutate({ fan: key as 'part' | 'aux' | 'chamber', speed })}
+                              onSubmit={(speed) => fanSpeedMutation.mutate({ fan: key as 'part' | 'aux' | 'aux2' | 'chamber', speed })}
                             />
                             />
                           )}
                           )}
                         </div>
                         </div>

+ 19 - 0
frontend/src/pages/SettingsPage.tsx

@@ -172,6 +172,7 @@ export function SettingsPage() {
     setMode,
     setMode,
     setDarkStyle, setDarkBackground, setDarkAccent,
     setDarkStyle, setDarkBackground, setDarkAccent,
     setLightStyle, setLightBackground, setLightAccent,
     setLightStyle, setLightBackground, setLightAccent,
+    progressInTitle, setProgressInTitle,
   } = useTheme();
   } = useTheme();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   // Transient typed strings for the per-filament humidity threshold inputs
   // Transient typed strings for the per-filament humidity threshold inputs
@@ -1811,6 +1812,24 @@ export function SettingsPage() {
               <p className="text-xs text-bambu-gray">
               <p className="text-xs text-bambu-gray">
                 {t('settings.themeToggleHint')}
                 {t('settings.themeToggleHint')}
               </p>
               </p>
+
+              <div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
+                <div>
+                  <p className="text-white">{t('settings.progressInTitle')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.progressInTitleDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={progressInTitle}
+                    onChange={(e) => { setProgressInTitle(e.target.checked); showToast(t('settings.toast.settingsSaved'), 'success'); }}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
 
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-C2LOlVCR.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/index-D4bpNaiw.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
static/assets/index-oReXTzKG.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-xPJs-OAQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
+    <script type="module" crossorigin src="/assets/index-C2LOlVCR.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů