Merge branch 'main' into dev

This commit is contained in:
MartinNYHC
2026-05-19 14:12:50 +02:00
committed by GitHub
7 changed files with 126 additions and 228 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
## [0.2.4.1] - 2026-05-16
### Added
- **Docker: opt-in system trust store for self-signed CA certificates (#1431, contributed by @WizBangCrash, requested in #1289)** — Reporter runs a private LAN with self-signed certificates for internal HTTPS endpoints (his Home Assistant instance being the canonical case) and wanted Bambuddy to trust those CAs without disabling TLS verification end-to-end. Bambuddy talks to Home Assistant via `httpx.AsyncClient` (`backend/app/services/homeassistant.py:46`) with default `verify=True`, which under httpx 0.28 means "use `certifi`'s CA bundle and nothing else" — so manually copying a CA file into the container had no effect. **The fix is opt-in and container-side only**: setting `USE_SYSTEM_TRUST_STORE=<any non-empty value>` in the compose `environment:` block, combined with mounting the user's CA file(s) into `/usr/local/share/ca-certificates`, makes the entrypoint run `update-ca-certificates --fresh` at startup and `export SSL_CERT_DIR=/etc/ssl/certs`. httpx 0.28 explicitly honours that env var (`_config.py`: `ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])`), and `update-ca-certificates` populates `/etc/ssl/certs` with the **Debian system CA bundle** (Let's Encrypt, DigiCert, GlobalSign, etc.) **plus** the user-mounted CAs — so standard endpoints (api.github.com, MakerWorld, Bambu Cloud) keep working alongside the user's self-signed CA. The `ca-certificates` apt package is added to the Dockerfile so `update-ca-certificates` exists in the image. The feature is **default-off** — when the env var is unset the entrypoint logs a one-line "skipping system trust store update" and goes straight to the existing PUID/PGID chown path, so non-users see zero behaviour change. **Fail-fast on misconfig**: if `USE_SYSTEM_TRUST_STORE` is set but the container is running as non-root (the entrypoint can't write `/etc/ssl/certs` without root), or `/usr/local/share/ca-certificates` has no `.crt` files mounted, or `update-ca-certificates` is missing from the image, or the trust-store rebuild itself fails, the entrypoint exits 1 with a clear error message rather than silently succeeding and leaving the user wondering why their HA connection still rejects the cert. **Compose template update**: `docker-compose.yml` ships commented-out examples for both the volume mount (`/path/to/certs:/usr/local/share/ca-certificates`) and the env var (`USE_SYSTEM_TRUST_STORE=true`) so the path from "I have a self-signed CA" to "Bambuddy trusts it" is two uncommented lines. **Caveat worth flagging in docs**: the feature requires the container to start as root so the entrypoint can run `update-ca-certificates`; users who pin `user: "1000:1000"` in compose get the clear "not running as root" exit with the reason, but they need to switch to the default PUID/PGID-style invocation to use this. Companion wiki PR documents the setup walkthrough at maziggy/bambuddy-wiki#31. Hardware-only path (shell entrypoint change) so no automated test — verified by the reporter's local install. Post-merge polish: the fatal-exit branch's log line was relabeled from "warning: update-ca-certificates failed:" to "error: update-ca-certificates failed" to match severity and the surrounding error messages.
+9
View File
@@ -619,12 +619,21 @@ async def _perform_update(target_ref: str):
# locally resolvable for the reset below. `--tags` is required —
# plain `git fetch origin` doesn't bring tags by default, so a
# release tag would not be resolvable.
#
# `--force` lets a moved tag on the remote overwrite the local copy.
# Without it, any tag that was re-tagged upstream (e.g. v0.2.1 being
# re-pointed after a hotfix re-tag) makes `git fetch --tags` return
# a non-zero exit even though every other ref fetched cleanly —
# which we'd then surface as "Failed to fetch updates" to the user.
# The in-app updater's contract is "sync me to the remote"; force-
# overwriting a stale local tag matches that intent.
process = await asyncio.create_subprocess_exec(
git_path,
*git_config,
"fetch",
"--prune",
"--tags",
"--force",
"origin",
cwd=str(base_dir),
stdout=asyncio.subprocess.PIPE,
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.2.5b1"
APP_VERSION = "0.2.4.1"
GITHUB_REPO = "maziggy/bambuddy"
BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
+31 -40
View File
@@ -627,6 +627,31 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
return stored_ams_mapping
def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> bool:
"""Start a layer-timelapse session for *archive_id* when the printer has
an external camera configured. Returns True if a session was started.
Three call sites in on_print_start (expected-archive promotion, fallback
archive creation, fresh-archive creation) used to inline this same
if-block; the inline copies kept drifting (#1353 fixed only one of them
on the first pass). Centralising the conditional + call here makes the
contract testable in isolation and keeps the three sites locked in step.
"""
if not (printer.external_camera_enabled and printer.external_camera_url):
return False
from backend.app.services.layer_timelapse import start_session
start_session(
printer_id,
archive_id,
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
logging.getLogger(__name__).info("Started layer timelapse for printer %s, archive %s", printer_id, archive_id)
return True
def _format_hms_error_summary(hms_errors: list[dict]) -> str | None:
"""Build a human-readable failure reason from MQTT hms_errors for PrintQueueItem.error_message.
@@ -2033,22 +2058,10 @@ async def on_print_start(printer_id: int, data: dict):
_active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
# Start timelapse session if external camera is enabled (#1353).
# The two new-archive paths below also call start_session, but
# queue / VP-dispatched prints land here in the expected-archive
# branch and used to skip it entirely — so the timelapse session
# never started, no frames were captured, and the post-print
# stitch silently returned None.
if printer.external_camera_enabled and printer.external_camera_url:
from backend.app.services.layer_timelapse import start_session
start_session(
printer_id,
archive.id,
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
logger.info("Started layer timelapse for printer %s, expected archive %s", printer_id, archive.id)
# Queue / VP-dispatched prints land here in the expected-archive
# branch and used to skip start_session entirely — frames were
# never captured and the post-print stitch silently returned None.
_maybe_start_layer_timelapse(printer, printer_id, archive.id)
# Inject ams_mapping into usage tracker session — the session was created
# before expected-print promotion, so it may have ams_mapping=None when
@@ -2562,18 +2575,7 @@ async def on_print_start(printer_id: int, data: dict):
logger.info("Created fallback archive %s for %s (no 3MF available)", fallback_archive.id, print_name)
# Start timelapse session if external camera is enabled
if printer.external_camera_enabled and printer.external_camera_url:
from backend.app.services.layer_timelapse import start_session
start_session(
printer_id,
fallback_archive.id,
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
logger.info("Started layer timelapse for printer %s, archive %s", printer_id, fallback_archive.id)
_maybe_start_layer_timelapse(printer, printer_id, fallback_archive.id)
# Track as active print
_active_prints[(printer_id, fallback_archive.filename)] = fallback_archive.id
@@ -2652,18 +2654,7 @@ async def on_print_start(printer_id: int, data: dict):
logger.info("Created archive %s for %s", archive.id, downloaded_filename)
# Start timelapse session if external camera is enabled
if printer.external_camera_enabled and printer.external_camera_url:
from backend.app.services.layer_timelapse import start_session
start_session(
printer_id,
archive.id,
printer.external_camera_url,
printer.external_camera_type or "mjpeg",
snapshot_url=printer.external_camera_snapshot_url,
)
logger.info("Started layer timelapse for printer %s, archive %s", printer_id, archive.id)
_maybe_start_layer_timelapse(printer, printer_id, archive.id)
# Record starting energy from smart plug if available (#941: persisted column)
await _record_energy_start(archive, printer_id, db, context="auto-archive")
@@ -443,6 +443,16 @@ class TestUpdatesAPI:
"for tag-based updates) are resolvable for the subsequent reset. "
f"Captured fetch call: {fetch_calls[0]['args']}"
)
# Fetch must include --force so a re-pointed tag on the remote
# (common after re-tagging a release post-release-notes edit) doesn't
# surface as "Failed to fetch updates" to the user just because their
# local copy of the moved tag would be clobbered. The relevant target
# ref is fetched fine; we only want git's tag-clobber to be silent.
assert "--force" in fetch_calls[0]["args"], (
"Fetch must use --force so re-pointed tags on the remote don't "
"fail the whole fetch (the rest of the refs update cleanly). "
f"Captured fetch call: {fetch_calls[0]['args']}"
)
@pytest.mark.asyncio
async def test_apply_update_passes_discovered_release_to_perform_update(self, async_client: AsyncClient):
@@ -12,212 +12,98 @@ The expected-archive branch — where reprints and queue dispatch land —
updated the existing archive's status to "printing" but never started a
timelapse session.
Fix: start_session is now called in the expected-archive branch too, guarded
by the same `external_camera_enabled and external_camera_url` check that
the other two paths use.
Fix: the three start_session call sites in on_print_start were unified
behind `_maybe_start_layer_timelapse(printer, printer_id, archive_id)`,
which gates on the same `external_camera_enabled and external_camera_url`
check. Testing the helper directly (instead of driving the whole
on_print_start flow) keeps this regression locked in without dragging in
unrelated side effects (plate detection, DB queries, MQTT relay, etc.).
"""
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from backend.app.main import (
_active_prints,
_expected_print_creators,
_expected_print_registered_at,
_expected_prints,
_print_ams_mappings,
register_expected_print,
)
from backend.app.main import _maybe_start_layer_timelapse
@pytest.fixture(autouse=True)
def _clear_dicts():
"""Clear module-level tracking dicts before and after each test."""
_expected_prints.clear()
_expected_print_registered_at.clear()
_expected_print_creators.clear()
_print_ams_mappings.clear()
_active_prints.clear()
yield
_expected_prints.clear()
_expected_print_registered_at.clear()
_expected_print_creators.clear()
_print_ams_mappings.clear()
_active_prints.clear()
def _build_mocks(*, external_camera_enabled: bool, external_camera_url: str | None):
"""Construct the mock matrix needed to drive on_print_start through the
expected-archive branch. Returns a dict of mock contexts that the test
enters via contextlib.ExitStack.
The session.execute mock returns the printer for the first call (printer
lookup) and the archive row for the second call (expected-archive
re-fetch). The archive row carries a unique filename so the
expected-print key lookup succeeds.
def _make_printer(*, external_camera_enabled: bool, external_camera_url: str | None):
"""Construct a minimal printer-shaped object with exactly the attributes
the helper reads. SimpleNamespace is used over MagicMock so attribute
access raises AttributeError on anything unexpected — keeps the test
honest about which fields the helper actually depends on.
"""
mock_printer = MagicMock()
mock_printer.id = 1
mock_printer.auto_archive = True
mock_printer.external_camera_enabled = external_camera_enabled
mock_printer.external_camera_url = external_camera_url
mock_printer.external_camera_type = "snapshot"
mock_printer.external_camera_snapshot_url = external_camera_url
mock_printer.name = "TestA1"
mock_archive = MagicMock()
mock_archive.id = 42
mock_archive.filename = "Universal_Spirit_level_Holder.3mf"
mock_archive.subtask_id = None
mock_archive.print_time_seconds = None
mock_archive.created_by_id = None
mock_archive.printer_id = 1
mock_archive.print_name = "Universal Spirit Level Holder"
mock_archive.status = "pending"
mock_archive.file_path = "/tmp/fake.3mf"
return mock_printer, mock_archive
@pytest.mark.asyncio
async def test_expected_archive_path_starts_timelapse_when_external_camera_enabled():
"""Queue/VP-dispatched prints land in the expected-archive branch and must
start the timelapse session there (the #1353 root cause)."""
mock_printer, mock_archive = _build_mocks(
external_camera_enabled=True, external_camera_url="http://camera.local:5000/snapshot.jpg"
return SimpleNamespace(
external_camera_enabled=external_camera_enabled,
external_camera_url=external_camera_url,
external_camera_type="snapshot",
external_camera_snapshot_url=external_camera_url,
)
# Register the expected print so the dispatch flow finds an archive_id.
register_expected_print(1, "Universal_Spirit_level_Holder.3mf", archive_id=42, ams_mapping=[1])
# on_print_start fires many db.execute() calls (settings lookups,
# usage tracker, plate detection, etc) before reaching the expected-
# archive branch. Route on SQL text so each query gets a sensible
# response regardless of order, rather than queuing N mocks.
def execute_router(stmt, *args, **kwargs):
sql = str(stmt).lower()
if "from printers" in sql or "from printer " in sql:
return MagicMock(
scalar_one_or_none=MagicMock(return_value=mock_printer),
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
)
if "from print_archives" in sql or "from print_archive" in sql:
return MagicMock(
scalar_one_or_none=MagicMock(return_value=mock_archive),
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
)
# Settings, spool assignments, anything else — return empty.
return MagicMock(
scalar_one_or_none=MagicMock(return_value=None),
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
)
def test_starts_timelapse_when_external_camera_enabled():
"""Queue/VP-dispatched prints land in the expected-archive branch and must
start the timelapse session there (the #1353 root cause). The helper is
called from all three on_print_start paths (expected-archive promotion,
fallback archive, fresh archive) so testing it once covers all three."""
printer = _make_printer(
external_camera_enabled=True,
external_camera_url="http://camera.local:5000/snapshot.jpg",
)
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock()
mock_session.execute = AsyncMock(side_effect=execute_router)
mock_session.commit = AsyncMock()
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=42)
with (
patch("backend.app.main.async_session") as mock_session_maker,
patch("backend.app.main.notification_service") as mock_notif,
patch("backend.app.main.smart_plug_manager") as mock_plug,
patch("backend.app.main.ws_manager") as mock_ws,
patch("backend.app.main.printer_manager") as mock_pm,
patch("backend.app.main.mqtt_relay") as mock_relay,
patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
patch("backend.app.main._load_objects_from_archive"),
patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
# The actual subject under test: assert start_session is called.
patch("backend.app.services.layer_timelapse.start_session") as mock_start_session,
):
mock_session_maker.return_value = mock_session
mock_notif.on_print_start = AsyncMock()
mock_plug.on_print_start = AsyncMock()
mock_ws.send_print_start = AsyncMock()
mock_ws.send_archive_updated = AsyncMock()
mock_relay.on_print_start = AsyncMock()
mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
from backend.app.main import on_print_start
await on_print_start(
1,
{
"filename": "Universal_Spirit_level_Holder.3mf",
"subtask_name": "Universal_Spirit_level_Holder",
},
)
mock_start_session.assert_called_once()
# Verify it was called with the archive_id from the expected-print
# registration, not a fresh one — that's the contract.
call_args = mock_start_session.call_args
assert call_args.args[0] == 1, "printer_id must match"
assert call_args.args[1] == 42, "archive_id must come from the expected-print registration"
assert call_args.args[2] == "http://camera.local:5000/snapshot.jpg"
assert call_args.args[3] == "snapshot"
assert started is True
mock_start_session.assert_called_once_with(
1,
42,
"http://camera.local:5000/snapshot.jpg",
"snapshot",
snapshot_url="http://camera.local:5000/snapshot.jpg",
)
@pytest.mark.asyncio
async def test_expected_archive_path_skips_timelapse_when_external_camera_disabled():
def test_skips_timelapse_when_external_camera_disabled():
"""The same guard that the new-archive paths use must hold here: no
external camera → no timelapse session. Otherwise we'd try to capture
from a None URL and crash the print-start flow."""
mock_printer, mock_archive = _build_mocks(external_camera_enabled=False, external_camera_url=None)
printer = _make_printer(external_camera_enabled=False, external_camera_url=None)
mock_archive.filename = "test.3mf"
mock_archive.id = 99
register_expected_print(1, "test.3mf", archive_id=99, ams_mapping=None)
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=99)
def execute_router(stmt, *args, **kwargs):
sql = str(stmt).lower()
if "from printers" in sql or "from printer " in sql:
return MagicMock(
scalar_one_or_none=MagicMock(return_value=mock_printer),
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
)
if "from print_archives" in sql or "from print_archive" in sql:
return MagicMock(
scalar_one_or_none=MagicMock(return_value=mock_archive),
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
)
return MagicMock(
scalar_one_or_none=MagicMock(return_value=None),
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
)
assert started is False
mock_start_session.assert_not_called()
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock()
mock_session.execute = AsyncMock(side_effect=execute_router)
mock_session.commit = AsyncMock()
with (
patch("backend.app.main.async_session") as mock_session_maker,
patch("backend.app.main.notification_service") as mock_notif,
patch("backend.app.main.smart_plug_manager") as mock_plug,
patch("backend.app.main.ws_manager") as mock_ws,
patch("backend.app.main.printer_manager") as mock_pm,
patch("backend.app.main.mqtt_relay") as mock_relay,
patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
patch("backend.app.main._load_objects_from_archive"),
patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
patch("backend.app.services.layer_timelapse.start_session") as mock_start_session,
):
mock_session_maker.return_value = mock_session
mock_notif.on_print_start = AsyncMock()
mock_plug.on_print_start = AsyncMock()
mock_ws.send_print_start = AsyncMock()
mock_ws.send_archive_updated = AsyncMock()
mock_relay.on_print_start = AsyncMock()
mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
def test_skips_timelapse_when_url_missing_even_if_flag_set():
"""If the toggle is on but the URL field is empty (legacy / half-configured
install), the guard must still hold — calling start_session with an empty
URL would crash downstream when the capture thread tries to fetch frames."""
printer = _make_printer(external_camera_enabled=True, external_camera_url=None)
from backend.app.main import on_print_start
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
started = _maybe_start_layer_timelapse(printer, printer_id=1, archive_id=7)
await on_print_start(1, {"filename": "test.3mf", "subtask_name": "test"})
assert started is False
mock_start_session.assert_not_called()
mock_start_session.assert_not_called()
def test_camera_type_defaults_to_mjpeg_when_unset():
"""external_camera_type defaults to 'mjpeg' in start_session when the
printer column is None — pre-existing contract preserved by the helper."""
printer = SimpleNamespace(
external_camera_enabled=True,
external_camera_url="http://cam/feed",
external_camera_type=None,
external_camera_snapshot_url=None,
)
with patch("backend.app.services.layer_timelapse.start_session") as mock_start_session:
_maybe_start_layer_timelapse(printer, printer_id=2, archive_id=11)
assert mock_start_session.called
call_kwargs = mock_start_session.call_args.kwargs
call_args = mock_start_session.call_args.args
assert call_args[3] == "mjpeg"
assert call_kwargs["snapshot_url"] is None
+2
View File
@@ -11,6 +11,8 @@ greenlet>=3.0.0
# Pydantic
pydantic>=2.0.0
pydantic-settings>=2.0.0
# Transitive of pydantic-settings, floor-pinned to patch CVE-2026-28684 (dotenv 1.2.1)
python-dotenv>=1.2.2
# Bambu Lab Printer Communication
paho-mqtt>=2.0.0