27 Commits
Author SHA1 Message Date
maziggy 0dfcff5925 Keep the RTSPS proxy's handler set off the server object (issue #3001)
asyncio's Server has a __dict__ and uvloop's, a Cython cdef class, does
not, so the attribute added in 1.2.5.4 raised AttributeError under uvloop.
Every RTSP camera failed before opening a socket, which is the
diagnostic's capture_exception at 0 ms.

Our own unit files all pin --loop asyncio for #1896 and were never
affected. The reports come from units we do not write: the Proxmox VE
Helper-Scripts LXC pins no loop, and installs predating that fix never
gained the flag because update.sh does not rewrite unit files. The loop
is not ours to assume, so fix the code rather than add another flag.

The set moves to a module-level WeakKeyDictionary, keyed weakly so an
abandoned proxy retires its own entry rather than leaking one and later
handing a new server a dead one's handlers.

Pinned on a real uvloop loop and, for hosts without uvloop, against a
__slots__ server; conftest builds its loop from the default policy, so
nothing in the suite had ever run the branch that broke.

Also routes the two external-camera teardowns through close_tls_proxy,
which #2968 introduced and left them out of.

-----

Say so at startup when running on uvloop (issue #3001)

An install on the wrong loop had no way to find out it was. #3001 was
loud enough to notice; the #1896 upload truncation it is also exposed to
is silent, and shows up as a print failing from a file that was corrupt
on arrival.

One WARNING in the lifespan naming the loop, the risk and the flag to
add. A warning and not a refusal: uvicorn has already chosen its loop by
the time any application code runs, and a server that answers requests
beats one that will not boot.

Asks the running loop what it is rather than whether uvloop imports --
uvicorn[standard] installs uvloop everywhere, so its presence says
nothing -- and matches on the module name so the question never imports
uvloop on a host without it.

-----

Repair a service file written before the --loop asyncio pin (issue #3001)

install.sh has pinned the loop since #1896, but nothing has ever
rewritten an existing service file, so every native install created
between 2025-11-28 (when uvicorn[standard] brought uvloop into the venv)
and 2026-07-05 still runs on uvloop no matter how often it is updated.

Both update scripts now add the flag themselves while the service is
stopped, so it takes effect on the same restart -- systemd via sed,
launchd via PlistBuddy, each backing the file up first and inserting
nothing but the flag.

Refuses to edit and explains instead when the shape is not a plain
single-line uvicorn unit: a wrapper script, a continued ExecStart,
several of them, a read-only file, or a service with drop-ins, since a
drop-in may be what defines ExecStart and editing the fragment would
change nothing while reporting success. A deliberate --loop uvloop is
left alone. Reads the effective ExecStart from systemd rather than the
file, so it is idempotent.
2026-08-30 08:01:42 +02:00
maziggy d4477e9b71 Log ffmpeg's error instead of its build banner
ffmpeg opens every run with ~20 lines of version and build banner and prints
its diagnosis last, so the stderr[:200] eight of the nine call sites used kept
the banner and threw the error away. The reporter's twelve capture failures all
read "ffmpeg version 7.1.4 ... configuration: --prefix=/usr --extra-version=",
identical on every install; the exit code was the only usable byte.

The banner-stripping summariser written for #925 lived private to the camera
route. It now lives in backend/app/utils/ffmpeg_output.py and every ffmpeg and
ffprobe stderr goes through it. Two things the scattered copies also got wrong:
four logged the input URL unmasked, publishing a printer access code or camera
password, and four called a bare .decode() on bytes ffmpeg copies stream
fragments into.

-----

Delete the files a no-3MF archive owns, without taking a printer folder

Both delete paths derived the directory from file_path, which such an archive
does not have, so they removed nothing and logged it at ERROR under a SECURITY
banner. That was true when the archive was an empty row and stopped being true
once one could hold a timelapse and finish photos in <archive_dir>/<id>/ and an
uploaded source in archive/no_source/<id>/.

The two are cleaned up by different means, because <archive_dir>/<id> shares a
namespace with the per-printer folders: a normal archive lives at
<archive_dir>/<printer_id>/<timestamp>_<name>/, so archive/1 is printer 1's
folder and also the directory the shared helper hands archive id 1. Ids come
from unrelated sequences, so the first few archives collide with the printers
on every install, and an rmtree there takes every print that printer made --
measured on a scratch tree. no_source/<id> is a level deeper under a name no
printer id can take and is removed whole; the id-named directory gives up only
its photos subdirectory and the video the row records, then goes only if that
left it empty. The depth guard moves from one to two for the same reason: a
file_path that lost a path component could point the delete at a printer
folder, and no archive directory has been one level deep since the first
commit.

Hard delete had its own copy of these rules, which the helper's docstring says
it exists to prevent, and it had diverged -- it skipped the print-log thumbnail
cleanup whenever a guard tripped.

-----

Stop the RTSPS proxy leaving a handler behind at shutdown

asyncio.start_server keeps only a weak reference to the connection callback's
task, so a handler still awaiting its forwarders could be collected while
pending -- "Task was destroyed but it is pending!", at ERROR with a traceback
into camera.py, once every few hundred snapshots. Teardown had the matching
gap: server.close() leaves established connections running, so the close waited
on a handler that only finishes when the peer drops, and ffmpeg has already
been reaped by then.

Handlers are held for as long as they run and cancelled at shutdown, which is
Server.close_clients() by hand -- that landed in 3.13 and Bambuddy supports
3.10. Both the snapshot path and the streaming endpoint share the shutdown.
2026-08-29 08:51:33 +02:00
maziggy 432e956eff fix(camera): rotate every still exactly once, and cover the sources that let ffmpeg write the file
Review follow-ups on applying camera_rotation to finish photos and
layer-timelapse frames.

Rotating the frame popped from _stage22_finish_frames rotated one of its
sources twice. The cache has two kinds of feeder: live grabs, which are raw,
and the #1867 in-print bank, whose bytes come from
_capture_snapshot_for_notification and have already been rotated on the way
in. The consumer cannot tell them apart, so on the finish_state trigger - the
path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180
degree rotation cancelled itself out and the photo was upside-down again,
which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now
happens where each frame is captured, so every entry in the cache carries one
rotation whatever produced it, and the invariant is stated both where the
cache is declared and where it is consumed.

Two finish-photo sources were still writing unrotated files: the built-in
camera's own capture_finish_photo, and the still extracted from a
printer-recorded timelapse - which is the *preferred* source for a built-in
camera print, so a user with a rotation set got a correctly oriented photo or
not depending on which source happened to win. Neither ever holds the frame as
bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file
handles that case and is best-effort - a failed rotate leaves the unrotated
file rather than losing a delivered photo. The archived video itself is the
printer's own file and is not re-encoded, so it still plays at the camera's
native orientation; the CHANGELOG says so rather than leaving it to be
discovered.

apply_camera_rotation logs at debug, not info. It was on a path that runs once
per layer, where a tall print would have put hundreds of lines in the log for
something the surrounding capture already reports at debug.

The moved rotation logic had no test of its own - every existing test patches
it out and asserts the call, so a flipped sign or a dropped expand=True would
have shipped green. test_camera_rotation.py drives the real round trip: a
corner marker pins which way it turns, the dimensions pin that the frame is
not cropped, and an undecodable frame comes back by identity because a capture
path must not lose a frame to a failed rotate.

Tests for the fix itself sit on both sides of the cache. The producer half is
driven directly; the consumer half is a closure nested inside on_print_complete
with nothing able to reach it, so it is pinned by an AST guard - checked
against the source because the alternative is no check at all. Reverting
main.py to the pre-fix shape fails three of the five, the guard among them.

The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit
flagged (B108); they take tmp_path now.
2026-07-31 09:30:06 +02:00
CarlandClaude Sonnet 5 5db4c75ca0 fix(camera): apply camera_rotation to layer-timelapse frames too
Same gap as the finish-photo fix: camera_rotation was only ever wired
into the notification-snapshot path, so a layer-timelapse video came
out upside-down whenever a rotation was configured - every frame
(fresh or reused from the live view's buffer) was written to disk raw.

Extracts the rotation logic out of main.py into a shared
apply_camera_rotation(image_data, rotation, logger) in services/camera.py
(taking the rotation value directly rather than a printer object, so
both main.py's printer-shaped callers and layer_timelapse's plain int
field can use it). main.py's _apply_camera_rotation becomes a thin
compatibility wrapper so its existing call sites are unchanged.

Threads a `rotation` field through TimelapseSession/start_session,
set from printer.camera_rotation in _maybe_start_layer_timelapse, and
applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in
capture_layer before each frame is written - after #2707's
live_frame_for_capture() resolves the frame, regardless of whether it
came fresh or from the live view's buffer.

Rebased onto #2707's landed implementation: capture_layer now calls
live_frame_for_capture() instead of the older is_stream_active/
try_get_active_buffered_frame pair this was originally written
against, so the layer-timelapse tests are updated to match, and the
now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class
(superseded by #2707's own test_external_camera_live_frame_reuse.py)
is dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:34:13 +01:00
maziggy 73afa95047 fix(camera): share one connection between concurrent one-shot captures (#2705)
Bambu firmware allows exactly one camera connection. The existing guards
(is_stream_active / try_get_active_buffered_frame, #1271 and #1348) only
stop a one-shot capturer from competing with the fan-out broadcaster.
Nothing coordinated the capturers with each other, so with no viewer
attached every consumer correctly concluded it was not competing with a
viewer and then collided with the others. On the reporter's P2S an Obico
poll and a snapshot opened two RTSP sockets 207 ms apart, which knocked
over the fan-out stream feeding the camera wall; it was then reaped for
having received no frames for 58s.

capture_camera_frame_bytes() now coalesces: the first caller opens the
connection, callers arriving while it is in flight await the same result.
Eight paths reach that function independently - Obico polling, the
snapshot route, the finish-photo moment and its disk-writing sibling,
plate detection, the camera test and the diagnose tool - so the
single-flight sits at the bottom of the stack and no call site changes.

Keyed by IP, since that is what the firmware's limit applies to and the
function never sees a printer_id. The key excludes the timeout on
purpose: the call sites disagree about it, from 10s to 30s, so keying on
it would mean the Obico-vs-snapshot pair from the report never coalesced
at all.

It coalesces, it does not cache. A call arriving after the previous
capture finished still captures fresh, because plate detection and the
finish-photo path judge a running print from these frames and a stale one
there is worse than a slow one - #1397 was a finish photo taken seconds
late showing the bed already lowered.

Each caller waits on its own deadline rather than inheriting whichever
one happened to open the connection, and shield() means giving up leaves
the capture running for whoever else is still waiting. A follower whose
leader fails takes a turn of its own instead of inheriting a failure it
never had a chance to avoid; the leader has finished by then, so there is
nothing left to compete with. Bounded at two rounds. That also covers the
follower whose timeout is longer than the leader's, which coalescing
alone cannot. Cancellation is disambiguated via leader.cancelled(), so a
follower's own cancellation propagates while a cancelled leader is
treated as a failed one.

The leader is deliberately not wrapped in a second wait_for: the
implementation already enforces the timeout internally, where it can also
kill the ffmpeg process, and an outer deadline would abandon the
subprocess instead of killing it.

The diagnose tool now marks a stage whose frame came from a capture
already in flight as coalesced_capture. The pass is real evidence the
camera works, but duration_ms is then mostly time spent queueing, and a
diagnostic must not report a connection it never opened - the same reason
that file declares its live_stream_active shortcut instead of quietly
passing. Failures are not annotated, since a follower whose leader fails
goes on to capture on its own.
2026-07-30 09:49:56 +02:00
maziggy e325948dcc Security hardening (maziggy/bambuddy-security #N)
Subprocess output and user-supplied URLs are scrubbed of credentials
before they reach the application log. Adds a shared redaction helper in
core/logging_filters and routes the existing support-bundle sanitizer
through the same pattern.
2026-07-29 12:36:17 +02:00
maziggy 12d17bfbe7 fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
  live-camera grab captured the bed already dropped, ruining the photo
  framing. Source the photo from a brief Bambu timelapse instead —
  firmware stops timelapse recording AFTER toolhead parks but BEFORE
  bed-drop runs, so the last frame frames the finished print correctly.

  When capture_finish_photo is on AND the user did not opt in to
  timelapse for this print, force timelapse=True at dispatch + mark the
  new PrintArchive.bambuddy_forced_timelapse column. After extraction
  (success or failure), cleanup deletes the locally-attached file,
  clears archive.timelapse_path, and walks the four scanner directories
  (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
  against the original filename. User-opted-in timelapses pass through
  unchanged.

  Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
  (module-level so the print queue can reuse it). Both dispatch paths
  wired: background_dispatch.py (Print Now / Reprint) AND
  print_scheduler.py:_start_print (the queue). Field testing caught the
  scheduler gap on the first round — AST regression test now asserts
  start_print(timelapse=...) references effective_timelapse, not the raw
  item.timelapse, so a future refactor can't silently drop it.

  Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
  frames overwrite the same output file, so the file left on disk is the
  literal last frame regardless of duration. Bambu records one frame per
  layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
  original -sseof -1.0 approach seeked before the start of the file and
  returned frame 0 (empty bed). Decoding every frame is fine; Bambu
  timelapses are short by construction even on hours-long prints.

  Migration adds bambuddy_forced_timelapse branched on is_sqlite()
  (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
  Verified live on postgres:16-alpine.

  Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
  so the notification carries the bed-up photo instead of falling back
  to the live-cam grab on slow links.

  Scope limit, documented in the camera wiki: prints started directly
  on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
  both dispatch paths, so the override doesn't fire there. Future
  option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.

  Setting description rewritten in all 11 locales to drop the "only
  works when timelapse enabled" caveat (Bambuddy now forces it) and
  explain the kept-or-deleted behaviour.
2026-06-05 13:53:26 +02:00
maziggy 396e9aa09e security: harden path-traversal class across routes + services; fifth CI backstop
Two attacker-controlled strings were being joined to library_dir with no
  resolve + containment check in the project ZIP import endpoint:

    - linked_folders[*].name from the request's project.json
    - per-entry zf.namelist() paths from the ZIP itself

  An absolute path in either field collapsed the join (Path("/lib") / "/etc"
  becomes Path("/etc") because pathlib discards the left side when the right
  is absolute) and the next write_bytes landed wherever the attacker chose.

  Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
  had NO validation on filename and FileResponse-served arbitrary paths -
  the DELETE counterpart at least gated on the photos membership check.

  Adjacent finding from the services audit: ArchiveService.attach_timelapse
  wrote archive_dir / filename where filename ultimately came from a printer's
  FTP listing (compromised-printer threat model) or the /timelapse/select
  query param. A malicious printer that exposes a directory entry with ..
  segments could write the timelapse outside the archive directory.

  New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
  single source of truth: rejects empty / null-byte / absolute parts up-front,
  joins under parent, resolves both sides, asserts is_relative_to. Returns the
  resolved canonical path on success, raises HTTPException(400) on escape, or
  PathTraversalError when http=False (for service-layer callers that need to
  match a non-HTTP return contract).

  Wired into the import vectors, both archive photo handlers, and the
  attach_timelapse service. The full audit sweep inspected every Path/Name
  join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
  sites + 8 service-layer sites confirmed safe and tagged with
  # SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.

  Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
  AST-walks both layers and fails the build on any <dir-like>/<bare variable>
  join that doesn't either route through safe_join_under or carry the marker.
  The services layer is in scope because it receives values verbatim from the
  routes AND from external sources Bambuddy has no control over (the printer
  FTP-listing case above).

  SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
  the rule now names the printer FTP-listing case explicitly so future
  services-layer audits set the right expectation.

--------------

  fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files

  Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
  "solid test\nendsolid test" shape) produced one WARNING per file in
  stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
  correct - trimesh returns a valid Mesh with zero vertices, the safeguard
  matches, and the function returns None so the library entry is still
  created without a thumbnail - but the volume turned a successful upload
  into thousands of WARNING lines in the journal.

  Two changes:

  1. The per-file "Failed to load STL or empty mesh" message in
     stl_thumbnail.py is now logger.debug instead of logger.warning. It's
     a per-file content observation, not an actionable error; the caller
     already handles None correctly. The branch now catches the rare
     "large enough but trimesh still can't parse it" case, visible in
     debug logs without spamming production.

  2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
     with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
     below any real STL). The three thumbnail call sites in library.py
     (extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
     pre-skip files below this size before calling generate_stl_thumbnail.
     Stubs never enter the trimesh pipeline at all.

  Behavior is unchanged for real STLs: any file >=200 bytes runs through
  the existing pipeline, MAX_VERTICES still triggers simplification at
  100k vertices for the 256x256 thumbnail render, large files still get
  thumbnails.

------------

  fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)

  On first STL upload, three matplotlib-internal log lines surfaced:

    WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
    INFO    [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
    INFO    [matplotlib.font_manager] generated new fontManager

  The writable-dir warning fired because Bambuddy's $HOME isn't writable for
  matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
  which lost the font cache on every host reboot, so font_manager rebuilt it
  each cold start - producing another batch of INFO lines.

  Fix is two small additions in stl_thumbnail.py before the matplotlib import:

  1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
     settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
     persists across container restarts and the writable-dir warning never
     fires. Respects an externally-set MPLCONFIGDIR so operators who chose
     their own path aren't overridden. Best-effort with a debug fallback if
     settings can't be imported or the mkdir fails.

  2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
     import demotes the per-font INFO scan that fires when font_manager
     builds its cache cold. Real font warnings (>= WARNING) still surface.

  3 new tests: font_manager logger at WARNING after module import;
  _configure_matplotlib_cache creates the directory under base_dir and sets
  MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
  5516 backend tests green, frontend gates clean.
2026-06-02 12:12:54 +02:00
maziggy eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504)
A previous attempt swapped `-timeout` → `-stimeout` unconditionally to
  fix EADDRINUSE on the reporter's transitional ffmpeg. That broke every
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.
  install on a modern ffmpeg (5+/6+/7+) — current Debian/Ubuntu/Homebrew
  — where `-stimeout` was removed and `-timeout` is back to meaning
  socket I/O. Verified locally: `ffmpeg -stimeout ...` errors
  "Unrecognized option 'stimeout'" on ffmpeg 7.1.

  ffmpeg has shipped THREE arrangements of this option over time and
  Bambuddy supports the full range:

  - Pre-deprecation (early 4.x and earlier): `-timeout` is socket I/O.
  - Transitional (~late-4.x, Jammy-era): `-timeout` is deprecated and
    repurposed to RTSP listen-mode timeout; any non-zero value implies
    `-listen`, which makes ffmpeg bind the TLS-proxy port and fail with
    EADDRINUSE. `-stimeout` is the replacement socket I/O option.
  - Modern (5.x / 6.x / 7.x): `-stimeout` REMOVED. `-timeout` is back to
    socket I/O — the original meaning.

  So no single literal is correct on all installs.

  Fix: `rtsp_socket_timeout_flag()` in services/camera.py probes
  `ffmpeg -h demuxer=rtsp` once and picks `-stimeout` when ffmpeg
  advertises it (covers transitional + older builds that kept it as an
  alias), else `-timeout` (modern + pre-deprecation). Cached at module
  level for the process lifetime — ffmpeg doesn't swap mid-run.

  The function returns the option name without a leading dash; callers
  prepend it themselves so a formatting bug can't pass an empty flag.

  Wired into both RTSP ffmpeg call sites in lockstep: routes/camera.py
  (printer camera) and services/external_camera.py (external RTSP),
  which use the same TLS-proxy + ffmpeg pattern and would hit the same
  regression on either ffmpeg cohort.

  Tests: 8 in test_ffmpeg_rtsp_timeout_flag.py — 6 probe unit tests
  (prefers stimeout when advertised, falls back to timeout on modern,
  defaults to timeout when ffmpeg missing or probe raises, caches across
  calls, trailing-space substring guard against `-listen_timeout`
  false-positives), 2 parametrised guards against either RTSP ffmpeg
  argv re-hard-coding a literal instead of consuming the probe. 37
  probe + existing external-camera tests green.
2026-05-24 08:49:21 +02:00
maziggy 60d0c33172 fix(camera): catch RuntimeError in TLS proxy forwarders for uvloop
The bidirectional forwarders inside create_tls_proxy._handle catch
  (ConnectionError, OSError, asyncio.CancelledError) on writes, but
  uvloop's UVStream.write raises a plain RuntimeError from
  UVHandle._ensure_alive when the underlying handle is already closed.
  asyncio's default selector loop reports the same situation as
  ConnectionResetError, so the bug only surfaced on uvloop — and only at
  the moment ffmpeg (or a snapshot-capture subprocess) dropped its socket
  while the proxy was mid-flush.

  The RuntimeError slipped past the except tuple, escaped the forwarder
  coroutine, and asyncio's client_connected_cb task-exception handler
  logged a noisy multi-line traceback ending in:

      RuntimeError: unable to perform operation on
                    <TCPTransport closed=True ...>; the handler is closed

  Adds RuntimeError to the except tuple in both _fwd_to_server and
  _fwd_to_client (the latter is the actual frame from the bug report —
  server→client is where buffered TLS chunks land after the client has
  gone). The forwarders are intentionally fire-and-forget on tear-down;
  the existing dst.close() in the finally block already handles cleanup.

  No functional regression possible — the connection is already dead by
  the time the exception fires; this only changes whether asyncio logs an
  "Unhandled exception" trace for it.

  2 new regression contract tests in test_camera_tls_proxy.py use
  inspect.getsource to assert both forwarder closures' except clauses
  include RuntimeError. Source-level rather than a runtime test because
  the forwarders are nested closures inside _handle and extracting them
  just for testability would require a pure-cosmetic refactor.

  Latent since 0feed83c (Fix P2S camera TLS compatibility via OpenSSL
  proxy, #661, 2026-03-15) — only commit that ever touched these
  forwarders.
2026-04-26 09:39:36 +02:00
maziggy ef37ffa7c7 fix(obico): exclude snapshot capture PIDs from stream cleanup (#172)
The periodic camera cleanup task scans /proc for ffmpeg processes and
  kills any not in the active-streams registry. The Obico detection
  service's capture_camera_frame_bytes() spawns short-lived ffmpeg for
  snapshots but never registered the PID — so cleanup killed it as
  "orphaned" mid-capture (SIGKILL, exit -9), producing false errors and
  missed detection frames.

  Track capture PIDs in _active_capture_pids and exclude them from the
  cleanup kill list.
2026-04-17 08:38:22 +02:00
maziggy 6fb814c5ea feat(printer): add X2D support — camera, dual-nozzle, K-profile, maintenance (#988)
The Bambu Lab X2D (launched April 2026, dual-nozzle, enclosed, hardened
  steel rod gantry, AMS 2 Pro compatible) identifies itself as internal
  model code N6 via SSDP/MQTT, and real serials begin with 20P9. None of
  these identifiers existed in Bambuddy's registries, so the camera
  service fell back to the chamber-image protocol on port 6000 (X2D
  doesn't speak it), firmware-check logged "Unknown printer model: N6",
  and the dual-nozzle K-profile paths — gated on the H2D serial prefix
  "094" — would have treated X2D as single-nozzle.

  Backend:
  - Register N6 → X2D across every registry (PRINTER_MODEL_ID_MAP,
    PRINTER_MODEL_MAP, STEEL_ROD_MODELS, ETHERNET_MODELS,
    CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys + wiki path,
    virtual-printer SSDP/product/serial tables, DB vp_model_fixes).
  - supports_rtsp(): match the X2 display-name prefix and the N6 internal
    code; camera now routes to RTSP on port 322.
  - Dual-nozzle serial prefix check in bambu_mqtt.delete_kprofile and
    kprofiles.set_kprofile broadened to ("094", "20P9") — X2D now takes
    the H2D-style cali_idx in-place edit path.
  - is_h2d model gate in bambu_mqtt.start_print extended with "X2D" so
    timelapse / bed_leveling / flow_cali / vibration_cali / layer_inspect
    are sent as integers and external-spool ams_id 254/255 routing is
    preserved (H2D-style deputy-nozzle addressing).

  X2D uses hardened steel rods like P2S — it is intentionally placed in
  STEEL_ROD_MODELS, not CARBON_ROD_MODELS. A regression-guard test pins
  the classification.

  Frontend:
  - mapModelCode in PrintersPage and SpoolBuddyAmsPage handle N6 and X2D.
  - Enclosure-door badge and airduct-mode whitelists include X2D.
  - MaintenancePage.getMaintenanceWikiUrl routes X2D to P2S wiki URLs for
    steel-rod lubrication, belt tension, cold-pull, and PTFE tube
    (exported to enable direct unit testing).

  Tests:
  - test_printer_models.py: TestX2DModel (10 assertions).
  - test_bambu_mqtt.py: X2D in start_print ams_mapping and is_h2d gate;
    TestDeleteKProfileDualNozzleDetection across H2D, X2D, P2S, X1C.
  - MaintenancePageWikiUrls.test.tsx: 15 assertions covering X2D, P2S
    regression, X1C/H2D/A1Mini regression, and model-name normalisation.

  Docs:
  - README: added X2 series to the supported printers table.
  - CHANGELOG: new entry under 0.2.3b4 Fixed.

  Credit to @krautech for the report and debug bundle, and to @legend813
  for PR #989 which seeded most of the registry changes — rod-type
  classification was corrected (steel, not carbon) and the dual-nozzle /
  K-profile / is_h2d gaps were added on top.
2026-04-16 10:40:32 +02:00
maziggy 2a6df22075 Restrict temp file permissions for camera snapshots
Camera snapshot, test, and plate detection endpoints created temporary
  JPEG files with default 0644 permissions. Switch from NamedTemporaryFile
  to mkstemp with explicit 0600 permissions.
2026-04-04 13:18:53 +02:00
maziggy 0feed83ce4 Fix P2S camera TLS compatibility via OpenSSL proxy (#661)
The Debian ffmpeg package uses GnuTLS, whose hardened defaults reject
  TLS renegotiation and legacy ciphers that some Bambu printer firmwares
  (notably P2S) rely on — causing RTSP sessions to drop after a few
  seconds.

  Add a local TLS termination proxy (Python ssl/OpenSSL) that handles
  the TLS connection to the printer and exposes a plain RTSP port to
  ffmpeg. The proxy rewrites RTSP request-line URLs (rtsp://proxy →
  rtsps://printer) while preserving Authorization headers so Digest
  auth hashes remain valid.

  Also:
  - Reduce RTSP reconnect delay from 1.0s to 0.2s
  - Add ffmpeg fast-start flags (-probesize 32, -analyzeduration 0,
    -fflags nobuffer, -flags low_delay)
  - Fix external camera double rate-limiting causing choppy streams
  - Apply TLS proxy to external camera rtsps:// URLs and snapshot capture
  - Update orphan ffmpeg cleanup to match rtsp:// (proxied) URLs
  - Add unit tests for RTSP URL rewriting and proxy lifecycle
2026-03-15 11:52:32 +01:00
maziggy 9f93c04f6f Revert " Camera P2S + snapshot fix (#661):"
This reverts commit 7ea78a2969.
2026-03-14 10:33:21 +01:00
maziggy 7ea78a2969 Camera P2S + snapshot fix (#661):
Fix P2S camera stream dropping and snapshot capture race (#661)

  P2S firmware's TLS renegotiation is rejected by Debian's hardened GnuTLS
  defaults, causing ffmpeg RTSP sessions to drop after ~3 seconds. Add
  GnuTLS config allowing unsafe renegotiation and legacy ciphers. Also add
  ffmpeg fast-start flags, reduce reconnect delay from 1.0s to 0.2s,
  remove double rate-limiting on external camera streams, and fix orphan
  cleanup killing snapshot capture ffmpeg processes (exit code -9).

  Or as a single combined commit:
  Fix P2S camera streaming, snapshot race, and energy stats (#661, #695)

  Camera: P2S firmware's TLS renegotiation rejected by Debian's hardened
  GnuTLS defaults, dropping RTSP sessions after ~3s. Add GnuTLS compat
  config, ffmpeg fast-start flags, reduce reconnect delay to 0.2s, remove
  external camera double rate-limiting, and register snapshot ffmpeg PIDs
  with the orphan tracker to prevent SIGKILL during capture.
2026-03-14 10:31:46 +01:00
maziggy ce97a47627 Add H2C dual nozzle variant O1C2 model support (#489)
The H2C dual nozzle variant reports model code O1C2 via MQTT, but only
  O1C was recognized. This caused the camera to use the wrong protocol
  (chamber image on port 6000 instead of RTSP on port 322), producing a
  reconnect loop. Added O1C2 to all model ID maps across 8 files.
2026-02-28 11:53:38 +01:00
maziggy 5b0a985da2 Add explanatory comments to 265 empty except blocks
CodeQL flags except blocks where `pass` has no comment explaining
why the exception is silently ignored (py/empty-except rule).

Added context-specific comments to all 265 instances across 31 files:
- database.py (~112): ALTER TABLE migrations — "Already applied"
- archive/library/3MF parsing (~64): "Skip unparseable metadata"
- virtual_printer network cleanup (~32): "Best-effort socket cleanup"
- discovery/SSDP (~13): "SO_REUSEPORT not available" / socket cleanup
- bambu_ftp/mqtt (~13): FTP cleanup, JSON decode, signal parsing
- remaining routes/services (~31): context-specific comments
2026-02-06 11:58:38 +01:00
maziggy 53bd4fadb3 Fix safe security findings: hashlib, log injection, broad excepts
- Add usedforsecurity=False to MD5 (AMS fingerprint) and SHA1 (git blob
  hash) calls to silence Bandit B303 / CodeQL weak-crypto findings
- Convert ~996 f-string logging calls to parameterized %s-style across
  55 files to prevent log injection (Bandit G201 / CodeQL log-injection)
- Narrow ~199 broad except Exception blocks to specific types:
  OperationalError for DB migrations, OSError for network/file cleanup,
  (OSError, ftplib.error_reply) for FTP, and targeted tuples for
  ZIP/XML/JSON parsing — 36 intentionally left broad (mixed async,
  re-raise patterns)
2026-02-06 11:37:59 +01:00
SBCrumb 6aa7a560d8 Add camera image attachments to Pushover notifications 2026-02-04 09:21:59 -05:00
maziggy e87fe7ad87 Add H2D Pro printer model support
- Add H2D Pro option to printer model dropdowns (add/edit modals)
- Support both O1E and O2D internal codes for H2D Pro compatibility
- Add O2D to RTSP-capable and chamber temp supported models
- Add H2DPRO variant to firmware check mapping
- Add H2D Pro and X1E to bug report issue template

Closes #192
2026-01-30 15:38:52 +01:00
maziggy 08e4797544 Fix: Internal Model Code Detection for Camera Protocol
Root Cause: P2S (and other newer models) may report their model as internal codes (e.g., "N7" for P2S) rather than display names in MQTT/SSDP responses. The camera code
only checked for display names like "P2S", causing it to incorrectly use the chamber image protocol (for A1/P1) instead of RTSP.

Internal Code Mapping:
BL-P001 → X1/X1C (RTSP)
C13     → X1E    (RTSP)
O1D     → H2D    (RTSP)
O1C     → H2C    (RTSP)
O1S     → H2S    (RTSP)
O1E     → H2D Pro (RTSP)
N7      → P2S    (RTSP)
C11     → P1P    (Chamber)
C12     → P1S    (Chamber)
N2S     → A1     (Chamber)
N1      → A1 Mini (Chamber)

Closes #127
2026-01-23 07:09:22 +01:00
maziggy 2db76c325b Updated README 2026-01-13 09:49:52 +01:00
maziggy 11a6bb282a Key Discovery
A1/P1 printers don't support RTSP - they use a custom chamber image protocol on port 6000:
  - SSL/TLS connection
  - 80-byte binary auth payload (magic + "bblp" + access code)
  - 16-byte header with payload size + JPEG data

  Changes Made

  backend/app/services/camera.py

  - Added supports_rtsp() - detects X1/H2/P2 (RTSP) vs A1/P1 (chamber image)
  - Added is_chamber_image_model() - inverse check
  - Added _create_chamber_auth_payload() - builds 80-byte auth
  - Added _create_ssl_context() - SSL for self-signed certs
  - Added read_chamber_image_frame() - single frame capture
  - Added generate_chamber_image_stream() - persistent connection
  - Added read_next_chamber_frame() - read from stream
  - Updated capture_camera_frame() - uses correct protocol per model

  backend/app/api/routes/camera.py

  - Added generate_chamber_mjpeg_stream() - MJPEG from chamber protocol
  - Renamed generate_mjpeg_stream() → generate_rtsp_mjpeg_stream()
  - Updated camera_stream endpoint - chooses protocol based on model
  - Updated stop_camera_stream - cleans up both stream types
  - Added tracking for _active_chamber_streams

  Protocol Matrix

  | Model                       | Protocol      | Port |
  |-----------------------------|---------------|------|
  | X1, X1C, X1E, H2C, H2D, P2S | RTSP          | 322  |
  | A1, A1MINI, P1P, P1S        | Chamber Image | 6000 |
2025-12-25 07:44:53 +01:00
maziggy 7622d2ae5c - Fix total print hours calculation in set_total_hours to include all
prints (not just completed), matching get_printer_total_hours behavior
  - Add option to keep or delete archives when deleting a printer
  - Custom maintenance types no longer auto-assign to all printers
  - Add UI to manually assign/remove custom maintenance types per printer
  - Add backend endpoints for assigning types to printers and removing items
  - Exclude static/assets from large file pre-commit check
  - Fix A1/P1 camera streaming with extended timeouts and lower FPS cap
2025-12-24 08:41:23 +01:00
maziggy e688c35719 sync 2025-12-07 14:29:54 +00:00
Martin Ziegler 12f763efde Added feature to take a photo from printer camera when print completes 2025-11-29 11:35:10 +01:00