mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
132 lines
5.9 KiB
Python
132 lines
5.9 KiB
Python
"""Per-printer-model camera tuning knobs.
|
|
|
|
Bambuddy talks to multiple Bambu Lab printer models that all expose a
|
|
camera but in subtly different ways:
|
|
|
|
- **Chamber image** (port 6000, proprietary binary protocol) — A1, A1
|
|
Mini, P1P, P1S. Frame pacing and TLS quirks are firmware-driven and
|
|
don't go through ffmpeg.
|
|
- **RTSPS** (port 322) — X1 series, X2D, H2 series, P2S. Wrapped by a
|
|
local TLS proxy + ffmpeg to MJPEG.
|
|
|
|
The RTSPS path used to live with hard-coded module constants in
|
|
``camera.py``: a single ``-probesize 32 -analyzeduration 0`` tuned for
|
|
X1/H2 fast startup. That breaks the P2S on firmware 01.02.00.00, whose
|
|
RTSP keyframe pacing is slow enough that ffmpeg can't lock onto the
|
|
stream within 32 bytes and gives up with "not enough frames to estimate
|
|
rate" (#1395 follow-up — Tschipel's reproduction).
|
|
|
|
This module replaces those module constants with per-model
|
|
:class:`CameraProfile` entries. Defaults match the historical pre-fix
|
|
behaviour, so existing models (X1, H2, X2D, X1E) keep their fast-
|
|
startup tuning unchanged. Quirky models override the relevant fields
|
|
only — the P2S entry below is the first example.
|
|
|
|
Adding a new model's quirk is a config edit (an entry in ``_PROFILES``
|
|
plus the alias for its internal SSDP code if needed), not another
|
|
hard-coded global constant.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CameraProfile:
|
|
"""Tuning knobs for one printer model's camera path.
|
|
|
|
All defaults reflect the historical X1/H2 behaviour (fast startup,
|
|
minimal probing). Models with quirky firmware override individual
|
|
fields rather than re-defining the whole profile.
|
|
"""
|
|
|
|
# --- RTSPS / ffmpeg path -------------------------------------------------
|
|
# ffmpeg's `-probesize` (bytes). Smaller = lower startup latency but
|
|
# less margin to lock onto a stream whose first keyframe is delayed
|
|
# or whose container metadata is incomplete. P2S 01.02.00.00 needs a
|
|
# full MB to lock; X1/H2 lock within ~32 bytes.
|
|
probesize: int = 32
|
|
# ffmpeg's `-analyzeduration` (microseconds). 0 = skip format
|
|
# analysis entirely. Same trade-off as probesize.
|
|
analyzeduration: int = 0
|
|
# Max consecutive ffmpeg respawns when the printer drops the RTSP
|
|
# session mid-stream. Some firmwares cut the stream after a few
|
|
# seconds (originally noted on P2S), so we transparently respawn
|
|
# to keep the MJPEG client alive. Consecutive means failed sessions
|
|
# in a row: a session that delivered frames resets the count. A stock
|
|
# X1C ends every session after about a minute, so a lifetime count
|
|
# stopped a live view for good after half an hour.
|
|
rtsp_reconnect_max: int = 30
|
|
# Seconds before respawning after a session that delivered frames --
|
|
# the routine drop, reconnected fast enough that viewers don't notice.
|
|
rtsp_reconnect_delay: float = 0.2
|
|
# Each further failed attempt in a row doubles the delay up to this cap,
|
|
# so a printer that is refusing sessions for a while (another client on
|
|
# its camera, a busy camera service) gets minutes rather than 30 dials in
|
|
# nine seconds before the stream is given up.
|
|
rtsp_reconnect_backoff_max: float = 5.0
|
|
|
|
# --- Extra ffmpeg input args ---------------------------------------------
|
|
# Hook for future per-model knobs (e.g. `-fflags` overrides) without
|
|
# changing the dataclass shape. Tuple, not list, so the dataclass
|
|
# stays hashable / frozen-friendly.
|
|
extra_ffmpeg_input_args: tuple[str, ...] = field(default_factory=tuple)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profile registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Default profile = historical X1/H2 fast-startup behaviour. Used for
|
|
# every RTSP-capable model that doesn't have an entry in ``_PROFILES``.
|
|
DEFAULT_PROFILE = CameraProfile()
|
|
|
|
# Per-model overrides. Keys are uppercase display names (e.g. "P2S")
|
|
# AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
|
|
# ``_MODEL_ALIASES`` below.
|
|
_PROFILES: dict[str, CameraProfile] = {
|
|
# P2S firmware 01.02.00.00 has two RTSP quirks, both surfaced by #1395:
|
|
#
|
|
# 1. Slow keyframe pacing — ffmpeg's "32-byte probe + zero analyze"
|
|
# combo can't estimate the frame rate ("consider increasing
|
|
# probesize"). Fixed by the relaxed probesize/analyzeduration below.
|
|
#
|
|
# 2. Non-advancing RTP timestamps — every frame is stamped at ~t=0.06s.
|
|
# With ffmpeg's default CFR rate conversion (`-r 15`), this freezes
|
|
# the output clock after the first frame and drops every subsequent
|
|
# frame as a same-timestamp duplicate (ffmpeg stderr: `frame=1
|
|
# time=00:00:00.06 dup=0 drop=526`). `-use_wallclock_as_timestamps 1`
|
|
# regenerates each packet's PTS from arrival wall-clock time, so the
|
|
# output clock advances and CFR conversion works. X1/H2 send correct
|
|
# timestamps and need no override.
|
|
"P2S": CameraProfile(
|
|
probesize=1_000_000,
|
|
analyzeduration=500_000,
|
|
extra_ffmpeg_input_args=("-use_wallclock_as_timestamps", "1"),
|
|
),
|
|
}
|
|
|
|
# SSDP internal codes that should resolve to a display-name profile.
|
|
# Display-name lookup is the canonical path; this just lets the camera
|
|
# code pass through whatever ``Printer.model`` carries without each
|
|
# call site needing to know the code→name map.
|
|
_MODEL_ALIASES: dict[str, str] = {
|
|
"N7": "P2S", # P2S internal SSDP code
|
|
}
|
|
|
|
|
|
def get_camera_profile(model: str | None) -> CameraProfile:
|
|
"""Return the :class:`CameraProfile` for *model*, or the default.
|
|
|
|
``model`` can be either a display name (e.g. ``"P2S"``) or an
|
|
internal SSDP code (e.g. ``"N7"``). Unknown models fall back to
|
|
:data:`DEFAULT_PROFILE` so the camera path is never blocked on a
|
|
missing entry.
|
|
"""
|
|
if not model:
|
|
return DEFAULT_PROFILE
|
|
key = model.upper().strip()
|
|
key = _MODEL_ALIASES.get(key, key)
|
|
return _PROFILES.get(key, DEFAULT_PROFILE)
|