mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Merge branch 'dev' into feature/oidc-env-config
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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}$")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}%"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user