diff --git a/CHANGELOG.md b/CHANGELOG.md index f6675c5ab..b850dab40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 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. diff --git a/backend/app/api/routes/updates.py b/backend/app/api/routes/updates.py index 326a656b8..30a147624 100644 --- a/backend/app/api/routes/updates.py +++ b/backend/app/api/routes/updates.py @@ -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, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2fab2f3dc..cda45f22c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index c6f10fd2c..a24fc5114 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/tests/integration/test_updates_api.py b/backend/tests/integration/test_updates_api.py index 8945c386b..63c42b539 100644 --- a/backend/tests/integration/test_updates_api.py +++ b/backend/tests/integration/test_updates_api.py @@ -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): diff --git a/backend/tests/unit/test_layer_timelapse_expected_archive.py b/backend/tests/unit/test_layer_timelapse_expected_archive.py index d9bdec972..4d7318fa9 100644 --- a/backend/tests/unit/test_layer_timelapse_expected_archive.py +++ b/backend/tests/unit/test_layer_timelapse_expected_archive.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 2d4d49c1e..4a2762129 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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