Explorar o código

Merge branch 'dev' into fix/external-camera-capture-coalescing

MartinNYHC hai 1 mes
pai
achega
6ad5bf30f7
Modificáronse 40 ficheiros con 1323 adicións e 58 borrados
  1. 1 0
      CHANGELOG.md
  2. 20 5
      backend/app/api/routes/_oidc_helpers.py
  3. 25 3
      backend/app/api/routes/_url_safety.py
  4. 14 0
      backend/app/api/routes/github_backup.py
  5. 39 5
      backend/app/api/routes/printers.py
  6. 5 0
      backend/app/schemas/printer.py
  7. 101 2
      backend/app/services/bambu_mqtt.py
  8. 30 7
      backend/app/services/homeassistant.py
  9. 2 0
      backend/app/services/mqtt_relay.py
  10. 17 1
      backend/app/services/obico_detection.py
  11. 2 0
      backend/app/services/printer_manager.py
  12. 35 15
      backend/app/services/rest_smart_plug.py
  13. 22 2
      backend/app/services/tasmota.py
  14. 30 0
      backend/app/utils/printer_models.py
  15. 72 3
      backend/tests/integration/test_printers_api.py
  16. 394 0
      backend/tests/unit/services/test_p2s_accessory_fans.py
  17. 38 6
      backend/tests/unit/services/test_rest_smart_plug.py
  18. 274 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  19. 2 0
      backend/tests/unit/test_plate_clear_mqtt_notification.py
  20. 2 0
      backend/tests/unit/test_printer_manager_status_broadcast.py
  21. 107 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  22. 6 1
      frontend/src/api/client.ts
  23. 2 0
      frontend/src/i18n/locales/de.ts
  24. 2 0
      frontend/src/i18n/locales/en.ts
  25. 2 0
      frontend/src/i18n/locales/es.ts
  26. 2 0
      frontend/src/i18n/locales/fr.ts
  27. 2 0
      frontend/src/i18n/locales/it.ts
  28. 2 0
      frontend/src/i18n/locales/ja.ts
  29. 2 0
      frontend/src/i18n/locales/ko.ts
  30. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  31. 2 0
      frontend/src/i18n/locales/ru.ts
  32. 2 0
      frontend/src/i18n/locales/tr.ts
  33. 2 0
      frontend/src/i18n/locales/uk.ts
  34. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  35. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  36. 56 5
      frontend/src/pages/PrintersPage.tsx
  37. 0 0
      static/assets/index-C2LOlVCR.js
  38. 0 1
      static/assets/index-D4bpNaiw.css
  39. 1 0
      static/assets/index-oReXTzKG.css
  40. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it.
 - **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests.
 - **Telegram notifications can target a forum topic (#1518, reporter @vmhomelab)** — Telegram groups with Topics enabled always received Bambuddy's notifications in the **General** topic, because only Bot Token and Chat ID were configurable. Getting a per-printer split therefore meant creating a separate chat per printer. The Telegram provider now takes an optional **Forum Topic ID** — the last number in a topic's link (`t.me/c/1234567890/25`) — and routes its messages into that topic, so a single group can carry one topic per printer. Left empty, the behaviour is unchanged. The ID is sent on both the plain-text and the thumbnail code paths, and is validated as a number in the form and again server-side, so a typo is reported instead of silently breaking only text notifications. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Per-file print progress inside a project (#1897, reporter @FedericoPuntelli)** — Projects that consist of many distinct files each needing N prints (e.g. 13 plates × 10 sets = 130 prints) only had aggregate progress; finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line. Projects now take an optional **Copies per File** target: each printable file in the project's linked folders shows an **X / N** badge with a mini progress bar (gray not started, amber in progress, green target reached), and the progress card gains a **Complete Sets** bar — the minimum per-file count, i.e. how many finished assemblies you can ship right now. Without the new target, files simply show a printed-count badge (3×). Counting matches the aggregate stats: only completed runs, attributed to a file by a new `library_file_id` stamp on queue-dispatched archives, with content-hash and filename fallbacks covering historical prints. Also fixed along the way: **files queued from a project-linked File Manager folder now attribute their prints to that project** — previously only prints started from the project page counted toward project statistics. Translated in all locales; wiki updated. Covered by backend and frontend tests.

+ 20 - 5
backend/app/api/routes/_oidc_helpers.py

@@ -13,7 +13,12 @@ from __future__ import annotations
 import ipaddress
 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:
@@ -38,10 +43,12 @@ def assert_safe_public_https_url(url: str) -> None:
     - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
       check so an attacker can't bypass via IPv6 encoding.
 
-    Hostname-based addresses are accepted without DNS resolution — 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.
+    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)
     if parsed.scheme.lower() != "https":
@@ -49,6 +56,14 @@ def assert_safe_public_https_url(url: str) -> None:
 
     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):
         raise ValueError("icon URL must not use numeric-encoded IP addresses")
 

+ 25 - 3
backend/app/api/routes/_url_safety.py

@@ -40,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``
 # for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
@@ -90,10 +102,11 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
       indicative of misuse.
     - IPv4-mapped IPv6 encodings of any of the above.
 
-    Symbolic hostnames are accepted without DNS resolution, matching the
-    public-internet guard: resolution here would be both a TOCTOU (DNS can
+    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.
+    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"):
@@ -101,6 +114,15 @@ def assert_safe_lan_service_url(url: str, *, label: str) -> None:
 
     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")
 

+ 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
     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)
     if not result.get("success"):
         message = result.get("message") or "Connection test failed"

+ 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.http import build_content_disposition
+from backend.app.utils.printer_models import uses_exhaust_fan_label
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -798,6 +799,8 @@ async def get_printer_status(
         big_fan1_speed=state.big_fan1_speed,
         big_fan2_speed=state.big_fan2_speed,
         heatbreak_fan_speed=state.heatbreak_fan_speed,
+        left_aux_fan_speed=state.left_aux_fan_speed,
+        exhaust_fan_present=state.exhaust_fan_present,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
         ams_filament_backup=state.ams_filament_backup if state else None,
@@ -3193,16 +3196,28 @@ async def set_chamber_temperature(
 @router.post("/{printer_id}/fan-speed")
 async def set_fan_speed(
     printer_id: int,
-    fan: str = Query(..., description="Fan to control: part, aux, or chamber"),
+    fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
-    """Set a fan speed by percentage."""
-    fan_ids = {"part": 1, "aux": 2, "chamber": 3}
+    """Set a fan speed by percentage.
+
+    Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
+    P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
+    profile gcode does. It only exists when the printer reports airduct part 10,
+    so the request is rejected rather than sending M106 P10 into the void on a
+    machine that has no such fan.
+
+    That gate also rejects for the short window between connecting and the
+    first airduct push, when nothing is known about the fan yet. The card hides
+    the badge over the same window, so there is no control to click; a direct
+    API caller gets a 400 and should retry once the status reports the fan.
+    """
+    fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
     fan_id = fan_ids.get(fan)
     if fan_id is None:
-        raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'")
+        raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -3213,12 +3228,31 @@ async def set_fan_speed(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
+    # Presence gate for the accessory fan. Without this, aux2 is accepted for
+    # every model and an A1 would be sent M106 P10 for a fan it does not have.
+    # The UI already hides the badge; this closes the same hole on the API.
+    if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
+        raise HTTPException(
+            400,
+            "This printer does not report a left auxiliary fan "
+            "(no airduct part 10). The fan is an accessory kit on the P2S "
+            "and factory-fitted on the X2D.",
+        )
+
     pwm_speed = round(speed * 255 / 100)
     success = client.set_fan_speed(fan_id, pwm_speed)
     if not success:
         raise HTTPException(500, "Failed to set fan speed")
 
-    fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"}
+    # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
+    # match whatever the printer card badge shows so the toast agrees with the
+    # control the user just clicked.
+    fan_names = {
+        "part": "Part cooling fan",
+        "aux": "Auxiliary fan",
+        "aux2": "Left auxiliary fan",
+        "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
+    }
     return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
 
 

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

@@ -360,6 +360,11 @@ class PrinterStatus(BaseModel):
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10).
+    # None = not installed / not reported by this model.
+    left_aux_fan_speed: int | None = None
+    # Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).
+    exhaust_fan_present: bool = False
     # Firmware version (from info.module[name="ota"].sw_ver)
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown

+ 101 - 2
backend/app/services/bambu_mqtt.py

@@ -472,6 +472,21 @@ class PrinterState:
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
+    # Left auxiliary part cooling fan (optional 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), ...]
     # Used by usage tracker to split filament weight on mid-print tray switch
     tray_change_log: list = field(default_factory=list)
@@ -3522,6 +3537,83 @@ class BambuMQTTClient:
                             f"[{self.serial_number}] airduct_mode changed: {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
                 # Check if we recently set the target locally (within 5 seconds)
                 local_set_time = self.state.temperatures.get("_chamber_target_set_time", 0)
@@ -5793,13 +5885,16 @@ class BambuMQTTClient:
         """Set fan speed.
 
         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)
 
         Returns:
             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)
             return False
 
@@ -5818,6 +5913,10 @@ class BambuMQTTClient:
         """Set chamber fan speed (0-255)."""
         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:
         """Set air conditioning mode (cooling or heating).
 

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

@@ -187,17 +187,40 @@ class HomeAssistantService:
 
     @staticmethod
     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:
-            parsed = urlparse(url)
+            assert_safe_lan_service_url(url, label="Home Assistant URL")
         except ValueError:
             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 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:
         """Test connection to Home Assistant.

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

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

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

@@ -365,7 +365,23 @@ class ObicoDetectionService:
         }
 
     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/"
         try:
             async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:

+ 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_fan2_speed": state.big_fan2_speed,
         "heatbreak_fan_speed": state.heatbreak_fan_speed,
+        "left_aux_fan_speed": state.left_aux_fan_speed,
+        "exhaust_fan_present": state.exhaust_fan_present,
         # Chamber light state
         "chamber_light": state.chamber_light,
         # 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."""
 
-import ipaddress
 import json
 import logging
 from typing import TYPE_CHECKING, Any
-from urllib.parse import urlparse
 
 import httpx
 
@@ -24,18 +22,39 @@ class RESTSmartPlugService:
         self.timeout = timeout
 
     @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:
-            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]:
         """Parse JSON string to dict of headers."""
@@ -273,8 +292,9 @@ class RESTSmartPlugService:
             - success: bool
             - 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)
 

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

@@ -26,12 +26,32 @@ class TasmotaService:
 
     @staticmethod
     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:
             addr = ipaddress.ip_address(ip)
         except ValueError:
             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(
         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:
     """Return True if the printer model has an ethernet port."""
     if not model:

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

@@ -3855,8 +3855,10 @@ class TestSetChamberTemperatureAPI:
 class TestSetFanSpeedAPI:
     """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
@@ -3879,13 +3881,17 @@ class TestSetFanSpeedAPI:
     @pytest.mark.integration
     @pytest.mark.parametrize(
         "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):
         """Verify each fan name maps to the correct hardware fan-id."""
         printer = await printer_factory(name="P", model="X1C")
         mock_client = MagicMock()
         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:
             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")
@@ -3893,6 +3899,39 @@ class TestSetFanSpeedAPI:
         called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         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.integration
     @pytest.mark.parametrize(
@@ -3911,6 +3950,36 @@ class TestSetFanSpeedAPI:
         _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
         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.integration
     async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):

+ 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):
         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):
         assert service._validate_url("http:///api") is False
@@ -405,6 +433,10 @@ class TestTestConnection:
 
     @pytest.mark.asyncio
     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 "blocked" in result["error"].lower()
+        assert "cloud metadata" in result["error"].lower()

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

@@ -40,6 +40,9 @@ 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 = [
@@ -54,6 +57,11 @@ UNIVERSALLY_BLOCKED = [
     "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.
@@ -374,3 +382,269 @@ async def test_test_config_refuses_metadata_targets_without_a_request(provider_t
     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_fan2_speed=0,
         heatbreak_fan_speed=0,
+        left_aux_fan_speed=None,
+        exhaust_fan_present=False,
     )
 
 

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

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

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

@@ -310,6 +310,113 @@ describe('PrintersPage', () => {
         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', () => {

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

@@ -565,6 +565,11 @@ export interface PrinterStatus {
   big_fan1_speed: number | null;     // Auxiliary fan
   big_fan2_speed: number | null;     // Chamber/exhaust 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
   // Developer LAN mode: true = enabled, false = disabled, null = unknown
   developer_mode: boolean | null;
@@ -3934,7 +3939,7 @@ export const api = {
       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}`, {
       method: 'POST',
     }),

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: 'Bauteilkühlung',
       auxiliary: 'Hilfsventilator',
+      leftAuxiliary: 'Linker Hilfsventilator',
+      exhaust: 'Abluft',
       chamber: 'Kammerventilator',
     },
     // HMS errors

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

@@ -672,6 +672,8 @@ export default {
     fans: {
       partCooling: 'Part Cooling Fan',
       auxiliary: 'Auxiliary Fan',
+      leftAuxiliary: 'Left Auxiliary Fan',
+      exhaust: 'Exhaust',
       chamber: 'Chamber Fan',
     },
     // HMS errors

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: 'Ventilador de refrigeración de piezas',
       auxiliary: 'Ventilador auxiliar',
+      leftAuxiliary: 'Ventilador auxiliar izquierdo',
+      exhaust: 'Extracción',
       chamber: 'Ventilador de la cámara',
     },
     // HMS errors

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: 'Ventilateur pièce',
       auxiliary: 'Ventilateur auxiliaire',
+      leftAuxiliary: 'Ventilateur auxiliaire gauche',
+      exhaust: 'Extraction',
       chamber: 'Ventilateur chambre',
     },
     // HMS errors

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: 'Ventola raffreddamento parte',
       auxiliary: 'Ventola ausiliaria',
+      leftAuxiliary: 'Ventola ausiliaria sinistra',
+      exhaust: 'Estrazione',
       chamber: 'Ventola camera',
     },
     // HMS errors

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

@@ -667,6 +667,8 @@ export default {
     fans: {
       partCooling: 'パーツ冷却ファン',
       auxiliary: '補助ファン',
+      leftAuxiliary: '左補助ファン',
+      exhaust: '排気',
       chamber: 'チャンバーファン',
     },
     // HMS errors

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

@@ -625,6 +625,8 @@ export default {
     fans: {
       partCooling: '파트 냉각 팬',
       auxiliary: '보조 팬',
+      leftAuxiliary: '왼쪽 보조 팬',
+      exhaust: '배기',
       chamber: '챔버 팬'
     },
     clickToViewHmsErrors: 'HMS 오류 보기 클릭',

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: 'Ventilador de resfriamento da peça',
       auxiliary: 'Ventilador auxiliar',
+      leftAuxiliary: 'Ventilador auxiliar esquerdo',
+      exhaust: 'Exaustão',
       chamber: 'Ventilador da câmara',
     },
     // HMS errors

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

@@ -630,6 +630,8 @@ export default {
     fans: {
       partCooling: "Вентилятор обдува модели",
       auxiliary: "Дополнительный вентилятор",
+      leftAuxiliary: "Левый дополнительный вентилятор",
+      exhaust: "Вытяжка",
       chamber: "Вентилятор камеры",
     },
     clickToViewHmsErrors: "Нажмите, чтобы посмотреть ошибки HMS",

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: 'Parça Soğutma Fanı',
       auxiliary: 'Yardımcı Fan',
+      leftAuxiliary: 'Sol Yardımcı Fan',
+      exhaust: 'Egzoz',
       chamber: 'Hazne Fanı',
     },
     // HMS hataları

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

@@ -672,6 +672,8 @@ export default {
     fans: {
       partCooling: "Вентилятор охолодження моделі",
       auxiliary: "Допоміжний вентилятор",
+      leftAuxiliary: "Лівий допоміжний вентилятор",
+      exhaust: "Витяжка",
       chamber: "Камерний вентилятор",
     },
     // HMS errors

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: '零件冷却风扇',
       auxiliary: '辅助风扇',
+      leftAuxiliary: '左辅助风扇',
+      exhaust: '排气',
       chamber: '腔室风扇',
     },
     // HMS errors

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

@@ -668,6 +668,8 @@ export default {
     fans: {
       partCooling: '零件冷卻風扇',
       auxiliary: '輔助風扇',
+      leftAuxiliary: '左輔助風扇',
+      exhaust: '排氣',
       chamber: '腔室風扇',
     },
     // HMS errors

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

@@ -1490,6 +1490,17 @@ const MODELS_WITH_CHAMBER_FAN: ReadonlySet<string> = new Set([
   '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
 function mapModelCode(ssdpModel: string | null): string {
   if (!ssdpModel) return '';
@@ -2449,7 +2460,7 @@ function PrinterCard({
   });
 
   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),
     onMutate: async ({ fan, speed }) => {
       await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
@@ -2457,6 +2468,7 @@ function PrinterCard({
       const fanField = {
         part: 'cooling_fan_speed',
         aux: 'big_fan1_speed',
+        aux2: 'left_aux_fan_speed',
         chamber: 'big_fan2_speed',
       }[fan];
       queryClient.setQueryData(['printerStatus', printer.id], (old: PrinterStatus | undefined) =>
@@ -3886,7 +3898,30 @@ function PrinterCard({
               // control that does nothing. Mirrors the enclosure-door badge
               // gate above.
               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',
                   label: t('printers.fans.partCooling'),
@@ -3894,6 +3929,22 @@ function PrinterCard({
                   Icon: Fan,
                   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',
                   label: t('printers.fans.auxiliary'),
@@ -3901,11 +3952,11 @@ function PrinterCard({
                   Icon: Wind,
                   activeClass: 'text-blue-600 dark:text-blue-400',
                 },
-                ...(hasChamberFan
+                ...(showChamberFan
                   ? [
                       {
                         key: 'chamber',
-                        label: t('printers.fans.chamber'),
+                        label: chamberFanLabel,
                         value: status.big_fan2_speed ?? 0,
                         Icon: AirVent,
                         activeClass: 'text-green-600 dark:text-green-400',
@@ -4157,7 +4208,7 @@ function PrinterCard({
                               isPending={fanSpeedMutation.isPending}
                               options={buildPresetOptions(fanSpeedPresets, '%')}
                               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>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-C2LOlVCR.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/index-D4bpNaiw.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
static/assets/index-oReXTzKG.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-apAuCUp0.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>
   <body>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio