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:
maziggy
2026-08-02 09:46:25 +02:00
20 changed files with 308 additions and 48 deletions
+80 -30
View File
@@ -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(