Commit Graph
3504 Commits
Author SHA1 Message Date
maziggy 91ac499e20 Merge pull request #2723 from bitbarista/fix/camera-rotation-finish-photo-timelapse
fix(camera): apply camera_rotation to finish photos and layer-timelapse frames
2026-08-02 09:47:58 +02:00
maziggy 72a8dafde9 y 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-08-02 09:47:29 +02:00
maziggy cdba011b1e Merge pull request #2722 from bitbarista/fix/timelapse-orphaned-session-cleanup
fix(camera): sweep orphaned timelapse session directories on startup
2026-08-02 09:47:15 +02:00
maziggy f6b38f3926 fix(camera): protect an in-progress stitch from the orphan sweep, and only sweep this feature's own files
Review follow-ups on the orphaned timelapse session cleanup.

    The sweep's own docstring said min_age_seconds made it safe to call mid-run.
    It did not. on_print_complete drops the session from _active_sessions before
    handing frames_dir to ffmpeg, so for the length of a stitch the directory
    matches no active session, and its mtime is the last layer's frame write -
    which on a tall print's final layer is easily older than the margin. The
    default margin is 300s and the stitch timeout is also 300s, so the two were
    tied with no headroom at all: a sweep landing in that window deleted ffmpeg's
    input from under it. _finalizing_sessions now covers the stitch, set as the
    session leaves _active_sessions and cleared in a finally so a failed stitch
    cannot leak the marker and make that printer's leftovers permanently
    un-sweepable. The docstring names all three guards and which gap each covers,
    including that the margin does have real headroom for the two cases it suits -
    a session mid-creation, and the freshly written .mp4 awaiting attach.

    The file branch now requires the timelapse_<session_id>.mp4 shape its own
    comment describes. It previously deleted any file under
    timelapse_frames/<printer_id>/ past the margin; nothing else writes there
    today, but age alone is not a reason to delete a file this feature did not
    create.

    Dropped ignore_errors=True from the rmtree. It made the surrounding
    except OSError unreachable, so a read-only mount or a permissions problem was
    counted and logged as a successful removal - and that log is the only evidence
    an operator has of what was deleted.

    Tests 5 -> 9: sparing a session mid-stitch, the finalizing marker cleared even
    when the stitch raises, unrelated files left alone, and a failed removal not
    counted. The failure test's rmtree stub honours the real contract and returns
    silently when ignore_errors=True, because that silent no-op is exactly what the
    old call could never observe; a stub that raised unconditionally would have
    passed against both versions and proved nothing.

    main.py is unchanged: it has no module-level logger, and the inline
    logging.getLogger(__name__) the sweep uses is the idiom throughout lifespan.
2026-08-02 09:46:59 +02:00
maziggy b4ea8e28c5 Merge pull request #2721 from bitbarista/fix/external-camera-capture-coalescing
fix(camera): share one connection between concurrent one-shot external-camera captures
2026-08-02 09:46:42 +02:00
maziggy 53844b46a5 fix(camera): redact credentials, contain failures, and stop the external-camera test claiming a connection it never opened
Review follow-ups on the external-camera capture coalescing.

    The coalescing was transplanted from camera.py, which is keyed by printer IP
    and so has nothing to hide in a log line. These keys carry the camera URL, and
    an RTSP camera URL routinely embeds user:pass@ - so the five new log lines
    printed the password, one of them at warning level, where it reaches support
    bundles. All five now go through _log_key(), which redacts before truncating:
    slicing first can cut the URL short of the @ the pattern anchors on and leave
    the password intact, which is why every other URL log in the module already
    does it in that order.

    _capture_frame_uncoalesced gained the blanket catch its camera.py counterpart
    has. That is load-bearing once captures are shared: the wrapper hands one
    task's outcome to every caller waiting on it and can only give a follower its
    own turn for an outcome it recognises, so an escaping exception reached all of
    them at once and none retried - one caller's failure becoming N. The per-type
    helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the
    guarantee belongs here rather than resting on their coverage. CancelledError
    is re-raised ahead of it, since the wrapper distinguishes a cancelled leader
    from a failed one.

    test_connection reports whether it shared a capture. It reaches capture_frame
    like any other consumer, so a test landing while Obico is polling got that
    frame back and answered "connected" for a connection it never made - the one
    answer a connection test must not give silently. It still shares rather than
    forcing its own capture, because forcing one would open the second handle to a
    single-reader device that this whole mechanism exists to prevent. The response
    carries `coalesced`, which also gives capture_in_flight() the consumer its
    camera.py counterpart has in the Diagnose tool, and the Test button says
    "shared with a capture already running" instead of a bare success.

    Tests 12 -> 20: an unexpected error reported as a failed capture, a raising
    leader whose follower still gets a frame, the three coalesced states, and
    redaction on each log line that can carry a URL. The raising-leader test
    patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a
    stand-in installed in the latter's place sits above the catch and would test
    the wrapper against a shape it can no longer be handed.
2026-08-02 09:46:25 +02:00
maziggy 51f36a4d24 Security hardening (maziggy/bambuddy-security #8) 2026-08-02 09:46:09 +02:00
maziggy 2e45f77a4b fix(camera): apply camera_rotation to finish photos
_apply_camera_rotation was only ever wired into
    _capture_snapshot_for_notification (print-start photo, in-print frame
    bank) - the finish-photo pipeline (_background_finish_photo's three
    branches: stage-22 pre-captured frame, external-camera fallback,
    built-in buffered-frame fallback) saved frames straight to disk with
    no rotation, so a finish photo came out upside-down whenever
    camera_rotation was configured. Pre-existing gap, unrelated to
    yesterday's collision fixes - just more visible now that a finish
    photo reliably gets captured at all.

    Built-in camera's final capture_finish_photo() fallback (writes its
    own file, returns only a filename) is left unrotated - out of scope
    here since Carl uses an external camera; would need its own change to
    services/camera.py.
2026-08-02 09:43:54 +02:00
maziggy ed3173eb3f docs: add CHANGELOG entry for #2709 orphaned timelapse session cleanup 2026-08-02 09:43:40 +02:00
maziggy e762ed296d fix(camera): sweep orphaned timelapse session directories on startup
_active_sessions is in-memory only, so a process restart mid-print
    loses track of any active layer-timelapse session without ever calling
    cancel_session()/cleanup() - the frames directory (and, if stitching
    had already produced output before the restart, a stray
    timelapse_<session_id>.mp4) are then orphaned on disk permanently, with
    no equivalent to the ffmpeg orphan janitor to reap them.

    Confirmed live: 38MB of exactly this leftover on the OrangePi after
    several restarts during this week's testing, including two corrupt
    48-byte .mp4s from stitches that got interrupted mid-write.

    Adds cleanup_orphaned_timelapse_sessions(), run once at startup: for
    each printer_id under timelapse_frames/, remove any frame directory or
    stitched-output file that doesn't match that printer's current active
    session (if any) and is older than a defensive margin (5 min default).
    A restart-recovered print never gets a new timelapse session either
    (#1353's _maybe_start_layer_timelapse only fires on fresh PRINT_START
    events), so nothing orphaned here can ever be resumed - safe to always
    remove once it's old enough not to be a startup race.
2026-08-02 09:43:20 +02:00
maziggy c9f99b35d1 fix(camera): share one connection between concurrent one-shot external-camera captures
#2705 fixed simultaneous captures colliding on the built-in camera path,
    keyed by printer IP through capture_camera_frame_bytes(). External
    cameras reach the same collision through a different function -
    external_camera.capture_frame() - that #2705 didn't touch, and a V4L2
    USB device allows exactly one open handle just like Bambu's own RTSP
    limit.

    Nothing coalesced two one-shot capturers here either: Obico polling,
    the in-print frame bank, the finish-photo moment, plate detection and
    the notification snapshot could each open their own connection to the
    same USB camera and collide - is_stream_active() only stops a
    capturer from competing with an attached viewer, not with another
    capturer (that's what #2707 fixed).

    capture_frame() is now a single-flight coalescing wrapper (actual
    dispatch moved to _capture_frame_uncoalesced), keyed by (url,
    camera_type, snapshot_url) - snapshot_url is part of the key since
    #1177's override routes to a completely different endpoint. Mirrors
    #2705's shape: coalesces, doesn't cache (a call after the previous one
    finishes always captures fresh); each caller keeps its own timeout via
    wait_for(shield(...)) rather than inheriting the leader's; a follower
    whose leader fails takes its own turn instead of inheriting a failure
    it never had a chance to avoid, bounded at two rounds; cancellation is
    disambiguated via leader.cancelled() so a follower's own cancellation
    still propagates while a cancelled leader is treated as a failed one.

    12 tests mirroring test_camera_capture_coalescing.py's structure.
2026-08-02 09:43:04 +02:00
maziggy 8bf110ee62 Post work PR #2691 2026-08-02 09:42:53 +02:00
maziggy 2c94cc9e53 Merge pull request #2691 from gzimbric/feature/p2s-x2d-accessory-fans
feat(printers): expose P2S/X2D accessory fans (left aux + exhaust)
2026-08-02 09:42:40 +02:00
maziggy 7096787be6 fix(printers): don't retract a fan kit on a partial airduct frame
device.airduct is pushed field by field - the modeCur handler reads it with
    an "in" check for that reason - so a frame can carry parts without carrying
    every fan. Absence in that list is what tells us a kit is not fitted, and
    taken from a truncated frame it made both accessory badges vanish mid-print
    and started rejecting fan=aux2 on a printer that has the fan.

    A parts list now counts as a full inventory only when it carries ids 1 (part
    cooling) and 2 (aux). Neither is optional on a 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.
    Anything narrower is a diff frame: its speeds are applied, presence is left
    alone. Presence can still be added from a partial frame; only retraction
    needs the full list, so a kit that really is removed still disappears.

    Also compose showChamberFan from both model lists rather than branching
    between them, so the P2S/X2D entries in MODELS_WITH_CHAMBER_FAN stay
    reachable instead of reading as dead, and note in the fan-speed docstring
    that the aux2 gate also rejects between connect and the first airduct push.
2026-08-02 09:42:26 +02:00
maziggy 3466195d60 fix(slice): give the slice modal one filament row per project slot (#2712)
The filament list is positional from the modal down to the CLI's
    filament_N.json parts, but for a source that already carries slice_info the
    requirements endpoint returns only the slots the plate consumes. A
    MakerWorld model declaring four filaments and painting with slot 4 alone
    therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
    slot 4 sliced with the profile baked into the source, and the print came out
    PLA.

    The endpoint now takes full_slots, which widens that answer to every
    project slot with used_in_plate flags, and only the slice modal passes it.
    Print-time AMS matching shares the endpoint and keeps the used-only list, so
    it still asks for exactly the spools the job needs.
2026-08-02 09:42:07 +02:00
maziggy 89804b810d fix(slice): report a finished slice once, not once per queued poll
setInterval does not await an async callback. Slicing a large project
    blocks the backend for seconds, so poll ticks piled up behind one stalled
    request, each holding a snapshot taken while the job was still active.
    They resolved together, and every one of them ran the completion path —
    one toast and two query invalidations each. A 20s stall against the 1.5s
    interval produced 13 "Sliced X" toasts from a single slice.

    Only one poll round is now in flight at a time, which also stops queueing
    requests against a backend that is already saturated. Completion is
    recorded once per job id, and a round still awaiting a response when the
    effect tears down now returns instead of acting.
2026-08-02 09:41:46 +02:00
maziggy 4045ddbd1f fix(queue): withdraw an expected print when the command never goes out
feat(db): warn when the connection pool can outgrow the PostgreSQL server

    fix(mqtt): an unusable layer_num must not drop the printer connection

    test: patch settings.base_dir via monkeypatch so it unwinds on error

    test: restore the config module after reloading it
2026-08-02 09:41:32 +02:00
maziggy bdcb8a3fc7 fix(mqtt): keep the layer total that arrives with the print-start frame (#2702)
fix(support): redact push_status values, not the serialised JSON (#2702)
2026-08-02 09:41:19 +02:00
maziggy 216a8a3695 Security hardening (maziggy/bambuddy-security #7)
fix(settings): accept JSON booleans on the Spoolman settings endpoint
2026-08-02 09:41:04 +02:00
maziggy 8eb185b239 fix(camera): reuse the live view's frame for external-camera captures (#2707)
On a printer with an external camera, watching the live view while a print
    ran meant the layer timelapse recorded almost nothing and the finish photo
    went out with no image. The reporter measured 0 of 87 layer captures on one
    print and 0 of 105 on another, both watched throughout. A USB camera allows
    one V4L2 handle, so a capture during a live view fails outright.

    The built-in camera has had this rule since #1348 and #1271: reuse the
    viewer's buffered frame rather than opening a second connection. It was
    never extended to the external paths, and it could not have been -- the
    buffer it depends on was only ever populated by the built-in paths.
    generate_mjpeg_stream yields multipart-wrapped chunks, so the route layer
    could not recover the JPEG, and a guarded caller would have found an empty
    buffer and skipped every time.

    So the stream now publishes each raw frame through a new on_frame callback
    (parallel to on_process from #2675), and the six one-shot consumers reuse
    it: layer timelapse, the finish-photo moment and its background fallback,
    the notification snapshot, Obico polling, and the plate check. A viewer
    attached with nothing buffered yet skips that one attempt rather than
    competing -- kicking the viewer off is worse than missing a frame.

    on_frame exceptions are logged and swallowed, like iter_subscriber's
    on_unsubscribe: buffering is a side effect and must never be able to take
    the live stream down with it. The external stream's teardown now releases
    the buffered frame too, ownership-checked so a concurrent viewer of the
    same printer keeps its own.

    Two side effects on paths not touched here, both improvements: the snapshot
    endpoint and the finish-photo fallback chain consult get_buffered_frame and
    can now serve an external camera's live frame. plate_detection's docstring
    already claimed this behaviour while implementing it only for the built-in
    fallback; that drift is resolved.
2026-08-02 09:40:50 +02:00
maziggy 55f4aa61eb fix(camera): drain a streaming ffmpeg's stderr continuously (#2707)
ffmpeg is spawned with stderr=PIPE and it was only read on the error
    paths, so for the life of a working stream nobody read that pipe. ffmpeg
    writes its banner, the input analysis, then a progress line at a steady
    rate; a 64 KiB pipe fills eventually, ffmpeg blocks writing to it, frames
    stop, and the stream's own 30s timeout fires -- logged as "RTSP read
    timeout" with no hint that we starved it ourselves.

    How long that takes is unmeasured and evidently long: one H2D upstream
    ran 21m36s without stalling, and an earlier 512 B/s extrapolation of mine
    was mostly the one-off startup banner. So this is a bounded resource being
    treated as unbounded, not a fault anyone has reported.

    _FfmpegStderrTail drains the pipe continuously and keeps a 16 KiB rolling
    tail. That tail is what the error paths now report, which is better
    material than before: it holds what ffmpeg said as things went wrong,
    where the on-demand read returned whatever was printed first -- usually
    the banner, which the summariser strips anyway.

    Three readers wanted this one pipe, and asyncio rejects concurrent reads
    on a StreamReader, so the collector is authoritative: it registers by pid,
    _read_ffmpeg_stderr returns its tail when present and otherwise reads the
    pipe unchanged, and _terminate_ffmpeg skips its own stderr drain when the
    collector owns it (the collector keeps draining through teardown, which is
    all wait() needs). The generator starts it after the immediate-failure
    check, which reads the pipe directly because the process is already dead,
    and releases it after _terminate_ffmpeg.

    text() goes through _summarize_ffmpeg_stderr like every other stderr log
    here, so the access code ffmpeg echoes in its input URL stays masked.
    aclose() awaits the cancelled pump rather than firing and forgetting, so
    no pending task survives into loop teardown.
2026-08-02 09:40:34 +02:00
maziggy 097e10b344 fix(camera): one registry key per stream, not per printer (issue #2707)
Closing a camera view and reopening it immediately could leave the new
    stream unregistered while it was running and delivering frames. The
    damage was all indirect: is_stream_active() reported no viewer, so Obico
    polling and snapshots opened a second camera connection against the live
    view (the thing #1348 and #1271 exist to prevent); the janitor's /proc
    scan found an ffmpeg missing from _active_streams and killed the live
    stream as an orphan; and /camera/stop reported "Stopped 0" with a stream
    running.

    The fan-out stream id was f"{printer_id}-fanout" -- constant per printer,
    so every successive stream shared one registry key, and the departing
    generator's finally popped whatever was under it, including its
    successor's entry. The same finally also cleared the per-printer frame
    buffer unconditionally, discarding the new stream's frame. It needed the
    two streams to overlap, which the 4s teardown made easy.

    Each stream now gets its own key via _new_fanout_stream_id(), so a
    generator can only clean up after itself -- the external-camera path
    already does this (#2675) and this brings the fan-out path in line. The
    per-printer dicts are released through _release_printer_frame_state(),
    which checks that no other stream for the printer is still running; both
    the RTSP and chamber-image cleanups had the same unconditional pop.

    Also hoisted time and uuid to module level and dropped four
    function-local `import time` statements. A local import shadows the name
    for the whole function, so any use on a branch that doesn't reach the
    import raises UnboundLocalError -- a real hazard in camera_stream, whose
    external-camera branch imported both while the RTSP path needs them too.
    A test pins camera_stream as free of function-local imports.
2026-08-02 09:40:19 +02:00
maziggy 562e325972 fix(camera): drain ffmpeg's pipes during teardown (#NNNN)
Closing a camera view logged "ffmpeg didn't terminate gracefully,
    killing" followed by "ffmpeg did not exit within 2.0s of SIGKILL;
    abandoning wait", on every single close. Both waits expired every time,
    so teardown took a fixed 4.00s -- and since the firmware allows one
    camera connection, that was 4s in which nothing else could use it.

    ffmpeg is spawned with stdout and stderr as pipes and the teardown paths
    have stopped reading them, so it sits blocked in write() on a full 64 KiB
    pipe. SIGTERM cannot be acted on there: the handler only sets a flag that
    the main loop polls, and the loop never gets back to the check. SIGKILL
    does kill it, but asyncio resolves Process.wait()'s waiter through
    _try_finish(), which requires every pipe transport to report
    disconnected; paused, unread pipes never reach EOF, so wait() blocks with
    returncode already set. A negative-control test shows returncode=-9 at
    the instant the abandon fires.

    Draining both pipes while stopping the process fixes both halves: 4.00s
    becomes ~0.15s. The signal ladder and its bounds stay as backstops, so a
    genuinely wedged process still cannot hang a stream, a Stop request or
    the janitor.

    This corrects _FFMPEG_KILL_TIMEOUT's premise and #2580's conclusion. That
    12-hour hang was the unbounded form of this same self-inflicted stall, not
    an ffmpeg stuck in uninterruptible I/O -- the process observed doing it
    was in state S, which cannot survive a delivered SIGKILL. Bounding the
    wait capped the symptom without removing the cause.
2026-08-02 09:40:04 +02:00
maziggy 54908115a2 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-08-02 09:39:40 +02:00
maziggy 9b2b433497 fix(filament): a unique preset match no longer counts as a colour match (#2687)
The Filament Mapping panel reported "(Ready)" with a green tick for a slot
    where the slice wanted dark red and the auto-matched tray held dark green.
    Manually picking that same tray reported the mismatch correctly, which is
    what made it obvious something was inconsistent.

    Auto-match ranks candidates by tray_info_idx first, and a uniquely-matching
    preset was accepted as definitive on the premise "same preset = same spool =
    same colour". The preset names the variant, not the spool: GFA00 is PLA
    Basic, GFA01 PLA Matte, GFA17 PLA Translucent, in every colour Bambu sells.
    The reporter's own bundle has eight GFA00 trays in eight colours. With one
    Matte spool loaded, every Matte requirement idx-matched it and the colour
    comparison was never reached - which is why this surfaced on PLA Matte and
    not on Basic, where several spools are usually loaded and the match falls
    through to the branch that does compare colours.

    The verdict now comes from the tray that was selected rather than from which
    rule selected it, and both branches share one comparison so they cannot
    drift apart again. Selection is unchanged - the right variant still wins per
    mismatch and the slot stays selected.

    A requirement with no colour at all is treated as satisfied rather than
    mismatched; 3MFs that omit it parse to "" and there is nothing to disagree
    with. That also affects the manual branch, which used to flag it.

    No dispatch change: _get_missing_force_color_slots already required an exact
    colour, so force colour match was gated correctly throughout.
2026-08-02 09:39:25 +02:00
maziggy c79a583f7a feat(file-manager): show last activity on folder rows via the existing date toggle (issue #2680)
Follow-up to #2680: the calendar toggle only put dates on the file pane, so
    the folder tree had no way to show the timestamp it was already sorting on.
    FolderTreeItem now takes showModified and renders latest_activity_at under
    the folder name, threaded through the recursive call so nested folders get it
    too. No backend change - the field was already on the wire from the sort fix.

    Folders are labelled "last activity", not "last modified", and get their own
    i18n key. The value is the newest timestamp among the folder, its files and
    everything below it, so a folder can read as newer than its own directory
    mtime - calling that "modified" would look like a fresh instance of the
    ls -lt mismatch the issue was originally about. Folders with no activity
    render nothing rather than an Invalid Date placeholder.

    The name span moved into a flex column so the second line does not disturb
    the row's link badge, file count or kebab menu. That broke a folder-delete
    test that reached the row via parentElement, now fixed to use closest().

    Separately, the #996 collapse describe left an implementation on the
    module-global localStorage.getItem mock, which silently collapsed the folder
    tree for every describe after it. It resets in afterEach now; without that,
    any later test asserting on nested folders fails for reasons unrelated to
    what it is testing.
2026-08-02 09:39:10 +02:00
maziggy c655413971 fix(tests): add the new fan fields to the plate-clear status fixture
mqtt_relay reads state.left_aux_fan_speed, but the SimpleNamespace fixture in
    test_plate_clear_mqtt_notification enumerates its fields explicitly, so the two
    status-payload tests raised AttributeError. I updated the equivalent fixture in
    test_printer_manager_status_broadcast and missed this one — running only the
    touched suites is what hid it.

    Also addresses the round-2 review notes:

    - exhaust_fan_present: documented that the H2 series reports part 3 too, so the
      flag is not model-specific despite the name.
    - Mask the part id after shifting, matching get_flag_bits(id, 4, 8), for
      consistency with the state decode. No behaviour change for any observed id.
    - Noted the unmapped H2 id 6 beside the id branches.
    - Reject fan=aux2 when the printer reports no left_aux_fan_speed, so a POST
      against an A1 no longer sends M106 P10 for absent hardware. The UI already
      hid the badge; this closes the same hole on the API.
    - Added a test asserting EXHAUST_FAN_LABEL_MODELS and the frontend's
      MODELS_WITH_EXHAUST_LABEL cannot drift apart.
2026-08-02 09:38:51 +02:00
maziggy 647cda6b9d Post work PR #2693 2026-08-02 09:38:38 +02:00
maziggy 83df15c0a3 Merge pull request #2693 from Chachigo/feature/print-progress-in-title
feat: show live print progress in the browser tab title
2026-08-02 09:38:23 +02:00
maziggy 4c733dfd45 fix(tab-progress): drop the redundant status poll and quieten the test suite
The hook is mounted globally in WebSocketProvider, so refetchInterval on its
    per-printer status queries added one request per printer every 30s on every
    page. The Printers page already runs that fallback on the same query key, and
    useWebSocket writes ['printerStatus', id] straight into the cache, so the poll
    bought nothing outside the Printers page and cost a request per printer per
    tab everywhere else.

    Also captures document.title at mount instead of restoring to a hardcoded
    'Bambuddy', so the default no longer has to be kept in sync with index.html.

    jsdom has no canvas backend, so getContext('2d') logged a "Not implemented"
    jsdomError with a full React stack on every run of the hook's tests, and the
    favicon branch bailed on the null context and went untested. Stubbing
    getContext/toDataURL removes the noise and lets the ring code run, so the
    favicon swap and the restore-on-toggle-off path are now asserted.
2026-08-02 09:38:08 +02:00
maziggy 3e326adce8 feat(notifications): optional Telegram forum topic via message_thread_id (#1518)
Telegram groups with Topics enabled always received notifications in the
    General topic, since only Bot Token and Chat ID were configurable. Splitting
    notifications per printer meant running a separate chat for each one.

    The Telegram provider now takes an optional Forum Topic ID - the last number
    in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left
    empty, nothing changes.

    The value is coerced to an int once in _send_telegram and attached to both the
    sendMessage JSON body and the sendPhoto form data. That ordering matters:
    Telegram rejects a string message_thread_id in the JSON body while accepting
    one in the multipart call, so passing the raw form value through would have
    worked for thumbnail notifications and 400'd for plain-text ones. A
    non-numeric value is rejected in the form and again server-side before any
    request goes out.

    No migration - provider config is a JSON blob.

    Adds Forum Topic ID plus help text to the Telegram section of the provider
    dialog, translated in all 13 locales. Backend tests cover omitted / blank /
    int-typed / non-numeric values and both send paths; frontend tests cover the
    field being optional, absent for other providers, round-tripping on save, and
    blocking save on a bad value.
2026-08-02 09:37:46 +02:00
maziggy a817394a23 feat: show live print progress in the browser tab title
Optional and off by default, toggled under Settings -> Appearance. When enabled, the browser tab shows the soonest-finishing print's percentage plus a green progress-
ring favicon, updated live over the existing WebSocket. The preference is stored per-browser in localStorage.

    Adds the usePrintProgressTitle hook (with tests), a ThemeContext preference, the Settings toggle, i18n strings for all locales, and a README entry.
2026-08-02 09:37:28 +02:00
maziggy cf4cced388 fix(i18n): list Ukrainian after Russian in the language picker
The uk locale was inserted before ru in i18n/index.ts - in the import block,
    the resources map and availableLanguages. Locales are appended to those lists
    as they land (tr, then ru on 2026-07-19, then uk on 2026-07-28), and
    SettingsPage renders availableLanguages in array order, so the picker showed
    Ukrainian above Russian while every other entry followed the order it was
    added.

    Moves uk to the end of all three lists. SUPPORTED_LNGS is sorted
    alphabetically and already had uk in the correct position, so it is unchanged.

    Frontend-only, no behaviour change beyond the picker's row order.
2026-08-02 09:37:13 +02:00
maziggy 65b4c59175 Merge branch 'dev' into feature/p2s-x2d-accessory-fans 2026-08-02 09:36:55 +02:00
maziggy 6d8434b250 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-08-02 09:36:38 +02:00
maziggy fe476dd98e fix(timelapse): poll longer, diff without a clock, delete once archived (#2704)
Timelapse was on, the video never reached the archive, and Scan for Timelapse
    found nothing afterwards. Across 247 support bundles this was the norm, not an
    edge case: 457 automatic scans scheduled, 262 attached.

    The scan looked four times over ~65s. The attempt that found the video was #1
    272 times, then 17 / 13 / 13 — flat against the cutoff, not decaying, i.e.
    files were still arriving when we stopped. What ran afterwards searched for the
    print name inside the filename; Bambu only writes "video_<timestamp>", so it
    fired 159 times and matched zero.

    The manual Scan had no baseline at all and matched on filename timestamp, FTP
    mtime, or "there is only one video" — all reading a clock a LAN-only printer
    cannot sync. The reporter's P1S was six and a half days out.

    - Poll for minutes instead of ~65s; drop the name-match fallback.
    - Persist the print-start baseline on the archive, so the diff survives a
      restart mid-print and the manual Scan runs the same comparison. With a
      baseline present the clock-based strategies are skipped entirely — they can
      only turn an honest "pick one" into a confident wrong answer.
    - When several files are new (a previous print's video landing late), exclude
      the ones already attached to another archive instead of ordering the
      candidates. Ordering could only be done on the printer's clock.
    - Delete the video from the printer once archived. Keeps /timelapse to
      unclaimed files, which is what makes the diff unambiguous, and stops P1S
      cards filling with AVIs.
    - Gate that delete on a verified transfer: download_file now compares against
      the size from the listing. An FTPS connection closing early does not always
      raise, so a partial buffer was being attached as a complete video — which
      would also have been the one case where deleting the source lost data.

    Bounded twice on purpose: wall-clock deadline plus a derived round cap, since
    the deadline stops bounding the loop as soon as the sleeps are shortened.
    Per-round logging only speaks when the listing changed — 31 rounds of full
    listings would bury the interesting line in the support bundle.

    Migration adds print_archives.timelapse_baseline as JSON, spelled the same on
    both dialects so a migrated database matches a fresh one.

    -----------

    fix(finish-photo): add the timelapse frame to the archive after the notification (#2704)

    When a print records a timelapse, its last frame is the better finish photo:
    the firmware stops recording with the toolhead parked and before the end
    G-code drops the bed, where a live grab at that moment catches a lowered
    plate. Bambuddy waited 60s for the video and then gave up, because the
    print-complete notification blocks on that photo and holding a notification
    for minutes is worse than sending it with the live grab.

    P1-series printers write MJPEG AVI rather than H.264 MP4 and serve it slowly.
    Measured over 261 attaches in the support bundles: P1S median 33s, p90 167s,
    worst 546s, while every other model finished inside 26s. So the printers that
    most needed the better framing were the ones that never got it.

    Keep the notification on the same bound, and keep waiting off to the side.
    _capture_finish_photo_from_timelapse now reports whether it ran out of time or
    concluded — a video that landed and failed extraction is not worth retrying,
    one that never arrived is. On the first, schedule a background task that waits
    up to 15 minutes and inserts the extracted frame at the front of the archive's
    photo list, where the gallery opens.

    The live grab stays on disk: the notification already links to that exact
    file, so removing it would leave a broken image in Discord or Telegram.

    The length check proves we received what the listing said, not that the file
    was finished. The first look happens ~5s after the print ends, while the
    printer may still be writing, so a growing file can be listed short, served
    short, and pass. Re-list after the download and only accept the video once its
    size has stopped changing — a failed re-list counts as not settled, since
    "could not check" must not mean "safe to delete".
2026-08-02 09:36:07 +02:00
maziggy d14c969197 feat(diagnostics): log end-of-print telemetry for finish-photo trigger research (#2547)
The finish photo needs a "printing done, toolhead parked, filament unload
    not started" moment. stg_cur=22 was meant to be it (#1721) and fires on no
    model in the field: across 247 support bundles there is not one
    FINISH PHOTO MOMENT (stage-22), including the 2026-06-13..07-08 window where
    it was the only pre-FINISH trigger in the code — 104 captures on A1, A1 Mini,
    H2C, H2D, P1S, P2S, X1C and X2D, all of them the FINISH fallback.

    A replacement can't be designed from the bundles we have. Out of that window
    Bambuddy parses only stg_cur and mc_print_sub_stage; every other stage/action
    field arrives and is dropped unread. The candidates that sound right
    (print_real_action, mc_action, mc_stage) are absent from A1/A1 Mini/P1S
    payloads, so none of them can be the universal answer alone.

    Dump the raw fields for the window between the last object layer and
    gcode_state=FINISH at DEBUG. Opens on the first end-of-print signal (last
    layer, progress >= 99, or no remaining time) so a dropped layer_num packet
    doesn't lose it, logs only what changed frame to frame, closes on the
    transition out of RUNNING, and arms once per print.

    Instrumentation only: gated on DEBUG being enabled, read-only against printer
    state, wrapped so it cannot break ingest, and capped at 400 frames per print.
    The probed fields are stage codes, counters and bitfields — nothing
    identifying, and no access code.
2026-08-02 09:35:49 +02:00
maziggy 1cda64c35a fix(mqtt): report why a printer refused the connection instead of looping silently
A printer with a wrong access code gave no explanation anywhere. The connect
    callback's failure branch was a bare `state.connected = False`, discarding the
    CONNACK reason code the printer had just sent, so the only trace was paho's
    follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
    which is exactly what a powered-off printer produces. In the report behind this
    fix one of three printers had been in that loop for the whole capture, and
    neither the log nor the support bundle could say why.

    Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
    reason codes 134 and 135. Both are now logged with the printer's own reason
    string and, for those two, the remedy: the access code is regenerated whenever
    LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
    The access code itself is never logged -- it would land in every bundle.

    The reason is kept on the client as a stable slug and plumbed through
    test_connection into the connection diagnostic, which now distinguishes two
    cases it previously conflated. "The printer refused our credentials" is
    asserted only when the printer said so; when all Bambuddy knows is that there
    is no session, the text hedges and names the alternatives (rebooting, or
    already at its limit of simultaneous connections). The old wording claimed the
    access code was most likely wrong in both cases.

    Frontend needed no change -- ConnectionDiagnostic already renders
    `<status>_<reason>` variants with fallback to the plain per-status text, so an
    unrecognised slug degrades to today's wording rather than a missing key.
2026-08-02 09:35:23 +02:00
maziggy 30ad040ac2 Updated README 2026-08-02 09:34:57 +02:00
maziggy 469bd408a0 Updated CONTRIBUTING.md 2026-08-02 09:34:50 +02:00
maziggy 7e954efb10 Updated BACKERS 2026-08-02 09:34:38 +02:00
MartinNYHC 1c2d219b89 Merge pull request #2723 from bitbarista/fix/camera-rotation-finish-photo-timelapse
fix(camera): apply camera_rotation to finish photos and layer-timelapse frames
2026-07-31 09:36:00 +02:00
MartinNYHC cd7b869419 Merge branch 'dev' into fix/camera-rotation-finish-photo-timelapse 2026-07-31 09:33:35 +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
MartinNYHC 3ae854ffcf Merge pull request #2722 from bitbarista/fix/timelapse-orphaned-session-cleanup
fix(camera): sweep orphaned timelapse session directories on startup
2026-07-31 09:03:51 +02:00
maziggy 08c9ec6749 fix(camera): protect an in-progress stitch from the orphan sweep, and only sweep this feature's own files
Review follow-ups on the orphaned timelapse session cleanup.

The sweep's own docstring said min_age_seconds made it safe to call mid-run.
It did not. on_print_complete drops the session from _active_sessions before
handing frames_dir to ffmpeg, so for the length of a stitch the directory
matches no active session, and its mtime is the last layer's frame write -
which on a tall print's final layer is easily older than the margin. The
default margin is 300s and the stitch timeout is also 300s, so the two were
tied with no headroom at all: a sweep landing in that window deleted ffmpeg's
input from under it. _finalizing_sessions now covers the stitch, set as the
session leaves _active_sessions and cleared in a finally so a failed stitch
cannot leak the marker and make that printer's leftovers permanently
un-sweepable. The docstring names all three guards and which gap each covers,
including that the margin does have real headroom for the two cases it suits -
a session mid-creation, and the freshly written .mp4 awaiting attach.

The file branch now requires the timelapse_<session_id>.mp4 shape its own
comment describes. It previously deleted any file under
timelapse_frames/<printer_id>/ past the margin; nothing else writes there
today, but age alone is not a reason to delete a file this feature did not
create.

Dropped ignore_errors=True from the rmtree. It made the surrounding
except OSError unreachable, so a read-only mount or a permissions problem was
counted and logged as a successful removal - and that log is the only evidence
an operator has of what was deleted.

Tests 5 -> 9: sparing a session mid-stitch, the finalizing marker cleared even
when the stitch raises, unrelated files left alone, and a failed removal not
counted. The failure test's rmtree stub honours the real contract and returns
silently when ignore_errors=True, because that silent no-op is exactly what the
old call could never observe; a stub that raised unconditionally would have
passed against both versions and proved nothing.

main.py is unchanged: it has no module-level logger, and the inline
logging.getLogger(__name__) the sweep uses is the idiom throughout lifespan.
2026-07-31 09:02:58 +02:00
MartinNYHC 4bd6761487 Merge branch 'dev' into fix/timelapse-orphaned-session-cleanup 2026-07-31 08:49:55 +02:00
MartinNYHC 804256e3bf Merge pull request #2721 from bitbarista/fix/external-camera-capture-coalescing
fix(camera): share one connection between concurrent one-shot external-camera captures
2026-07-31 08:48:32 +02:00
maziggy 4888485a54 fix(camera): redact credentials, contain failures, and stop the external-camera test claiming a connection it never opened
Review follow-ups on the external-camera capture coalescing.

The coalescing was transplanted from camera.py, which is keyed by printer IP
and so has nothing to hide in a log line. These keys carry the camera URL, and
an RTSP camera URL routinely embeds user:pass@ - so the five new log lines
printed the password, one of them at warning level, where it reaches support
bundles. All five now go through _log_key(), which redacts before truncating:
slicing first can cut the URL short of the @ the pattern anchors on and leave
the password intact, which is why every other URL log in the module already
does it in that order.

_capture_frame_uncoalesced gained the blanket catch its camera.py counterpart
has. That is load-bearing once captures are shared: the wrapper hands one
task's outcome to every caller waiting on it and can only give a follower its
own turn for an outcome it recognises, so an escaping exception reached all of
them at once and none retried - one caller's failure becoming N. The per-type
helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the
guarantee belongs here rather than resting on their coverage. CancelledError
is re-raised ahead of it, since the wrapper distinguishes a cancelled leader
from a failed one.

test_connection reports whether it shared a capture. It reaches capture_frame
like any other consumer, so a test landing while Obico is polling got that
frame back and answered "connected" for a connection it never made - the one
answer a connection test must not give silently. It still shares rather than
forcing its own capture, because forcing one would open the second handle to a
single-reader device that this whole mechanism exists to prevent. The response
carries `coalesced`, which also gives capture_in_flight() the consumer its
camera.py counterpart has in the Diagnose tool, and the Test button says
"shared with a capture already running" instead of a bare success.

Tests 12 -> 20: an unexpected error reported as a failed capture, a raising
leader whose follower still gets a frame, the three coalesced states, and
redaction on each log line that can carry a URL. The raising-leader test
patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a
stand-in installed in the latter's place sits above the catch and would test
the wrapper against a shape it can no longer be handed.
2026-07-31 08:47:57 +02:00
MartinNYHC 6ad5bf30f7 Merge branch 'dev' into fix/external-camera-capture-coalescing 2026-07-31 08:32:46 +02:00