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

This commit is contained in:
MartinNYHC
2026-07-31 08:20:49 +02:00
committed by GitHub
96 changed files with 7583 additions and 492 deletions
+30 -11
View File
@@ -1,9 +1,11 @@
"""Pure helper functions for OIDC routes.
Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
the public internet (IdP-hosted), so private addresses there are SSRF probes.
Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
reachable on the public internet, so a private address there is an SSRF probe
rather than a configuration.
"""
from __future__ import annotations
@@ -11,15 +13,21 @@ 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:
"""Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
guard: also rejects loopback, private (RFC-1918), and link-local addresses
because an OIDC icon legitimately lives only on the public internet.
Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
than the LAN-service SSRF guard: also rejects loopback, private
(RFC-1918), and link-local addresses because an IdP and its icon
legitimately live only on the public internet.
Checks performed:
- Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
@@ -35,9 +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 (consistent
with ``_validate_issuer_url`` policy — the operator is trusted to
configure a sensible IdP host).
Hostname-based addresses are otherwise accepted without DNS resolution —
the operator is trusted to configure a sensible IdP host, and resolving
here would both add a TOCTOU gap (DNS can change between validation and
request) and make the validator issue network requests of its own. The
fixed cloud-metadata hostnames are the exception: matching them is a
literal string comparison, not a resolution.
"""
parsed = urlparse(url)
if parsed.scheme.lower() != "https":
@@ -45,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")
+9 -55
View File
@@ -5,17 +5,15 @@ No heavy dependencies — importable in unit tests without the full backend stac
from __future__ import annotations
import ipaddress
import json
import logging
import math
import re
from typing import Any
from urllib.parse import urlparse
from typing_extensions import TypedDict
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
logger = logging.getLogger(__name__)
@@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
def assert_safe_spoolman_url(url: str) -> None:
"""Raise ValueError if *url* should be blocked as an SSRF risk.
"""Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
Bambuddy is typically deployed on a home LAN alongside Spoolman, so
loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
172.16-31.x) must be permitted — they are THE normal Spoolman topology.
This guard therefore targets the genuinely dangerous cases only.
Thin wrapper over the shared LAN-service policy — see
``_url_safety.assert_safe_lan_service_url`` for what is and isn't
rejected, and why loopback/RFC-1918 are deliberately permitted (running
Spoolman on the same host or home LAN is THE normal topology).
Checks performed:
- Scheme must be http or https (no file://, gopher://, dict://, etc.).
- Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
(e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
``ValueError`` for these forms so they would otherwise bypass the
explicit-IP block below, but libc (and browsers) resolve them as valid
IPv4 addresses.
- Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
- Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
are blocked — pointless as a destination and suggests misuse.
- IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
bypass the checks above.
Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
are out of scope — DNS resolution is deliberately not performed here.
Kept as a named function because the "Spoolman URL …" wording in its
errors is user-facing and asserted by existing tests.
"""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError("Spoolman URL must use http or https")
hostname = (parsed.hostname or "").lower()
# Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
# http://0x7f000001/). These slip past ipaddress.ip_address() but libc
# (and browsers) parse them as IPv4 — an obvious bypass if not caught.
if NUMERIC_IP_RE.match(hostname):
raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
# Not a bare IP address — includes intentional cases such as "localhost" and
# RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
# a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
# Running Spoolman on the same host or home LAN is the standard Bambuddy
# topology, so loopback and private ranges are deliberately NOT blocked here.
return
# Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
# encode a blocked IPv4 into an IPv6 literal to bypass the check.
effective = unwrap_ipv4_mapped(addr)
if effective in CLOUD_METADATA_IPS:
raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
if effective.is_multicast or effective.is_unspecified:
raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
assert_safe_lan_service_url(url, label="Spoolman URL")
_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
+98 -9
View File
@@ -1,19 +1,31 @@
"""Shared URL-safety primitives used by both SSRF guards in this package.
"""Shared URL-safety primitives for the SSRF guards in this package.
The two top-level assertion functions —
``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
loopback/RFC-1918 because same-LAN deployment is the standard topology) and
``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
on the public internet, so loopback/private are rejected) — share the
*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
*policy*. Only the data lives here. The functions stay in their respective
modules with their distinct policies intact.
Bambuddy has exactly two outbound-URL policies, and which one applies is a
property of the *service*, not of the caller:
- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
legitimately lives on the same host or home LAN, so loopback and RFC-1918
must be permitted; blocking them would break the normal topology. Used for
Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
resource can only sensibly live on the public internet, so a private
address is an SSRF probe rather than a configuration. Used for OIDC issuer
and icon URLs.
Both reject the cases that are dangerous regardless of topology: non-HTTP
schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
The LAN-service policy lives here because it now has several callers; the
public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
"""
from __future__ import annotations
import ipaddress
import re
from urllib.parse import urlparse
# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
# targets. Both guards reject these unconditionally.
@@ -28,6 +40,18 @@ CLOUD_METADATA_IPS = frozenset(
}
)
# The DNS-name form of the same targets. Neither guard resolves hostnames (see
# the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
# literal-string match needs no resolution and costs nothing. These names only
# resolve inside the respective cloud, so there is no legitimate reason for any
# Bambuddy integration to point at one.
CLOUD_METADATA_HOSTNAMES = frozenset(
{
"metadata.google.internal", # GCP
"metadata.goog", # GCP short form
}
)
# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
@@ -49,3 +73,68 @@ def unwrap_ipv4_mapped(
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
return addr.ipv4_mapped
return addr
def assert_safe_lan_service_url(url: str, *, label: str) -> None:
"""Raise ValueError if *url* is unsafe for a service that may live on the LAN.
``label`` names the setting in the error message ("Spoolman URL", "ntfy
server URL", …) so the user sees which field they need to correct.
Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
**permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
same host or home LAN is THE normal topology, not an attack. A blanket
private-address block would break those integrations for most installs.
What is rejected is dangerous under any topology:
- Schemes other than http/https. ``httpx`` already raises
``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
about returning a clear validation error at configuration time rather
than an opaque failure at delivery time.
- Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
libc and browsers resolve these, but Python's ``ipaddress`` raises
ValueError on them, so they would slip past the checks below.
- Cloud-provider metadata endpoints — the high-value SSRF target, and
never a legitimate destination for any of these services.
- Multicast and unspecified addresses — pointless as a destination and
indicative of misuse.
- IPv4-mapped IPv6 encodings of any of the above.
Symbolic hostnames are otherwise accepted without DNS resolution, matching
the public-internet guard: resolution here would be both a TOCTOU (DNS can
change between validation and request) and a request the validator
shouldn't be making. The one exception is the fixed set of cloud-metadata
hostnames, which is a literal-string match and needs no resolution.
"""
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
raise ValueError(f"{label} must use http or https")
hostname = (parsed.hostname or "").lower()
# "http:///path" parses to an empty hostname. Never a valid destination,
# and without this it falls through the ip_address() ValueError branch
# below and is accepted as if it were a symbolic hostname.
if not hostname:
raise ValueError(f"{label} must include a hostname")
if hostname in CLOUD_METADATA_HOSTNAMES:
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
if NUMERIC_IP_RE.match(hostname):
raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
try:
addr = ipaddress.ip_address(hostname)
except ValueError:
return # symbolic hostname — out of scope by design (no DNS check)
effective = unwrap_ipv4_mapped(addr)
if effective in CLOUD_METADATA_IPS:
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
if effective.is_multicast or effective.is_unspecified:
raise ValueError(f"{label} must not point to a multicast or unspecified address")
+10
View File
@@ -33,6 +33,7 @@ from backend.app.services.design_settings import overrides_from_config
from backend.app.utils.http import build_content_disposition
from backend.app.utils.safe_path import safe_join_under
from backend.app.utils.threemf_tools import (
expand_to_project_slots,
extract_embedded_presets_from_3mf,
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
@@ -3937,6 +3938,7 @@ async def get_filament_requirements(
archive_id: int,
plate_id: int | None = None,
request_id: str | None = None,
full_slots: bool = False,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
@@ -4046,6 +4048,14 @@ async def get_filament_requirements(
}
)
# Re-slicing a source that already carries slice_info (#2712).
# See library.py for the full rationale: the slice modal's list is
# positional, so a source using only slot 4 must still present
# four slots or the pick lands on slot 1. The print path keeps the
# used-only list it depends on.
if full_slots and filaments:
filaments = expand_to_project_slots(zf, filaments)
# Unsliced project files: see library.py for full rationale.
# Return the FULL project_settings.config slot list with a
# used_in_plate flag derived from the preview slice; the
+284 -29
View File
@@ -1,10 +1,13 @@
"""Camera streaming API endpoints for Bambu Lab printers."""
import asyncio
import contextlib
import logging
import os
import subprocess
import sys
import time
import uuid
from collections.abc import AsyncGenerator
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -46,12 +49,25 @@ from backend.app.services.camera_profiles import get_camera_profile
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/printers", tags=["camera"])
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580). A killed
# ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily
# long to exit — an unbounded post-kill wait() parked the fan-out stream
# coroutine for 12 hours on a P2S, leaving every viewer attached to a stalled
# broadcaster. Abandoning the wait is safe: cleanup_orphaned_streams' /proc scan
# reaps any Bambu ffmpeg not attached to an active stream on its next pass.
# Grace period for a SIGTERMed ffmpeg to shut down before we SIGKILL it. Only
# reachable when ffmpeg genuinely ignores SIGTERM: _terminate_ffmpeg drains the
# pipes first, and a drained ffmpeg exits in ~0.15s.
_FFMPEG_TERM_TIMEOUT = 2.0
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580).
#
# The original diagnosis — "a killed ffmpeg stuck in uninterruptible I/O on a
# dead RTSP socket" — was wrong, and this bound was capping a deadlock of our
# own making rather than waiting out a stuck process. A process that survives
# SIGKILL would have to be in uninterruptible sleep (state D); the ffmpeg seen
# doing this was in state S, and its returncode was already set to -9 while
# wait() was still blocked. The real cause was undrained pipes (see
# _terminate_ffmpeg), which made this timeout fire on *every* camera close.
#
# Kept as a backstop now that the cause is fixed: it should no longer be
# reachable, and if it ever is, abandoning the wait is still safe because
# cleanup_orphaned_streams' /proc scan reaps any Bambu ffmpeg not attached to
# an active stream on its next pass.
_FFMPEG_KILL_TIMEOUT = 2.0
# Track active ffmpeg processes for cleanup
@@ -83,6 +99,14 @@ _disconnect_events: dict[str, asyncio.Event] = {}
# Track last frame time per stream_id (not just per printer_id) for stale detection
_stream_last_frame_times: dict[str, float] = {}
# How much of a streaming ffmpeg's stderr to retain: enough for the input
# analysis plus a burst of errors, capped so a long-running stream can't grow it.
_FFMPEG_STDERR_TAIL_BYTES = 16384
# Live stderr collectors by pid — see _FfmpegStderrTail. Present means "this
# process's stderr already has a reader; do not open a second one".
_stderr_tails: dict[int, "_FfmpegStderrTail"] = {}
def get_buffered_frame(printer_id: int) -> bytes | None:
"""Get the last buffered frame for a printer from an active stream.
@@ -194,8 +218,6 @@ async def generate_chamber_mjpeg_stream(
# Save frame to buffer for photo capture and track timestamp
if printer_id is not None:
import time
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
@@ -227,10 +249,7 @@ async def generate_chamber_mjpeg_stream(
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
_release_printer_frame_state(printer_id)
# Close the connection
try:
@@ -241,14 +260,127 @@ async def generate_chamber_mjpeg_stream(
logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
def _new_fanout_stream_id(printer_id: int) -> str:
"""Registry key for one fan-out stream INSTANCE, not for the printer.
A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
printer shared one key, so a departing generator's cleanup removed the entry
its successor had just registered. The external-camera path already carries a
per-instance suffix for exactly this reason (#2675); this gives the fan-out
path the same property.
The ``f"{printer_id}-"`` prefix is load-bearing — ``is_stream_active``,
``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
scanning for it — so the suffix goes on the end.
"""
return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
def live_frame_for_capture(printer_id: int) -> tuple[bool, bytes | None]:
"""Should a one-shot capture stand down for the live view, and to what frame?
Returns ``(defer, frame)``. ``defer`` True means DO NOT open a capture of
your own: use ``frame`` when it isn't None, and otherwise skip this attempt
rather than competing.
Both camera kinds allow exactly one reader — Bambu firmware permits one
connection, and a USB camera permits one V4L2 handle — so a capture that
races the live view doesn't degrade, it fails outright. #2707 measured 0 of
87 and 0 of 105 layer-timelapse captures on prints watched throughout, and
finish photos going out with no image attached.
Skipping when the buffer is momentarily empty (stream starting, mid-
reconnect) rather than falling through to a capture is the #1348 rule:
opening a competing handle kicks the viewer off, which is a worse outcome
than missing one frame.
"""
if not is_stream_active(printer_id):
return False, None
return True, _last_frames.get(printer_id)
def _release_printer_frame_state(printer_id: int | None) -> None:
"""Drop a printer's buffered frame and timings — unless a stream still owns them.
These three dicts are keyed by printer, not by stream, so a departing
generator must not clear them while a newer stream for the same printer is
running. That used to happen routinely: stream ids were per-printer, so a
predecessor's cleanup wiped its successor's state, leaving
``is_stream_active()`` False with a viewer attached (which is exactly what
the #1348 / #1271 guards read before deciding whether it is safe to open a
second camera connection), the janitor free to reap the live ffmpeg as an
orphan, and snapshots without a frame to reuse.
Call this AFTER removing the departing stream's own key, so the check
reports on other streams rather than on the caller.
"""
if printer_id is None or is_stream_active(printer_id):
return
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
async def _drain_pipe(reader) -> None:
"""Read a subprocess pipe to EOF and discard, so it can never block.
Best-effort by design: any read failure means we cannot drain further, and
the caller is tearing the process down regardless.
"""
if reader is None:
return
try:
while await reader.read(65536):
pass
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 — teardown must not fail on a dying pipe
return
async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
"""Terminate an ffmpeg process gracefully, then kill if needed."""
"""Terminate an ffmpeg process gracefully, then kill if needed.
Drains stdout/stderr throughout, which is load-bearing rather than hygiene.
ffmpeg is spawned with both as pipes, and every caller of this has already
stopped reading stdout — so by the time we get here ffmpeg is typically
blocked in write() on a full 64 KiB pipe. Two things then go wrong:
* SIGTERM cannot be acted on. ffmpeg's handler only sets a flag that its
main loop polls, and a loop blocked in write() never reaches the check,
so the whole grace period is dead time.
* SIGKILL does kill it, but wait() cannot observe that. asyncio resolves
Process.wait()'s waiter through BaseSubprocessTransport._try_finish(),
which requires every pipe transport to report disconnected; paused,
unread pipes never reach EOF, so wait() blocks with returncode already
set. That is what made the "did not exit within Ns of SIGKILL" error
fire on every single camera close, and unbounded it was the 12-hour
hang in #2580.
Draining fixes both: SIGTERM becomes actionable and the exit observable.
Measured on an H2D: 4.0s of dead time per close before, ~0.15s after —
which matters because the printer allows exactly one camera connection,
so every one of those seconds was a connection nobody could use.
Discarding what we drain is deliberate. The stream loop already reads
stderr on its error paths (_read_ffmpeg_stderr), and it does so before
calling this, so nothing diagnostic is lost.
"""
if process.returncode is not None:
_spawned_ffmpeg_pids.pop(process.pid, None)
return # Already dead
drainers = [asyncio.create_task(_drain_pipe(process.stdout))]
# A streaming ffmpeg's stderr already has a reader (_FfmpegStderrTail), and
# it keeps draining right through teardown, which is all we need here. Adding
# a second reader would race it — asyncio rejects concurrent reads on one
# StreamReader — so only drain stderr when nobody else owns it.
if process.pid not in _stderr_tails:
drainers.append(asyncio.create_task(_drain_pipe(process.stderr)))
try:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2.0)
await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
except TimeoutError:
logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
process.kill()
@@ -257,7 +389,8 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
except TimeoutError:
# Do NOT keep waiting (#2580): the caller is the stream
# generator, and blocking here pins the fan-out pump forever.
# The orphan janitor reaps the process later.
# The orphan janitor reaps the process later. With the pipes
# drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
logger.error(
"ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
_FFMPEG_KILL_TIMEOUT,
@@ -267,7 +400,11 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
pass # Already dead
except OSError as e:
logger.warning("Error terminating ffmpeg: %s", e)
_spawned_ffmpeg_pids.pop(process.pid, None)
finally:
for drainer in drainers:
drainer.cancel()
await asyncio.gather(*drainers, return_exceptions=True)
_spawned_ffmpeg_pids.pop(process.pid, None)
def _summarize_ffmpeg_stderr(text: str | None) -> str:
@@ -303,6 +440,82 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
return "\n".join(meaningful[-10:])
class _FfmpegStderrTail:
"""Owns a long-lived ffmpeg's stderr: drains it continuously, keeps the tail.
Reading stderr only when something has already gone wrong leaves a pipe
nobody reads for the whole life of the stream. ffmpeg writes its banner, the
input analysis and then a progress line at a steady rate, so a 64 KiB pipe
fills eventually and ffmpeg blocks writing to it — at which point it stops
producing frames, the stream's own read timeout fires, and the log says
"RTSP read timeout" with no hint that we starved it ourselves.
How long that takes is unmeasured and may be a long time: one H2D upstream
ran 21m36s continuously without stalling, so this is a bounded resource
being treated as unbounded rather than an observed failure. Draining removes
the ceiling either way, and the tail is *better* diagnostic material than
the old on-demand read: it holds ffmpeg's most recent output at the moment
things went wrong, where reading the buffered pipe returned whatever was
printed first (usually the startup banner, which the summariser then strips).
Registers itself in ``_stderr_tails`` so the two other readers of this pipe
can defer to it — asyncio raises if two coroutines read one StreamReader
concurrently. See ``_read_ffmpeg_stderr`` and ``_terminate_ffmpeg``.
"""
def __init__(self, process: asyncio.subprocess.Process) -> None:
self._process = process
self._buffer = bytearray()
self._task: asyncio.Task | None = None
if process.stderr is None:
return
self._task = asyncio.create_task(self._pump())
_stderr_tails[process.pid] = self
async def _pump(self) -> None:
reader = self._process.stderr
try:
while True:
chunk = await reader.read(8192)
if not chunk:
return # EOF — ffmpeg has exited
self._buffer.extend(chunk)
excess = len(self._buffer) - _FFMPEG_STDERR_TAIL_BYTES
if excess > 0:
del self._buffer[:excess]
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 — a broken pipe just ends the tail
return
def text(self) -> str | None:
"""The retained tail, summarised. None when nothing was captured.
Goes through _summarize_ffmpeg_stderr like every other stderr log in
this module: ffmpeg echoes its input URL, which carries the access code.
"""
if not self._buffer:
return None
return _summarize_ffmpeg_stderr(self._buffer.decode(errors="replace")) or None
async def aclose(self) -> None:
"""Stop draining and release ownership of the pipe. Idempotent.
Awaits the cancelled pump rather than firing and forgetting, so the task
is finished before the caller moves on — an abandoned pending task
becomes an "unraisable exception" warning at an arbitrary later point,
usually during interpreter or loop teardown.
"""
task, self._task = self._task, None
if _stderr_tails.get(self._process.pid) is self:
del _stderr_tails[self._process.pid]
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
"""Read whatever ffmpeg has written to stderr so far (best-effort).
@@ -313,8 +526,18 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
banner + stream-analysis lines ffmpeg already printed. Reading in bounded
chunks returns the buffered output promptly whether or not ffmpeg has
exited. Returns the content with ffmpeg's boilerplate banner stripped.
When a _FfmpegStderrTail owns this process's stderr — every streaming
ffmpeg — its retained tail is returned instead. Reading the pipe here as
well would race that collector, and asyncio refuses two concurrent readers
on one StreamReader outright.
"""
if not process or not process.stderr:
if not process:
return None
tail = _stderr_tails.get(getattr(process, "pid", None))
if tail is not None:
return tail.text()
if not process.stderr:
return None
chunks: list[bytes] = []
total = 0
@@ -435,6 +658,7 @@ async def generate_rtsp_mjpeg_stream(
jpeg_end = b"\xff\xd9"
reconnect_count = 0
process = None
stderr_tail: _FfmpegStderrTail | None = None
got_any_frames = False
try:
@@ -487,6 +711,14 @@ async def generate_rtsp_mjpeg_stream(
reconnect_count += 1
continue
# Take ownership of stderr for the life of this process. Started
# only after the immediate-failure check above, which reads the pipe
# directly (correct there: the process is already dead, so
# read-to-EOF returns at once and cannot be raced by a collector).
# Nothing is lost by starting late — the banner ffmpeg printed in the
# meantime is still sitting in the pipe.
stderr_tail = _FfmpegStderrTail(process)
# Read JPEG frames from ffmpeg stdout
buffer = b""
stream_ended = False
@@ -530,8 +762,6 @@ async def generate_rtsp_mjpeg_stream(
got_any_frames = True
if printer_id is not None:
import time
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
if stream_id:
@@ -562,6 +792,12 @@ async def generate_rtsp_mjpeg_stream(
# Clean up this ffmpeg process before reconnecting or exiting
await _terminate_ffmpeg(process, stream_id)
# Released after teardown, not before: _terminate_ffmpeg deliberately
# leaves stderr to this collector, which has to keep draining while
# the process is stopped or wait() can't observe the exit.
if stderr_tail is not None:
await stderr_tail.aclose()
stderr_tail = None
process = None
if client_gone:
@@ -604,15 +840,16 @@ async def generate_rtsp_mjpeg_stream(
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
_release_printer_frame_state(printer_id)
if process:
await _terminate_ffmpeg(process, stream_id)
logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
# Same order as in the loop: terminate first, then release stderr.
if stderr_tail is not None:
await stderr_tail.aclose()
# Shut down the TLS proxy
proxy_server.close()
await proxy_server.wait_closed()
@@ -672,9 +909,11 @@ async def camera_stream(
# Check for external camera first
if printer.external_camera_enabled and printer.external_camera_url:
import time
import uuid
# NB: no `import time` / `import uuid` here, and don't reintroduce them.
# A local import anywhere in this function makes the name function-local
# for the WHOLE function, so the RTSP/chamber path below — which never
# executes this branch — would raise UnboundLocalError on any printer
# without an external camera. Both are imported at module level.
from backend.app.services.external_camera import generate_mjpeg_stream
# Limit external camera FPS to reduce browser load
@@ -710,6 +949,18 @@ async def camera_stream(
_spawned_ffmpeg_pids[proc.pid] = time.time()
_stream_last_frame_times[stream_id] = time.time()
def _publish_external_frame(frame: bytes) -> None:
"""Make the live frame reusable by one-shot consumers (#2707).
Only the built-in camera paths populated _last_frames, so every
external-camera consumer — layer timelapse, finish photo, Obico,
plate check — found an empty buffer and opened its own handle on a
device that allows exactly one reader, which simply failed while a
viewer was attached. Raw frame, not the multipart-wrapped chunk the
generator yields, because that is what those consumers expect.
"""
_last_frames[printer_id] = frame
async def external_stream_wrapper():
"""Wrap external stream to track start/stop and update frame times."""
try:
@@ -718,6 +969,7 @@ async def camera_stream(
printer.external_camera_type,
fps,
on_process=_register_external_process,
on_frame=_publish_external_frame,
stop_event=stop_event,
):
# generate_mjpeg_stream already handles rate limiting;
@@ -738,6 +990,11 @@ async def camera_stream(
_disconnect_events.pop(stream_id, None)
_stream_last_frame_times.pop(stream_id, None)
_active_external_streams.discard(printer_id)
# Now that this path publishes a buffered frame, it has to
# retract it too — ownership-checked, so a concurrent viewer of
# the same printer keeps its own. Also clears the per-printer
# timings this path used to leave behind.
_release_printer_frame_state(printer_id)
logger.info("External camera stream ended for printer %s", printer_id)
return StreamingResponse(
@@ -769,8 +1026,6 @@ async def camera_stream(
# attached — otherwise /camera/status would report stream_uptime jumping
# backward whenever a second viewer joins. The upstream generator's
# finally clears this entry when the upstream actually ends.
import time
_stream_start_times.setdefault(printer_id, time.time())
# Fan-out broadcaster (#1089): one upstream connection per printer, shared
@@ -783,7 +1038,7 @@ async def camera_stream(
# broadcaster. Concurrent viewers share that rate; new viewers after
# teardown create a fresh broadcaster at their requested fps.
fanout_key = f"printer-{printer_id}"
upstream_stream_id = f"{printer_id}-fanout"
upstream_stream_id = _new_fanout_stream_id(printer_id)
def _factory(disconnect_event: asyncio.Event):
# Re-bind locals into the closure so the async generator below sees
+14
View File
@@ -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"
+35 -2
View File
@@ -73,6 +73,7 @@ from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_miss
from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
from backend.app.utils.threemf_tools import (
expand_to_project_slots,
extract_embedded_presets_from_3mf,
extract_nozzle_mapping_from_3mf,
extract_project_filaments_from_3mf,
@@ -3114,6 +3115,7 @@ async def get_library_file_filament_requirements(
file_id: int,
plate_id: int | None = None,
request_id: str | None = None,
full_slots: bool = False,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
@@ -3130,6 +3132,10 @@ async def get_library_file_filament_requirements(
Args:
file_id: The library file ID
plate_id: Optional plate index to get filaments for a specific plate
full_slots: Return one entry per *project* slot rather than only the
slots the plate consumes. See :func:`_expand_to_project_slots`.
Only the slice modal wants this; print-time AMS matching must keep
the used-only list.
"""
import defusedxml.ElementTree as ET
@@ -3232,6 +3238,17 @@ async def get_library_file_filament_requirements(
}
)
# Re-slicing a source that already carries slice_info (#2712).
# The block above answers "what does this plate consume", which is
# what print-time AMS matching needs. The slice modal needs "what
# slots exist", because its list is positional and the CLI binds
# entry N to slot N — so a source using only slot 4 handed the
# user's single pick to slot 1 and sliced slot 4 with the source's
# embedded default. Widen here rather than in the modal so the
# print path keeps the narrow list it depends on.
if full_slots and filaments:
filaments = expand_to_project_slots(zf, filaments)
# Unsliced project files: slice_info had no per-plate data.
# Return the FULL project_settings.config AMS slot list so
# the slicer CLI receives a profile for every project slot
@@ -3761,10 +3778,26 @@ async def _run_slicer_with_fallback(
# with printer …" (#2628). Replace unused-slot entries with the
# plate's lowest used slot before the real slice so the loaded set is
# materially homogeneous and printer-correct.
if is_3mf and request.plate is not None:
#
# ``plate`` is absent for single-plate and STL sources — the SliceModal
# skips the picker and omits the field — and absent means plate 1, the
# same reading as ``plate_num`` further down and as the schema's own
# description. Treating it as "unknown plate" instead is what left every
# single-plate 3MF unsubstituted (#2711): a MakerWorld project defining
# four filaments but painting only one reached the CLI with the other
# three still holding presets baked into the source for a different
# printer, and the slice died on the first of them.
#
# ``plate=0`` is the slice-all sentinel, not a plate: every slot is used
# by some plate, so there is nothing to substitute. It has to be excluded
# explicitly because the support-filament slots unioned in below are
# read from the project config and are not plate-scoped — they would
# survive the (empty) geometry lookup for plate 0 and become the anchor,
# collapsing every colour of a slice-all onto the support filament.
if is_3mf and request.plate != 0:
from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
# Cross-class slice-all loop (#1493): when the user asks for
# ``plate=0`` (all plates) AND the source's nozzle class differs from
+39 -5
View File
@@ -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}%"}
+100 -13
View File
@@ -42,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
return setting.value if setting else None
# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
# column and every reader compares them as strings, so these are normalised to
# "true"/"false" on the way in. The sets are deliberately generous: these
# endpoints are part of the documented REST surface, reached by scripts and by
# Home Assistant rest_command, where "True", "1" and "on" are all natural.
_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
def setting_is_true(value: object) -> bool:
"""Return True if a *stored* settings value means "on".
Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
it matches only what every other reader in the codebase treats as on
(``value.lower() == "true"``). Submitted values are canonicalised on write,
so a stored value is always "true"/"false"/""; accepting "1" or "on" here
would make this function disagree with the rest of the app about any legacy
row containing them.
A bool is tolerated for the case of a row written before values were
normalised, where SQLite coerced a raw bool into the VARCHAR column.
"""
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() == "true"
def normalize_bool_setting(key: str, value: object) -> str:
"""Coerce a boolean-ish settings value to the canonical "true"/"false".
Raises HTTPException(400) for values with no sensible interpretation, so an
API client gets a message naming the field instead of a 500.
A JSON boolean is the natural thing for an API client to send, and before
this normalisation it caused two distinct failures on
``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
raw bool was written into a VARCHAR column, which SQLite silently coerces
to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
"""
if isinstance(value, bool): # must precede the int branch — bool is an int
return "true" if value else "false"
if isinstance(value, int):
if value in (0, 1):
return "true" if value else "false"
raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
if isinstance(value, str):
candidate = value.strip().lower()
if not candidate:
# Empty is stored verbatim rather than normalised to "false".
# get_spoolman_settings reads these with ``or "<default>"``, so an
# empty stored value means "use the default" — and two of them
# (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
# ON. Rewriting "" to "false" would silently switch them off for any
# client that submits a blank value.
return ""
if candidate in _TRUTHY_SETTING_VALUES:
return "true"
if candidate in _FALSY_SETTING_VALUES:
return "false"
raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
def normalize_str_setting(key: str, value: object) -> str:
"""Return a string settings value, rejecting types that would store garbage.
``str()`` on a dict or list would persist its repr, so those are refused
rather than silently written. Numbers are accepted and stringified: a port
or a bare host submitted unquoted is a plausible client mistake, not a
reason to fail the request.
"""
if isinstance(value, str):
return value
if value is None:
return ""
if isinstance(value, bool | int | float):
return str(value)
raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
async def get_external_login_url(db: AsyncSession) -> str:
"""Get the external URL for the login page.
@@ -435,14 +517,20 @@ async def update_spoolman_settings(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
):
"""Update Spoolman integration settings."""
"""Update Spoolman integration settings.
The body is a free-form dict rather than a schema, so each value is
normalised before it is persisted — see ``normalize_bool_setting`` for why
a JSON boolean used to produce a 500 here.
"""
if "spoolman_enabled" in settings:
old_val = await get_setting(db, "spoolman_enabled") or "false"
new_val = settings["spoolman_enabled"]
was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
now_enabled = new_val == "true"
await set_setting(db, "spoolman_enabled", new_val)
# Switching to Spoolman: clear built-in inventory slot assignments
if old_val.lower() != "true" and new_val.lower() == "true":
if not was_enabled and now_enabled:
from backend.app.models.spool_assignment import SpoolAssignment
result = await db.execute(delete(SpoolAssignment))
@@ -452,21 +540,20 @@ async def update_spoolman_settings(
# spoolman_slot_assignments rows linger and would wrongly count as
# "assigned" in any mode-agnostic check (e.g. the missing-spool-
# assignment notification, which unions both tables — #1473).
elif old_val.lower() == "true" and new_val.lower() != "true":
elif was_enabled and not now_enabled:
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
result = await db.execute(delete(SpoolmanSlotAssignment))
logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
if "spoolman_url" in settings:
await set_setting(db, "spoolman_url", settings["spoolman_url"])
await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
if "spoolman_sync_mode" in settings:
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
if "spoolman_disable_weight_sync" in settings:
await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
if "spoolman_report_partial_usage" in settings:
await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
if "auto_add_unknown_rfid" in settings:
await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
await set_setting(
db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
)
for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
if bool_key in settings:
await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
+36 -6
View File
@@ -1227,6 +1227,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
return out
def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
"""Sanitize a push_status snapshot's string *values*, never its JSON text.
This used to run :func:`sanitize_log_content` over the serialised snapshot.
That pass includes a generic Bambu-serial regex
(``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
shipped invalid JSON — unusable for exactly the ground-truth purpose the
snapshot exists for (found while diagnosing #2702).
Walking the structure instead leaves numbers, bools and None untouched, so
the output always parses. Keys are structural and never rewritten.
"""
if isinstance(node, str):
return sanitize_log_content(node, sensitive_strings)
if isinstance(node, dict):
return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
if isinstance(node, list | tuple):
# Tuples too: `json.dumps` renders them as arrays, so stringifying one
# here would change the file's shape rather than just its content.
return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
if node is None or isinstance(node, bool | int | float):
return node
# Anything else (datetime, Decimal, …) would be stringified by json.dumps'
# ``default=str`` *after* this pass and so escape sanitisation entirely.
return sanitize_log_content(str(node), sensitive_strings)
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
"""Get recent log lines, sanitized for inclusion in bug reports."""
# Collect sensitive strings from DB for redaction
@@ -1300,12 +1329,13 @@ async def generate_support_bundle(
"captured_at": datetime.now(timezone.utc).isoformat(),
"raw_data": redacted,
}
# Belt-and-suspenders: pass the JSON text through the string-based
# sanitizer so any user-named string (printer name, serial baked
# into a tray uuid) the structural pass missed still gets caught.
snapshot_json = json.dumps(snapshot, indent=2, default=str)
snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
# Belt-and-suspenders: pass every string value through the
# string-based sanitizer so any user-named string (printer name,
# serial baked into a tray uuid) the structural pass missed still
# gets caught. Values only — sanitizing the serialised JSON text
# corrupted numeric literals (see _sanitize_push_status_values).
snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
# Add log file
# Off the event loop: this reads up to 10 MB and then runs one full regex
+110
View File
@@ -27,6 +27,11 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
# /system/db-pool can report it without re-deriving the dialect defaults.
_pool_config: dict = {}
# What the PostgreSQL server itself will allow, read once at startup. None on
# SQLite, or when the probe could not run. Reported by get_pool_status() so a
# support bundle carries both sides of the comparison.
_server_connection_limits: dict | None = None
def _resolve_pool_kwargs() -> dict:
"""Build the pool kwargs for ``create_async_engine`` (issue #2572).
@@ -151,6 +156,10 @@ def get_pool_status() -> dict:
return {
"dialect": "sqlite" if is_sqlite() else "postgresql",
"config": dict(_pool_config),
# Both sides of the ceiling-vs-server comparison, so a support bundle
# shows whether a TooManyConnectionsError was a misconfiguration or a
# genuine leak. None on SQLite or if the startup probe couldn't run.
"server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
**gauges,
}
@@ -317,6 +326,107 @@ async def init_db():
await seed_spool_catalog()
await seed_color_catalog()
await check_pool_fits_server()
async def check_pool_fits_server() -> None:
"""Warn when the pool may ask PostgreSQL for more connections than it allows.
``pool_size + max_overflow`` is the most connections one worker process will
ever open. If that exceeds what the server permits, the pool never reaches
its own limit and so never queues: it goes straight to the server, which
refuses with ``TooManyConnectionsError``. That surfaces wherever the next
connection happened to be needed — in the reported case, halfway through a
queue dispatch, which then left an expected-print registration and a dispatch
claim behind (#2702 follow-up).
The distinction is worth knowing when reading a log: SQLAlchemy's own
``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
concurrency, or connections held too long), whereas asyncpg's
``TooManyConnectionsError`` means the pool's ceiling is above the server's.
Not clamped, deliberately. Pool sizes are fixed when the engine is created,
which happens at import — before any connection exists to ask the server
with — and ``engine`` / ``async_session`` are imported by name in ~150 places,
so swapping the engine afterwards would leave stale references. The correct
ceiling also depends on the worker count and on anything else sharing the
server, neither of which Bambuddy can see. So this reports the mismatch with
both numbers and the knobs to fix it, and leaves the choice to the operator.
"""
global _server_connection_limits
if is_sqlite():
return
from sqlalchemy import text
in_use: int | None = None
try:
async with engine.connect() as conn:
max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
try:
in_use = int(
(
await conn.execute(
text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
)
).scalar_one()
)
except Exception as exc:
# `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
# restricted role sees fewer rows. The count is a nice-to-have
# for spotting other clients; the warning itself only needs the
# two settings above, so losing it must not cost the warning.
# Done last on purpose: a failed statement can abort the
# transaction, and nothing else uses this connection after it.
logger.debug("Could not count client backends: %s", exc)
except Exception as exc:
# A diagnostic must never be the reason startup fails. An older server
# or a restricted role may refuse these.
logger.debug("Could not read PostgreSQL connection limits: %s", exc)
return
available = max_conn - reserved
ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
_server_connection_limits = {
"max_connections": max_conn,
"superuser_reserved_connections": reserved,
"available_to_bambuddy": available,
"client_backends_at_startup": in_use,
"pool_ceiling_per_worker": ceiling,
}
if ceiling > available:
in_use_note = (
f" {in_use} client connection(s) are open on the server right now, including "
"this one — a count well above 1 means something else shares it."
if in_use is not None
else ""
)
logger.warning(
"DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
"connections (pool_size %d + max_overflow %d) but the server permits %d "
"(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
"as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
"Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
"max_connections — and account for every worker process and any other "
"client sharing this server.",
ceiling,
_pool_config.get("pool_size", 0),
_pool_config.get("max_overflow", 0),
available,
max_conn,
reserved,
in_use_note,
)
else:
logger.info(
"DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
ceiling,
available,
max_conn,
)
# B2: Module-level counter exposing the number of rows skipped during the last
# _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
+84 -15
View File
@@ -740,6 +740,51 @@ def register_expected_print(
)
def unregister_expected_print(printer_id: int, filename: str, archive_id: int) -> None:
"""Undo :func:`register_expected_print` when the print never went out.
Registration has to happen *before* the MQTT print command, because the
printer can report the print before the line after the send executes. So
every path that registers and then fails to send — a cancel winning the
#1853 CAS race, a ``start_print()`` that returns False, or any exception in
between — leaves an expectation for a print that will never arrive.
The TTL sweep evicts those after two hours, which is far longer than it
takes a user to react to a failed dispatch by pressing print again: that
reprint would be folded into the *old* archive and take the stale
``ams_mapping`` / ``plate_id`` with it. Hence the explicit inverse.
Mirrors the sweep's rules, including the one that is easy to get wrong:
``_print_ams_mappings`` / ``_print_plate_ids`` are keyed by archive, not by
file, so they may only be dropped once no live key still points at that
archive.
"""
keys = [(printer_id, filename)]
if filename.endswith(".3mf"):
base = filename[:-4]
keys.append((printer_id, base))
keys.append((printer_id, f"{base}.gcode"))
removed = False
for key in keys:
if _expected_prints.pop(key, None) is not None:
removed = True
_expected_print_creators.pop(key, None)
_expected_print_registered_at.pop(key, None)
if archive_id not in set(_expected_prints.values()):
_print_ams_mappings.pop(archive_id, None)
_print_plate_ids.pop(archive_id, None)
if removed:
logging.getLogger(__name__).info(
"Unregistered expected print: printer=%s, file=%s, archive=%s (print was never sent)",
printer_id,
filename,
archive_id,
)
def _compute_run_filament_grams(
status: str,
archive_filament_used_grams: float | None,
@@ -2172,13 +2217,21 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
# Try external camera first
if printer.external_camera_enabled and printer.external_camera_url:
logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
from backend.app.api.routes.camera import live_frame_for_capture
from backend.app.services.external_camera import capture_frame
frame_data = await capture_frame(
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
# An external camera allows one reader, so capturing while a viewer
# is attached fails (#2707). A None here falls through to the paths
# below exactly as a failed capture did.
defer, buffered = live_frame_for_capture(printer_id)
if defer:
frame_data = buffered
else:
frame_data = await capture_frame(
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
if frame_data and len(frame_data) <= 2_500_000:
logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
return _apply_camera_rotation(frame_data, printer, logger)
@@ -4337,13 +4390,21 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
)
if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
from backend.app.api.routes.camera import live_frame_for_capture
from backend.app.services.external_camera import capture_frame
frame_bytes = await capture_frame(
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
# #2707: this used to collide with the live view and fail, which is
# how finish-photo notifications went out with no image attached.
# Leaving frame_bytes None keeps the rest of the fallback chain.
defer, buffered = live_frame_for_capture(printer_id)
if defer:
frame_bytes = buffered
else:
frame_bytes = await capture_frame(
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
if frame_bytes:
logger.info(
"[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
@@ -5307,13 +5368,21 @@ async def on_print_complete(printer_id: int, data: dict):
if not photo_filename:
if printer.external_camera_enabled and printer.external_camera_url:
logger.info("[PHOTO-BG] Using external camera")
from backend.app.api.routes.camera import live_frame_for_capture
from backend.app.services.external_camera import capture_frame
frame_data = await capture_frame(
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
# #2707: the second half of the finish-photo failure — the
# pre-capture and this fallback both collided with the live
# view. None here continues down the fallback chain.
defer, buffered = live_frame_for_capture(printer_id)
if defer:
frame_data = buffered
else:
frame_data = await capture_frame(
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
if frame_data:
photos_dir = archive_dir / "photos"
photos_dir.mkdir(parents=True, exist_ok=True)
+23 -14
View File
@@ -360,28 +360,37 @@ def _validate_icon_url(v: str | None) -> str | None:
def _validate_issuer_url(v: str | None) -> str | None:
"""Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
"""Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
HTTP is no longer accepted — OIDC providers must be reachable over TLS.
Private-network and loopback addresses are rejected to prevent SSRF attacks
where an admin-supplied URL could reach internal services.
An OIDC provider must be reachable over TLS on the public internet, so
this uses the public-internet policy: private, loopback and link-local
addresses are all rejected.
Delegates to the runtime guard ``assert_safe_public_https_url`` for the
same reason ``_validate_icon_url`` does — no policy drift between the
schema layer and the fetcher. The hand-rolled version this replaced
checked only ``is_private | is_loopback | is_link_local``, which left
numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
(``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
able to express a target the policy meant to forbid. The guard's
docstring already claimed the two were consistent; now they are.
Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
schemas avoid top-level imports from that layer.
"""
import ipaddress
from urllib.parse import urlparse
if v is None:
return v
if not v.startswith("https://"):
raise ValueError("issuer_url must start with https://")
host = urlparse(v).hostname or ""
from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
try:
addr = ipaddress.ip_address(host)
if addr.is_private or addr.is_loopback or addr.is_link_local:
raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
assert_safe_public_https_url(v)
except ValueError as exc:
if "issuer_url" in str(exc):
raise
# hostname is a domain name, not a bare IP — that's fine
# The guard's messages say "icon URL" — rewrite for this field so the
# user sees the setting they actually submitted.
detail = str(exc).replace("icon URL", "issuer_url")
raise ValueError(detail) from exc
return v
+5
View File
@@ -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
+56 -1
View File
@@ -1,9 +1,23 @@
import json
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from backend.app.schemas.print_queue import TriState
# Outbound service URLs validated on save, so a bad value is rejected at
# configuration time with a clear message rather than failing opaquely at
# request time. Every one of these services is commonly self-hosted on the same
# host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
# RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
# IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
# ``_url_safety.assert_safe_lan_service_url``.
#
# Module-level rather than a class attribute so the CI backstop in
# tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
# cannot drift from it. Any new outbound-URL setting belongs here (or, if it
# must be reachable on the public internet, on the stricter OIDC guard).
LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
class AppSettings(BaseModel):
"""Application settings schema."""
@@ -600,6 +614,47 @@ class AppSettingsUpdate(BaseModel):
default_sidebar_order: str | None = None
forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
@field_validator(*LAN_SERVICE_URL_SETTINGS)
@classmethod
def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
"""Reject SSRF-unsafe outbound service URLs on save.
Empty (and whitespace-only) is the documented "not configured / fall
back to the env var" value for all four fields and must keep passing.
Values that are not absolute URLs at all ("192.168.1.10:3333",
"localhost:3333") are left alone rather than rejected. Two reasons:
- They are inert. Every consumer of these four settings goes through
httpx, which raises UnsupportedProtocol for a URL with no scheme, so
no request is ever issued and there is nothing to guard against.
- They were storable before this validator existed, and the settings
UI is a plain text input with no scheme enforcement. Newly rejecting
them would break saves that have nothing to do with the URL: the
Obico panel, for one, sends obico_ml_url with every change and
auto-saves, so one legacy value would block toggling detection on or
off. A pre-existing misconfiguration should keep failing where it
already failed (at request time), not spread to unrelated fields.
``urlparse`` is no help in telling the two apart — it reads
"localhost:3333" as scheme "localhost" — so the test is the literal
"://" that makes a string an absolute URL.
"""
if v is None or not v.strip():
return v
candidate = v.strip()
if "://" not in candidate:
return v
# Lazy-imported: schemas avoid top-level imports from api/routes,
# matching the existing pattern in auth.py's _validate_icon_url.
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
except ValueError as exc:
raise ValueError(str(exc)) from exc
return v
@field_validator("gcode_snippets")
@classmethod
def validate_gcode_snippets(cls, v: str | None) -> str | None:
+197 -17
View File
@@ -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)
@@ -721,6 +736,12 @@ class BambuMQTTClient:
# and the FINISH-state fallback don't both fire on the same
# print. Reset to False on every print start.
self._finish_photo_captured: bool = False
# #2702: one-shot re-request of the layer total. Armed at print start
# when the starting frame carried no `total_layer_num`, spent on the
# first layer advance that still has no denominator. Bambu firmware
# only re-sends *changed* fields, so a total we never received (or
# dropped) is only recoverable via a full pushall.
self._total_layers_refresh_armed: bool = False
# #2547 end-of-print telemetry probe state. `_armed` is cleared once the
# window has run for a print so a late FINISH re-send can't reopen it.
self._eop_probe_armed: bool = True
@@ -2978,8 +2999,49 @@ class BambuMQTTClient:
f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
)
self.state.mc_print_sub_stage = new_sub_stage
# Positive `total_layer_num` carried by *this* frame, or 0. Read up
# front because three places below consult it and they run in an order
# that is not the order they read most naturally in: the layer-advance
# refresh (#2702) must not fire on a frame that already answers it, the
# apply step must ignore firmware-reset 0s (#1771), and the new-print
# reset must not discard a total that belongs to the starting print.
total_from_this_frame = 0
if "total_layer_num" in data:
try:
total_from_this_frame = max(int(data["total_layer_num"] or 0), 0)
except (TypeError, ValueError):
# Must not escape. `_on_message` catches only JSONDecodeError
# and paho is left at `suppress_exceptions = False`, so an
# exception raised here is re-raised on the network thread and
# takes the printer connection down over one unusable field.
# Treat it as "not reported": the refresh below then recovers
# the real total from a pushall.
logger.debug(
"[%s] ignoring unusable total_layer_num: %r",
self.serial_number,
data["total_layer_num"],
)
if "layer_num" in data:
new_layer = int(data["layer_num"])
try:
new_layer = int(data["layer_num"])
except (TypeError, ValueError):
# Contained for the same reason as `total_layer_num` above: an
# exception raised here escapes `_update_state` and paho
# re-raises it on the network thread. Losing this frame would
# also lose the print-start and completion detection further
# down, which is worse than losing a layer number.
#
# Held at the last known layer rather than substituted with 0:
# a fabricated 0 reads as the firmware's cancel reset, which
# would move `_last_valid_layer_num` and show layer 0 in the UI
# until the next good frame.
logger.debug(
"[%s] ignoring unusable layer_num: %r",
self.serial_number,
data["layer_num"],
)
new_layer = self.state.layer_num
old_layer = self.state.layer_num
# Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
if old_layer > 0:
@@ -2988,6 +3050,25 @@ class BambuMQTTClient:
# Trigger layer change callback if layer increased
if new_layer > old_layer and self.on_layer_change:
self.on_layer_change(new_layer)
# #2702: the print is demonstrably laying down layers but we still
# have no denominator, so the pushall requested at print start
# either went unanswered or raced the printer learning the total.
# Ask once more — by layer 1 the printer definitely knows it.
# One-shot: an unanswered pushall must not turn into a per-layer
# retry loop for the rest of the print.
if (
new_layer > old_layer
and self._total_layers_refresh_armed
and not self.state.total_layers
and not total_from_this_frame
):
self._total_layers_refresh_armed = False
logger.debug(
"[%s] layer %s with no total_layer_num — re-requesting full status",
self.serial_number,
new_layer,
)
self._request_push_all()
# #1867 last-layer finish-photo trigger. A1 Mini (and other
# firmware variants) skips `stg_cur=22`, so the fallback fires
# at gcode_state=FINISH — which runs AFTER user End G-code
@@ -3017,15 +3098,12 @@ class BambuMQTTClient:
"timelapse_was_active": self._timelapse_during_print,
}
)
if "total_layer_num" in data:
# Some firmware (P1S observed) resets `total_layer_num` to 0 at
# print end — same shape as the `layer_num` reset guarded above.
# Preserve the last known good value so the usage-tracker split
# path (#1771) has a denominator that survives the reset frame.
# Explicit reset to 0 happens on print start (`_handle_print_start`).
new_total = int(data["total_layer_num"])
if new_total > 0:
self.state.total_layers = new_total
if total_from_this_frame:
# Firmware (P1S observed) resets `total_layer_num` to 0 at print
# end — same shape as the `layer_num` reset guarded above. Applying
# only positive values preserves the last known good denominator so
# the usage-tracker split path (#1771) survives the reset frame.
self.state.total_layers = total_from_this_frame
# Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
# Convert to 0-100 percentage for display
@@ -3459,6 +3537,83 @@ class BambuMQTTClient:
f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}"
)
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)
@@ -4084,11 +4239,29 @@ class BambuMQTTClient:
# Reset layer tracking for new print (needed for layer-based timelapse)
self.state.layer_num = 0
# Reset total_layers so the previous print's value can't bleed into
# this print's usage-tracker split before the new push_status arrives
# with the slicer's total (#1771 follow-on to the preservation guard
# above at line ~2135 — the guard now ignores firmware-reset 0s, so
# the explicit reset has to happen here instead).
self.state.total_layers = 0
# this print's usage-tracker split (#1771 follow-on to the
# preservation guard at the `total_layer_num` parse above — that
# guard ignores firmware-reset 0s, so the explicit reset has to
# happen here instead).
#
# #2702: reset to *this frame's* total, not to 0. The frame that
# trips the new-print detection can carry the new print's
# `total_layer_num` as well — the parse above has already applied
# it, and zeroing unconditionally threw it away. That looked
# harmless but is not recoverable: Bambu firmware sends only
# changed fields, so the printer never offers the total again, and
# the print runs to completion at `n/0` in the UI, in
# `{total_layers}` notifications, and as the usage-split
# denominator. The value only reappears on the next full pushall
# (reconnect / Force Refresh), which is why the symptom looked
# random and why a *stable* connection made it worse.
self.state.total_layers = total_from_this_frame
# If the starting frame brought no total, ask for one. Costs one
# MQTT message per print and covers the ordering where the printer
# published the total a frame or two before the state flip.
self._total_layers_refresh_armed = not total_from_this_frame
if self._total_layers_refresh_armed:
self._request_push_all()
# Reset completion tracking for new print
self._was_running = True
self._completion_triggered = False
@@ -5712,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
@@ -5737,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).
+133 -3
View File
@@ -6,6 +6,7 @@ Supports two camera protocols:
"""
import asyncio
import functools
import logging
import os
import shutil
@@ -34,6 +35,26 @@ _rtsp_socket_timeout_flag: str | None = None
# The cleanup task in routes/camera.py checks this set to avoid killing active captures.
_active_capture_pids: set[int] = set()
# In-flight one-shot captures, keyed by printer IP (#2705).
#
# Bambu firmware allows exactly one camera connection, and the existing guards
# (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a
# capturer from competing with the fan-out BROADCASTER. They do nothing for
# capturer-vs-capturer with no viewer attached, where every consumer correctly
# concludes it isn't competing with a viewer and then collides with the others.
# Eight paths reach capture_camera_frame_bytes() independently — Obico polling,
# /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate
# detection, the camera test and the diagnose tool — so the single-flight lives
# at the bottom of the stack and needs no call-site changes.
#
# Keyed by IP rather than printer_id because IP is what the firmware's one-
# connection limit applies to: two printer rows pointing at the same address
# still share one camera. (This function never sees a printer_id anyway.) The
# key deliberately excludes the timeout, or callers that disagree about it —
# and they all do, from 10s to 30s — would never coalesce, which is exactly
# the Obico-vs-snapshot pair from the report.
_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
def get_ffmpeg_path() -> str | None:
"""Find the ffmpeg executable path.
@@ -529,6 +550,38 @@ async def capture_camera_frame(
return False
def capture_in_flight(ip_address: str) -> bool:
"""Return True iff a one-shot capture for this IP is running right now.
For callers that need to know whether they will JOIN someone else's
capture rather than perform their own — currently only the diagnose tool,
which reports on what it measured and so must not present a coalesced
frame as proof that it opened its own connection (see camera_diagnose).
Ordinary consumers should ignore this: they want "a recent frame", and
capture_camera_frame_bytes() already does the right thing for them.
"""
task = _inflight_captures.get(ip_address)
return task is not None and not task.done()
def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None:
"""Done-callback: drop the finished task from the in-flight registry.
Guarded on identity so a slow task that finishes after a newer capture
has registered can't evict its successor.
Also retrieves the exception, if any. The leader normally awaits the task
and would surface it, but a leader whose own caller was cancelled leaves
nobody to collect it — and an unretrieved task exception is logged by
asyncio as a warning with a traceback at an arbitrary later point.
"""
if _inflight_captures.get(ip_address) is task:
del _inflight_captures[ip_address]
if not task.cancelled() and task.exception() is not None:
logger.debug("In-flight camera capture for %s ended in an exception", ip_address)
async def capture_camera_frame_bytes(
ip_address: str,
access_code: str,
@@ -537,18 +590,95 @@ async def capture_camera_frame_bytes(
) -> bytes | None:
"""Capture a single frame and return as JPEG bytes (no disk write).
Uses the same protocol selection as capture_camera_frame but returns
bytes directly instead of writing to disk.
Concurrent callers for the same printer share one capture (#2705): the
first opens the connection, everyone arriving while it is in flight awaits
the same result. Every consumer here wants "a recent frame" rather than
"a frame captured at exactly my timestamp", so handing identical bytes to
simultaneous callers is correct — and it is the only way to honour the
firmware's one-connection limit without serialising captures behind a lock
(which would just turn a collision into a queue).
This coalesces; it does not cache. A call that arrives after the previous
capture finished always captures fresh. Two consumers of these frames —
plate detection and the finish-photo path — decide things about a running
print from them, and a stale frame there is worse than a slow one: the
whole of #1397 was a finish photo taken seconds late showing the bed
already lowered.
Args:
ip_address: Printer IP address
access_code: Printer access code
model: Printer model (X1, H2D, P1, A1, etc.)
timeout: Timeout in seconds for the capture operation
timeout: Timeout in seconds for the capture operation. Applies to this
caller's own wait, including when it joins another caller's
capture — the call sites disagree about the value (10s for plate
detection, 20s for Obico), and a follower must not silently
inherit the leader's deadline in either direction.
Returns:
JPEG bytes if capture was successful, None otherwise
"""
# A follower whose leader fails takes a turn of its own rather than
# inheriting a failure it never had a chance to avoid — by then the leader
# has finished, so there is no socket left to compete with. Bounded at two
# rounds: if the capture we joined AND its replacement both failed, a third
# connection won't help, and this caller has already spent its patience.
for _ in range(2):
leader = _inflight_captures.get(ip_address)
if leader is None or leader.done():
break
try:
frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
except TimeoutError:
# shield() keeps the capture running for whoever else is still
# waiting on it — giving up is this caller's decision alone.
logger.warning(
"Gave up waiting %ss on the in-flight camera capture for %s",
timeout,
ip_address,
)
return None
except asyncio.CancelledError:
# Distinguish "the capture I joined was cancelled" from "I was
# cancelled". Only the former is ours to recover from.
if not leader.cancelled():
raise
logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address)
continue
if frame is not None:
logger.info(
"Reusing in-flight camera capture for %s: %s bytes (no second connection opened)",
ip_address,
len(frame),
)
return frame
logger.info("In-flight camera capture for %s failed; capturing our own", ip_address)
else:
return None
task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout))
_inflight_captures[ip_address] = task
task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address))
# No wait_for here: this caller IS the capture, and the implementation
# already enforces `timeout` internally where it can also kill the ffmpeg
# process. A second deadline on top would abandon the subprocess instead.
# shield() so that a cancelled leader (a client navigating away mid-
# snapshot is routine) doesn't take the capture down with it — the
# followers already waiting on it still get their frame.
return await asyncio.shield(task)
async def _capture_camera_frame_bytes_uncoalesced(
ip_address: str,
access_code: str,
model: str | None,
timeout: int = 15,
) -> bytes | None:
"""Open a connection and capture one frame. See capture_camera_frame_bytes.
Callers want that wrapper, not this: it opens a socket unconditionally,
which is the collision #2705 is about.
"""
# Chamber image models: A1/P1 - returns bytes directly
if is_chamber_image_model(model):
logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
+25 -2
View File
@@ -35,6 +35,13 @@ out broadcaster to prevent). When ``is_stream_active`` reports True
AND a buffered frame is fresh (last 10 s), we short-circuit the test
with ``live_stream_active`` and report success — the user is
literally watching the camera right now, no test needed.
The related case is another one-shot capture (Obico polling, the cam
wall) being in flight when the user hits Diagnose. There the capture
layer coalesces for us (#2705) and no competing socket is opened, but
the frame we get back was someone else's — so ``first_frame`` still
passes and carries a ``coalesced_capture`` code, because a diagnostic
that reports a connection it didn't open is worse than a slow one.
"""
from __future__ import annotations
@@ -46,6 +53,7 @@ from dataclasses import dataclass, field
from backend.app.services.camera import (
capture_camera_frame_bytes,
capture_in_flight,
get_camera_port,
is_chamber_image_model,
)
@@ -69,8 +77,10 @@ class CameraDiagnoseStage:
name: str # "tcp_reachable" | "first_frame" | "live_stream_active"
status: str # "ok" | "failed" | "skipped"
duration_ms: int = 0
# Optional machine-readable code for failures so the frontend can
# render a stage-specific hint without parsing free-text errors.
# Optional machine-readable code so the frontend can render a stage-
# specific hint without parsing free-text errors. Usually a failure
# reason; "coalesced_capture" qualifies a PASS whose frame came from a
# capture already in flight, so duration_ms isn't a connection time.
code: str | None = None
@@ -166,6 +176,15 @@ async def _check_first_frame(
"""Stage 2 — capture one frame end-to-end. Combines auth + protocol
handshake + first keyframe; either it works or it doesn't."""
started = time.monotonic()
# A capture already running for this printer (an Obico poll, the cam wall)
# means capture_camera_frame_bytes will hand us THAT capture's frame rather
# than opening its own connection (#2705). Good for the printer, but this
# stage exists to report what it measured: the frame would be real evidence
# the camera works, while duration_ms would be mostly time spent queueing,
# and a pass would be claimed for a connection we never opened. So the
# stage says so, the same way the live-stream shortcut above declares
# itself instead of quietly passing.
coalesced = capture_in_flight(ip_address)
try:
jpeg = await capture_camera_frame_bytes(
ip_address=ip_address,
@@ -190,7 +209,11 @@ async def _check_first_frame(
name="first_frame",
status="ok",
duration_ms=int((time.monotonic() - started) * 1000),
code="coalesced_capture" if coalesced else None,
)
# No annotation on the failure path: a follower whose leader fails goes on
# to capture on its own, so a None here means this stage did get its own
# attempt (or watched two consecutive captures fail — same verdict).
return CameraDiagnoseStage(
name="first_frame",
status="failed",
+24 -4
View File
@@ -607,6 +607,7 @@ async def generate_mjpeg_stream(
fps: int = 10,
*,
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
on_frame: Callable[[bytes], None] | None = None,
stop_event: asyncio.Event | None = None,
) -> AsyncGenerator[bytes, None]:
"""Generator yielding MJPEG frames for streaming.
@@ -622,6 +623,16 @@ async def generate_mjpeg_stream(
open (#2675). Without it the process is reachable only from this
generator's own ``finally``, which an abrupt client disconnect can
skip (same cancellation-timing class as #776).
on_frame: Called with each RAW frame, before it is wrapped for the wire,
so the route layer can publish it as the printer's buffered frame
(#2707). It has to be a callback: what this generator yields is
multipart-wrapped, so a consumer of the stream cannot recover the
JPEG, and until now nothing populated the buffer for external
cameras at all — leaving every one-shot consumer (layer timelapse,
finish photo, Obico, plate check) with nothing to reuse and no
option but to open a competing handle on a single-reader device.
Exceptions are logged and swallowed: buffering must never be able
to break the live stream.
stop_event: When set, the reconnect loops stop retrying — so an explicit
stop (which kills the current ffmpeg) doesn't immediately respawn a
new process and reacquire the device.
@@ -632,6 +643,15 @@ async def generate_mjpeg_stream(
frame_interval = 1.0 / max(fps, 1)
last_frame_time = 0.0
def _publish(frame: bytes) -> bytes:
"""Hand the raw frame to on_frame, then format it for the wire."""
if on_frame is not None:
try:
on_frame(frame)
except Exception:
logger.exception("on_frame callback raised")
return _format_mjpeg_frame(frame)
if camera_type == "mjpeg":
# Proxy MJPEG stream directly, with reconnect on timeout
max_retries = 3
@@ -642,7 +662,7 @@ async def generate_mjpeg_stream(
current_time = asyncio.get_event_loop().time()
if current_time - last_frame_time >= frame_interval:
last_frame_time = current_time
yield _format_mjpeg_frame(frame)
yield _publish(frame)
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
break
logger.warning(
@@ -659,7 +679,7 @@ async def generate_mjpeg_stream(
frame_yielded = False
async for frame in _stream_rtsp(url, fps, on_process=on_process):
frame_yielded = True
yield _format_mjpeg_frame(frame)
yield _publish(frame)
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
break
logger.warning(
@@ -672,7 +692,7 @@ async def generate_mjpeg_stream(
elif camera_type == "usb":
# Use ffmpeg to stream from USB camera
async for frame in _stream_usb(url, fps, on_process=on_process):
yield _format_mjpeg_frame(frame)
yield _publish(frame)
elif camera_type == "snapshot":
# Poll snapshot URL at interval
@@ -680,7 +700,7 @@ async def generate_mjpeg_stream(
try:
frame = await _capture_snapshot(url, timeout=10)
if frame:
yield _format_mjpeg_frame(frame)
yield _publish(frame)
await asyncio.sleep(frame_interval)
except asyncio.CancelledError:
break
+30 -7
View File
@@ -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:
# 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
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."):
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.
+20 -1
View File
@@ -67,7 +67,26 @@ class TimelapseSession:
self.last_layer = layer_num
try:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
# Reuse the live view's frame instead of opening a second handle on
# a single-reader device (#2707). Unguarded, a print watched from
# start to finish recorded zero successful layer captures, and the
# stitched video came out empty or badly truncated.
from backend.app.api.routes.camera import live_frame_for_capture
defer, buffered = live_frame_for_capture(self.printer_id)
if defer:
if not buffered:
# Viewer attached but nothing buffered yet: skip this layer
# rather than compete and kick them off (#1348).
logger.debug(
"Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
layer_num,
self.printer_id,
)
return False
frame_data = buffered
else:
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
if frame_data:
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
await asyncio.to_thread(frame_path.write_bytes, frame_data)
+2
View File
@@ -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
+94 -7
View File
@@ -64,6 +64,55 @@ def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
"""Validate a provider URL taken from user-supplied config.
Returns an error message on rejection, or None when the URL is
acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
than raising, so a message is more useful here than an exception.
Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
receiver on the home LAN is normal and must keep working, so loopback and
RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
non-HTTP schemes are rejected.
"""
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
try:
assert_safe_lan_service_url(url, label=label)
except ValueError as exc:
return str(exc)
return None
def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
"""Failure message for a provider whose destination host the user supplies.
The response body is deliberately **not** returned to the caller. Provider
URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
the default Operators group carries and which does not imply
``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
straight from the request body without persisting anything. Echoing the
response body there turned an intended "does my webhook work?" check into
an authenticated read primitive against any host the Bambuddy process can
reach, including services that are not exposed to the network at all.
Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
keep returning the upstream body — there is no trust boundary to cross
when the destination cannot be influenced.
The body is logged at debug level, where it stays available to whoever
already administers the host without being handed back over the API.
"""
logger.debug(
"%s delivery failed with HTTP %s; body: %s",
label,
response.status_code,
(response.text or "")[:200],
)
return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
class NotificationService:
"""Service for sending notifications through various providers."""
@@ -265,6 +314,10 @@ class NotificationService:
if not device_key:
return False, "Device key is required"
url_error = _assert_safe_provider_url(server, label="Bark server URL")
if url_error:
return False, url_error
payload: dict[str, Any] = {
"device_key": device_key,
"title": title,
@@ -291,9 +344,13 @@ class NotificationService:
except ValueError:
body = None
if isinstance(body, dict) and body.get("code") not in (200, None):
return False, f"Bark error {body.get('code')}: {str(body.get('message'))[:200]}"
# Only the numeric code is echoed. A server chosen by the caller
# controls this body too, so the free-text message is a (narrow)
# read channel of the same kind _opaque_http_failure closes.
logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
return True, "Message sent successfully"
return False, f"HTTP {response.status_code}: {response.text[:200]}"
return False, _opaque_http_failure(response, label="Bark server")
async def _send_ntfy(
self,
@@ -311,6 +368,10 @@ class NotificationService:
if not topic:
return False, "Topic is required"
url_error = _assert_safe_provider_url(server, label="ntfy server URL")
if url_error:
return False, url_error
url = f"{server}/{topic}"
# ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
# for str header values, but printer names and filenames can contain
@@ -363,7 +424,7 @@ class NotificationService:
"Fight Mode, or front the server with Cloudflare Access using a "
"service token. (#1534)"
)
return False, f"HTTP {response.status_code}: {response.text[:200]}"
return False, _opaque_http_failure(response, label="ntfy server")
async def _send_pushover(
self, config: dict, title: str, message: str, image_data: bytes | None = None
@@ -438,6 +499,19 @@ class NotificationService:
if not bot_token or not chat_id:
return False, "Bot token and chat ID are required"
# Optional forum topic (#1518). Telegram expects message_thread_id as an
# integer in the JSON sendMessage body — a string 400s there even though
# the multipart sendPhoto call below would happily accept one. Coerce it
# once, up front, so both call sites agree and a bad value fails loudly
# instead of silently breaking only the text notifications.
thread_id_raw = str(config.get("message_thread_id") or "").strip()
message_thread_id: int | None = None
if thread_id_raw:
try:
message_thread_id = int(thread_id_raw)
except ValueError:
return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
# Escape underscores in the message body so Telegram Markdown
# parsing doesn't break on job names like "A1_plate_8" or error
# codes like "0300_0001". The title is already wrapped in *bold*
@@ -452,18 +526,23 @@ class NotificationService:
if image_data:
# Use sendPhoto to attach the thumbnail with the caption
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
if message_thread_id is not None:
form["message_thread_id"] = message_thread_id
response = await client.post(
url,
data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
data=form,
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
)
else:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data = {
data: dict[str, Any] = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
if message_thread_id is not None:
data["message_thread_id"] = message_thread_id
response = await client.post(url, json=data)
if response.status_code == 200:
@@ -665,6 +744,10 @@ class NotificationService:
if not webhook_url:
return False, "Webhook URL is required"
url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
if url_error:
return False, url_error
# Build payload based on format
if payload_format == "slack":
# Slack/Mattermost format - just text field
@@ -710,7 +793,7 @@ class NotificationService:
if response.status_code in (200, 201, 202, 204):
return True, "Webhook delivered successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
return False, _opaque_http_failure(response, label="webhook endpoint")
except Exception as e:
return False, f"Webhook error: {str(e)}"
@@ -811,7 +894,11 @@ class NotificationService:
elif response.status_code == 401:
return False, "Home Assistant authentication failed - check your token"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
# ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
# this is a narrower channel than the per-request provider URLs — but
# it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
# gets the same treatment.
return False, _opaque_http_failure(response, label="Home Assistant endpoint")
async def _send_to_provider(
self,
+32 -1
View File
@@ -193,6 +193,21 @@ class ObicoDetectionService:
return None
if printer.external_camera_enabled and printer.external_camera_url:
# Same rule as the built-in branch below, which this used to skip:
# an external camera is single-reader too, so polling while a viewer
# is attached just fails (#2707).
from backend.app.api.routes.camera import live_frame_for_capture
defer, buffered = live_frame_for_capture(printer_id)
if defer:
if buffered:
return buffered
logger.info(
"Obico: viewer attached for printer %s but buffer empty; "
"skipping this poll to avoid competing camera handle (#2707)",
printer_id,
)
return None
return await capture_external_frame(
printer.external_camera_url,
printer.external_camera_type,
@@ -350,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:
+20 -8
View File
@@ -604,16 +604,28 @@ async def capture_camera_image(
# Try external camera first if requested and available
if use_external and external_camera_url and external_camera_type:
try:
from backend.app.api.routes.camera import live_frame_for_capture
from backend.app.services.external_camera import capture_frame
image_data = await capture_frame(
external_camera_url,
external_camera_type,
snapshot_url=external_camera_snapshot_url,
)
if image_data:
camera_source = "external"
logger.debug("Captured frame from external camera for printer %s", printer_id)
# What this function's docstring already promised, but only the
# built-in fallback below delivered: an external camera is
# single-reader too, so capturing while a viewer watches fails
# (#2707).
defer, buffered = live_frame_for_capture(printer_id)
if defer:
if buffered:
image_data = buffered
camera_source = "external (buffered)"
logger.debug("Using buffered external frame for printer %s", printer_id)
else:
image_data = await capture_frame(
external_camera_url,
external_camera_type,
snapshot_url=external_camera_snapshot_url,
)
if image_data:
camera_source = "external"
logger.debug("Captured frame from external camera for printer %s", printer_id)
except Exception as e:
logger.warning("Failed to capture from external camera: %s", e)
+116 -16
View File
@@ -280,17 +280,29 @@ class PrintScheduler:
# event-loop thread, so this dict needs no lock.
# item_id -> (task, printer_id)
self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
# Expected prints registered by `_start_print` that have not yet had a
# print command sent. Populated at registration, dropped once
# `start_print()` succeeds, and rolled back by `_dispatch_one` on every
# other exit. Same threading argument as `_inflight` above: one
# sequential caller, callbacks on the same loop, so no lock.
# item_id -> (printer_id, remote_filename, archive_id)
self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
async def run(self):
"""Main loop - check queue every interval."""
self._running = True
logger.info("Print scheduler started")
await self._clear_stale_dispatch_claims()
await self._clear_stale_dispatch_claims(at_startup=True)
while self._running:
dispatched = False
try:
# No-op while any upload is in flight; on a quiet tick it releases
# a claim whose best-effort clear failed (e.g. the database was
# briefly unreachable), instead of leaving the row wedged until
# the next restart.
await self._clear_stale_dispatch_claims()
dispatched = await self.check_queue()
except Exception as e:
logger.error("Scheduler error: %s", e)
@@ -299,14 +311,29 @@ class PrintScheduler:
# not stall behind the idle interval; otherwise sleep normally (#2555).
await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
async def _clear_stale_dispatch_claims(self) -> None:
"""Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
async def _clear_stale_dispatch_claims(self, *, at_startup: bool = False) -> None:
"""Clear dispatch claims with no live dispatch coroutine behind them (#2615).
A claim is only ever held by a live dispatch coroutine, and no coroutine
survives a process restart — so every ``dispatching_at`` present at startup
is stale. Clearing them lets those still-pending rows be re-selected for a
fresh, consistent dispatch instead of being wedged out of the selection
query forever. Called once at the top of ``run()``."""
A claim is only ever held by a live dispatch coroutine, so when this
process has nothing in ``_inflight`` every ``dispatching_at`` in the table
is stale. At startup that is trivially true — no coroutine survives a
restart. It is equally true on any later tick where no upload is running,
which is what makes this safe to repeat rather than only run once.
Repeating it matters because ``_clear_dispatch_claim`` is best-effort: if
the database is briefly unreachable at exactly the moment dispatch ends,
the claim survives and the row is wedged out of the selection query. That
used to last until the next restart (#2702 follow-up, seen when
PostgreSQL refused a connection mid-dispatch).
``_inflight`` is populated when the task is spawned, before the coroutine
claims its row, and pruned by a done-callback that cannot run before the
coroutine's own ``finally`` — so "claim present, nothing in flight" has no
race window and needs no age threshold. A size-derived upload deadline
(``max(600s, size/25KB/s)``) has no safe fixed bound anyway.
"""
if self._inflight:
return
try:
async with async_session() as db:
res = await db.execute(
@@ -314,9 +341,13 @@ class PrintScheduler:
)
await db.commit()
if res.rowcount:
logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
logger.info(
"Cleared %d orphaned dispatch claim(s)%s (#2615)",
res.rowcount,
" at startup" if at_startup else "",
)
except Exception as exc:
logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
logger.error("Failed to clear orphaned dispatch claims: %s", exc)
def stop(self):
"""Stop the scheduler."""
@@ -929,6 +960,14 @@ class PrintScheduler:
return
await self._start_print(item_db, item)
finally:
# Undo an expected-print registration whose print command never
# went out. One choke point covers every way `_start_print` can
# end without sending: a raised exception (a DB failure mid-
# dispatch is the reported case), an early return, a cancel
# winning the #1853 CAS, or `start_print()` returning False.
# A confirmed send removes the entry itself, so this is a no-op
# on the happy path.
self._rollback_unconfirmed_expected_print(item_id)
# Release the claim on every exit. Once dispatch has finished the
# row's status carries the lock (printing/failed/cancelled are all
# != pending), so the token is only needed for the duration of the
@@ -936,6 +975,30 @@ class PrintScheduler:
# dispatchable again on the next tick.
await self._clear_dispatch_claim(item_db, item_id)
def _rollback_unconfirmed_expected_print(self, item_id: int) -> None:
"""Drop an expectation for a print command that was never sent.
Best-effort and never raises: this runs in the ``finally`` of dispatch,
where the interesting exception is usually the one already propagating.
"""
pending = self._unconfirmed_expected_print.pop(item_id, None)
if pending is None:
return
printer_id, remote_filename, archive_id = pending
try:
from backend.app.main import unregister_expected_print
unregister_expected_print(printer_id, remote_filename, archive_id)
except Exception:
logger.warning(
"Queue item %s: failed to unregister expected print (printer=%s, file=%s, archive=%s)",
item_id,
printer_id,
remote_filename,
archive_id,
exc_info=True,
)
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
@@ -954,12 +1017,39 @@ class PrintScheduler:
async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
"""Clear the dispatch claim (#2615). Best-effort: a failure here must not
mask the dispatch outcome, and startup reconciliation clears any leftover."""
try:
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
await db.commit()
except Exception as exc:
logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
mask the dispatch outcome.
Retried, because the failure mode in practice is transient and narrow: a
database that is momentarily unreachable — PostgreSQL out of connection
slots is the observed case — refuses this write for a second or two while
the dispatch that just ended is still holding the row out of the selection
query. One attempt was enough to wedge the item; a couple of spaced
attempts clear it. Each attempt rolls back first, since a failed write
leaves the session needing it before it can be reused.
If every attempt fails, ``_clear_stale_dispatch_claims`` picks the row up
on the next quiet tick.
"""
for attempt in range(1, 4):
try:
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
await db.commit()
return
except Exception as exc:
try:
await db.rollback()
except Exception:
pass
if attempt == 3:
logger.warning(
"Queue item %s: failed to clear dispatch claim after %d attempts: %s "
"— a later quiet tick will release it",
item_id,
attempt,
exc,
)
return
await asyncio.sleep(0.5 * attempt)
async def _find_idle_printer_for_model(
self,
@@ -3432,6 +3522,12 @@ class PrintScheduler:
created_by_id=item.created_by_id,
plate_id=item.plate_id,
)
# Registration happens before the print command by necessity (the
# printer can report the print before the send returns), so record
# what to undo if we never get as far as sending. `_dispatch_one`
# rolls back anything still pending here on every exit — exception,
# early return, or cancel winning the CAS below.
self._unconfirmed_expected_print[item.id] = (item.printer_id, remote_filename, archive.id)
# Propagate the queue item's owner into printer_manager so the
# print-complete callback can credit the user in the PrintLogEntry
@@ -3556,6 +3652,10 @@ class PrintScheduler:
)
if started:
# The command is away, so the expectation is now legitimate and must
# survive. Anything still in this dict when _dispatch_one exits gets
# rolled back.
self._unconfirmed_expected_print.pop(item.id, None)
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
# No dispatch-toast event here: the legacy bg-dispatch path kept
# status='processing' from upload start until the printer acked
+2
View File
@@ -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
View File
@@ -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)
+15 -2
View File
@@ -264,14 +264,27 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
doesn't even use.
The substitution is a no-op when:
- ``plate_id`` is None (we can't determine which slots are unused),
- ``plate_id`` is not a real plate — ``None`` (caller couldn't say) or
``0`` (the slice-all sentinel, where every slot is used by *some*
plate so there is nothing unused to substitute). Callers that know
"absent means plate 1" must resolve that themselves before calling;
this function will not guess, because guessing wrong rewrites a
filament the plate actually prints with.
- the source isn't a valid 3MF / zip,
- the source doesn't carry plate-extruder metadata (parse returns
empty set — treat as "every slot is used", same fallback the
SliceModal uses),
- ``items`` has fewer than 2 entries (nothing to substitute).
The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
1-indexed, so the geometry lookup for plate 0 matches nothing — but
the support-filament slots unioned in below come from the project
config and carry no plate scope at all. Without the guard a slice-all
of a project with a dedicated support slot would see ``used`` as just
that one slot, anchor on it, and rewrite every colour in the project
to the support material (#2711).
"""
if plate_id is None or len(items) < 2:
if plate_id is None or plate_id < 1 or len(items) < 2:
return items
# Local import keeps the bytes->ZipFile boundary in this module and
# avoids dragging zipfile into every caller.
+22 -2
View File
@@ -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
View File
@@ -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:
+45
View File
@@ -907,6 +907,51 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
return out
def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
"""Widen a used-only filament list to one entry per project slot.
``used`` is the slice_info-derived list: only the slots whose G-code
actually consumed filament, each carrying real usage figures. That is the
right answer for print-time AMS matching, and the wrong one for the slice
modal, because the list the modal builds is **positional** — index 0 is
slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
A source whose only used slot is 4 therefore produced a single dropdown
whose pick the CLI bound to slot 1, leaving slot 4 — the one the model
prints with — on whatever the source had baked in (#2712).
Returns the project's slots in slot order, each flagged ``used_in_plate``.
Rows present in ``used`` are kept whole, so their usage figures, resolved
type/colour and ``tray_info_idx`` survive; the rest come from the project
configuration with zero usage. A used slot beyond the project's slot count
is appended rather than dropped — the caller asked for a superset, and
silently losing the one slot that prints would be the original bug again.
``used`` is returned unchanged when the file carries no project settings
to widen against: a narrower-than-ideal list still prints correctly, an
invented one might not.
"""
project = extract_project_filaments_from_3mf(zf)
if not project:
return used
by_slot = {f["slot_id"]: f for f in used}
out: list[dict] = []
for slot in project:
known = by_slot.pop(slot["slot_id"], None)
if known is not None:
known["used_in_plate"] = True
out.append(known)
else:
slot["used_in_plate"] = False
out.append(slot)
# Anything slice_info reported that the project doesn't declare.
for leftover in by_slot.values():
leftover["used_in_plate"] = True
out.append(leftover)
out.sort(key=lambda f: f["slot_id"])
return out
def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
"""Slots referenced by the process settings for support material.
@@ -93,15 +93,24 @@ async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0)
@pytest.fixture
async def slice_test_setup(db_session, tmp_path):
"""Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
async def slice_test_setup(db_session, tmp_path, monkeypatch):
"""Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer.
``base_dir`` is patched via ``monkeypatch`` rather than assigned and
restored by hand. ``app_settings`` is a process-wide singleton, and the
hand-rolled version only restored after the ``yield`` — so anything raising
during setup (a commit, a refresh) left ``base_dir`` pointing at a
``tmp_path`` that pytest then deleted, and every later test in that xdist
worker which reads it failed. That was the cause of intermittent failures
in ``TestLibraryPathHelpers`` and ``TestArchivePlatesDesignOverrides``,
which share nothing with this module but land in the same worker.
"""
storage_dir = tmp_path / "library" / "files"
storage_dir.mkdir(parents=True, exist_ok=True)
src_path = storage_dir / "Cube.stl"
src_path.write_bytes(b"solid Cube\nendsolid\n")
original_base_dir = app_settings.base_dir
app_settings.base_dir = tmp_path
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
src_file = LibraryFile(
filename="Cube.stl",
@@ -137,7 +146,6 @@ async def slice_test_setup(db_session, tmp_path):
"tmp_path": tmp_path,
}
app_settings.base_dir = original_base_dir
slicer_api_module.set_shared_http_client(None)
@@ -1618,3 +1626,352 @@ class TestNozzleClassGuard:
if resp.status_code == 400:
detail = resp.json().get("detail", "")
assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
class TestUnusedSlotSubstitutionOnSinglePlateSource:
"""#2711: a single-plate 3MF must still get its unused slots substituted.
The SliceModal omits ``plate`` entirely for single-plate and STL sources —
it skips the plate picker, so ``selectedPlate`` stays null and the field
never reaches the body. The schema documents an absent plate as "plate 1",
but the substitution used to read it as "unknown plate" and skip, so every
single-plate project reached the CLI with the dropdown values of slots the
plate never paints with.
In the reported case that was a MakerWorld project declaring four filaments
while plate 1 paints with one, the other three carrying presets baked into
the source for a different printer. The CLI rejected the whole slice with
"filament preset ... (slot 1) is not compatible with printer ...", and the
modal disables unused rows so there was no way to correct it by hand.
"""
@staticmethod
def _single_plate_using_only_slot_3() -> bytes:
"""One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
declared by the project but unused. Mirrors the reported file."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
)
zf.writestr(
"Metadata/model_settings.config",
"<?xml version='1.0'?>\n<config>"
'<object id="1"><metadata key="extruder" value="3"/></object>'
'<plate><metadata key="plater_id" value="1"/>'
'<model_instance><metadata key="object_id" value="1"/>'
'<metadata key="instance_id" value="0"/></model_instance>'
"</plate></config>",
)
return buf.getvalue()
@staticmethod
def _filament_names_sent(body: bytes) -> list[str]:
"""Pull the ``name`` of each ``filamentProfile`` part, in slot order.
``slice_model`` sends one repeated ``filamentProfile`` part per slot as
``filament_N.json``; the parts stay in submission order, so a plain
scan preserves the slot mapping.
"""
names: list[str] = []
marker = b'name="filamentProfile"; filename="filament_'
pos = body.find(marker)
while pos != -1:
start = body.find(b"{", pos)
end = body.find(b"\r\n", start)
names.append(json.loads(body[start:end].decode("utf-8"))["name"])
pos = body.find(marker, end)
return names
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unused_slots_are_substituted_when_the_body_omits_plate(
self, async_client: AsyncClient, db_session, slice_test_setup
):
tmp_path = slice_test_setup["tmp_path"]
src = tmp_path / "library" / "files" / "train.3mf"
src.write_bytes(self._single_plate_using_only_slot_3())
threemf = LibraryFile(
filename="train.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(threemf)
# Four distinguishable filament presets, one per project slot. Only
# slot 3's is compatible with the target in the reported scenario.
slots = []
for i in range(1, 5):
p = LocalPreset(
name=f"slot{i}",
preset_type="filament",
source="orcaslicer",
setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
)
db_session.add(p)
slots.append(p)
await db_session.commit()
await db_session.refresh(threemf)
for p in slots:
await db_session.refresh(p)
captured: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(self._filament_names_sent(request.content))
return httpx.Response(
status_code=200,
content=_make_3mf_with_settings(),
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
"x-filament-used-mm": "100",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{threemf.id}/slice",
json={
"printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
"filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
# No "plate" — exactly what the modal sends for a single-plate
# source. This is the whole point of the test.
},
)
assert response.status_code == 202, response.text
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
assert captured, "sidecar was never called"
# Every slot carries slot 3's profile: the array length stays intact
# (the source's per-slot references depend on it) while nothing the
# plate doesn't print with can fail the CLI's validators.
assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
"""``plate=0`` is the all-plates sentinel, so nothing is unused.
It reaches the same call site, and plate ids are 1-indexed — the
geometry lookup for plate 0 matches nothing. Without an explicit
exclusion the project's support-filament slot would be the only
member of the used set and would be copied over every colour.
"""
tmp_path = slice_test_setup["tmp_path"]
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps(
{
"enable_support": "1",
"support_filament": "4",
"support_interface_filament": "4",
"filament_type": ["PLA", "PLA", "PLA", "PVA"],
}
),
)
zf.writestr(
"Metadata/model_settings.config",
"<?xml version='1.0'?>\n<config>"
'<object id="1"><metadata key="extruder" value="1"/></object>'
'<object id="2"><metadata key="extruder" value="2"/></object>'
'<plate><metadata key="plater_id" value="1"/>'
'<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
'<plate><metadata key="plater_id" value="2"/>'
'<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
"</config>",
)
src = tmp_path / "library" / "files" / "multi.3mf"
src.write_bytes(buf.getvalue())
threemf = LibraryFile(
filename="multi.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(threemf)
slots = []
for i in range(1, 5):
p = LocalPreset(
name=f"slot{i}",
preset_type="filament",
source="orcaslicer",
setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
)
db_session.add(p)
slots.append(p)
await db_session.commit()
await db_session.refresh(threemf)
for p in slots:
await db_session.refresh(p)
captured: list[list[str]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(self._filament_names_sent(request.content))
return httpx.Response(
status_code=200,
content=_make_3mf_with_settings(),
headers={
"x-print-time-seconds": "100",
"x-filament-used-g": "1.0",
"x-filament-used-mm": "100",
},
)
_install_mock_sidecar(handler)
response = await async_client.post(
f"/api/v1/library/files/{threemf.id}/slice",
json={
"printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
"filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
"plate": 0,
},
)
assert response.status_code == 202, response.text
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
assert captured, "sidecar was never called"
assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]
class TestFilamentRequirementsFullSlots:
"""#2712: what the slice modal is handed must be positional.
The modal's filament list maps index 0 to slot 1, and the backend forwards
it in that order as ``filament_1.json``..``filament_N.json``. A MakerWorld
source that ships slice_info and paints with slot 4 alone therefore has to
present four rows — a one-row list binds the user's pick to slot 1, and
slot 4 slices with whatever the source had baked in. Picking PETG produced
a PLA print, and the print dialog then correctly refused to match PETG.
Print-time AMS matching shares this endpoint and needs the opposite: only
the slots the plate consumes, so it doesn't demand spools for slots the
G-code never touches. Hence the opt-in flag rather than a shape change.
"""
@staticmethod
def _sliced_source_using_only_slot_4() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps(
{
"filament_type": ["PLA", "PLA", "PLA", "PLA"],
"filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
}
),
)
# MakerWorld ships slice_info without plate G-code, which is what
# sends this file down the "already sliced" branch.
zf.writestr(
"Metadata/slice_info.config",
"<?xml version='1.0'?>\n<config><plate>"
"<metadata key='index' value='1'/>"
"<filament id='4' tray_info_idx='GFL99' type='PLA' color='#898989'"
" used_m='35.51' used_g='105.92'/>"
"</plate></config>",
)
return buf.getvalue()
async def _make_file(self, db_session, tmp_path) -> int:
src = tmp_path / "library" / "files" / "tunnel.3mf"
src.write_bytes(self._sliced_source_using_only_slot_4())
lib = LibraryFile(
filename="tunnel.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(lib)
await db_session.commit()
await db_session.refresh(lib)
return lib.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_full_slots_returns_one_row_per_project_slot(
self, async_client: AsyncClient, db_session, slice_test_setup
):
file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1&full_slots=true")
assert r.status_code == 200, r.text
filaments = r.json()["filaments"]
assert [f["slot_id"] for f in filaments] == [1, 2, 3, 4]
# Only slot 4 prints, so only its row is selectable in the modal.
assert [f["used_in_plate"] for f in filaments] == [False, False, False, True]
# The used row keeps what the slice actually reported.
assert filaments[3]["used_grams"] == 105.9
assert filaments[3]["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_print_path_still_gets_only_the_used_slot(
self, async_client: AsyncClient, db_session, slice_test_setup
):
"""Without the flag the response must be byte-for-byte what it was.
PrintModal drives AMS matching off this; widening it would ask the
user to load three spools the print never touches.
"""
file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1")
assert r.status_code == 200, r.text
filaments = r.json()["filaments"]
assert [f["slot_id"] for f in filaments] == [4]
assert filaments[0]["used_in_plate"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unsliced_sources_are_unaffected_by_the_flag(
self, async_client: AsyncClient, db_session, slice_test_setup
):
"""Those already returned the full project list; the flag must not
double-handle them or change what the modal has been getting."""
tmp_path = slice_test_setup["tmp_path"]
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
zf.writestr(
"Metadata/project_settings.config",
json.dumps({"filament_type": ["PLA", "PETG"], "filament_colour": ["#000000", "#FFFFFF"]}),
)
src = tmp_path / "library" / "files" / "raw.3mf"
src.write_bytes(buf.getvalue())
lib = LibraryFile(
filename="raw.3mf",
file_path=str(src.relative_to(tmp_path)),
file_type="3mf",
file_size=src.stat().st_size,
)
db_session.add(lib)
await db_session.commit()
await db_session.refresh(lib)
with_flag = await async_client.get(
f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1&full_slots=true"
)
without = await async_client.get(f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1")
assert with_flag.status_code == 200 and without.status_code == 200
assert with_flag.json()["filaments"] == without.json()["filaments"]
assert [f["slot_id"] for f in with_flag.json()["filaments"]] == [1, 2]
+72 -3
View File
@@ -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):
@@ -0,0 +1,288 @@
"""Single-flight coalescing of one-shot camera captures (#2705).
Bambu firmware allows exactly one camera connection. The pre-existing guards
(``is_stream_active`` / ``try_get_active_buffered_frame``, #1271 + #1348) only
keep a one-shot capturer from competing with the fan-out broadcaster; nothing
kept the capturers from competing with EACH OTHER when no viewer was attached,
so an Obico poll and a ``/camera/snapshot`` 200 ms apart each opened their own
RTSP socket and knocked the other over.
These tests drive ``capture_camera_frame_bytes`` at the public boundary and
count how many times the underlying capture ran, since "how many connections
did we open" is the entire point of the fix.
"""
import asyncio
import pytest
from backend.app.services import camera as camera_module
from backend.app.services.camera import capture_camera_frame_bytes, capture_in_flight
FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
@pytest.fixture(autouse=True)
def _clear_inflight():
"""The registry is module-global; don't leak tasks between tests."""
camera_module._inflight_captures.clear()
yield
camera_module._inflight_captures.clear()
class RecordingCapture:
"""Stand-in for the real capture, recording each call.
``gate`` (when set) holds every capture open until released, which is how
these tests create the overlap window that used to produce two sockets.
"""
def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None):
self.calls: list[tuple[str, int]] = []
self._frames = list(frames)
self._gate = gate
self.started = asyncio.Event()
async def __call__(self, ip_address, access_code, model, timeout=15):
self.calls.append((ip_address, timeout))
self.started.set()
if self._gate is not None:
await self._gate.wait()
return self._frames.pop(0) if self._frames else None
@property
def count(self) -> int:
return len(self.calls)
@pytest.fixture
def patch_capture(monkeypatch):
def _install(capture):
monkeypatch.setattr(camera_module, "_capture_camera_frame_bytes_uncoalesced", capture)
return capture
return _install
async def _let_leader_start(capture: RecordingCapture) -> None:
"""Wait until the leader is inside the capture, so the next caller joins it.
Without this the second caller can reach the registry before the first has
even been scheduled, which tests a different (and uninteresting) race.
"""
await asyncio.wait_for(capture.started.wait(), timeout=1)
@pytest.mark.asyncio
async def test_simultaneous_callers_share_one_capture(patch_capture):
"""The reported collision: two consumers, one connection, two frames."""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20))
await _let_leader_start(capture)
follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=15))
await asyncio.sleep(0)
gate.set()
assert await leader == FRAME_A
assert await follower == FRAME_A
assert capture.count == 1
@pytest.mark.asyncio
async def test_five_callers_one_capture(patch_capture):
"""Verified on live hardware in the report: 5 callers, 1 connection."""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await _let_leader_start(capture)
rest = [asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) for _ in range(4)]
await asyncio.sleep(0)
gate.set()
assert await asyncio.gather(first, *rest) == [FRAME_A] * 5
assert capture.count == 1
@pytest.mark.asyncio
async def test_different_printers_do_not_coalesce(patch_capture):
"""The one-connection limit is per printer, so the key must be too."""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
one = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await _let_leader_start(capture)
two = asyncio.create_task(capture_camera_frame_bytes("10.0.2.44", "code", "P2S"))
await asyncio.sleep(0)
gate.set()
assert {await one, await two} == {FRAME_A, FRAME_B}
assert capture.count == 2
assert {ip for ip, _ in capture.calls} == {"10.0.2.43", "10.0.2.44"}
@pytest.mark.asyncio
async def test_coalescing_is_not_caching(patch_capture):
"""Sequential callers each capture fresh.
Deliberate: plate detection and the finish-photo path decide things about a
running print from these frames, and #1397 was a finish photo a few seconds
stale showing the bed already lowered.
"""
capture = patch_capture(RecordingCapture())
assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_A
assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_B
assert capture.count == 2
@pytest.mark.asyncio
async def test_registry_is_empty_after_a_capture_finishes(patch_capture):
"""No leak, and nothing left behind for the next caller to join."""
patch_capture(RecordingCapture())
await capture_camera_frame_bytes("10.0.2.43", "code", "P2S")
await asyncio.sleep(0) # let the done-callback run
assert camera_module._inflight_captures == {}
assert capture_in_flight("10.0.2.43") is False
@pytest.mark.asyncio
async def test_failed_leader_does_not_poison_its_followers(patch_capture):
"""A follower that never got its own attempt gets one when the leader fails.
Safe by then: the leader has finished, so there is no socket to compete
with. This also covers the follower whose timeout is LONGER than the
leader's — it isn't cut short by someone else's deadline.
"""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=10))
await _let_leader_start(capture)
follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20))
await asyncio.sleep(0)
gate.set()
assert await leader is None
assert await follower == FRAME_B
assert capture.count == 2
@pytest.mark.asyncio
async def test_two_consecutive_failures_give_up(patch_capture):
"""Bounded retry: a follower doesn't chase failing captures forever.
Two followers behind a failing leader. The first takes its own turn, the
second joins THAT capture, and when it fails too the second gives up rather
than opening a third connection.
"""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate))
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await _let_leader_start(capture)
first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await asyncio.sleep(0)
second = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await asyncio.sleep(0)
gate.set()
assert await leader is None
assert await first is None
assert await second is None
# The leader's capture plus one retry — not one per disappointed caller.
assert capture.count == 2
@pytest.mark.asyncio
async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture):
"""A follower giving up leaves the capture running for everyone else.
The call sites disagree about the timeout (10s plate detection, 20s Obico),
so a follower must be able to abandon a join without cancelling a capture
other callers are still waiting on.
"""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30))
await _let_leader_start(capture)
impatient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=0.01))
patient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30))
assert await impatient is None # gave up on its own deadline
gate.set()
assert await leader == FRAME_A
assert await patient == FRAME_A # unaffected by the one that walked away
assert capture.count == 1
@pytest.mark.asyncio
async def test_cancelled_leader_still_delivers_to_followers(patch_capture):
"""Snapshot requests get cancelled routinely (client navigates away).
The follower must not lose the frame because the caller that happened to
open the connection went away.
"""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await _let_leader_start(capture)
follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await asyncio.sleep(0)
leader.cancel()
with pytest.raises(asyncio.CancelledError):
await leader
gate.set()
assert await follower == FRAME_A
assert capture.count == 1
@pytest.mark.asyncio
async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture):
"""The mirror case: the follower's cancellation is its own business."""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await _let_leader_start(capture)
follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await asyncio.sleep(0)
follower.cancel()
with pytest.raises(asyncio.CancelledError):
await follower
gate.set()
assert await leader == FRAME_A
assert capture.count == 1
@pytest.mark.asyncio
async def test_capture_in_flight_reports_the_window(patch_capture):
"""The predicate the diagnose tool uses to know it will join, not measure."""
gate = asyncio.Event()
capture = patch_capture(RecordingCapture(gate=gate))
assert capture_in_flight("10.0.2.43") is False
leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
await _let_leader_start(capture)
assert capture_in_flight("10.0.2.43") is True
assert capture_in_flight("10.0.2.44") is False # per printer
gate.set()
await leader
await asyncio.sleep(0)
assert capture_in_flight("10.0.2.43") is False
@@ -221,6 +221,79 @@ class TestFirstFrameStage:
assert result.overall_status == "ok"
assert result.summary_code == "all_ok"
assert all(s.status == "ok" for s in result.stages)
assert result.stages[1].code is None # we opened our own connection
@pytest.mark.asyncio
async def test_pass_riding_on_an_inflight_capture_says_so(self):
"""#2705: a capture already running means we get its frame, not our own.
The frame is real evidence the camera works, so the stage still passes —
but duration_ms is then mostly time spent queueing behind someone
else's capture, and claiming a connection we never opened is exactly
what a diagnostic must not do."""
async def _tcp_ok(*_a, **_kw):
writer = AsyncMock()
return AsyncMock(), writer
with (
patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new=_tcp_ok,
),
patch(
"backend.app.services.camera_diagnose.capture_in_flight",
return_value=True,
),
patch(
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
new_callable=AsyncMock,
return_value=b"\xff\xd8\xff\xd9",
),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.overall_status == "ok"
assert result.summary_code == "all_ok"
assert result.stages[1].status == "ok"
assert result.stages[1].code == "coalesced_capture"
@pytest.mark.asyncio
async def test_failure_is_not_annotated_as_coalesced(self):
"""A follower whose leader fails goes on to capture on its own, so a
failure here was this stage's own attempt — no qualifier needed."""
async def _tcp_ok(*_a, **_kw):
writer = AsyncMock()
return AsyncMock(), writer
with (
patch(
"backend.app.services.camera_diagnose.asyncio.open_connection",
new=_tcp_ok,
),
patch(
"backend.app.services.camera_diagnose.capture_in_flight",
return_value=True,
),
patch(
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
new_callable=AsyncMock,
return_value=None,
),
):
result = await diagnose_camera(
ip_address="192.0.2.1",
access_code="x",
model="P2S",
printer_id=1,
)
assert result.summary_code == "no_frame"
assert result.stages[1].code == "no_frame"
class TestResultMetadata:
@@ -1168,16 +1168,24 @@ class TestBarkProvider:
mock_client.post.assert_not_called()
@pytest.mark.asyncio
async def test_send_bark_error_in_200_body(self, service):
"""bark-server can wrap a failure in HTTP 200; the body code must win."""
async def test_send_bark_error_in_200_body(self, service, caplog):
"""bark-server can wrap a failure in HTTP 200; the body code must win.
Only the numeric code is returned — the server is caller-supplied
(bark is self-hostable), so its free-text message is the same read
channel the HTTP-failure path closes. The text goes to the debug log.
"""
mock_client = self._client_returning(200, {"code": 400, "message": "device token invalid"})
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
mock_get_client.return_value = mock_client
success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
with caplog.at_level("DEBUG", logger="backend.app.services.notification_service"):
success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
assert success is False
assert "device token invalid" in message
assert "Bark error 400" in message
assert "device token invalid" not in message
assert "device token invalid" in caplog.text
@pytest.mark.asyncio
async def test_send_bark_http_error(self, service):
@@ -2476,10 +2484,15 @@ class TestNtfyOutbound:
assert "<!DOCTYPE" not in detail
@pytest.mark.asyncio
async def test_ntfy_normal_403_still_surfaces_body(self, service):
"""A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
the original body so the user can debug the real error — we
only intercept the Cloudflare-challenge shape."""
async def test_ntfy_normal_403_is_not_misread_as_a_cloudflare_challenge(self, service, caplog):
"""A non-Cloudflare 403 (e.g. ntfy auth fail) must report the real
status rather than the Cloudflare-challenge advice — we only intercept
the challenge shape.
The origin's body is no longer returned to the API caller: the ntfy
server URL is caller-supplied, so echoing it made this an SSRF read
primitive. It goes to the debug log instead.
"""
import httpx
mock_response = httpx.Response(
@@ -2491,7 +2504,10 @@ class TestNtfyOutbound:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
with (
patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
):
ok, detail = await service._send_ntfy(
{"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
title="t",
@@ -2500,16 +2516,19 @@ class TestNtfyOutbound:
assert ok is False
assert "Cloudflare" not in detail
assert "invalid auth token" in detail
assert detail.startswith("HTTP 403:")
assert detail.startswith("HTTP 403")
assert "invalid auth token" not in detail
assert "invalid auth token" in caplog.text
@pytest.mark.asyncio
async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service, caplog):
"""Cloudflare adds Server: cloudflare to EVERY proxied response,
including legitimate origin errors. A real 401 "wrong token"
from an ntfy server that happens to sit behind Cloudflare must
still surface the origin's actual error body — we must not flip
still be reported as the origin's status — we must not flip
every CF-fronted 4xx into a "your Cloudflare is blocking" message.
As above, the origin body reaches the debug log rather than the caller.
"""
import httpx
@@ -2527,7 +2546,10 @@ class TestNtfyOutbound:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
with (
patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
):
ok, detail = await service._send_ntfy(
{"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
title="t",
@@ -2536,8 +2558,9 @@ class TestNtfyOutbound:
assert ok is False
assert "Cloudflare" not in detail
assert "unauthorized" in detail
assert detail.startswith("HTTP 401:")
assert detail.startswith("HTTP 401")
assert "unauthorized" not in detail
assert "unauthorized" in caplog.text
@pytest.mark.asyncio
async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):
@@ -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"
)
@@ -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()
@@ -443,3 +443,53 @@ class TestSubstituteUnusedPlateFilaments:
result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
assert result == ["pla.json", "pla.json", "pva.json"]
# ---- #2711: plate 0 is the slice-all sentinel, not a plate ----------
def test_no_op_for_the_slice_all_sentinel(self):
"""``plate=0`` means every plate, so every slot is used by something."""
zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
items = ["pla_white.json", "pla_red.json"]
result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
assert result == items
def test_slice_all_is_not_collapsed_onto_the_support_filament(self):
"""The guard that makes the plate-0 no-op load-bearing.
Geometry lookup for plate 0 matches nothing (plates are 1-indexed),
but the support-filament union reads project settings and has no
plate scope — so it survives as the *only* member of the used set.
Anchored on it, a slice-all would rewrite every colour in the
project to the support material and print the whole thing in PVA.
"""
model_settings = self._model_settings_xml([(1, [1]), (2, [2]), (3, [3])])
project_settings = json.dumps(
{
"enable_support": "1",
"support_filament": "4",
"support_interface_filament": "4",
"filament_type": ["PLA", "PLA", "PLA", "PVA"],
}
).encode()
zip_bytes = _make_3mf(
{
"Metadata/model_settings.config": model_settings,
"Metadata/project_settings.config": project_settings,
}
)
items = ["white.json", "red.json", "blue.json", "pva.json"]
result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
assert result == items, "slice-all collapsed the project onto the support filament"
def test_negative_plate_id_is_a_no_op(self):
"""Not reachable through the schema (``ge=0``), but the function is the
thing that must not guess — a caller resolving a plate wrongly should
get the user's picks back, not a rewrite anchored on nothing."""
zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
items = ["a.json", "b.json"]
assert substitute_unused_plate_filaments(zip_bytes, plate_id=-1, items=items) == items
@@ -0,0 +1,255 @@
"""The layer total must survive the print-start reset (#2702).
`_update_state` applies `total_layer_num` early and, further down, resets
`total_layers` when it detects a new print (added by #1771 so the previous
print's total can't bleed into the next one's usage split). Those two run in
the same function on the same frame, so a frame that carried both the new
print's total *and* the transition into RUNNING had its total applied and then
zeroed.
That is unrecoverable rather than merely late: Bambu firmware sends only
changed fields, so the printer never re-sends a total it already published.
The value reappears only in a full pushall — i.e. on reconnect or a manual
Force Refresh — which is why the reporter saw `n/0` for nine minutes on a
flawless connection, why it looked random, and why a *stable* link made it
worse.
Frames here are trimmed to the fields the code under test reads. No printer is
needed: the fix is a property of how one function orders its own writes.
"""
from __future__ import annotations
import json
import pytest
@pytest.fixture
def client():
"""A client with a recording stand-in for the MQTT connection."""
from unittest.mock import MagicMock
from backend.app.services.bambu_mqtt import BambuMQTTClient
c = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
c._client = MagicMock()
# A new print is only detected once a previous state has been observed
# (#1304 guard), so give every test a plausible pre-print history.
c._previous_gcode_state = "IDLE"
c._previous_gcode_file = None
c._was_running = False
return c
def pushalls(client) -> list[dict]:
"""Every pushall published on this client, decoded."""
sent = []
for call in client._client.publish.call_args_list:
payload = json.loads(call.args[1])
if payload.get("pushing", {}).get("command") == "pushall":
sent.append(payload)
return sent
def running_frame(**extra) -> dict:
"""A frame that flips the printer into RUNNING with a file — a new print."""
return {"gcode_state": "RUNNING", "gcode_file": "widget.3mf", "subtask_name": "widget", **extra}
# ---------------------------------------------------------------------------
# The regression
# ---------------------------------------------------------------------------
def test_total_arriving_with_the_start_frame_survives(client):
"""The reported bug: total and transition in one frame lost the total."""
client._update_state(running_frame(total_layer_num=33, layer_num=0))
assert client.state.total_layers == 33
def test_total_arriving_with_the_start_frame_needs_no_pushall(client):
"""We already have the denominator, so don't spend a round-trip on it."""
client._update_state(running_frame(total_layer_num=33))
assert pushalls(client) == []
assert client._total_layers_refresh_armed is False
def test_previous_prints_total_still_cannot_bleed_through(client):
"""#1771's reason for the reset — preserved exactly.
A start frame with no total of its own must land on 0, never on the
finished print's denominator.
"""
client.state.total_layers = 120 # left over from the print that just ended
client._update_state(running_frame())
assert client.state.total_layers == 0
def test_start_frame_without_a_total_asks_the_printer_for_one(client):
"""Covers the ordering where the total was published a frame or two early.
Re-applying this frame's own value can't help there — the value was
already consumed and zeroed — so recovery has to come from a pushall,
the only message that re-sends unchanged fields.
"""
client._update_state(running_frame())
assert len(pushalls(client)) == 1
assert client._total_layers_refresh_armed is True
# ---------------------------------------------------------------------------
# The one-shot re-request
# ---------------------------------------------------------------------------
def test_first_layer_advance_without_a_total_re_requests_once(client):
client._update_state(running_frame())
assert len(pushalls(client)) == 1 # from print start
client._update_state({"layer_num": 1})
assert len(pushalls(client)) == 2
assert client._total_layers_refresh_armed is False
def test_later_layer_advances_do_not_keep_re_requesting(client):
"""An unanswered pushall must not become a per-layer retry loop."""
client._update_state(running_frame())
client._update_state({"layer_num": 1})
before = len(pushalls(client))
for layer in range(2, 12):
client._update_state({"layer_num": layer})
assert len(pushalls(client)) == before
def test_no_re_request_once_the_total_is_known(client):
"""The pushall answered: layers advance without further traffic."""
client._update_state(running_frame())
client._update_state({"total_layer_num": 33}) # the pushall's answer
before = len(pushalls(client))
client._update_state({"layer_num": 1})
client._update_state({"layer_num": 2})
assert client.state.total_layers == 33
assert len(pushalls(client)) == before
def test_the_recovered_total_is_what_downstream_reads(client):
"""End-to-end on the reporter's sequence, minus the 9-minute wait.
Start with no total, layers advance at `n/0`, the pushall answers, and
from then on the UI, `{total_layers}` notifications and the usage-split
denominator all see 33 — they read this one field.
"""
client._update_state(running_frame())
client._update_state({"layer_num": 1})
assert client.state.total_layers == 0 # the symptom in the screenshot
client._update_state({"layer_num": 2, "total_layer_num": 33})
assert (client.state.layer_num, client.state.total_layers) == (2, 33)
def test_the_pushall_answer_does_not_re_trigger_the_reset(client):
"""Loop safety: the answer is a *full* frame, gcode_state and file included.
If that re-tripped the new-print detection it would reset the total it just
delivered and request another pushall, once per round-trip, forever.
"""
client._update_state(running_frame())
assert len(pushalls(client)) == 1
client._update_state(running_frame(total_layer_num=33, layer_num=1, mc_percent=3))
assert client.state.total_layers == 33
assert len(pushalls(client)) == 1
# ---------------------------------------------------------------------------
# Interaction with the pre-existing firmware-reset guard
# ---------------------------------------------------------------------------
def test_firmware_reset_to_zero_mid_print_is_still_ignored(client):
"""P1S zeroes total_layer_num at print end; #1771's guard keeps the total."""
client._update_state(running_frame(total_layer_num=33))
client._update_state({"layer_num": 33, "total_layer_num": 0})
assert client.state.total_layers == 33
@pytest.mark.parametrize("value", [None, "", 0, "0", -1, "abc", "33.7", [], {}, 3.9])
def test_unusable_totals_do_not_break_ingest(client, value):
"""A bad total must not escape `_update_state`.
The old parse did a bare ``int(data["total_layer_num"])``. `_on_message`
catches only `JSONDecodeError` and paho is left at
``suppress_exceptions = False``, so anything this raised was re-raised on
the network thread and took the printer connection down over one field.
`None`, `[]` and `{}` all did exactly that.
"""
client._update_state(running_frame(total_layer_num=value))
assert client.state.total_layers in (0, 3) # 3.9 truncates; the rest are 0
assert client.state.gcode_file == "widget.3mf" # the rest of the frame landed
def test_an_unusable_total_does_not_stop_the_layer_counter(client):
"""The read happens before the layer block, so it must not be able to raise.
Otherwise a firmware sending a malformed total would freeze `layer_num` for
the whole print — the frame would abort before reaching it.
"""
client._update_state(running_frame())
client._update_state({"layer_num": 7, "total_layer_num": "not-a-number"})
assert client.state.layer_num == 7
def test_a_string_total_is_accepted(client):
"""Bambu ships numbers as strings in plenty of other fields."""
client._update_state(running_frame(total_layer_num="33"))
assert client.state.total_layers == 33
def test_a_zero_total_on_the_start_frame_counts_as_no_total(client):
"""`total_layer_num: 0` is the firmware's "don't know yet", not a value."""
client.state.total_layers = 120
client._update_state(running_frame(total_layer_num=0))
assert client.state.total_layers == 0
assert len(pushalls(client)) == 1
# ---------------------------------------------------------------------------
# A restarted print (file change while RUNNING) takes the same path
# ---------------------------------------------------------------------------
def test_file_change_while_running_also_keeps_its_own_total(client):
"""`is_file_change` shares the reset, so it needs the same treatment."""
client._update_state(running_frame(total_layer_num=33))
client._was_running = True
client._update_state(
{"gcode_state": "RUNNING", "gcode_file": "other.3mf", "subtask_name": "other", "total_layer_num": 77}
)
assert client.state.total_layers == 77
@@ -1,20 +1,33 @@
"""Bounded post-kill ffmpeg cleanup (#2580, fix shape from PR #2581 by @ronaldheft).
"""ffmpeg teardown: draining the pipes, and the bounded waits behind it.
A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take
arbitrarily long to be reaped. The cleanup paths used to ``await process.wait()``
unbounded after ``kill()`` — on a P2S RTSP read timeout this parked the fan-out
stream coroutine for 12 hours, leaving every viewer attached to a stalled
broadcaster while snapshots/diagnostics (fresh connections) kept working.
Originally #2580 (fix shape from PR #2581 by @ronaldheft): the cleanup paths
``await process.wait()``-ed unbounded after ``kill()``, which on a P2S RTSP read
timeout parked the fan-out stream coroutine for 12 hours, leaving every viewer
attached to a stalled broadcaster while snapshots (fresh connections) kept
working. Three places had it, all bounded now:
The same unbounded wait existed in THREE places, all bounded now:
1. ``_terminate_ffmpeg`` — the stream generator's cleanup (the reported hang).
2. ``stop_camera`` — hung the very request a user makes to recover.
3. ``cleanup_orphaned_streams`` — hung the janitor that is the safety net.
That diagnosis — "a SIGKILLed ffmpeg stuck in uninterruptible I/O" — turned out
to be wrong, and the bound was capping a deadlock of our own making. ffmpeg was
blocked writing to a stdout pipe nobody was reading, which makes SIGTERM
unactionable, and ``wait()`` cannot observe an exit while a pipe transport is
still undrained. So the abandon path fired on every camera close, costing 4s of
the printer's single camera connection each time. The pipes are drained now; the
bounds remain as backstops, and the tests for them stay valid.
The draining tests below drive a REAL subprocess, because the failure is in
asyncio's pipe/transport bookkeeping — a fake process object cannot reproduce
it and would happily pass against the broken code.
"""
from __future__ import annotations
import asyncio
import logging
import sys
import time
from contextlib import suppress
@@ -24,6 +37,46 @@ from backend.app.api.routes import camera
pytestmark = pytest.mark.asyncio
# Stands in for ffmpeg: floods stdout, and handles SIGTERM the way ffmpeg does
# — a handler that sets a flag which only the main loop checks, so a process
# blocked in write() never acts on it until something drains the pipe.
_FFMPEG_LIKE = """
import signal, sys
stop = False
def _handler(*_a):
global stop
stop = True
signal.signal(signal.SIGTERM, _handler)
sys.stderr.write("x" * 4096)
sys.stderr.flush()
while not stop:
sys.stdout.buffer.write(b"x" * 65536)
sys.stdout.buffer.flush()
"""
# Same, but SIGTERM is ignored outright — forces the SIGKILL branch.
_SIGTERM_PROOF = """
import signal, sys
signal.signal(signal.SIGTERM, signal.SIG_IGN)
while True:
sys.stdout.buffer.write(b"x" * 65536)
sys.stdout.buffer.flush()
"""
async def _spawn(program: str) -> asyncio.subprocess.Process:
"""Start the stand-in and let it fill its stdout pipe, as the cancel path
leaves a real ffmpeg."""
process = await asyncio.create_subprocess_exec(
sys.executable,
"-c",
program,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.sleep(0.4)
return process
class _FakeServer:
def close(self) -> None:
@@ -106,6 +159,64 @@ class _FrameProcess:
return self.returncode
# ---------------------------------------------------------------------------
# 0. _terminate_ffmpeg drains the pipes — against a real subprocess
# ---------------------------------------------------------------------------
async def test_terminate_drains_stdout_so_sigterm_works(caplog):
"""A process blocked writing to a full pipe still shuts down on SIGTERM.
Undrained, this took the full grace period plus the SIGKILL bound (4s
measured) and ended in the abandon error. Drained, SIGTERM lands.
"""
process = await _spawn(_FFMPEG_LIKE)
camera._spawned_ffmpeg_pids[process.pid] = time.time()
with caplog.at_level(logging.WARNING, logger=camera.logger.name):
started = time.monotonic()
await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-drain"), timeout=5.0)
elapsed = time.monotonic() - started
assert process.returncode is not None, "wait() must observe the exit"
# Comfortably under the 2.0s grace period: proves SIGTERM was acted on
# rather than timing out into the kill branch.
assert elapsed < 1.5, f"teardown took {elapsed:.2f}s — pipes likely not drained"
assert "didn't terminate gracefully" not in caplog.text
assert "abandoning wait" not in caplog.text
assert process.pid not in camera._spawned_ffmpeg_pids
async def test_terminate_observes_kill_of_a_sigterm_proof_process(monkeypatch, caplog):
"""Even when SIGTERM is genuinely ignored, wait() must see the SIGKILL.
This is the case the abandon error was invented for. With the pipes drained
the exit is observable, so it must not fire.
"""
monkeypatch.setattr(camera, "_FFMPEG_TERM_TIMEOUT", 0.3)
process = await _spawn(_SIGTERM_PROOF)
camera._spawned_ffmpeg_pids[process.pid] = time.time()
with caplog.at_level(logging.WARNING, logger=camera.logger.name):
await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-kill"), timeout=5.0)
assert process.returncode == -9, "SIGKILLed exit must be observed, not abandoned"
assert "didn't terminate gracefully" in caplog.text # SIGTERM really was ignored
assert "abandoning wait" not in caplog.text
assert process.pid not in camera._spawned_ffmpeg_pids
async def test_terminate_is_a_noop_for_an_already_dead_process():
"""The early return must still drop the pid from the tracking dict."""
process = await asyncio.create_subprocess_exec(sys.executable, "-c", "pass")
await process.wait()
camera._spawned_ffmpeg_pids[process.pid] = time.time()
await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-dead"), timeout=2.0)
assert process.pid not in camera._spawned_ffmpeg_pids
# ---------------------------------------------------------------------------
# 1. _terminate_ffmpeg — the helper itself is bounded
# ---------------------------------------------------------------------------
@@ -0,0 +1,287 @@
"""Continuous stderr draining for streaming ffmpeg (_FfmpegStderrTail).
ffmpeg is spawned with stderr=PIPE, and stderr used to be read only when
something had already gone wrong — so for the life of a stream nobody read that
pipe. ffmpeg writes its banner, the input analysis and then a progress line at a
steady rate, so a 64 KiB pipe fills eventually, ffmpeg blocks writing to it,
frames stop, and the stream's own read timeout fires with nothing in the log
explaining that we starved it.
How long that takes is unmeasured and evidently long — one H2D upstream ran
21m36s without stalling — so this is a bounded resource being treated as
unbounded rather than an observed failure. These tests pin the four properties
that matter: the pipe is always drained, the retained tail is bounded, the tail
is what the error paths report, and it goes through the same redaction funnel as
every other stderr log in this module.
"""
from __future__ import annotations
import asyncio
import pytest
from backend.app.api.routes import camera
pytestmark = pytest.mark.asyncio
class _Reader:
"""Feeds queued chunks, then blocks like a live-but-quiet ffmpeg."""
def __init__(self, chunks: list[bytes], then_block: bool = True) -> None:
self._chunks = list(chunks)
self._then_block = then_block
self.reads = 0
async def read(self, _size: int = -1) -> bytes:
self.reads += 1
if self._chunks:
return self._chunks.pop(0)
if self._then_block:
await asyncio.Event().wait() # never returns, never EOFs
return b""
class _Proc:
def __init__(self, reader, pid: int = 88010) -> None:
self.pid = pid
self.returncode = None
self.stdout = None
self.stderr = reader
@pytest.fixture(autouse=True)
def _no_leaked_tails():
yield
# Last-resort teardown only — this fixture is sync, so it cancels without
# awaiting. Tests are expected to aclose() their own tails.
for tail in list(camera._stderr_tails.values()):
if tail._task is not None:
tail._task.cancel()
camera._stderr_tails.clear()
async def _settle() -> None:
"""Let the pump task run."""
for _ in range(5):
await asyncio.sleep(0)
async def test_it_keeps_draining_a_stream_that_never_closes_stderr():
"""The whole point: the pipe is read continuously, not on demand."""
reader = _Reader([b"first\n", b"second\n"])
tail = camera._FfmpegStderrTail(_Proc(reader))
await _settle()
assert reader.reads >= 3, "pump stopped reading instead of following the pipe"
assert "second" in (tail.text() or "")
await tail.aclose()
async def test_the_retained_tail_is_bounded():
"""A long-running stream must not turn the pipe into unbounded memory."""
oversized = b"x" * (camera._FFMPEG_STDERR_TAIL_BYTES * 3)
tail = camera._FfmpegStderrTail(_Proc(_Reader([oversized])))
await _settle()
assert len(tail._buffer) == camera._FFMPEG_STDERR_TAIL_BYTES
await tail.aclose()
async def test_the_tail_keeps_the_newest_output():
"""Recent output is what explains a failure; the banner gets stripped anyway."""
filler = b"stale-line\n" * 4000
tail = camera._FfmpegStderrTail(_Proc(_Reader([filler, b"Connection timed out\n"])))
await _settle()
text = tail.text() or ""
assert "Connection timed out" in text
assert len(tail._buffer) <= camera._FFMPEG_STDERR_TAIL_BYTES
await tail.aclose()
async def test_read_ffmpeg_stderr_defers_to_the_collector():
"""Two readers on one StreamReader raise, so the on-demand read must not
touch a pipe the collector owns."""
reader = _Reader([b"Server returned 401 Unauthorized\n"])
process = _Proc(reader)
tail = camera._FfmpegStderrTail(process)
await _settle()
reads_before = reader.reads
result = await camera._read_ffmpeg_stderr(process)
assert "401 Unauthorized" in (result or "")
assert reader.reads == reads_before, "on-demand read raced the collector"
await tail.aclose()
async def test_read_ffmpeg_stderr_still_reads_the_pipe_without_a_collector():
"""An immediately-failed ffmpeg has no collector; that path must still work."""
process = _Proc(_Reader([b"Server returned 404 Not Found\n"], then_block=False))
result = await camera._read_ffmpeg_stderr(process)
assert "404 Not Found" in (result or "")
async def test_the_tail_redacts_the_access_code():
"""ffmpeg echoes its input URL, which carries the printer's access code.
This is a new stderr-to-log path, so it gets the same guarantee as the rest:
everything goes through _summarize_ffmpeg_stderr.
"""
secret = "12345678"
leaky = f"[rtsp @ 0x55] Failed to resolve rtsp://bblp:{secret}@127.0.0.1:8554/streaming/live/1\n"
tail = camera._FfmpegStderrTail(_Proc(_Reader([leaky.encode()])))
await _settle()
text = tail.text() or ""
assert secret not in text, "access code leaked into a log line"
# Assert the line SURVIVED with the credential masked, not that it was
# dropped — otherwise this passes whenever the summariser happens to filter
# the line out, and proves nothing about redaction.
assert "Failed to resolve" in text, "line was filtered, so redaction is untested"
assert "[REDACTED]" in text
await tail.aclose()
async def test_close_releases_ownership_and_is_idempotent():
process = _Proc(_Reader([b"line\n"]))
tail = camera._FfmpegStderrTail(process)
await _settle()
assert camera._stderr_tails.get(process.pid) is tail
await tail.aclose()
await tail.aclose() # must not raise
assert process.pid not in camera._stderr_tails
async def test_a_process_without_stderr_is_handled():
"""Fakes and some spawn paths pass stderr=None; must not register or crash."""
process = _Proc(None, pid=88099)
tail = camera._FfmpegStderrTail(process)
assert tail.text() is None
assert process.pid not in camera._stderr_tails
await tail.aclose()
async def test_the_stream_generator_owns_then_releases_the_collector(monkeypatch):
"""Lifecycle inside the real generator: registered while streaming, gone after.
The other generator tests use fakes with stderr=None, so they never build a
collector at all — this is the one that would catch a missing close() or a
reader race between the collector and teardown.
"""
printer_id = 8842
stream_id = f"{printer_id}-fanout-stderrtail"
class _FrameThenBlock:
def __init__(self) -> None:
self._sent = False
async def read(self, _size: int = -1) -> bytes:
if self._sent:
await asyncio.Event().wait() # stay alive, don't trigger reconnect
self._sent = True
return b"\xff\xd8frame\xff\xd9"
class _Proc2:
def __init__(self) -> None:
self.pid = 88042
self.returncode = None
self.stdout = _FrameThenBlock()
self.stderr = _Reader([b"Stream #0:0: Video: h264\n"])
def terminate(self) -> None:
self.returncode = 0
def kill(self) -> None:
self.returncode = -9
async def wait(self) -> int:
if self.returncode is None:
self.returncode = 0
return self.returncode
process = _Proc2()
class _FakeServer:
def close(self) -> None:
pass
async def wait_closed(self) -> None:
pass
async def _fake_exec(*_a, **_kw):
return process
async def _fake_proxy(_ip, _port):
return 48777, _FakeServer()
monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
stream = camera.generate_rtsp_mjpeg_stream(
ip_address="192.0.2.44",
access_code="c",
model="P2S",
fps=15,
stream_id=stream_id,
disconnect_event=asyncio.Event(),
printer_id=printer_id,
)
try:
chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
assert b"frame" in chunk
assert process.pid in camera._stderr_tails, "generator did not take stderr ownership"
await asyncio.wait_for(stream.aclose(), timeout=5.0)
assert process.pid not in camera._stderr_tails, "collector outlived its stream"
finally:
camera._active_streams.pop(stream_id, None)
camera._disconnect_events.pop(stream_id, None)
camera._stream_last_frame_times.pop(stream_id, None)
camera._last_frames.pop(printer_id, None)
camera._last_frame_times.pop(printer_id, None)
camera._stream_start_times.pop(printer_id, None)
camera._spawned_ffmpeg_pids.pop(process.pid, None)
async def test_terminate_skips_stderr_while_a_collector_owns_it():
"""_terminate_ffmpeg must not add a second reader to an owned pipe."""
reader = _Reader([b"tearing down\n"])
class _Killable(_Proc):
def terminate(self):
self.returncode = 0
def kill(self):
self.returncode = -9
async def wait(self):
if self.returncode is None:
self.returncode = 0
return self.returncode
process = _Killable(reader, pid=88020)
tail = camera._FfmpegStderrTail(process)
await _settle()
reads_before = reader.reads
await asyncio.wait_for(camera._terminate_ffmpeg(process, "88020-fanout-abcd"), timeout=2.0)
# The collector, not _terminate_ffmpeg, is the only reader that advanced.
assert reader.reads >= reads_before
assert tail.text() is not None
await tail.aclose()
@@ -0,0 +1,238 @@
"""A departing camera stream must not clean up its successor's state.
The fan-out stream id used to be ``f"{printer_id}-fanout"`` — constant per
printer, so every successive stream shared one registry key — and the
generator's ``finally`` popped the per-printer frame buffer unconditionally.
Teardown taking ~4s (the undrained-pipe deadlock, fixed separately) made the
overlap wide enough to hit by closing and reopening the camera:
12.221 stream A cancelled, begins teardown
12.324 new viewer attaches
16.223 A finishes killing
16.224 new generator registers _active_streams["1-fanout"]
...then A's finally pops that very entry
The damage is not cosmetic. ``is_stream_active()`` is what the #1348 / #1271
guards consult before deciding whether opening a second camera connection is
safe, so a printer with a viewer attached looked idle; the janitor's /proc scan
reaps any ffmpeg missing from ``_active_streams``, so it killed the live stream;
and ``/camera/stop`` reported ``Stopped 0``.
The external-camera path already solved this with a per-instance id (#2675).
These tests pin the same property for the fan-out path.
"""
from __future__ import annotations
import asyncio
import time
from contextlib import suppress
import pytest
from backend.app.api.routes import camera
pytestmark = pytest.mark.asyncio
PRINTER_ID = 7701
@pytest.fixture(autouse=True)
def _clean_registries():
"""These registries are module-global; leave them as we found them."""
def _purge():
for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
camera._active_streams.pop(sid, None)
for sid in [k for k in camera._active_chamber_streams if k.startswith(f"{PRINTER_ID}-")]:
camera._active_chamber_streams.pop(sid, None)
for sid in [k for k in camera._stream_last_frame_times if k.startswith(f"{PRINTER_ID}-")]:
camera._stream_last_frame_times.pop(sid, None)
for sid in [k for k in camera._disconnect_events if k.startswith(f"{PRINTER_ID}-")]:
camera._disconnect_events.pop(sid, None)
camera._last_frames.pop(PRINTER_ID, None)
camera._last_frame_times.pop(PRINTER_ID, None)
camera._stream_start_times.pop(PRINTER_ID, None)
_purge()
yield
_purge()
def _seed_frame_state() -> None:
camera._last_frames[PRINTER_ID] = b"\xff\xd8live\xff\xd9"
camera._last_frame_times[PRINTER_ID] = time.time()
camera._stream_start_times[PRINTER_ID] = time.time()
# ---------------------------------------------------------------------------
# _new_fanout_stream_id — one key per stream, not per printer
# ---------------------------------------------------------------------------
async def test_fanout_stream_ids_are_unique_per_stream():
"""Two streams for one printer must never collide in the registries."""
ids = {camera._new_fanout_stream_id(PRINTER_ID) for _ in range(50)}
assert len(ids) == 50, "ids collide, so one stream can clean up another's entry"
async def test_camera_stream_has_no_function_local_module_imports():
"""A local ``import x`` anywhere in camera_stream shadows x for the WHOLE
function, including branches that never reach the import.
This is not hypothetical: an ``import uuid`` inside the external-camera
branch meant building the fan-out id on the RTSP path raised
UnboundLocalError, so the camera would not start on any printer without an
external camera configured. ``time`` and ``uuid`` are module-level now;
keep them that way.
"""
import ast
import inspect
tree = ast.parse(inspect.getsource(camera.camera_stream))
local_imports = [alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names]
assert local_imports == [], f"function-local imports shadow the whole function: {local_imports}"
async def test_fanout_stream_id_keeps_the_printer_prefix():
"""is_stream_active / stop_camera_stream / camera-status all scan for it."""
stream_id = camera._new_fanout_stream_id(PRINTER_ID)
assert stream_id.startswith(f"{PRINTER_ID}-")
camera._active_streams[stream_id] = object()
assert camera.is_stream_active(PRINTER_ID) is True
# ---------------------------------------------------------------------------
# _release_printer_frame_state — the ownership check itself
# ---------------------------------------------------------------------------
async def test_frame_state_survives_when_another_rtsp_stream_is_live():
_seed_frame_state()
camera._active_streams[f"{PRINTER_ID}-fanout-successor"] = object()
camera._release_printer_frame_state(PRINTER_ID)
assert PRINTER_ID in camera._last_frames, "successor's buffered frame was wiped"
assert PRINTER_ID in camera._last_frame_times
assert PRINTER_ID in camera._stream_start_times
async def test_frame_state_survives_when_a_chamber_stream_is_live():
"""A1/P1 models register in a different dict; ownership spans both."""
_seed_frame_state()
camera._active_chamber_streams[f"{PRINTER_ID}-fanout-successor"] = (None, None)
camera._release_printer_frame_state(PRINTER_ID)
assert PRINTER_ID in camera._last_frames
async def test_last_stream_out_releases_the_frame_state():
"""The other half: with nothing left running, stale state must not linger."""
_seed_frame_state()
camera._release_printer_frame_state(PRINTER_ID)
assert PRINTER_ID not in camera._last_frames
assert PRINTER_ID not in camera._last_frame_times
assert PRINTER_ID not in camera._stream_start_times
async def test_release_is_a_noop_without_a_printer_id():
_seed_frame_state()
camera._release_printer_frame_state(None)
assert PRINTER_ID in camera._last_frames
# ---------------------------------------------------------------------------
# The whole generator cleanup path, with a successor already registered
# ---------------------------------------------------------------------------
class _FakeServer:
def close(self) -> None:
pass
async def wait_closed(self) -> None:
pass
class _OneFrameThenEOF:
def __init__(self) -> None:
self._sent = False
async def read(self, _size: int = -1) -> bytes:
if self._sent:
return b""
self._sent = True
return b"\xff\xd8predecessor\xff\xd9"
class _Proc:
def __init__(self, pid: int = 77010) -> None:
self.pid = pid
self.returncode = None
self.stdout = _OneFrameThenEOF()
self.stderr = None
def terminate(self) -> None:
self.returncode = 0
def kill(self) -> None:
self.returncode = -9
async def wait(self) -> int:
if self.returncode is None:
self.returncode = 0
return self.returncode
async def test_departing_generator_leaves_its_successors_registry_entry_alone(monkeypatch):
"""End of the real cleanup path, with a second stream already registered."""
async def _fake_exec(*_args, **_kwargs):
return _Proc()
async def _fake_proxy(_ip: str, _port: int):
return 48999, _FakeServer()
monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
predecessor_id = f"{PRINTER_ID}-fanout-aaaaaaaa"
successor_id = f"{PRINTER_ID}-fanout-bbbbbbbb"
stream = camera.generate_rtsp_mjpeg_stream(
ip_address="192.0.2.31",
access_code="test-code",
model="P2S",
fps=15,
stream_id=predecessor_id,
disconnect_event=asyncio.Event(),
printer_id=PRINTER_ID,
)
# Drive it far enough to buffer a frame, as a real viewer would.
chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
assert b"predecessor" in chunk
assert camera._last_frames[PRINTER_ID].endswith(b"predecessor\xff\xd9")
# A viewer reopens the camera mid-teardown: a fresh stream registers under
# its own id and republishes the buffered frame.
camera._active_streams[successor_id] = object()
camera._last_frames[PRINTER_ID] = b"\xff\xd8successor\xff\xd9"
with suppress(Exception):
await asyncio.wait_for(stream.aclose(), timeout=5.0)
assert successor_id in camera._active_streams, "predecessor removed its successor's entry"
assert camera.is_stream_active(PRINTER_ID) is True, "a viewer is attached; guards must see it"
assert camera._last_frames[PRINTER_ID].endswith(b"successor\xff\xd9"), "successor's frame was wiped"
assert predecessor_id not in camera._active_streams, "predecessor must still clean up after itself"
@@ -33,6 +33,10 @@ class _CleanProc:
def __init__(self, pid: int) -> None:
self.pid = pid
self.returncode = None
# Real Process objects always expose these (None when not piped), and
# _terminate_ffmpeg drains them so a full pipe can't wedge the exit.
self.stdout = None
self.stderr = None
def terminate(self) -> None:
self.returncode = 0
@@ -9,6 +9,35 @@ import logging
import pytest
@pytest.fixture(autouse=True)
def _restore_config_module():
"""Undo the ``importlib.reload`` these tests depend on.
Reloading ``backend.app.core.config`` re-executes it, so ``settings`` becomes
a *new* object built from the environment as it stands mid-test. Nothing put
the old one back. ``monkeypatch`` unwinds the env vars, not the reload.
The result is two live ``Settings`` instances in one process: every module
that did ``from ... config import settings`` at import time keeps the
original, while anything resolving ``config.settings`` afterwards gets the
replacement — and under xdist that split persisted for every later test in
the same worker. It surfaced as unrelated path assertions failing with a
``base_dir`` from *this* module's tmp_path (``TestLibraryPathHelpers``,
``TestUploadSourceThreeMF``, ``TestArchivePlatesDesignOverrides``,
``TestSystemHealthAPI``), which is why it looked like a random flake and
moved between runs as the work distribution changed.
Snapshotting the whole module dict rather than just ``settings`` restores
object *identity*, which is what the two views have to agree on.
"""
import backend.app.core.config as cfg_mod
saved = dict(cfg_mod.__dict__)
yield
cfg_mod.__dict__.clear()
cfg_mod.__dict__.update(saved)
@pytest.mark.unit
def test_unknown_mfa_env_var_logs_info(monkeypatch, caplog):
"""A typo'd MFA_* env var must be logged at INFO so operators see it."""
@@ -0,0 +1,144 @@
"""A dispatch claim must not survive the dispatch that held it (#2615, #2702).
``dispatching_at`` holds a queue row out of the selection query for the duration
of an upload. Clearing it is best-effort, and the observed failure was narrow:
PostgreSQL refused a connection for a second or two at exactly the moment
dispatch ended, the single clear attempt failed, and the row stayed invisible to
the scheduler until the process restarted.
Two independent recoveries, tested here: the clear retries, and a later tick
releases any claim with no dispatch behind it.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def scheduler():
from backend.app.services.print_scheduler import PrintScheduler
return PrintScheduler()
def _session(fail_times: int) -> MagicMock:
"""A session whose execute() fails `fail_times` times, then succeeds."""
db = MagicMock()
calls = {"n": 0}
async def execute(*_a, **_k):
calls["n"] += 1
if calls["n"] <= fail_times:
raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
return MagicMock(rowcount=1)
db.execute = AsyncMock(side_effect=execute)
db.commit = AsyncMock()
db.rollback = AsyncMock()
db._calls = calls
return db
# ---------------------------------------------------------------------------
# The retry
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.unit
async def test_a_transient_failure_is_retried_and_the_claim_clears(scheduler):
"""The reported case: one failed attempt used to wedge the row."""
db = _session(fail_times=1)
with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
await scheduler._clear_dispatch_claim(db, 597)
assert db._calls["n"] == 2
assert db.commit.await_count == 1
@pytest.mark.asyncio
@pytest.mark.unit
async def test_the_session_is_rolled_back_between_attempts(scheduler):
"""A failed write leaves the session needing a rollback before reuse."""
db = _session(fail_times=1)
with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
await scheduler._clear_dispatch_claim(db, 597)
assert db.rollback.await_count == 1
@pytest.mark.asyncio
@pytest.mark.unit
async def test_retries_are_bounded_and_never_raise(scheduler):
"""Dispatch's outcome must not be masked by this cleanup failing."""
db = _session(fail_times=99)
with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
await scheduler._clear_dispatch_claim(db, 597) # must not raise
assert db._calls["n"] == 3
@pytest.mark.asyncio
@pytest.mark.unit
async def test_no_retry_when_the_first_attempt_works(scheduler):
"""The happy path must not pay for the retry."""
db = _session(fail_times=0)
await scheduler._clear_dispatch_claim(db, 597)
assert db._calls["n"] == 1
# ---------------------------------------------------------------------------
# The quiet-tick sweep
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.unit
async def test_the_sweep_does_nothing_while_an_upload_is_in_flight(scheduler):
"""An in-flight dispatch owns its claim — clearing it would let a second
dispatch pick up the same row mid-upload, which is what #2615 prevents."""
scheduler._inflight[597] = (MagicMock(), 1)
with patch("backend.app.services.print_scheduler.async_session") as sess:
await scheduler._clear_stale_dispatch_claims()
sess.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_the_sweep_releases_a_claim_with_nothing_in_flight(scheduler):
"""`_inflight` is populated before the coroutine claims its row, and pruned
after its `finally` — so "claim present, nothing in flight" is orphaned."""
db = MagicMock()
db.execute = AsyncMock(return_value=MagicMock(rowcount=1))
db.commit = AsyncMock()
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=db)
ctx.__aexit__ = AsyncMock(return_value=False)
with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
await scheduler._clear_stale_dispatch_claims()
assert db.execute.await_count == 1
assert db.commit.await_count == 1
@pytest.mark.asyncio
@pytest.mark.unit
async def test_the_sweep_survives_a_database_that_is_still_down(scheduler):
"""It runs every tick; a failure must not break the scheduler loop."""
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("still refusing connections"))
ctx.__aexit__ = AsyncMock(return_value=False)
with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
await scheduler._clear_stale_dispatch_claims() # must not raise
@@ -0,0 +1,263 @@
"""A dispatch that never sends the print command must leave no expectation.
``register_expected_print`` has to run *before* the MQTT command, because the
printer can report the print before the send returns. So any path that
registers and then fails to send leaves Bambuddy expecting a print that will
never arrive: a cancel winning the #1853 CAS race, ``start_print()`` returning
False, or an exception in between — a PostgreSQL connection failure mid-dispatch
is the case that surfaced this (#2702 follow-up).
The two-hour TTL sweep does eventually evict such an entry, but two hours is far
longer than it takes someone to react to a failed dispatch by pressing print
again. That reprint would be folded into the *old* archive and inherit its
``ams_mapping`` and ``plate_id`` instead of creating a fresh one.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def expected_print_tables():
"""The module-level registries, emptied around each test."""
from backend.app import main
names = (
"_expected_prints",
"_expected_print_creators",
"_expected_print_registered_at",
"_print_ams_mappings",
"_print_plate_ids",
)
saved = {n: dict(getattr(main, n)) for n in names}
for n in names:
getattr(main, n).clear()
yield main
for n in names:
getattr(main, n).clear()
getattr(main, n).update(saved[n])
# ---------------------------------------------------------------------------
# unregister_expected_print is the exact inverse of register_expected_print
# ---------------------------------------------------------------------------
def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
"""The strongest form: register then unregister is a round trip to empty."""
main = expected_print_tables
main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1)
assert main._expected_prints, "nothing registered — the test proves nothing"
main.unregister_expected_print(1, "widget.3mf", 298)
assert main._expected_prints == {}
assert main._expected_print_creators == {}
assert main._expected_print_registered_at == {}
assert main._print_ams_mappings == {}
assert main._print_plate_ids == {}
def test_unregister_clears_the_filename_variants_too(expected_print_tables):
"""Registration stores the name three ways; a partial undo still matches."""
main = expected_print_tables
main.register_expected_print(1, "widget.3mf", 298)
main.unregister_expected_print(1, "widget.3mf", 298)
for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
assert key not in main._expected_prints, f"{key} survived"
def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
main = expected_print_tables
main.register_expected_print(1, "widget.3mf", 298)
main.register_expected_print(2, "widget.3mf", 299)
main.unregister_expected_print(1, "widget.3mf", 298)
assert main._expected_prints[(2, "widget.3mf")] == 299
def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
expected_print_tables,
):
"""Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
by file. Two files can be registered against one archive, so dropping them
on the first unregister would strip usage-tracking data from a print that is
still expected.
"""
main = expected_print_tables
main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
main.unregister_expected_print(1, "plate1.3mf", 298)
assert main._print_ams_mappings.get(298) == [3]
assert 298 in main._print_plate_ids
def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
"""Runs from a ``finally``, so it must tolerate having nothing to do."""
main = expected_print_tables
main.unregister_expected_print(99, "never-registered.3mf", 1234)
assert main._expected_prints == {}
# ---------------------------------------------------------------------------
# The scheduler's rollback hook
# ---------------------------------------------------------------------------
def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
main = expected_print_tables
from backend.app.services.print_scheduler import PrintScheduler
sched = PrintScheduler()
main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
sched._rollback_unconfirmed_expected_print(597)
assert main._expected_prints == {}
assert sched._unconfirmed_expected_print == {}
def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
"""A sent print's expectation must survive — the callback needs it."""
main = expected_print_tables
from backend.app.services.print_scheduler import PrintScheduler
sched = PrintScheduler()
main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
# What `_start_print` does once start_print() returns True.
sched._unconfirmed_expected_print.pop(597, None)
sched._rollback_unconfirmed_expected_print(597)
assert main._expected_prints[(1, "widget.3mf")] == 298
assert main._print_ams_mappings[298] == [3, 6]
def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
"""It runs in the ``finally`` of dispatch, usually with an exception already
propagating — it must not replace it with one of its own."""
from backend.app.services.print_scheduler import PrintScheduler
sched = PrintScheduler()
sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
monkeypatch.setattr(
expected_print_tables,
"unregister_expected_print",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
)
sched._rollback_unconfirmed_expected_print(597) # must not raise
assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
"""End to end through `_dispatch_one`, on the reported failure.
A database error inside `_start_print` must leave no expectation behind, must
still release the claim, and must not be swallowed — the background-task
runner logs it, and hiding it here would turn a loud failure into a silent
one.
"""
from unittest.mock import AsyncMock, MagicMock, patch
main = expected_print_tables
from backend.app.services.print_scheduler import PrintScheduler
sched = PrintScheduler()
async def fake_start_print(db, item):
# What `_start_print` does before the point the real one died.
main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
db = MagicMock()
db.get = AsyncMock(return_value=MagicMock(id=597))
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=db)
ctx.__aexit__ = AsyncMock(return_value=False)
with (
patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
patch.object(sched, "_start_print", side_effect=fake_start_print),
patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
pytest.raises(RuntimeError),
):
await sched._dispatch_one(597)
assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
assert main._print_ams_mappings == {}
assert main._print_plate_ids == {}
assert sched._unconfirmed_expected_print == {}
clear.assert_awaited_once_with(db, 597)
@pytest.mark.asyncio
@pytest.mark.unit
async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
"""The mirror image: a confirmed send must survive dispatch teardown, or the
print-complete callback would create a duplicate archive."""
from unittest.mock import AsyncMock, MagicMock, patch
main = expected_print_tables
from backend.app.services.print_scheduler import PrintScheduler
sched = PrintScheduler()
async def fake_start_print(db, item):
main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
sched._unconfirmed_expected_print.pop(597, None) # start_print() returned True
db = MagicMock()
db.get = AsyncMock(return_value=MagicMock(id=597))
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=db)
ctx.__aexit__ = AsyncMock(return_value=False)
with (
patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
patch.object(sched, "_start_print", side_effect=fake_start_print),
patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
):
await sched._dispatch_one(597)
assert main._expected_prints[(1, "widget.3mf")] == 298
assert main._print_ams_mappings[298] == [3, 6]
def test_rollback_entries_are_per_item(expected_print_tables):
"""Two dispatches in flight must not roll back each other's registration."""
main = expected_print_tables
from backend.app.services.print_scheduler import PrintScheduler
sched = PrintScheduler()
main.register_expected_print(1, "a.3mf", 1)
main.register_expected_print(2, "b.3mf", 2)
sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
sched._rollback_unconfirmed_expected_print(10)
assert (1, "a.3mf") not in main._expected_prints
assert main._expected_prints[(2, "b.3mf")] == 2
@@ -0,0 +1,297 @@
"""External-camera captures must reuse the live view's frame (#2707).
A USB camera allows exactly one V4L2 handle, so a one-shot capture taken while
somebody is watching the live view doesn't degrade — it fails. The reporter
measured 0 of 87 and 0 of 105 layer-timelapse captures on prints watched from
start to finish, and finish-photo notifications going out with no image.
The guards for the built-in camera (#1348, #1271) were never extended to the
external paths, and the deeper reason they couldn't be: ``_last_frames`` was
only ever populated by the built-in paths. ``generate_mjpeg_stream`` yields
multipart-wrapped chunks, so the route layer had no way to recover the JPEG —
hence the ``on_frame`` callback, and hence a guard alone would have found an
empty buffer and skipped every time.
These tests cover the plumbing (raw frames reach the callback) and each consumer
that used to compete: layer timelapse, Obico polling, and plate detection.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.api.routes import camera
from backend.app.services import external_camera, layer_timelapse
from backend.app.services.obico_detection import ObicoDetectionService
pytestmark = pytest.mark.asyncio
LIVE_FRAME = b"\xff\xd8live-viewer-frame\xff\xd9"
FRESH_FRAME = b"\xff\xd8fresh-capture\xff\xd9"
PRINTER_ID = 9310
@pytest.fixture(autouse=True)
def _clean_registries():
def _purge():
for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
camera._active_streams.pop(sid, None)
camera._last_frames.pop(PRINTER_ID, None)
camera._last_frame_times.pop(PRINTER_ID, None)
camera._stream_start_times.pop(PRINTER_ID, None)
_purge()
yield
_purge()
def _attach_viewer(frame: bytes | None = LIVE_FRAME) -> None:
"""Register a live external stream, as the stream route does."""
camera._active_streams[f"{PRINTER_ID}-ext-deadbeef"] = object()
if frame is not None:
camera._last_frames[PRINTER_ID] = frame
# ---------------------------------------------------------------------------
# live_frame_for_capture — the shared decision
# ---------------------------------------------------------------------------
async def test_no_viewer_means_capture_normally():
defer, frame = camera.live_frame_for_capture(PRINTER_ID)
assert defer is False
assert frame is None
async def test_viewer_with_a_buffered_frame_is_reused():
_attach_viewer()
defer, frame = camera.live_frame_for_capture(PRINTER_ID)
assert defer is True
assert frame == LIVE_FRAME
async def test_viewer_with_an_empty_buffer_means_skip_not_capture():
"""#1348: competing for the device is worse than missing one frame."""
_attach_viewer(frame=None)
defer, frame = camera.live_frame_for_capture(PRINTER_ID)
assert defer is True
assert frame is None
# ---------------------------------------------------------------------------
# on_frame plumbing — without this the buffer is always empty
# ---------------------------------------------------------------------------
async def test_on_frame_receives_the_raw_jpeg_not_the_multipart_chunk():
"""The consumers want a JPEG; the stream yields multipart. Hence a callback."""
captured: list[bytes] = []
async def _fake_usb(_url, _fps, on_process=None):
yield FRESH_FRAME
with patch.object(external_camera, "_stream_usb", _fake_usb):
chunks = [
chunk
async for chunk in external_camera.generate_mjpeg_stream(
"/dev/video0", "usb", fps=15, on_frame=captured.append
)
]
assert captured == [FRESH_FRAME], "callback did not get the raw frame"
assert b"--frame" in chunks[0], "wire format should still be multipart"
assert b"--frame" not in captured[0]
async def test_a_raising_on_frame_callback_cannot_break_the_stream():
"""Buffering is a side effect; it must never take the live view down."""
async def _fake_usb(_url, _fps, on_process=None):
yield FRESH_FRAME
yield FRESH_FRAME
def _boom(_frame: bytes) -> None:
raise RuntimeError("buffering blew up")
with patch.object(external_camera, "_stream_usb", _fake_usb):
chunks = [
chunk async for chunk in external_camera.generate_mjpeg_stream("/dev/video0", "usb", fps=15, on_frame=_boom)
]
assert len(chunks) == 2, "stream stopped because the callback raised"
# ---------------------------------------------------------------------------
# Layer timelapse — the 0-of-87 case
# ---------------------------------------------------------------------------
def _session(tmp_path) -> layer_timelapse.TimelapseSession:
with patch.object(layer_timelapse.settings, "base_dir", tmp_path):
return layer_timelapse.TimelapseSession(
printer_id=PRINTER_ID,
archive_id=None,
camera_url="/dev/video0",
camera_type="usb",
)
async def test_timelapse_uses_the_live_frame_instead_of_competing(tmp_path):
session = _session(tmp_path)
_attach_viewer()
with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
captured = await session.capture_layer(1)
assert captured is True, "layer capture failed with a viewer attached"
# Would have opened a competing handle on a single-reader device.
mock_capture.assert_not_called()
written = sorted(session.frames_dir.glob("layer_*.jpg"))
assert len(written) == 1
assert written[0].read_bytes() == LIVE_FRAME
async def test_timelapse_skips_a_layer_rather_than_competing_on_an_empty_buffer(tmp_path):
session = _session(tmp_path)
_attach_viewer(frame=None)
with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
captured = await session.capture_layer(1)
assert captured is False
mock_capture.assert_not_called()
assert sorted(session.frames_dir.glob("layer_*.jpg")) == []
async def test_timelapse_captures_normally_with_no_viewer(tmp_path):
"""The unwatched path must be untouched — this is the common case."""
session = _session(tmp_path)
with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
captured = await session.capture_layer(1)
assert captured is True
mock_capture.assert_awaited_once()
written = sorted(session.frames_dir.glob("layer_*.jpg"))
assert written[0].read_bytes() == FRESH_FRAME
# ---------------------------------------------------------------------------
# Obico polling — external branch, mirroring the built-in one
# ---------------------------------------------------------------------------
def _external_printer() -> MagicMock:
return MagicMock(
external_camera_enabled=True,
external_camera_url="/dev/video0",
external_camera_type="usb",
external_camera_snapshot_url=None,
ip_address="192.168.1.10",
access_code="12345678",
model="A1",
)
def _db_returning(printer) -> MagicMock:
session = MagicMock()
session.get = AsyncMock(return_value=printer)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=None)
return ctx
async def test_obico_reuses_the_live_external_frame():
_attach_viewer()
svc = ObicoDetectionService()
with (
patch(
"backend.app.services.obico_detection.async_session",
return_value=_db_returning(_external_printer()),
),
patch(
"backend.app.services.external_camera.capture_frame",
new=AsyncMock(return_value=FRESH_FRAME),
) as mock_capture,
):
result = await svc._capture_frame(printer_id=PRINTER_ID)
assert result == LIVE_FRAME
mock_capture.assert_not_called()
async def test_obico_skips_the_poll_when_the_external_buffer_is_empty():
_attach_viewer(frame=None)
svc = ObicoDetectionService()
with (
patch(
"backend.app.services.obico_detection.async_session",
return_value=_db_returning(_external_printer()),
),
patch(
"backend.app.services.external_camera.capture_frame",
new=AsyncMock(return_value=FRESH_FRAME),
) as mock_capture,
):
result = await svc._capture_frame(printer_id=PRINTER_ID)
assert result is None
mock_capture.assert_not_called()
async def test_obico_still_captures_when_nobody_is_watching():
svc = ObicoDetectionService()
with (
patch(
"backend.app.services.obico_detection.async_session",
return_value=_db_returning(_external_printer()),
),
patch(
"backend.app.services.external_camera.capture_frame",
new=AsyncMock(return_value=FRESH_FRAME),
) as mock_capture,
):
result = await svc._capture_frame(printer_id=PRINTER_ID)
assert result == FRESH_FRAME
mock_capture.assert_awaited_once()
# ---------------------------------------------------------------------------
# Plate detection — its docstring already promised this
# ---------------------------------------------------------------------------
async def test_plate_detection_reuses_the_live_external_frame():
from backend.app.services import plate_detection
_attach_viewer()
with patch(
"backend.app.services.external_camera.capture_frame",
new=AsyncMock(return_value=FRESH_FRAME),
) as mock_capture:
image, source = await plate_detection.capture_camera_image(
printer_id=PRINTER_ID,
ip_address="192.168.1.10",
access_code="12345678",
model="A1",
external_camera_url="/dev/video0",
external_camera_type="usb",
use_external=True,
)
assert image == LIVE_FRAME
assert source == "external (buffered)"
mock_capture.assert_not_called()
@@ -0,0 +1,650 @@
"""Outbound-URL SSRF policy: two tiers, applied consistently.
Bambuddy makes outbound HTTP requests to hosts the operator configures. Which
policy applies is a property of the *service*, not the caller:
- LAN-service (Spoolman, ntfy, Bark, webhooks, Home Assistant, Obico ML, the
slicer sidecars) — loopback and RFC-1918 MUST stay reachable, because
self-hosting those next to Bambuddy is the normal topology. Blocking them
would break most installs, which is why a blanket private-IP blocklist is
the wrong fix here.
- Public-internet (OIDC issuer and icon URLs) — a private address cannot be a
real IdP, so it is a probe.
Both tiers reject what is dangerous under any topology: non-HTTP schemes,
numeric-encoded IPs, cloud-metadata endpoints, multicast/unspecified, and
IPv4-mapped IPv6 encodings of the above.
The separate concern covered here is *response-body echo*. Notification
provider URLs are writable by anyone holding ``NOTIFICATIONS_CREATE`` — which
the default Operators group carries and which does NOT imply
``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` takes the URL
from the request body without persisting it. Returning the upstream body there
made an intended reachability check into an authenticated read primitive
against anything the process can reach. Providers whose host Bambuddy pins
(Pushover, Telegram, CallMeBot, Discord) may still echo, since the caller
cannot influence the destination.
"""
from __future__ import annotations
import inspect
import re
import httpx
import pytest
from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
from backend.app.schemas.auth import OIDCProviderCreate, OIDCProviderUpdate
from backend.app.schemas.settings import LAN_SERVICE_URL_SETTINGS, AppSettingsUpdate
from backend.app.services import notification_service as ns
from backend.app.services.homeassistant import HomeAssistantService
from backend.app.services.rest_smart_plug import RESTSmartPlugService
from backend.app.services.tasmota import TasmotaService
# Dangerous under any topology — both tiers must reject all of these.
UNIVERSALLY_BLOCKED = [
"file:///etc/passwd",
"gopher://127.0.0.1:6379/_INFO",
"ftp://internal.example.com/",
"http://169.254.169.254/latest/meta-data/",
"http://100.100.100.200/",
"http://[fd00:ec2::254]/",
"http://2130706433/",
"http://0x7f000001/",
"http://[::ffff:169.254.169.254]/",
"http://0.0.0.0/",
"http://239.255.255.250/",
# The DNS-name form of the same target. Neither tier resolves hostnames,
# but these are a fixed literal set, so matching them costs no lookup.
"http://metadata.google.internal/",
"http://METADATA.GOOGLE.INTERNAL/computeMetadata/v1/",
"http://metadata.goog/",
]
# The normal self-hosted topology — the LAN tier must permit all of these.
LAN_ALLOWED = [
"http://127.0.0.1:7912/",
"http://localhost:3003",
"http://192.168.1.50:8123",
"http://10.0.0.7:3333",
"http://172.16.4.9:8080",
"https://ntfy.example.com/",
"http://spoolman.lan:7912",
]
# ---------------------------------------------------------------------------
# The LAN-service tier
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
def test_lan_tier_rejects_universally_dangerous_targets(url: str):
with pytest.raises(ValueError):
assert_safe_lan_service_url(url, label="Test URL")
@pytest.mark.parametrize("url", LAN_ALLOWED)
def test_lan_tier_permits_the_normal_self_hosted_topology(url: str):
"""A blanket private-IP block here would break most real installs."""
assert_safe_lan_service_url(url, label="Test URL")
def test_lan_tier_names_the_field_in_its_error():
with pytest.raises(ValueError, match="ntfy server URL"):
assert_safe_lan_service_url("file:///etc/passwd", label="ntfy server URL")
def test_spoolman_wrapper_keeps_its_user_facing_wording():
"""The wording is asserted by pre-existing tests; delegation must not change it."""
with pytest.raises(ValueError, match="^Spoolman URL must use http or https$"):
assert_safe_spoolman_url("file:///etc/passwd")
with pytest.raises(ValueError, match="^Spoolman URL must not point to a cloud metadata endpoint$"):
assert_safe_spoolman_url("http://169.254.169.254/")
# ---------------------------------------------------------------------------
# The public-internet tier
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
def test_public_tier_rejects_universally_dangerous_targets(url: str):
with pytest.raises(ValueError):
assert_safe_public_https_url(url)
@pytest.mark.parametrize(
"url",
[
"https://127.0.0.1/",
"https://192.168.1.5/",
"https://10.1.2.3/",
"https://[fe80::1]/",
"https://[::ffff:127.0.0.1]/",
"http://accounts.google.com/", # scheme must be https
],
)
def test_public_tier_additionally_rejects_private_and_plain_http(url: str):
with pytest.raises(ValueError):
assert_safe_public_https_url(url)
# ---------------------------------------------------------------------------
# OIDC issuer_url — the encoding bypasses the hand-rolled validator missed
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"url",
[
"https://2130706433/", # decimal-encoded 127.0.0.1
"https://0x7f000001/", # hex-encoded 127.0.0.1
"https://[::ffff:127.0.0.1]/", # IPv4-mapped loopback
"https://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS
"https://0.0.0.0/",
"https://239.255.255.250/",
"https://169.254.169.254/",
"https://127.0.0.1/",
"https://192.168.1.5/",
"http://idp.example.com/",
],
)
def test_issuer_url_rejects_encoded_and_private_targets(url: str):
with pytest.raises(ValueError):
OIDCProviderCreate(
name="SSO",
issuer_url=url,
client_id="cid",
client_secret="secret",
)
def test_issuer_url_update_is_guarded_too():
"""The update path matters most: it can change the issuer while the stored
client_secret stays, which is the shape that would exfiltrate a real secret."""
with pytest.raises(ValueError):
OIDCProviderUpdate(issuer_url="https://[::ffff:127.0.0.1]/")
def test_issuer_url_error_names_the_field_not_the_icon():
with pytest.raises(ValueError, match="issuer_url"):
OIDCProviderUpdate(issuer_url="https://127.0.0.1/")
def test_a_real_idp_still_validates():
provider = OIDCProviderCreate(
name="SSO",
issuer_url="https://accounts.google.com",
client_id="cid",
client_secret="secret",
)
assert provider.issuer_url == "https://accounts.google.com"
# ---------------------------------------------------------------------------
# Settings URLs
# ---------------------------------------------------------------------------
# Imported from the schema rather than duplicated, so the backstop below cannot
# silently disagree with what is actually validated.
LAN_SERVICE_SETTINGS = LAN_SERVICE_URL_SETTINGS
@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
def test_settings_urls_reject_dangerous_targets(field: str, url: str):
with pytest.raises(ValueError):
AppSettingsUpdate(**{field: url})
@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
@pytest.mark.parametrize("url", LAN_ALLOWED)
def test_settings_urls_permit_lan_hosts(field: str, url: str):
assert AppSettingsUpdate(**{field: url})
@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
@pytest.mark.parametrize("empty", ["", " "])
def test_settings_urls_accept_empty_meaning_not_configured(field: str, empty: str):
"""Empty is the documented "fall back to the env var" value for all four."""
assert AppSettingsUpdate(**{field: empty})
@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
@pytest.mark.parametrize(
"legacy",
[
"192.168.1.10:3333", # urlparse: scheme='', netloc='', hostname=None
"localhost:3003", # urlparse: scheme='localhost' (!), hostname=None
"obico.local:3333", # same trap, with dots
"192.168.1.10",
],
)
def test_settings_urls_do_not_newly_reject_scheme_less_legacy_values(field: str, legacy: str):
"""Compatibility guard, not an endorsement.
The settings inputs are plain text with no scheme enforcement, so values
like these are already in the wild. They are inert — httpx raises
UnsupportedProtocol, so no request is issued — and they were storable
before the validator existed. Rejecting them now would block saves of
unrelated fields bundled in the same request (the Obico panel auto-saves
obico_ml_url alongside every other Obico setting).
"""
assert AppSettingsUpdate(**{field: legacy})
@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
def test_settings_urls_still_reject_a_real_non_http_scheme(field: str):
"""The leniency above is scoped to strings that are not URLs at all."""
with pytest.raises(ValueError):
AppSettingsUpdate(**{field: "file:///etc/passwd"})
def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
"""CI backstop: a new outbound-URL setting can't land unvalidated.
Any new ``*_url`` field on AppSettingsUpdate must be added to the
validator's field tuple or listed as exempt here with a reason. This
catches the failure mode the original report correctly identified — guards
added per-incident rather than to the whole class of fields.
"""
exempt = {
# Bambuddy's own public address, not a destination it requests. It is
# rendered into notification bodies and OIDC redirect URIs, and handed
# to Obico's ML server as the `img` parameter for that server to fetch
# (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
# {nonce}`). Pointing it at a private address only breaks Bambuddy's own
# links; it cannot make Bambuddy request anything it otherwise wouldn't.
"external_url",
# Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
# location_service.py, inventory.py, spoolbuddy.py,
# spoolman_inventory.py) rather than in the schema, keeping its
# established user-facing "Spoolman URL ..." error wording.
"spoolman_url",
# Not an HTTP URL: ldap:// or ldaps://, handed to an LDAP client, never
# to httpx. The LAN-service guard requires http/https and would reject
# every valid value. It also cannot reach a cloud-metadata endpoint,
# since IMDS only speaks HTTP.
"ldap_server_url",
}
url_fields = {name for name in AppSettingsUpdate.model_fields if name.endswith("_url")}
unguarded = url_fields - set(LAN_SERVICE_SETTINGS) - exempt
assert not unguarded, (
f"New outbound URL setting(s) {sorted(unguarded)} are not covered by a "
f"SSRF guard. Add them to AppSettingsUpdate._LAN_SERVICE_URL_FIELDS (or "
f"the public-internet guard), or add them to `exempt` above with a reason."
)
# ---------------------------------------------------------------------------
# Notification providers: URL guard + no response-body echo
# ---------------------------------------------------------------------------
def _response(status: int = 500, body: str = "root:x:0:0:root:/root:/bin/bash") -> httpx.Response:
return httpx.Response(status_code=status, text=body, request=httpx.Request("POST", "http://10.0.0.1/"))
SECRET_BODY = "root:x:0:0:root:/root:/bin/bash"
def test_opaque_failure_does_not_return_the_response_body():
message = ns._opaque_http_failure(_response(), label="webhook endpoint")
assert SECRET_BODY not in message
assert "500" in message, "the status code is still useful and is not sensitive"
assert "webhook endpoint" in message
def test_opaque_failure_logs_the_body_for_the_operator(caplog):
"""The body stays available to whoever administers the host — via logs,
not via the API response."""
with caplog.at_level("DEBUG", logger=ns.__name__):
ns._opaque_http_failure(_response(), label="ntfy server")
assert SECRET_BODY in caplog.text
@pytest.mark.parametrize(
"provider_label",
["ntfy server", "Bark server", "webhook endpoint", "Home Assistant endpoint"],
)
def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
"""Guards the mapping itself: each user-supplied-host provider must route
its HTTP failure through _opaque_http_failure rather than formatting the
body inline."""
src = inspect.getsource(ns)
assert f'_opaque_http_failure(response, label="{provider_label}")' in src
def test_no_user_supplied_host_provider_formats_the_body_inline():
"""Any remaining ``response.text[:200]`` must belong to a host-pinned provider.
Pushover/Telegram/CallMeBot/Discord all target hardcoded hosts (Discord via
a webhook-prefix allowlist), so there is no trust boundary to cross.
"""
src = inspect.getsource(ns).split("\n")
host_pinned = {"_send_callmebot", "_send_pushover", "_send_telegram", "_send_discord"}
current = None
offenders = []
for line in src:
match = re.match(r"\s+async def (_send_\w+)", line)
if match:
current = match.group(1)
if "response.text[:200]" in line and current not in host_pinned:
offenders.append(current)
assert not offenders, (
f"{offenders} echo the upstream response body but do not target a "
f"hardcoded host. Route the failure through _opaque_http_failure."
)
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
def test_provider_url_guard_rejects_dangerous_targets(url: str):
assert ns._assert_safe_provider_url(url, label="Webhook URL") is not None
@pytest.mark.parametrize("url", LAN_ALLOWED)
def test_provider_url_guard_permits_self_hosted_servers(url: str):
assert ns._assert_safe_provider_url(url, label="ntfy server URL") is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("provider_type", "config"),
[
("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
],
)
async def test_test_config_refuses_metadata_targets_without_a_request(provider_type: str, config: dict, monkeypatch):
"""The end-to-end shape of the reported attack: an unsaved config aimed at
IMDS via the test endpoint. It must be refused before any HTTP call."""
called = False
async def _fail_if_called(*_a, **_kw):
nonlocal called
called = True
raise AssertionError("outbound request should not have been attempted")
service = ns.NotificationService()
monkeypatch.setattr(service, "_get_client", _fail_if_called)
success, message = await service.send_test_notification(provider_type, config)
assert success is False
assert called is False
assert "cloud metadata" in message
# ---------------------------------------------------------------------------
# Smart plugs: the same request-body-URL shape as the notification test endpoint
# ---------------------------------------------------------------------------
#
# POST /smart-plugs/{ha,rest}/test-connection take their URL from the request
# body and never persist it, so the schema-layer validator on ``ha_url`` does
# not apply. Both are reachable with only ``SMART_PLUGS_CONTROL``, which the
# default Operators group carries and which does NOT imply ``SETTINGS_UPDATE``
# — identical to the notification case above.
#
# Both previously used hand-rolled checks that got the policy wrong in both
# directions: the REST one rejected a literal ``127.0.0.1`` while allowing
# every non-literal hostname, and the HA one matched three literal strings and
# never parsed the hostname as an IP at all.
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
def test_rest_plug_guard_rejects_dangerous_targets(url: str):
assert RESTSmartPlugService._validate_url(url) is False
@pytest.mark.parametrize("url", LAN_ALLOWED)
def test_rest_plug_guard_permits_the_normal_self_hosted_topology(url: str):
"""Includes literal 127.0.0.1, which the previous implementation rejected
while accepting the equivalent "localhost" — a plug bridge on the same
host could only be configured by spelling it one particular way."""
assert RESTSmartPlugService._validate_url(url) is True
@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
def test_ha_guard_rejects_dangerous_targets(url: str):
assert HomeAssistantService._validate_url(url) is None
@pytest.mark.parametrize("url", LAN_ALLOWED)
def test_ha_guard_permits_the_normal_self_hosted_topology(url: str):
assert HomeAssistantService._validate_url(url) is not None
def test_ha_guard_still_normalises_the_url_it_returns():
"""Delegating the policy must not change what the caller gets back:
scheme+host+port+path, with query and fragment dropped."""
assert HomeAssistantService._validate_url("http://192.168.1.5:8123/base?x=1#f") == "http://192.168.1.5:8123/base"
assert HomeAssistantService._validate_url("http://ha.lan") == "http://ha.lan"
def test_ha_guard_keeps_ipv6_literals_bracketed():
"""urlparse strips the brackets off an IPv6 host; re-emitting it without
them yields an unparseable URL that httpx cannot dial."""
assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
@pytest.mark.parametrize("ip", ["169.254.169.254", "100.100.100.200", "fd00:ec2::254", "0.0.0.0", "239.255.255.250"])
def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
"""Tasmota keeps its own stricter rule (bare IP literals only, loopback
rejected — a plug is always a separate LAN device), but must not miss the
destinations that are dangerous regardless of topology."""
assert TasmotaService._validate_ip(ip) is False
@pytest.mark.parametrize("ip", ["::ffff:169.254.169.254", "::ffff:100.100.100.200"])
def test_tasmota_guard_unwraps_ipv4_mapped_ipv6(ip: str):
assert TasmotaService._validate_ip(ip) is False
@pytest.mark.parametrize("ip", ["192.168.1.50", "10.0.0.7", "172.16.4.9"])
def test_tasmota_guard_still_permits_a_normal_lan_plug(ip: str):
assert TasmotaService._validate_ip(ip) is True
@pytest.mark.parametrize("ip", ["127.0.0.1", "tasmota.local", "not-an-ip"])
def test_tasmota_guard_keeps_failing_closed_on_non_lan_device_values(ip: str):
"""Deliberately stricter than the shared LAN guard, and unchanged here."""
assert TasmotaService._validate_ip(ip) is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
"target",
["http://169.254.169.254/", "http://100.100.100.200/", "http://metadata.google.internal/"],
)
async def test_rest_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
"""End-to-end shape of the reported attack, mirroring the notification
test above: an unsaved URL aimed at IMDS via the test endpoint must be
refused before any HTTP call is made."""
def _fail_if_called(*_a, **_kw):
raise AssertionError("outbound request should not have been attempted")
monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
result = await RESTSmartPlugService().test_connection(target, "GET", None)
assert result["success"] is False
assert "cloud metadata" in result["error"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"target",
["http://169.254.169.254", "http://100.100.100.200", "http://metadata.google.internal"],
)
async def test_ha_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
def _fail_if_called(*_a, **_kw):
raise AssertionError("outbound request should not have been attempted")
monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
result = await HomeAssistantService().test_connection(target, "token")
assert result["success"] is False
@pytest.mark.asyncio
@pytest.mark.parametrize("target", ["http://169.254.169.254", "http://metadata.google.internal"])
async def test_obico_test_connection_refuses_metadata_without_a_request(target: str, monkeypatch):
"""Same shape again: obico_ml_url is guarded when saved via settings, but
this route takes the URL from the request body and echoes the response."""
from backend.app.services.obico_detection import ObicoDetectionService
def _fail_if_called(*_a, **_kw):
raise AssertionError("outbound request should not have been attempted")
monkeypatch.setattr(httpx, "AsyncClient", _fail_if_called)
result = await ObicoDetectionService().test_connection(target)
assert result["ok"] is False
assert result["body"] is None
assert "cloud metadata" in result["error"]
# ---------------------------------------------------------------------------
# Drift backstop, part 2: URLs that arrive in a request body
# ---------------------------------------------------------------------------
#
# `test_every_url_setting_is_either_guarded_or_explicitly_exempt` above only
# walks `AppSettingsUpdate`. That is why the notification test endpoint, and
# then the two smart-plug test endpoints, each had to be found by hand: a URL
# that arrives in a request body and is never persisted is not a settings
# field, so nothing enumerated it. This walks the live route table instead.
def _request_body_url_fields() -> set[tuple[str, str]]:
"""Every (model, field) pair on a mutating route whose body carries a URL."""
from fastapi.routing import APIRoute
from pydantic import BaseModel
from backend.app.main import app
found: set[tuple[str, str]] = set()
for route in app.routes:
if not isinstance(route, APIRoute) or not ({"POST", "PUT", "PATCH"} & set(route.methods or ())):
continue
for param in route.dependant.body_params:
# FastAPI moved the resolved annotation from `type_` onto
# `field_info.annotation`; read both so this can't silently
# enumerate nothing (which would make the assertions vacuous).
annotation = getattr(param, "type_", None)
if annotation is None:
annotation = getattr(getattr(param, "field_info", None), "annotation", None)
if not (isinstance(annotation, type) and issubclass(annotation, BaseModel)):
continue
for field in annotation.model_fields:
if field == "url" or field.endswith("_url"):
found.add((annotation.__name__, field))
return found
# Guarded: the handler (or the service it calls) puts the value through one of
# the two tiers before any request is issued.
GUARDED_BODY_URLS = {
("AppSettingsUpdate", "bambu_studio_api_url"),
("AppSettingsUpdate", "ha_url"),
("AppSettingsUpdate", "obico_ml_url"),
("AppSettingsUpdate", "orcaslicer_api_url"),
("AppSettingsUpdate", "spoolman_url"), # assert_safe_spoolman_url at each consumer
("HATestConnectionRequest", "url"), # homeassistant._validate_url
("RESTTestConnectionRequest", "url"), # rest_smart_plug._validate_url
("TestConnectionRequest", "url"), # obico_detection.test_connection
("OIDCProviderCreate", "issuer_url"), # public tier, via schemas.auth
("OIDCProviderCreate", "icon_url"),
("OIDCProviderUpdate", "issuer_url"),
("OIDCProviderUpdate", "icon_url"),
# Gitea/Forgejo derive their API base from this and request it with the
# stored token, so it is a real fetch target — guarded in
# github_backup._enforce_private_repo, which both POST and PATCH funnel through.
("GitHubBackupConfigCreate", "repository_url"),
("GitHubBackupConfigUpdate", "repository_url"),
# SmartPlug{Create,Update} persist these; every read goes back out through
# RESTSmartPlugService._send_request, which applies the same guard.
("SmartPlugCreate", "rest_on_url"),
("SmartPlugCreate", "rest_off_url"),
("SmartPlugCreate", "rest_status_url"),
("SmartPlugCreate", "rest_power_url"),
("SmartPlugCreate", "rest_energy_url"),
("SmartPlugUpdate", "rest_on_url"),
("SmartPlugUpdate", "rest_off_url"),
("SmartPlugUpdate", "rest_status_url"),
("SmartPlugUpdate", "rest_power_url"),
("SmartPlugUpdate", "rest_energy_url"),
}
# Not a destination Bambuddy requests — no guard applies.
NOT_A_FETCH_TARGET = {
("AppSettingsUpdate", "external_url"), # Bambuddy's own address (see exempt list above)
("AppSettingsUpdate", "ldap_server_url"), # ldap://, handed to an LDAP client
("ProjectCreate", "url"), # stored link, rendered in the UI, never fetched
("ProjectUpdate", "url"),
("BOMItemCreate", "sourcing_url"), # stored supplier link, never fetched
("BOMItemUpdate", "sourcing_url"),
("MakerWorldResolveRequest", "url"), # parsed for a model id; fetches go to a pinned CDN allowlist
("DeviceRegisterRequest", "backend_url"), # the device's view of Bambuddy's own address
("HeartbeatRequest", "backend_url"),
("SystemConfigRequest", "backend_url"),
("ExternalLinkCreate", "url"), # sidebar link, rendered in the UI, never requested
("ExternalLinkUpdate", "url"),
("MaintenanceTypeCreate", "wiki_url"), # documentation link surfaced in the UI/notifications
("MaintenanceTypeUpdate", "wiki_url"),
("ArchiveUpdate", "external_url"), # stored source link for the model, never fetched
}
# Genuinely unguarded, and deliberately recorded rather than quietly exempted.
# These reach `external_camera.capture_frame`, which dials rtsp:// as well as
# http(s):// — the LAN-service guard rejects any non-HTTP scheme, so wiring it
# up as-is would break every RTSP camera. Closing these needs a scheme-aware
# variant of the guard, not a one-line delegation.
KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD = {
("PrinterCreate", "external_camera_url"),
("PrinterCreate", "external_camera_snapshot_url"),
("PrinterUpdate", "external_camera_url"),
("PrinterUpdate", "external_camera_snapshot_url"),
}
def test_the_route_walk_actually_finds_something():
"""Guards the guard. If FastAPI's internals move again and the walk starts
returning nothing, both assertions below pass vacuously and the backstop
silently stops working — which is the exact failure it exists to prevent."""
found = _request_body_url_fields()
assert ("RESTTestConnectionRequest", "url") in found
assert ("HATestConnectionRequest", "url") in found
assert len(found) > 20
def test_every_request_body_url_is_classified():
"""A new URL-bearing request field can't land without a decision.
Add it to GUARDED_BODY_URLS once the handler runs it through a guard, or
to NOT_A_FETCH_TARGET with the reason it is never requested. Do not add
anything to KNOWN_UNGUARDED_* without also raising it.
"""
classified = GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD
unclassified = _request_body_url_fields() - classified
assert not unclassified, (
f"Unclassified request-body URL field(s): {sorted(unclassified)}. Route the value "
f"through a guard and list it in GUARDED_BODY_URLS, or list it in NOT_A_FETCH_TARGET "
f"with the reason it is never fetched."
)
def test_classification_lists_do_not_drift_from_the_routes():
"""The reverse direction: a stale entry means a route was renamed or
removed and the list was not updated, which would hide the next one."""
actual = _request_body_url_fields()
stale = (GUARDED_BODY_URLS | NOT_A_FETCH_TARGET | KNOWN_UNGUARDED_NEEDS_SCHEME_AWARE_GUARD) - actual
assert not stale, f"Classification entries no longer match any route: {sorted(stale)}"
@@ -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,
)
+166
View File
@@ -0,0 +1,166 @@
"""The pool must not silently be allowed to outgrow the PostgreSQL server.
``pool_size + max_overflow`` is the most connections one worker will open. When
that exceeds what the server permits, the pool never hits its own limit and so
never queues — it asks the server, which refuses with
``TooManyConnectionsError`` at whatever happened to need a connection next. In
the report behind this, that was the middle of a queue dispatch.
The check is diagnostic, not corrective: pool sizes are fixed at engine creation
(import time, before any connection exists to ask with), and the right ceiling
depends on the worker count and on other clients sharing the server. So the
contract under test is "says something accurate and loud, and never breaks
startup".
"""
from __future__ import annotations
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _engine_reporting(max_conn: int, reserved: int, in_use: int | None = 0) -> MagicMock:
"""An engine whose connection answers the three probe queries in order.
``in_use=None`` makes the third query fail, standing in for PostgreSQL < 10
where ``pg_stat_activity.backend_type`` does not exist.
"""
conn = MagicMock()
conn.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one=MagicMock(return_value=max_conn)),
MagicMock(scalar_one=MagicMock(return_value=reserved)),
(
MagicMock(scalar_one=MagicMock(return_value=in_use))
if in_use is not None
else RuntimeError('column "backend_type" does not exist')
),
]
)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=conn)
ctx.__aexit__ = AsyncMock(return_value=False)
engine = MagicMock()
engine.connect = MagicMock(return_value=ctx)
return engine
async def _run_check(*, pool_size, max_overflow, max_conn, reserved, in_use=0, sqlite=False):
from backend.app.core import database
with (
patch.object(database, "is_sqlite", return_value=sqlite),
patch.object(database, "_pool_config", {"pool_size": pool_size, "max_overflow": max_overflow}),
patch.object(database, "engine", _engine_reporting(max_conn, reserved, in_use)),
patch.object(database, "_server_connection_limits", None),
):
await database.check_pool_fits_server()
return database._server_connection_limits
@pytest.mark.asyncio
@pytest.mark.unit
async def test_warns_when_the_ceiling_exceeds_what_the_server_allows(caplog):
"""Bambuddy's own PostgreSQL default against a stock server: 100 vs 100-3."""
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3)
assert any(r.levelno == logging.WARNING for r in caplog.records)
msg = caplog.text
# The numbers an operator needs, and the knobs to change.
for expected in ("100", "97", "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "max_connections"):
assert expected in msg, f"warning omits {expected!r}"
@pytest.mark.asyncio
@pytest.mark.unit
async def test_silent_when_the_pool_fits(caplog):
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
await _run_check(pool_size=20, max_overflow=80, max_conn=500, reserved=3)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
@pytest.mark.asyncio
@pytest.mark.unit
async def test_the_reserved_slots_count_against_the_budget(caplog):
"""Exactly at max_connections is still too many — reserved slots are not ours."""
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
await _run_check(pool_size=10, max_overflow=90, max_conn=100, reserved=3)
assert [r for r in caplog.records if r.levelno == logging.WARNING]
@pytest.mark.asyncio
@pytest.mark.unit
async def test_both_sides_are_recorded_for_the_support_bundle():
limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=41)
assert limits == {
"max_connections": 100,
"superuser_reserved_connections": 3,
"available_to_bambuddy": 97,
"client_backends_at_startup": 41,
"pool_ceiling_per_worker": 100,
}
@pytest.mark.asyncio
@pytest.mark.unit
async def test_sqlite_is_skipped_entirely():
"""No such concept, and the probe SQL is PostgreSQL-only."""
limits = await _run_check(pool_size=20, max_overflow=200, max_conn=0, reserved=0, sqlite=True)
assert limits is None
@pytest.mark.asyncio
@pytest.mark.unit
async def test_a_probe_failure_cannot_break_startup(caplog):
"""A restricted role or an older server may refuse these queries."""
from backend.app.core import database
engine = MagicMock()
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("permission denied"))
ctx.__aexit__ = AsyncMock(return_value=False)
engine.connect = MagicMock(return_value=ctx)
with (
patch.object(database, "is_sqlite", return_value=False),
patch.object(database, "_pool_config", {"pool_size": 20, "max_overflow": 80}),
patch.object(database, "engine", engine),
patch.object(database, "_server_connection_limits", None),
caplog.at_level(logging.WARNING, logger="backend.app.core.database"),
):
await database.check_pool_fits_server() # must not raise
assert database._server_connection_limits is None
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
@pytest.mark.asyncio
@pytest.mark.unit
async def test_an_old_server_without_backend_type_still_gets_the_warning(caplog):
"""`pg_stat_activity.backend_type` is PostgreSQL 10+; the docs recommend 14+
but asyncpg reaches back to 9.5. Losing that count must not cost the
warning, which only needs the two settings."""
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=None)
assert [r for r in caplog.records if r.levelno == logging.WARNING], "warning was lost with the count"
assert "100" in caplog.text and "97" in caplog.text
# The sentence about other clients is dropped rather than rendered as None.
assert "None client" not in caplog.text
assert limits["client_backends_at_startup"] is None
assert limits["max_connections"] == 100
@pytest.mark.unit
def test_get_pool_status_exposes_the_server_limits_key():
"""The support bundle reads this; the key must exist even on SQLite."""
from backend.app.core.database import get_pool_status
assert "server_limits" in get_pool_status()
@@ -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,
@@ -0,0 +1,163 @@
"""PUT /settings/spoolman must not 500 on a JSON boolean.
The endpoint takes a free-form ``dict`` body, and settings are persisted in a
VARCHAR column that every reader compares as a string. Sending the natural JSON
form — ``{"spoolman_enabled": true}`` — used to fail twice over:
- ``bool.lower()`` raised AttributeError while deciding whether the mode had
changed, surfacing as an opaque 500;
- the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
to 1/0 while asyncpg rejects it — so the stored representation depended on
the deployment's database.
The shipped UI sends strings, so this was reachable only through the REST API
(scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
boolean is the obvious thing to send.
These tests cover the normalisers directly. They are pure functions, so the
matrix stays readable and the endpoint keeps a single code path per field.
"""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from backend.app.api.routes.settings import (
normalize_bool_setting,
normalize_str_setting,
setting_is_true,
)
# ---------------------------------------------------------------------------
# The reported crash
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
"""The exact input that used to 500."""
assert normalize_bool_setting("spoolman_enabled", value) == expected
@pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
assert normalize_bool_setting("spoolman_enabled", value) == expected
# ---------------------------------------------------------------------------
# String spellings — generous on purpose, this is a documented REST surface
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
def test_truthy_spellings(value: str):
assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
@pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
def test_falsy_spellings(value: str):
assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
def test_python_style_capitalised_true_is_normalised_lowercase():
"""The frontend compares with a case-sensitive ``=== 'true'``.
A client sending "True" previously had it stored verbatim, so the UI
rendered the setting as OFF while every backend reader (which all use
``.lower()``) treated it as ON.
"""
assert normalize_bool_setting("spoolman_enabled", "True") == "true"
# ---------------------------------------------------------------------------
# Empty means "use the default" — deliberately NOT normalised to "false"
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["", " "])
def test_empty_is_preserved_not_turned_into_false(value: str):
"""get_spoolman_settings reads these with ``or "<default>"``.
spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
coercing a blank submission to "false" would silently switch them off.
Whitespace-only collapses to "" so it takes the same path rather than
being stored as a truthy-but-meaningless " ".
"""
assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
# ---------------------------------------------------------------------------
# Values with no sensible reading get a 400 naming the field, not a 500
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
def test_uninterpretable_values_raise_400_naming_the_field(value: object):
with pytest.raises(HTTPException) as exc:
normalize_bool_setting("spoolman_enabled", value)
assert exc.value.status_code == 400
assert "spoolman_enabled" in str(exc.value.detail)
# ---------------------------------------------------------------------------
# String settings
# ---------------------------------------------------------------------------
def test_str_setting_passes_strings_through_untouched():
assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
def test_str_setting_stringifies_numbers():
"""An unquoted host or port is a plausible client slip, not a hard error."""
assert normalize_str_setting("spoolman_url", 7912) == "7912"
def test_str_setting_maps_null_to_empty():
assert normalize_str_setting("spoolman_url", None) == ""
@pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
with pytest.raises(HTTPException) as exc:
normalize_str_setting("spoolman_url", value)
assert exc.value.status_code == 400
# ---------------------------------------------------------------------------
# setting_is_true — used for the mode-switch comparison
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("stored", "expected"),
[
("true", True),
("True", True),
("TRUE", True),
(" true ", True),
("false", False),
("", False),
("banana", False),
(None, False), # setting absent from the table
(True, True), # legacy row: SQLite coerced a raw bool into the column
(False, False),
],
)
def test_setting_is_true(stored: object, expected: bool):
assert setting_is_true(stored) is expected
@pytest.mark.parametrize("stored", ["1", "on", "yes"])
def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
"""Reading must agree with the rest of the codebase, which only accepts "true".
normalize_bool_setting is generous about what clients may *send*; every
reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
main) compares ``.lower() == "true"``. Accepting more here would make the
mode-switch check disagree with them about a legacy row.
"""
assert setting_is_true(stored) is False
+166
View File
@@ -1285,3 +1285,169 @@ class TestRedactRawPushStatus:
assert _redact_raw_push_status(None) == {} # type: ignore[arg-type]
assert _redact_raw_push_status([]) == {} # type: ignore[arg-type]
assert _redact_raw_push_status("") == {} # type: ignore[arg-type]
class TestSanitizePushStatusValues:
"""The bundled push_status snapshot must stay parseable JSON.
Sanitization used to run over the *serialised* snapshot. The generic
Bambu-serial regex in ``log_reader`` (``0[0-3][A-Z0-9][A-Z0-9]{9,13}``)
matches the decimal expansion of a float as readily as a serial, so an AMS
``k`` flow factor came out as ``0.[SERIAL]`` and the whole file stopped
parsing — found in a real bundle while diagnosing #2702, which is exactly
the case the snapshot was added to serve.
"""
def test_float_that_matches_the_serial_regex_survives(self):
"""The observed reproducer, verbatim."""
import json
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {"ams": [{"tray": [{"k": 0.0199999995529652}]}]}
out = _sanitize_push_status_values(raw, {})
assert json.loads(json.dumps(out)) == raw
def test_output_always_parses(self):
"""Whatever it does to values, the result must be valid JSON."""
import json
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {
"k_values": [0.0199999995529652, 0.019999999552965164, 0.02],
"home_flag": 7554487,
"sdcard": True,
"resolution": "",
"nozzle": None,
}
json.loads(json.dumps(_sanitize_push_status_values(raw, {})))
def test_still_redacts_strings(self):
"""The point of the pass is not lost — string values are sanitized."""
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {"tag_uid": "0123456789ABCDEF", "name": "Martin's P1S", "ip": "192.168.1.50"}
out = _sanitize_push_status_values(raw, {"Martin's P1S": "[PRINTER]"})
assert out["tag_uid"] == "[SERIAL]"
assert out["name"] == "[PRINTER]"
assert out["ip"] == "[IP]"
def test_walks_nested_containers(self):
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {"ams": [{"tray": [{"tray_uuid": "0123456789ABCDEF"}]}]}
out = _sanitize_push_status_values(raw, {})
assert out["ams"][0]["tray"][0]["tray_uuid"] == "[SERIAL]"
def test_keys_are_left_alone(self):
"""Keys are structural — renaming one would break the schema."""
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {"0123456789ABCDEF": 1}
assert list(_sanitize_push_status_values(raw, {})) == ["0123456789ABCDEF"]
def test_non_json_scalars_are_sanitized_not_smuggled(self):
"""``json.dumps(default=str)`` runs after this pass, so do it here."""
from datetime import datetime, timezone
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {"seen_at": datetime(2026, 7, 29, 23, 12, 40, tzinfo=timezone.utc), "who": object()}
out = _sanitize_push_status_values(raw, {"2026-07-29": "[WHEN]"})
assert out["seen_at"].startswith("[WHEN]")
assert isinstance(out["who"], str)
def test_bools_stay_bools(self):
"""`isinstance(True, int)` — a bool must not fall through to str()."""
from backend.app.api.routes.support import _sanitize_push_status_values
out = _sanitize_push_status_values({"sdcard": True, "force_upgrade": False}, {})
assert out["sdcard"] is True
assert out["force_upgrade"] is False
def test_the_full_bundle_chain_on_a_real_p1s_payload(self):
"""The route's transform, end to end, on the shape from the #2702 bundle.
`_redact_raw_push_status` then `_sanitize_push_status_values` then
`json.dumps(default=str)` — the composition the bundle writer applies.
The bundle that exposed this had five `k` values corrupted, so the
snapshot could not be read at all; the field the report was about
(`total_layer_num`) was sitting in it, intact and unreachable.
"""
import json
from backend.app.api.routes.support import (
_redact_raw_push_status,
_sanitize_push_status_values,
)
raw = {
"gcode_file": "AMS_Filament_Clip_3MF.3mf",
"layer_num": 2,
"total_layer_num": 33,
"home_flag": 7554487,
"sdcard": True,
"net": {"info": [{"ip": "192.168.1.50", "mask": 0}]},
"ams": {
"ams": [
{
"id": "0",
"humidity": "5",
"tray": [
{"id": "0", "k": 0.0199999995529652, "tag_uid": "0123456789ABCDEF"},
{"id": "1", "k": 0.0209999997168779, "tag_uid": "44F782D000000100"},
],
}
]
},
}
snapshot = {
"model": "P1S",
"firmware_version": "01.10.00.00",
"raw_data": _redact_raw_push_status(raw),
}
text = json.dumps(_sanitize_push_status_values(snapshot, {}), indent=2, default=str)
parsed = json.loads(text) # used to raise "Expecting ',' delimiter"
trays = parsed["raw_data"]["ams"]["ams"][0]["tray"]
assert [t["k"] for t in trays] == [0.0199999995529652, 0.0209999997168779]
assert parsed["raw_data"]["total_layer_num"] == 33
# Redaction still did its job on both fronts.
assert "gcode_file" not in parsed["raw_data"]
assert trays[0]["tag_uid"] == "[SERIAL]"
# The structural pass replaces the printer's LAN address with the
# sentinel 0.0.0.0, which is itself an IPv4 literal, so the value pass
# then masks it to [IP]. Harmless — the real address is already gone —
# and matches what shipped in the bundle behind #2702.
assert parsed["raw_data"]["net"]["info"][0]["ip"] == "[IP]"
def test_does_not_mutate_the_live_snapshot(self):
"""`state.raw_data` is read by the dispatcher on every tick.
The bundle writer passes a redacted copy, but a walker that mutated in
place would still be one refactor away from redacting the live state.
"""
import copy
from backend.app.api.routes.support import _sanitize_push_status_values
raw = {"tag_uid": "0123456789ABCDEF", "ams": [{"tray": [{"k": 0.02, "n": "0123456789ABCDEF"}]}]}
before = copy.deepcopy(raw)
out = _sanitize_push_status_values(raw, {})
assert raw == before, "input was mutated"
assert out["tag_uid"] == "[SERIAL]" # and the copy really was redacted
@@ -0,0 +1,107 @@
"""Tests for optional Telegram forum-topic delivery via message_thread_id (#1518).
Telegram forum groups route messages to a topic by ``message_thread_id``. The
field is optional: when it is absent, Telegram posts to the group's General
topic, which is the behaviour every existing install already relies on.
The subtlety worth pinning is the type. ``sendMessage`` is posted as JSON, and
Telegram rejects a *string* thread id there, while the multipart ``sendPhoto``
call would accept one. A string passed straight through would therefore work
for notifications carrying a thumbnail and 400 for plain-text ones — so these
tests assert an ``int`` reaches both call sites.
"""
import httpx
import pytest
from backend.app.services.notification_service import NotificationService
class _CaptureClient:
"""Stand-in for httpx.AsyncClient recording the JSON body and form data."""
def __init__(self):
self.is_closed = False
self.calls: list[dict] = []
async def post(self, url, data=None, files=None, json=None):
self.calls.append({"url": url, "data": data, "files": files, "json": json})
return httpx.Response(200, json={"ok": True, "result": {}})
@pytest.fixture
def service_with_capture():
service = NotificationService()
client = _CaptureClient()
service._http_client = client # bypass real HTTP
return service, client
BASE_CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
PNG = b"\x89PNG\r\n\x1a\n"
@pytest.mark.asyncio
async def test_thread_id_omitted_when_unset(service_with_capture):
"""Default config must produce exactly the pre-#1518 payload."""
service, client = service_with_capture
ok, _ = await service._send_telegram(BASE_CONFIG, "*T*\nbody")
assert ok
assert "message_thread_id" not in client.calls[0]["json"]
@pytest.mark.asyncio
async def test_blank_thread_id_is_treated_as_unset(service_with_capture):
"""An emptied-out form field must not turn into a bogus topic."""
service, client = service_with_capture
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": " "}, "*T*\nbody")
assert ok
assert "message_thread_id" not in client.calls[0]["json"]
@pytest.mark.asyncio
async def test_sendmessage_carries_thread_id_as_int(service_with_capture):
service, client = service_with_capture
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody")
assert ok
body = client.calls[0]["json"]
assert body["message_thread_id"] == 25
assert isinstance(body["message_thread_id"], int), "Telegram 400s on a string thread id in JSON"
@pytest.mark.asyncio
async def test_sendphoto_carries_thread_id(service_with_capture):
"""Thumbnail notifications take the multipart path and must route too."""
service, client = service_with_capture
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody", image_data=PNG)
assert ok
call = client.calls[0]
assert call["url"].endswith("/sendPhoto")
assert call["data"]["message_thread_id"] == 25
@pytest.mark.asyncio
async def test_thread_id_accepts_native_int(service_with_capture):
"""config is a JSON blob — the value may already deserialise as an int."""
service, client = service_with_capture
ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": 25}, "*T*\nbody")
assert ok
assert client.calls[0]["json"]["message_thread_id"] == 25
@pytest.mark.asyncio
async def test_non_numeric_thread_id_fails_without_sending(service_with_capture):
"""Reject locally rather than let Telegram answer with an opaque 400."""
service, client = service_with_capture
ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "General"}, "*T*\nbody")
assert not ok
assert "not a number" in error
assert client.calls == []
@pytest.mark.asyncio
async def test_error_message_does_not_leak_bot_token(service_with_capture):
service, _ = service_with_capture
ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "oops"}, "*T*\nbody")
assert not ok
assert "AAbbCC" not in error
+106
View File
@@ -10,6 +10,7 @@ import math
import zipfile
from backend.app.utils.threemf_tools import (
expand_to_project_slots,
extract_bed_type_from_3mf,
extract_embedded_presets_from_3mf,
extract_filament_usage_from_3mf,
@@ -480,6 +481,111 @@ class TestExtractProjectFilamentsFrom3mf:
assert extract_project_filaments_from_3mf(zf) == []
# ---------------------------------------------------------------------------
# Tests for expand_to_project_slots — #2712
# ---------------------------------------------------------------------------
class TestExpandToProjectSlots:
"""The slice modal's filament list is positional: index 0 is slot 1, all
the way through to the ``filament_N.json`` parts handed to the slicer.
A MakerWorld source that carries slice_info but paints with slot 4 alone
used to yield a one-row list, so the user's single pick was bound to slot
1 and slot 4 — the slot that actually prints — kept the source's embedded
default. Picking PETG produced a PLA print.
"""
PROJECT = json.dumps(
{
"filament_type": ["PLA", "PLA", "PLA", "PLA"],
"filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
}
)
def test_a_single_used_slot_still_produces_a_full_positional_list(self):
"""The reported file: four project slots, only slot 4 printed."""
used = [
{
"slot_id": 4,
"type": "PLA",
"color": "#898989",
"used_grams": 105.9,
"used_meters": 35.51,
"tray_info_idx": "GFL99",
"used_in_plate": True,
}
]
with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
out = expand_to_project_slots(zf, used)
assert [f["slot_id"] for f in out] == [1, 2, 3, 4]
assert [f["used_in_plate"] for f in out] == [False, False, False, True]
def test_the_used_row_keeps_its_usage_figures(self):
"""The modal shows the real weight and colour, and the print path
downstream reads ``tray_info_idx`` — none of it may be flattened into
a zeroed project row."""
used = [
{
"slot_id": 4,
"type": "PETG",
"color": "#FF0000",
"used_grams": 105.9,
"used_meters": 35.51,
"tray_info_idx": "GFL99",
"used_in_plate": True,
}
]
with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
out = expand_to_project_slots(zf, used)
slot4 = out[3]
assert slot4["used_grams"] == 105.9
assert slot4["tray_info_idx"] == "GFL99"
# Resolved from the slice, not the project's stale PLA/#898989.
assert (slot4["type"], slot4["color"]) == ("PETG", "#FF0000")
def test_padding_rows_carry_the_project_type_and_colour(self):
"""They drive the modal's pre-pick for the disabled rows."""
used = [{"slot_id": 4, "type": "PLA", "color": "#898989", "used_grams": 1.0, "used_meters": 1.0}]
with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
out = expand_to_project_slots(zf, used)
assert (out[0]["type"], out[0]["color"]) == ("PLA", "#38CC0A")
assert out[0]["used_grams"] == 0
def test_every_slot_used_is_a_shape_change_only(self):
used = [
{"slot_id": i, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0, "used_in_plate": True}
for i in (1, 2, 3, 4)
]
with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
out = expand_to_project_slots(zf, used)
assert len(out) == 4
assert all(f["used_in_plate"] for f in out)
assert all(f["used_grams"] == 5.0 for f in out)
def test_a_used_slot_beyond_the_project_list_is_kept(self):
"""Dropping it would recreate the original bug on a file whose
project settings and slice_info disagree — the one slot that prints
would vanish from the list entirely."""
used = [{"slot_id": 9, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
out = expand_to_project_slots(zf, used)
assert [f["slot_id"] for f in out] == [1, 2, 3, 4, 9]
assert out[-1]["used_in_plate"] is True
def test_returns_the_input_unchanged_without_project_settings(self):
"""Nothing to widen against — a narrow list still prints correctly,
an invented one might not."""
used = [{"slot_id": 4, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
assert expand_to_project_slots(zf, used) == used
# ---------------------------------------------------------------------------
# Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
# object top-level extruder, per-part extruder, painted-face quadtree leaves.