mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
This commit is contained in:
commit
53844b46a5
20 changed files with 308 additions and 48 deletions
|
|
@ -46,7 +46,7 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
- **Closing the camera held the printer's camera connection for four more seconds, then logged an error that wasn't true (#2707)** — Every time a camera view closed, the log recorded `ffmpeg didn't terminate gracefully, killing` and then `ffmpeg did not exit within 2.0s of SIGKILL; abandoning wait`. Both waits expired every single time, so each close cost a fixed four seconds — and because Bambu firmware allows exactly one camera connection, that was four seconds in which nothing else could use the camera: reopening the view, a snapshot, Obico, or the diagnostic. **Root cause.** ffmpeg is started with its output and error streams as pipes, and the shutdown path had stopped reading them. A process whose output pipe is full blocks mid-write, and ffmpeg's shutdown signal only sets a flag that its main loop checks on the next pass, so the polite request could never be acted on and the grace period was dead time. The forced kill did work — but Python cannot report an exit while a pipe is still unread, so the second wait expired too and Bambuddy concluded the process was stuck when it had already gone. Measured at 4.00s per close before, ~0.15s after. **Fix.** Both pipes are now drained while the process is being stopped, which makes the polite shutdown effective and the exit observable. The forced-kill path and its time limit remain as backstops, so a genuinely wedged process still can't hang a stream, a Stop request, or the cleanup task. This also corrects the conclusion recorded for #2580: that 12-hour hang was the unbounded form of this same self-inflicted stall rather than a stuck ffmpeg, so bounding the wait had capped the symptom without removing the cause. Covered by tests that drive a real subprocess — the fault lives in Python's pipe bookkeeping, so a stand-in object would pass against the broken code — including one that verifies a process ignoring the polite signal still has its forced exit observed rather than abandoned.
|
||||
- **A crash or restart mid-print left layer-timelapse files behind forever (#2709, reporter @bitbarista)** — `timelapse_frames/` grew slowly and never shrank on its own. After several routine restarts during testing, it had accumulated 38MB: three abandoned frame directories and two 48-byte `.mp4` files from stitches that never finished writing. **Root cause.** Which timelapse session is active lives only in memory. A restart for any reason — a redeploy, a crash, a power loss — loses that bookkeeping instantly, but the frames already written for that session, and any partially-stitched output file, stay on disk with nothing left that knows they exist. `on_print_complete`'s cleanup never runs for them, because nothing calls it: the session it would clean up no longer has an entry to be found by. A restart-recovered print doesn't get a replacement session either (`_maybe_start_layer_timelapse` only fires on a fresh `PRINT_START` event, #1353), so an orphaned directory can never be resumed or claimed by anything — it just sits there. **Fix.** A one-time sweep on startup removes any `timelapse_frames/<printer_id>/` entry that doesn't match that printer's current session and is old enough not to be a startup race (five minutes). Verified against the real 38MB of leftovers: startup logged each of the five removed by name, and the directory dropped to 8KB. Covered by tests for the orphaned-directory and stray-output-file cases, sparing a genuinely active session, sparing anything too recent to be sure about, and two defensive cases (no `timelapse_frames/` directory yet, an unrelated non-numeric entry under it).
|
||||
- **Two snapshots taken at the same moment opened two competing camera connections (#2705, reporter @gzimbric)** — Bambu firmware allows exactly one camera connection at a time. Bambuddy already knew this: a snapshot taken while somebody is watching the live view reuses the viewer's frame instead of opening a second socket. What nothing covered was two *snapshots* overlapping with no viewer attached at all — an Obico poll and a printer-wall refresh landing 200 ms apart, each correctly concluding it wasn't competing with a viewer, and then colliding with each other. On the reporter's P2S this knocked over the live stream that was feeding the camera wall, which was then reaped for having received no frames for 58 seconds. Eight paths take one-shot frames independently — Obico polling, `/camera/snapshot`, the finish-photo capture and its disk-writing sibling, plate detection, the camera connection test, and the diagnostic — so any pair of them could overlap, and a shorter Obico interval widened the window. **Fix.** Simultaneous captures for the same printer now share one connection: the first opens it, everyone arriving while it is in flight gets the same frame. Every consumer here wants "a recent frame" rather than a frame stamped at its own microsecond, so identical bytes are the right answer. This shares captures, it does not cache them — a request arriving after the previous capture finished still takes a fresh frame, because plate detection and the finish photo judge a running print from these images and a stale frame there is worse than a slow one. Each caller keeps its own deadline (they range from 10 to 30 seconds) rather than inheriting whichever one happened to open the connection, giving up alone leaves the capture running for whoever else is waiting on it, and a capture that fails doesn't hand its failure to callers that never got an attempt of their own — they retry, which by then competes with nothing. One visible consequence: when the **Diagnose** tool shares a capture this way its frame-capture stage is labelled `coalesced_capture`, because the pass is real but the timing shown is mostly time spent waiting, and a diagnostic must not report on a connection it never opened. Wiki updated. Covered by tests for the reported collision, the five-callers-one-connection case the reporter verified on live hardware, per-printer isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side.
|
||||
- **The same one-shot-capture collision could happen on external cameras too, with no viewer attached (#2707 follow-up, reporter @bitbarista)** — #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 kind of 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, with `is_stream_active()` unable to help since that guard only stops a capturer from competing with an *attached viewer*, not with another capturer. **Fix.** The same shape of fix as #2705, applied to `capture_frame()`: concurrent callers for the same camera (URL, type, and — since #1177's snapshot override routes to a different endpoint entirely — snapshot URL) share one capture rather than opening a second connection. Coalesces, does not cache, so a call after the previous one finishes always captures fresh. Each caller keeps its own timeout, giving up leaves the capture running for whoever else is waiting, and a capture that fails doesn't hand its failure to a caller that never got a turn of its own. Covered by tests mirroring #2705's: the reported-shape collision, five callers sharing one connection, per-camera and per-snapshot-URL isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side.
|
||||
- **The same one-shot-capture collision could happen on external cameras too, with no viewer attached (#2707 follow-up, reporter @bitbarista)** — #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 kind of 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, with `is_stream_active()` unable to help since that guard only stops a capturer from competing with an *attached viewer*, not with another capturer. **Fix.** The same shape of fix as #2705, applied to `capture_frame()`: concurrent callers for the same camera (URL, type, and — since #1177's snapshot override routes to a different endpoint entirely — snapshot URL) share one capture rather than opening a second connection. Coalesces, does not cache, so a call after the previous one finishes always captures fresh. Each caller keeps its own timeout, giving up leaves the capture running for whoever else is waiting, and a capture that fails doesn't hand its failure to a caller that never got a turn of its own. One visible consequence, mirroring the label #2705 added to the built-in **Diagnose** tool: pressing **Test** on an external camera while a capture is already running now says the frame was shared with it, because the result is real but the test did not open a connection of its own — and forcing one would be the very second handle this change exists to prevent. Covered by tests mirroring #2705's: the reported-shape collision, five callers sharing one connection, per-camera and per-snapshot-URL isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side — plus, for this path specifically, that an unexpected error is reported as a failed capture rather than raised at every waiting caller at once, and that the shared-capture log lines redact credentials, since an RTSP camera URL routinely carries `user:pass@` where the built-in path's key is only an IP address.
|
||||
- **Auto-matched filament showed a green tick when the colour was plainly wrong (#2687, reporter @pchulpjoost)** — The Filament Mapping panel reported a slot as matched, with the header reading **(Ready)**, while the swatch beside it showed the slice wanted dark red and the tray it had picked held Dark Green. Manually selecting that very same tray from the dropdown correctly reported the colour mismatch, which is what made the disagreement so visible. **Root cause.** Auto-match ranks candidate trays by filament preset ID (`tray_info_idx`) first, and when exactly one loaded tray carried the preset the slice asked for, that tray was accepted as a *definitive* match on the assumption "same preset means same spool, so the colour must agree too". The preset ID names the **variant**, not the spool — `GFA00` is PLA Basic, `GFA01` PLA Matte, `GFA17` PLA Translucent, in every colour Bambu sells it. So a user with one Matte spool loaded matched every Matte requirement regardless of colour, and the colour comparison was never reached. This is why the report came in for PLA Matte in particular: generic PLA Basic is usually loaded several times over, which sent the match down a different path that did compare colours correctly. **Fix.** The colour verdict is now taken from the tray that was actually selected, never from which rule selected it, and the automatic and manual paths share one comparison so they cannot drift apart again. The preset still decides *selection*, because the Basic/Matte/Silk distinction matters ([#2650](https://github.com/maziggy/bambuddy/issues/2650)) — a wrong-coloured tray of the right variant is still chosen, but it is now reported as an amber **Color mismatch** instead of a green tick, and you can print anyway or pick another slot. A near-enough shade still counts as a match, and a 3MF that specifies no colour for a slot is satisfied by any colour rather than being flagged. Dispatch behaviour is unchanged: **Force color match** already required an exact colour before sending a job, so nothing was ever printed in the wrong colour because of this — the panel was simply telling you it was fine when it wasn't. Frontend-only. Wiki updated. Covered by tests for the unique-preset wrong-colour case, agreement between the auto and manual verdicts, the near-shade and colourless-requirement cases, and the multi-preset path that already worked.
|
||||
- **P1-series archives kept the worse finish photo when the timelapse arrived late (#2704 follow-up)** — When a print records a timelapse, Bambuddy prefers the video's last frame as the finish photo: the firmware stops recording after the toolhead parks but before the end G-code drops the bed, so it frames the finished print properly, where a live camera grab at that moment catches an already-lowered plate. Bambuddy waited 60 seconds for the video and then gave up, because the print-complete notification is waiting on that photo and holding a notification for minutes is worse than sending it with the live grab. On P1-series printers the video usually arrives later than that — they write MJPEG AVI instead of H.264 MP4 and serve it slowly, so across the support bundles their median was 33 seconds but the 90th percentile was 167 and the slowest observed was 546; every other model finished inside 26 seconds. The result was that the printers most in need of the better photo were the ones that never got it. **Fix.** The notification still goes out on the same 60-second bound with the live grab, so nothing gets slower. If the video was still on its way when that bound expired, Bambuddy now keeps waiting in the background and adds the extracted frame to the archive when it lands, at the front of the photo list so opening the gallery shows it first. The live grab is kept rather than replaced — the notification that already went out links to that exact file, and removing it would leave a broken image in Discord or Telegram. Covered by tests for the ordering, the longer budget, idempotency and the cases where the video never arrives.
|
||||
- **Timelapses that never got attached, and a Scan button that could not find them (#2704)** — Timelapse was on for the print, the video never arrived in the archive, and pressing **Scan for Timelapse** afterwards turned up nothing. Measured across 247 support bundles, this was not rare: of 457 automatic scans only 262 ever attached a video. **Root cause, part one.** The scan looked four times, at 5, 10, 20 and 30 seconds, then stopped. The printer writes the video only after the print ends and a long print makes a large file, so it often arrived after the last look — the attempt that found the video was the first one 272 times and then 17 / 13 / 13, a flat tail against the cutoff rather than a decaying one. What ran after those four attempts was a fallback that searched for the print's name inside the video filename; Bambu firmware only ever writes `video_<timestamp>`, so in 247 bundles it fired 159 times and matched exactly zero. **Root cause, part two.** The manual Scan button had no such snapshot to work from and matched by filename timestamp, by FTP modification time, or by there being exactly one video on the printer — all of which read a clock the printer cannot set, because a printer in LAN Only mode never reaches Bambu's time server. The reporter's P1S was six and a half days out, which defeats every one of those. **Fix.** The automatic scan now polls for several minutes instead of giving up after about a minute, and the name-match fallback is gone. The list of videos present when the print started is saved with the archive, so the comparison survives a Bambuddy restart mid-print and the manual Scan button can use it too — same clock-independent comparison, no timestamps anywhere. When a previous print's video lands late and two files look new, the one already attached to another archive is ruled out by name rather than by picking whichever the printer listed first, which could attach the wrong video. **Bambuddy now deletes a timelapse from the printer once it has been archived**, which keeps the printer's folder down to unclaimed videos and stops P1-series cards filling up with AVIs; your copy is in the archive, where you can watch, edit, download or remove it. That delete only happens after the transfer has been checked against the size the printer reported — which also fixes a silent truncation: an FTPS transfer that ended early produced a partial video that was attached as though it were complete. Because the first look happens seconds after the print ends — while the printer may still be writing the video — the file is also re-checked afterwards and only accepted once it has stopped growing, so a partial video is never mistaken for a finished one and the printer's copy is never removed on the strength of one. Wiki updated. Covered by tests for candidate selection, the download check gating the delete, the poll bounds, baseline persistence and the manual scan.
|
||||
|
|
|
|||
|
|
@ -224,7 +224,20 @@ def _discard_inflight_capture(key: tuple[str, str, str | None], task: asyncio.Ta
|
|||
if _inflight_captures.get(key) is task:
|
||||
del _inflight_captures[key]
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.debug("In-flight external-camera capture for %s ended in an exception", key[0])
|
||||
logger.debug("In-flight external-camera capture for %s ended in an exception", _log_key(key))
|
||||
|
||||
|
||||
def _log_key(key: tuple[str, str, str | None]) -> str:
|
||||
"""Render an in-flight key for a log line, with credentials redacted.
|
||||
|
||||
Unlike camera.py's coalescing — which is keyed by IP address and so has
|
||||
nothing to hide — these keys carry the camera URL, and an RTSP camera URL
|
||||
routinely embeds ``user:pass@``. Redact before truncating: slicing first
|
||||
can cut the URL short of the ``@`` the pattern anchors on and leave the
|
||||
password in the log, which is why every other URL log in this module does
|
||||
it in this order.
|
||||
"""
|
||||
return redact_url_credentials(key[0])[:50] if key[0] else "None"
|
||||
|
||||
|
||||
async def capture_frame(
|
||||
|
|
@ -277,23 +290,25 @@ async def capture_frame(
|
|||
except TimeoutError:
|
||||
# shield() keeps the capture running for whoever else is still
|
||||
# waiting on it - giving up is this caller's decision alone.
|
||||
logger.warning("Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, key[0])
|
||||
logger.warning(
|
||||
"Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, _log_key(key)
|
||||
)
|
||||
return None
|
||||
except asyncio.CancelledError:
|
||||
# Distinguish "the capture I joined was cancelled" from "I was
|
||||
# cancelled". Only the former is ours to recover from.
|
||||
if not leader.cancelled():
|
||||
raise
|
||||
logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", key[0])
|
||||
logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", _log_key(key))
|
||||
continue
|
||||
if frame is not None:
|
||||
logger.debug(
|
||||
"Reusing in-flight external-camera capture for %s: %d bytes (no second connection opened)",
|
||||
key[0],
|
||||
_log_key(key),
|
||||
len(frame),
|
||||
)
|
||||
return frame
|
||||
logger.debug("In-flight external-camera capture for %s failed; capturing our own", key[0])
|
||||
logger.debug("In-flight external-camera capture for %s failed; capturing our own", _log_key(key))
|
||||
else:
|
||||
return None
|
||||
|
||||
|
|
@ -320,27 +335,48 @@ async def _capture_frame_uncoalesced(
|
|||
|
||||
Callers want that wrapper, not this: it opens a connection
|
||||
unconditionally, which is the collision #2705/#2707 are about.
|
||||
|
||||
Failure is reported as ``None``, never as an exception. That is load-
|
||||
bearing now that captures are shared: the coalescing wrapper hands one
|
||||
task's outcome to every caller waiting on it, and it can only give a
|
||||
follower its own turn for an outcome it can recognise. An exception
|
||||
escaping here would instead propagate to every follower at once —
|
||||
turning one caller's failure into N — and none of them would retry.
|
||||
The per-type helpers below each catch what they expect and return None,
|
||||
but they catch narrowly (``aiohttp.ClientError``/``OSError``/timeouts),
|
||||
so this is the structural guarantee rather than one contingent on their
|
||||
coverage. Mirrors ``_capture_camera_frame_bytes_uncoalesced`` in
|
||||
camera.py, which ends in the same blanket catch for the same reason.
|
||||
"""
|
||||
if snapshot_url:
|
||||
# Redact before truncating — slicing first can cut the URL short of the
|
||||
# ``@`` the pattern anchors on and leave the password in the log.
|
||||
logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
|
||||
return await _capture_snapshot(snapshot_url, timeout)
|
||||
logger.debug(
|
||||
"capture_frame called: type=%s, url=%s...",
|
||||
camera_type,
|
||||
redact_url_credentials(url)[:50] if url else "None",
|
||||
)
|
||||
if camera_type == "mjpeg":
|
||||
return await _capture_mjpeg_frame(url, timeout)
|
||||
elif camera_type == "rtsp":
|
||||
return await _capture_rtsp_frame(url, timeout)
|
||||
elif camera_type == "snapshot":
|
||||
return await _capture_snapshot(url, timeout)
|
||||
elif camera_type == "usb":
|
||||
return await _capture_usb_frame(url, timeout)
|
||||
else:
|
||||
logger.warning("Unknown camera type: %s", camera_type)
|
||||
try:
|
||||
if snapshot_url:
|
||||
# Redact before truncating — slicing first can cut the URL short of the
|
||||
# ``@`` the pattern anchors on and leave the password in the log.
|
||||
logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
|
||||
return await _capture_snapshot(snapshot_url, timeout)
|
||||
logger.debug(
|
||||
"capture_frame called: type=%s, url=%s...",
|
||||
camera_type,
|
||||
redact_url_credentials(url)[:50] if url else "None",
|
||||
)
|
||||
if camera_type == "mjpeg":
|
||||
return await _capture_mjpeg_frame(url, timeout)
|
||||
elif camera_type == "rtsp":
|
||||
return await _capture_rtsp_frame(url, timeout)
|
||||
elif camera_type == "snapshot":
|
||||
return await _capture_snapshot(url, timeout)
|
||||
elif camera_type == "usb":
|
||||
return await _capture_usb_frame(url, timeout)
|
||||
else:
|
||||
logger.warning("Unknown camera type: %s", camera_type)
|
||||
return None
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is not a capture failure and must stay distinguishable:
|
||||
# the wrapper checks ``leader.cancelled()`` to decide whether a
|
||||
# follower may take its own turn.
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("External camera capture failed for %s", redact_url_credentials(url)[:50] if url else "None")
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -691,12 +727,26 @@ async def test_connection(url: str, camera_type: str) -> dict:
|
|||
"""Test camera connection.
|
||||
|
||||
Returns:
|
||||
Dict with {success: bool, error?: str, resolution?: str}
|
||||
Dict with {success: bool, error?: str, resolution?: str, coalesced: bool}
|
||||
|
||||
``coalesced`` is True when the frame came from a capture that was already
|
||||
running rather than from a connection this test opened. Captures are shared
|
||||
(see ``capture_frame``), so a test that lands while Obico is polling — or
|
||||
while any other one-shot consumer is mid-capture — gets that frame back and
|
||||
would otherwise report a healthy connection it never made, which is the one
|
||||
answer a *connection test* must not give silently. Forcing an uncoalesced
|
||||
capture here would be worse: it would open the second handle to a
|
||||
single-reader device that this whole mechanism exists to prevent. So the
|
||||
test still shares, and says so. Mirrors the ``coalesced_capture`` code the
|
||||
built-in diagnostic reports for the same situation (camera_diagnose.py).
|
||||
"""
|
||||
logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
|
||||
# Sampled before the call, while it can still distinguish "someone else is
|
||||
# mid-capture" from "I am the one capturing".
|
||||
coalesced = capture_in_flight(url, camera_type)
|
||||
try:
|
||||
frame = await capture_frame(url, camera_type, timeout=10)
|
||||
logger.info("Capture result: %s bytes", len(frame) if frame else 0)
|
||||
logger.info("Capture result: %s bytes%s", len(frame) if frame else 0, " (coalesced)" if coalesced else "")
|
||||
|
||||
if frame:
|
||||
# Try to get resolution from JPEG header
|
||||
|
|
@ -715,15 +765,15 @@ async def test_connection(url: str, camera_type: str) -> dict:
|
|||
except (IndexError, ValueError):
|
||||
pass # Resolution detection is optional; fall back to default
|
||||
|
||||
return {"success": True, "resolution": resolution}
|
||||
return {"success": True, "resolution": resolution, "coalesced": coalesced}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to capture frame from camera"}
|
||||
return {"success": False, "error": "Failed to capture frame from camera", "coalesced": coalesced}
|
||||
|
||||
except Exception as e:
|
||||
# Sanitize error message - don't expose internal details
|
||||
error_type = type(e).__name__
|
||||
logger.error("Camera connection test failed: %s", e)
|
||||
return {"success": False, "error": f"Connection failed: {error_type}"}
|
||||
return {"success": False, "error": f"Connection failed: {error_type}", "coalesced": coalesced}
|
||||
|
||||
|
||||
async def generate_mjpeg_stream(
|
||||
|
|
|
|||
|
|
@ -306,3 +306,188 @@ async def test_capture_in_flight_reports_the_window(patch_capture):
|
|||
await asyncio.sleep(0)
|
||||
|
||||
assert capture_in_flight("/dev/video1", "usb") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure must arrive as None, never as an exception
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `test_failed_leader_does_not_poison_its_followers` above covers a leader that
|
||||
# RETURNS None. A leader that RAISES is a different path: the wrapper's retry
|
||||
# loop only catches TimeoutError and CancelledError, so an escaping exception
|
||||
# would reach every follower at once and none of them would take a turn of
|
||||
# their own — one caller's failure becoming N. The per-type helpers catch
|
||||
# narrowly (aiohttp.ClientError / OSError / timeouts), so the guarantee lives
|
||||
# in _capture_frame_uncoalesced's own blanket catch.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unexpected_error_is_reported_as_a_failed_capture():
|
||||
"""Not every failure is an OSError. An IncompleteReadError is an EOFError,
|
||||
which none of the per-type helpers catch."""
|
||||
|
||||
async def raising(url, timeout):
|
||||
raise asyncio.IncompleteReadError(partial=b"", expected=4)
|
||||
|
||||
import backend.app.services.external_camera as ec
|
||||
|
||||
original = ec._capture_snapshot
|
||||
ec._capture_snapshot = raising
|
||||
try:
|
||||
result = await ec._capture_frame_uncoalesced("http://cam/snap", "snapshot", 5, None)
|
||||
finally:
|
||||
ec._capture_snapshot = original
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_raising_leader_does_not_take_its_followers_down_with_it(monkeypatch):
|
||||
"""The whole point of coalescing is that one caller's connection serves
|
||||
several. It must not also mean one caller's crash fails several.
|
||||
|
||||
Patches the per-type helper rather than ``_capture_frame_uncoalesced``,
|
||||
deliberately: the guarantee lives in that function's blanket catch, so a
|
||||
stand-in installed in its place would test the wrapper against a shape the
|
||||
wrapper can no longer be handed.
|
||||
"""
|
||||
gate = asyncio.Event()
|
||||
attempts: list[str] = []
|
||||
|
||||
async def raise_then_succeed(url, timeout):
|
||||
attempts.append(url)
|
||||
if len(attempts) == 1:
|
||||
await gate.wait()
|
||||
raise RuntimeError("ffmpeg died in a way nobody catches")
|
||||
return FRAME_B
|
||||
|
||||
monkeypatch.setattr(ec_module, "_capture_rtsp_frame", raise_then_succeed)
|
||||
|
||||
leader = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
|
||||
await asyncio.sleep(0)
|
||||
follower = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
|
||||
await asyncio.sleep(0)
|
||||
gate.set()
|
||||
|
||||
leader_result, follower_result = await asyncio.gather(leader, follower, return_exceptions=True)
|
||||
|
||||
assert not isinstance(leader_result, BaseException), f"leader raised {leader_result!r}"
|
||||
assert not isinstance(follower_result, BaseException), f"follower raised {follower_result!r}"
|
||||
assert leader_result is None, "the leader's own capture failed, so it gets None"
|
||||
assert follower_result == FRAME_B, "the follower took its own turn and succeeded"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The connection test must not claim a connection it never opened
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_reports_when_it_shared_someone_elses_capture(patch_capture):
|
||||
"""A test landing while Obico is mid-poll gets that frame back. Reporting a
|
||||
bare success would credit a connection this test never made — and forcing
|
||||
its own would open the second handle the coalescing exists to prevent."""
|
||||
from backend.app.services.external_camera import test_connection
|
||||
|
||||
gate = asyncio.Event()
|
||||
capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
|
||||
|
||||
other = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
|
||||
await _let_leader_start(capture)
|
||||
|
||||
tested = asyncio.create_task(test_connection("rtsp://cam/1", "rtsp"))
|
||||
await asyncio.sleep(0)
|
||||
gate.set()
|
||||
|
||||
result = await tested
|
||||
await other
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["coalesced"] is True
|
||||
assert capture.count == 1, "no second connection was opened"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_reports_its_own_capture_as_not_coalesced(patch_capture):
|
||||
from backend.app.services.external_camera import test_connection
|
||||
|
||||
capture = patch_capture(RecordingCapture(frames=(FRAME_A,)))
|
||||
result = await test_connection("rtsp://cam/1", "rtsp")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["coalesced"] is False
|
||||
assert capture.count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_reports_coalesced_on_the_failure_path_too(patch_capture):
|
||||
"""The flag describes where the answer came from, not whether it was good."""
|
||||
from backend.app.services.external_camera import test_connection
|
||||
|
||||
capture = patch_capture(RecordingCapture(frames=(None,)))
|
||||
result = await test_connection("rtsp://cam/1", "rtsp")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["coalesced"] is False
|
||||
assert capture.count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credentials must not reach the log
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# camera.py's coalescing is keyed by IP address and has nothing to redact.
|
||||
# These keys carry the camera URL, and an RTSP camera URL routinely embeds
|
||||
# user:pass@ — which is why every other URL log in the module redacts.
|
||||
|
||||
CREDENTIALED_URL = "rtsp://admin:hunter2@192.168.1.50:554/Streaming/Channels/101"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_reuse_log_line_redacts_the_password(patch_capture, caplog):
|
||||
gate = asyncio.Event()
|
||||
capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
|
||||
|
||||
with caplog.at_level("DEBUG", logger=ec_module.__name__):
|
||||
leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
|
||||
await _let_leader_start(capture)
|
||||
follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
|
||||
await asyncio.sleep(0)
|
||||
gate.set()
|
||||
await asyncio.gather(leader, follower)
|
||||
|
||||
assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_gave_up_waiting_log_line_redacts_the_password(patch_capture, caplog):
|
||||
"""This one is a warning, so it shows at the default level and lands in
|
||||
support bundles."""
|
||||
gate = asyncio.Event()
|
||||
capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
|
||||
|
||||
with caplog.at_level("DEBUG", logger=ec_module.__name__):
|
||||
leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
|
||||
await _let_leader_start(capture)
|
||||
assert await capture_frame(CREDENTIALED_URL, "rtsp", timeout=0) is None
|
||||
gate.set()
|
||||
await leader
|
||||
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert any("Gave up waiting" in m for m in messages), "the timeout path did not run"
|
||||
assert not [m for m in messages if "hunter2" in m]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_failed_capture_log_line_redacts_the_password(patch_capture, caplog):
|
||||
gate = asyncio.Event()
|
||||
capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
|
||||
|
||||
with caplog.at_level("DEBUG", logger=ec_module.__name__):
|
||||
leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
|
||||
await _let_leader_start(capture)
|
||||
follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
|
||||
await asyncio.sleep(0)
|
||||
gate.set()
|
||||
await asyncio.gather(leader, follower)
|
||||
|
||||
assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]
|
||||
|
|
|
|||
|
|
@ -3890,7 +3890,11 @@ export const api = {
|
|||
method: 'POST',
|
||||
}),
|
||||
testExternalCamera: (printerId: number, url: string, cameraType: string) =>
|
||||
request<{ success: boolean; error?: string; resolution?: string }>(
|
||||
// `coalesced` is true when the frame came from a capture that was already
|
||||
// running (Obico polling, a snapshot) rather than a connection this test
|
||||
// opened — a single-reader camera is shared rather than opened twice, so
|
||||
// the result is real but says nothing about reaching the camera just now.
|
||||
request<{ success: boolean; error?: string; resolution?: string; coalesced?: boolean }>(
|
||||
`/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2302,6 +2302,7 @@ export default {
|
|||
connectionFailed: 'Verbindung fehlgeschlagen',
|
||||
testFailed: 'Test fehlgeschlagen',
|
||||
cameraConnected: 'Kamera verbunden{{resolution}}',
|
||||
cameraConnectedCoalesced: 'Kamera verbunden{{resolution}} (geteilt mit einer bereits laufenden Aufnahme)',
|
||||
},
|
||||
testConnection: 'Verbindung testen',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2321,6 +2321,7 @@ export default {
|
|||
connectionFailed: 'Connection failed',
|
||||
testFailed: 'Test failed',
|
||||
cameraConnected: 'Camera connected{{resolution}}',
|
||||
cameraConnectedCoalesced: 'Camera connected{{resolution}} (shared with a capture already running)',
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2305,6 +2305,7 @@ export default {
|
|||
connectionFailed: 'Error de conexión',
|
||||
testFailed: 'La prueba falló',
|
||||
cameraConnected: 'Cámara conectada{{resolution}}',
|
||||
cameraConnectedCoalesced: 'Cámara conectada{{resolution}} (compartida con una captura ya en curso)',
|
||||
},
|
||||
testConnection: 'Probar conexión',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2258,6 +2258,7 @@ export default {
|
|||
connectionFailed: 'Échec connexion',
|
||||
testFailed: 'Échec test',
|
||||
cameraConnected: 'Caméra connectée {{resolution}}',
|
||||
cameraConnectedCoalesced: 'Caméra connectée {{resolution}} (partagée avec une capture déjà en cours)',
|
||||
},
|
||||
testConnection: 'Tester la connexion',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2258,6 +2258,7 @@ export default {
|
|||
connectionFailed: 'Connessione fallita',
|
||||
testFailed: 'Test fallito',
|
||||
cameraConnected: 'Camera connessa{{resolution}}',
|
||||
cameraConnectedCoalesced: 'Camera connessa{{resolution}} (condivisa con un\'acquisizione già in corso)',
|
||||
},
|
||||
testConnection: 'Testa connessione',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2301,6 +2301,7 @@ export default {
|
|||
connectionFailed: '接続失敗',
|
||||
testFailed: 'テスト通知の送信に失敗しました',
|
||||
cameraConnected: 'カメラ接続{{resolution}}',
|
||||
cameraConnectedCoalesced: 'カメラ接続{{resolution}}(実行中のキャプチャと共有)',
|
||||
},
|
||||
testConnection: '接続テスト',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2170,6 +2170,7 @@ export default {
|
|||
connectionFailed: '연결 실패',
|
||||
testFailed: '테스트 실패',
|
||||
cameraConnected: '카메라 연결됨{{resolution}}',
|
||||
cameraConnectedCoalesced: '카메라 연결됨{{resolution}} (이미 진행 중인 캡처와 공유됨)',
|
||||
passwordNeedsUppercase: '비밀번호에 대문자가 최소 1개 포함되어야 합니다',
|
||||
passwordNeedsLowercase: '비밀번호에 소문자가 최소 1개 포함되어야 합니다',
|
||||
passwordNeedsDigit: '비밀번호에 숫자가 최소 1개 포함되어야 합니다',
|
||||
|
|
|
|||
|
|
@ -2258,6 +2258,7 @@ export default {
|
|||
connectionFailed: 'Falha na conexão',
|
||||
testFailed: 'Falha no teste',
|
||||
cameraConnected: 'Câmera conectada{{resolution}}',
|
||||
cameraConnectedCoalesced: 'Câmera conectada{{resolution}} (compartilhada com uma captura já em andamento)',
|
||||
},
|
||||
testConnection: 'Testar Conexão',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2175,6 +2175,7 @@ export default {
|
|||
connectionFailed: "Не удалось подключиться",
|
||||
testFailed: "Проверка завершилась ошибкой",
|
||||
cameraConnected: "Камера подключена{{resolution}}",
|
||||
cameraConnectedCoalesced: "Камера подключена{{resolution}} (используется уже выполняющийся захват)",
|
||||
},
|
||||
testConnection: "Проверить подключение",
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2306,6 +2306,7 @@ export default {
|
|||
connectionFailed: 'Bağlantı başarısız',
|
||||
testFailed: 'Test başarısız',
|
||||
cameraConnected: 'Kamera bağlandı{{resolution}}',
|
||||
cameraConnectedCoalesced: 'Kamera bağlandı{{resolution}} (hâlihazırda süren bir yakalamayla paylaşıldı)',
|
||||
},
|
||||
testConnection: 'Bağlantıyı Test Et',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2321,6 +2321,7 @@ export default {
|
|||
connectionFailed: "Помилка підключення",
|
||||
testFailed: "Тест не вдалося",
|
||||
cameraConnected: "Камера підключена{{resolution}}",
|
||||
cameraConnectedCoalesced: "Камера підключена{{resolution}} (спільно з уже виконуваним захопленням)",
|
||||
},
|
||||
testConnection: "Тестове підключення",
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2303,6 +2303,7 @@ export default {
|
|||
connectionFailed: '连接失败',
|
||||
testFailed: '测试失败',
|
||||
cameraConnected: '摄像头已连接{{resolution}}',
|
||||
cameraConnectedCoalesced: '摄像头已连接{{resolution}}(与正在进行的抓取共享)',
|
||||
},
|
||||
testConnection: '测试连接',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -2303,6 +2303,7 @@ export default {
|
|||
connectionFailed: '連線失敗',
|
||||
testFailed: '測試失敗',
|
||||
cameraConnected: '攝影機已連線{{resolution}}',
|
||||
cameraConnectedCoalesced: '攝影機已連線{{resolution}}(與進行中的擷取共用)',
|
||||
},
|
||||
testConnection: '測試連線',
|
||||
catalog: {
|
||||
|
|
|
|||
|
|
@ -1164,7 +1164,15 @@ export function SettingsPage() {
|
|||
const result = await api.testExternalCamera(printerId, url, cameraType);
|
||||
setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));
|
||||
if (result.success) {
|
||||
showToast(t('settings.toast.cameraConnected', { resolution: result.resolution || '' }), 'success');
|
||||
// A shared capture means the frame is real but was not fetched over a
|
||||
// connection this test opened, so say so rather than implying the
|
||||
// camera was just reached.
|
||||
showToast(
|
||||
result.coalesced
|
||||
? t('settings.toast.cameraConnectedCoalesced', { resolution: result.resolution || '' })
|
||||
: t('settings.toast.cameraConnected', { resolution: result.resolution || '' }),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showToast(result.error || t('settings.toast.connectionFailed'), 'error');
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-C2LOlVCR.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-fmZ_9rRe.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue