Skip to content

StreamerResource

StreamerResource

Streamer management — screenshots and OCR for PiKVM.

Source code in src/aiopikvm/resources/streamer.py
class StreamerResource(BaseResource):
    """Streamer management — screenshots and OCR for PiKVM."""

    async def get_state(self) -> StreamerState:
        """Get the current streamer state.

        Returns:
            Current streamer subsystem state.
        """
        return await self._get_model("/api/streamer", StreamerState)

    async def get_ustreamer_state(self, *, timeout: float | None = None) -> Streamer:
        """Read ustreamer's own state, straight from ustreamer.

        This is the object
        [`StreamerState.streamer`][aiopikvm.StreamerState] holds: kvmd polls
        ``/state`` on the streamer socket and relays the result into
        ``GET /api/streamer`` untouched. Reading it here skips that poll, so
        the numbers are the ones ustreamer has right now rather than the ones
        kvmd last collected — which is what makes
        [`StreamerStream.clients_stat`][aiopikvm.StreamerStream] usable for
        watching a stream this client itself opened.

        Nothing under ``/streamer`` speaks the kvmd envelope on the way out,
        so a failure here has no ``error`` field to match on.

        Args:
            timeout: Per-call timeout in seconds.

        Returns:
            The running streamer's state.

        Raises:
            APIError: The streamer process is not running, which nginx reports
                as HTTP 502 with a page of its own — it has no upstream socket
                to reach. It is not
                [`UnavailableError`][aiopikvm.UnavailableError]: that one means
                HTTP 503, and kvmd never sees this request. kvmd runs the
                streamer while at least one session asks for video, so open a
                [`ws()`][aiopikvm.PiKVM.ws] first and hold it open.
            ResponseError: The body was not the envelope this endpoint
                documents, which is what a proxy answering instead of
                ustreamer looks like.
        """
        return await self._get_model("/streamer/state", Streamer, timeout=timeout)

    async def mjpeg(
        self,
        *,
        key: str | None = None,
        extra_headers: bool = False,
        zero_data: bool = False,
        timeout: float | httpx.Timeout | None = None,
    ) -> AsyncIterator[MJPEGFrame]:
        """Read the MJPEG stream, one frame at a time.

        This is ustreamer's own ``multipart/x-mixed-replace`` stream, the one
        a browser renders by pointing an ``<img>`` at it. kvmd has no
        equivalent: ``GET /api/streamer/snapshot`` gives one frame per
        request, and this gives them as ustreamer encodes them.

        The iteration ends when the far end stops sending or says it has
        stopped — a multipart body's close delimiter, after which RFC 2046
        §5.1.1 leaves nothing to read. ustreamer never sends one, so in
        practice this is a loop to be left with a ``break`` or cancelled from
        outside. The streamer has to be running for there to be anything to
        read, and kvmd runs it while at least one session asks for video — so
        open a [`ws()`][aiopikvm.PiKVM.ws] around this, or the stream dies
        under this loop.

        Two of ustreamer's flags are deliberately missing. ``advance_headers``
        sends each part's headers before the frame they describe exists, which
        drops ``Content-Length`` — and every ``X-UStreamer-*`` header with it —
        so no parser that finds frames by their declared length can follow it;
        it is a Chromium rendering workaround with nothing to offer a client
        that reads bytes. ``dual_final_frames`` is the same for Safari.

        Args:
            key: A name for this connection. ustreamer echoes it in
                [`StreamerStream.clients_stat`][aiopikvm.StreamerStream],
                which is the only way to find this reader's own row there —
                the id those are keyed by is assigned by ustreamer and never
                sent to the client it belongs to.
            extra_headers: Ask ustreamer to annotate every part with its
                ``X-UStreamer-*`` headers. Without this only
                [`MJPEGFrame.timestamp`][aiopikvm.MJPEGFrame] is filled in.
            zero_data: Ask for the part headers with no JPEG payload behind
                them, which turns this into a cheap frame-timing feed:
                [`MJPEGFrame.data`][aiopikvm.MJPEGFrame] is then empty.
            timeout: Override the request timeout. By default the read timeout
                is disabled — a stream has no end to wait for — while connect,
                write and pool keep their client-level values.

        Yields:
            Each frame, with whatever its part headers said about it.

        Raises:
            APIError: The streamer process is not running (HTTP 502 from
                nginx, which has no upstream socket to reach), or the path was
                refused. Nothing under ``/streamer`` carries the kvmd
                envelope, so there is no ``error`` field on either.
            ResponseError: The response was not a multipart stream, or a part
                arrived with no ``Content-Length`` to find its end by.
            PiKVMError: PiKVM became unreachable, or the connection broke
                mid-stream.
        """
        params: dict[str, Any] = {}
        if key is not None:
            params["key"] = key
        if extra_headers:
            params["extra_headers"] = 1
        if zero_data:
            params["zero_data"] = 1
        async with self._stream(
            "GET",
            "/streamer/stream",
            params=params or None,
            timeout=timeout,
        ) as response:
            reader = _MultipartReader(_boundary_of(response))
            # No chunk size: httpx would then hand out whole pieces of one and
            # hold the remainder back until that much more arrived. A frame
            # that had arrived whole waited on the next read's worth of bytes,
            # and the close delimiter reached the reader only once enough
            # followed it — whatever was left to fill the piece, up to 65535
            # bytes of an epilogue RFC 2046 §5.1.1 says is not there at all.
            # Unchunked, each read goes to the reader as it comes off the
            # socket (#176).
            async for chunk in response.aiter_bytes():
                for headers, data in reader.feed(chunk):
                    payload: dict[str, Any] = {"data": data, "headers": headers}
                    payload.update(_meta_from_headers(headers, _FRAME_HEADERS))
                    yield self._validate(MJPEGFrame, payload, "/streamer/stream")
                if reader.closed:
                    # The body said it had ended. Reading on would be waiting
                    # for a stream that is over — and anything that did
                    # arrive is epilogue the reader now drops anyway.
                    break

    async def set_params(
        self,
        *,
        quality: int | None = None,
        desired_fps: int | None = None,
        resolution: str | None = None,
        h264_bitrate: int | None = None,
        h264_gop: int | None = None,
        timeout: float | None = None,
    ) -> None:
        """Change the streamer parameters.

        kvmd applies these asynchronously — the call returns once the change
        is queued, and [`StreamerState.applied`][aiopikvm.StreamerState] is
        what the running streamer ended up with. Read it back to confirm: a
        value outside the device's own limits is accepted with HTTP 200 and
        then dropped silently, so only re-reading the state shows what
        happened. What is rejected outright is a parameter the device does not
        have at all — the ones it has are the keys present in
        [`StreamerState.params`][aiopikvm.StreamerState].

        Asynchronously here means about a second: kvmd holds the batch open
        for further writes, then applies it and **restarts the streamer**, so
        video drops for a moment. Until that happens neither ``params`` nor
        ``applied`` moves — both describe the streamer that is still running.

        That lag has a sharp edge. kvmd compares each incoming value against
        the *running* streamer and queues only what differs, so writing the
        old value back does not cancel a pending change: it is equal to what
        is running, so it is dropped, and the pending change lands a moment
        later. Undoing a write means waiting for it to take and then writing
        the old value — by which time it differs again.

        Args:
            quality: JPEG quality, 1 to 100. Unsupported on devices with no
                adjustable encoder.
            desired_fps: Target frame rate, 0 to 120 for kvmd, and within
                [`StreamerLimits.desired_fps`][aiopikvm.StreamerLimits] to
                actually take effect.
            resolution: Capture resolution as ``"WIDTHxHEIGHT"``, one of
                [`StreamerLimits.available_resolutions`][aiopikvm.StreamerLimits].
                Only on resolution-capable hardware.
            h264_bitrate: H.264 bitrate in kbps, 25 to 20000 for kvmd, and
                within
                [`StreamerLimits.h264_bitrate`][aiopikvm.StreamerLimits] to
                take effect.
            h264_gop: H.264 group-of-pictures size, 0 to 60 for kvmd, and
                within [`StreamerLimits.h264_gop`][aiopikvm.StreamerLimits] to
                take effect.
            timeout: Per-call timeout in seconds.

        Raises:
            ConfigurationError: If no parameter is given at all.
            APIError: The device does not have one of these parameters
                (HTTP 400, e.g. ``StreamerH264NotSupported``), or a value is
                outside the range kvmd validates against.
        """
        params: dict[str, Any] = {
            name: value
            for name, value in (
                ("quality", quality),
                ("desired_fps", desired_fps),
                ("resolution", resolution),
                ("h264_bitrate", h264_bitrate),
                ("h264_gop", h264_gop),
            )
            if value is not None
        }
        if not params:
            raise ConfigurationError("set_params() needs at least one parameter")
        await self._post("/api/streamer/set_params", params=params, timeout=timeout)

    async def reset(self, *, timeout: float | None = None) -> None:
        """Restart the streamer process.

        The standard recovery for a pipeline that has frozen or wedged its
        capture device. Video drops for a moment while ustreamer restarts.

        Args:
            timeout: Per-call timeout in seconds.
        """
        await self._post("/api/streamer/reset", timeout=timeout)

    async def snapshot(
        self,
        *,
        allow_offline: bool = False,
        save: bool = False,
        load: bool = False,
        preview: bool = False,
        preview_max_width: int | None = None,
        preview_max_height: int | None = None,
        preview_quality: int | None = None,
        timeout: float | None = None,
    ) -> SnapshotImage:
        """Take a JPEG screenshot.

        Without ``allow_offline``, kvmd returns HTTP 503 whenever the video
        source is not online (host asleep, HDMI unplugged, etc.). Passing
        ``allow_offline=True`` makes kvmd return a "NO LIVE VIDEO" placeholder
        JPEG instead, and the returned
        [`SnapshotImage.online`][aiopikvm.SnapshotImage] says which one
        arrived. The flag has no effect when the streamer process is fully
        stopped (no UI clients) — the call still fails with HTTP 503, unless
        ``load`` is used.

        Args:
            allow_offline: When ``True``, accept a placeholder frame if the
                video source is offline.
            save: Also store this frame as the device's saved snapshot, where
                it shows up in
                [`StreamerState.snapshot`][aiopikvm.StreamerState] and
                survives the streamer being stopped. Ignored together with
                ``load``, which returns before anything is saved.
            load: Return the saved snapshot instead of capturing a new one.
                Works while the streamer is stopped, which is the point.
            preview: Have kvmd scale the image down before sending it. The
                reported ``width`` and ``height`` still describe the source
                frame, not the scaled data.
            preview_max_width: Width bound for the preview. Leaving *both*
                bounds unset gives a fifth of the source size; setting only
                this one leaves the height at the source height.
            preview_max_height: Height bound for the preview.
            preview_quality: JPEG quality of the preview, 1 to 100.
            timeout: Per-call timeout in seconds.

        Returns:
            The JPEG together with the metadata ustreamer reports for it.

        Raises:
            UnavailableError: The video source is offline and
                ``allow_offline`` was not set, the streamer process is
                stopped, or ``load`` was used with nothing saved (HTTP 503).
        """
        params: dict[str, Any] = {}
        if allow_offline:
            params["allow_offline"] = 1
        if save:
            params["save"] = 1
        if load:
            params["load"] = 1
        if preview:
            params["preview"] = 1
        if preview_max_width is not None:
            params["preview_max_width"] = preview_max_width
        if preview_max_height is not None:
            params["preview_max_height"] = preview_max_height
        if preview_quality is not None:
            params["preview_quality"] = preview_quality
        response = await self._get_raw(
            "/api/streamer/snapshot",
            params=params or None,
            accept="image/jpeg",
            timeout=timeout,
        )
        return self._snapshot_image(response)

    async def delete_snapshot(self) -> None:
        """Delete the cached snapshot."""
        await self._delete("/api/streamer/snapshot")

    async def get_ocr_info(self) -> OCRInfo:
        """Get OCR capability metadata (enabled flag, available languages).

        Returns:
            Installed OCR languages and the default selection.
        """
        result = await self._get("/api/streamer/ocr")
        ocr = result.get("ocr") if isinstance(result, dict) else None
        return self._validate(OCRInfo, ocr, "/api/streamer/ocr")

    async def ocr(
        self,
        *,
        langs: list[str] | None = None,
        left: int | None = None,
        top: int | None = None,
        right: int | None = None,
        bottom: int | None = None,
        allow_offline: bool = False,
        timeout: float = 30.0,
    ) -> str:
        """Perform OCR on the current screen.

        Sends ``GET /api/streamer/snapshot?ocr=1`` — the kvmd snapshot
        endpoint with the ``ocr`` flag, which returns recognized text as
        ``text/plain`` instead of a JPEG.

        Args:
            langs: Tesseract language codes (e.g. ``["eng"]``,
                ``["eng", "rus"]``). When omitted the kvmd default is used.
                Available languages can be queried via
                [`get_ocr_info()`][aiopikvm.resources.streamer.StreamerResource.get_ocr_info].
            left: Left edge of the region to read, in pixels. Cropping is what
                makes OCR quick: Tesseract needs 10-20 s for a full screen.
            top: Top edge of the region to read.
            right: Right edge of the region to read.
            bottom: Bottom edge of the region to read.
            allow_offline: When ``True``, run OCR on the "NO LIVE VIDEO"
                placeholder if the video source is offline; otherwise
                kvmd returns HTTP 503. No effect if the streamer process
                is fully stopped.
            timeout: Per-call timeout in seconds. OCR runs Tesseract on the
                Pi CPU and is intrinsically slow (10-20 s for full-screen),
                so the default is wider than the client-level default.

        Returns:
            Recognized text.
        """
        params: dict[str, Any] = {"ocr": 1}
        if langs:
            params["ocr_langs"] = ",".join(langs)
        for name, value in (
            ("ocr_left", left),
            ("ocr_top", top),
            ("ocr_right", right),
            ("ocr_bottom", bottom),
        ):
            if value is not None:
                params[name] = value
        if allow_offline:
            params["allow_offline"] = 1
        response = await self._get_raw(
            "/api/streamer/snapshot",
            params=params,
            accept="text/plain",
            timeout=timeout,
        )
        return response.text

    def _snapshot_image(self, response: httpx.Response) -> SnapshotImage:
        """Build a [`SnapshotImage`][aiopikvm.SnapshotImage] from a snapshot
        response.

        A header that cannot be read is dropped rather than failing the call:
        the JPEG is what the caller asked for, and these are ustreamer's own
        annotations, which no capture in this repository pins down.

        Args:
            response: The raw snapshot response.

        Returns:
            The image and whatever ustreamer metadata the headers carried.
        """
        payload: dict[str, Any] = {"data": response.content}
        payload.update(_meta_from_headers(response.headers, _SNAPSHOT_HEADERS))
        return self._validate(SnapshotImage, payload, "/api/streamer/snapshot")

get_state() async

Get the current streamer state.

Returns:

Type Description
StreamerState

Current streamer subsystem state.

Source code in src/aiopikvm/resources/streamer.py
async def get_state(self) -> StreamerState:
    """Get the current streamer state.

    Returns:
        Current streamer subsystem state.
    """
    return await self._get_model("/api/streamer", StreamerState)

get_ustreamer_state(*, timeout=None) async

Read ustreamer's own state, straight from ustreamer.

This is the object StreamerState.streamer holds: kvmd polls /state on the streamer socket and relays the result into GET /api/streamer untouched. Reading it here skips that poll, so the numbers are the ones ustreamer has right now rather than the ones kvmd last collected — which is what makes StreamerStream.clients_stat usable for watching a stream this client itself opened.

Nothing under /streamer speaks the kvmd envelope on the way out, so a failure here has no error field to match on.

Parameters:

Name Type Description Default
timeout float | None

Per-call timeout in seconds.

None

Returns:

Type Description
Streamer

The running streamer's state.

Raises:

Type Description
APIError

The streamer process is not running, which nginx reports as HTTP 502 with a page of its own — it has no upstream socket to reach. It is not UnavailableError: that one means HTTP 503, and kvmd never sees this request. kvmd runs the streamer while at least one session asks for video, so open a ws() first and hold it open.

ResponseError

The body was not the envelope this endpoint documents, which is what a proxy answering instead of ustreamer looks like.

Source code in src/aiopikvm/resources/streamer.py
async def get_ustreamer_state(self, *, timeout: float | None = None) -> Streamer:
    """Read ustreamer's own state, straight from ustreamer.

    This is the object
    [`StreamerState.streamer`][aiopikvm.StreamerState] holds: kvmd polls
    ``/state`` on the streamer socket and relays the result into
    ``GET /api/streamer`` untouched. Reading it here skips that poll, so
    the numbers are the ones ustreamer has right now rather than the ones
    kvmd last collected — which is what makes
    [`StreamerStream.clients_stat`][aiopikvm.StreamerStream] usable for
    watching a stream this client itself opened.

    Nothing under ``/streamer`` speaks the kvmd envelope on the way out,
    so a failure here has no ``error`` field to match on.

    Args:
        timeout: Per-call timeout in seconds.

    Returns:
        The running streamer's state.

    Raises:
        APIError: The streamer process is not running, which nginx reports
            as HTTP 502 with a page of its own — it has no upstream socket
            to reach. It is not
            [`UnavailableError`][aiopikvm.UnavailableError]: that one means
            HTTP 503, and kvmd never sees this request. kvmd runs the
            streamer while at least one session asks for video, so open a
            [`ws()`][aiopikvm.PiKVM.ws] first and hold it open.
        ResponseError: The body was not the envelope this endpoint
            documents, which is what a proxy answering instead of
            ustreamer looks like.
    """
    return await self._get_model("/streamer/state", Streamer, timeout=timeout)

mjpeg(*, key=None, extra_headers=False, zero_data=False, timeout=None) async

Read the MJPEG stream, one frame at a time.

This is ustreamer's own multipart/x-mixed-replace stream, the one a browser renders by pointing an <img> at it. kvmd has no equivalent: GET /api/streamer/snapshot gives one frame per request, and this gives them as ustreamer encodes them.

The iteration ends when the far end stops sending or says it has stopped — a multipart body's close delimiter, after which RFC 2046 §5.1.1 leaves nothing to read. ustreamer never sends one, so in practice this is a loop to be left with a break or cancelled from outside. The streamer has to be running for there to be anything to read, and kvmd runs it while at least one session asks for video — so open a ws() around this, or the stream dies under this loop.

Two of ustreamer's flags are deliberately missing. advance_headers sends each part's headers before the frame they describe exists, which drops Content-Length — and every X-UStreamer-* header with it — so no parser that finds frames by their declared length can follow it; it is a Chromium rendering workaround with nothing to offer a client that reads bytes. dual_final_frames is the same for Safari.

Parameters:

Name Type Description Default
key str | None

A name for this connection. ustreamer echoes it in StreamerStream.clients_stat, which is the only way to find this reader's own row there — the id those are keyed by is assigned by ustreamer and never sent to the client it belongs to.

None
extra_headers bool

Ask ustreamer to annotate every part with its X-UStreamer-* headers. Without this only MJPEGFrame.timestamp is filled in.

False
zero_data bool

Ask for the part headers with no JPEG payload behind them, which turns this into a cheap frame-timing feed: MJPEGFrame.data is then empty.

False
timeout float | Timeout | None

Override the request timeout. By default the read timeout is disabled — a stream has no end to wait for — while connect, write and pool keep their client-level values.

None

Yields:

Type Description
AsyncIterator[MJPEGFrame]

Each frame, with whatever its part headers said about it.

Raises:

Type Description
APIError

The streamer process is not running (HTTP 502 from nginx, which has no upstream socket to reach), or the path was refused. Nothing under /streamer carries the kvmd envelope, so there is no error field on either.

ResponseError

The response was not a multipart stream, or a part arrived with no Content-Length to find its end by.

PiKVMError

PiKVM became unreachable, or the connection broke mid-stream.

Source code in src/aiopikvm/resources/streamer.py
async def mjpeg(
    self,
    *,
    key: str | None = None,
    extra_headers: bool = False,
    zero_data: bool = False,
    timeout: float | httpx.Timeout | None = None,
) -> AsyncIterator[MJPEGFrame]:
    """Read the MJPEG stream, one frame at a time.

    This is ustreamer's own ``multipart/x-mixed-replace`` stream, the one
    a browser renders by pointing an ``<img>`` at it. kvmd has no
    equivalent: ``GET /api/streamer/snapshot`` gives one frame per
    request, and this gives them as ustreamer encodes them.

    The iteration ends when the far end stops sending or says it has
    stopped — a multipart body's close delimiter, after which RFC 2046
    §5.1.1 leaves nothing to read. ustreamer never sends one, so in
    practice this is a loop to be left with a ``break`` or cancelled from
    outside. The streamer has to be running for there to be anything to
    read, and kvmd runs it while at least one session asks for video — so
    open a [`ws()`][aiopikvm.PiKVM.ws] around this, or the stream dies
    under this loop.

    Two of ustreamer's flags are deliberately missing. ``advance_headers``
    sends each part's headers before the frame they describe exists, which
    drops ``Content-Length`` — and every ``X-UStreamer-*`` header with it —
    so no parser that finds frames by their declared length can follow it;
    it is a Chromium rendering workaround with nothing to offer a client
    that reads bytes. ``dual_final_frames`` is the same for Safari.

    Args:
        key: A name for this connection. ustreamer echoes it in
            [`StreamerStream.clients_stat`][aiopikvm.StreamerStream],
            which is the only way to find this reader's own row there —
            the id those are keyed by is assigned by ustreamer and never
            sent to the client it belongs to.
        extra_headers: Ask ustreamer to annotate every part with its
            ``X-UStreamer-*`` headers. Without this only
            [`MJPEGFrame.timestamp`][aiopikvm.MJPEGFrame] is filled in.
        zero_data: Ask for the part headers with no JPEG payload behind
            them, which turns this into a cheap frame-timing feed:
            [`MJPEGFrame.data`][aiopikvm.MJPEGFrame] is then empty.
        timeout: Override the request timeout. By default the read timeout
            is disabled — a stream has no end to wait for — while connect,
            write and pool keep their client-level values.

    Yields:
        Each frame, with whatever its part headers said about it.

    Raises:
        APIError: The streamer process is not running (HTTP 502 from
            nginx, which has no upstream socket to reach), or the path was
            refused. Nothing under ``/streamer`` carries the kvmd
            envelope, so there is no ``error`` field on either.
        ResponseError: The response was not a multipart stream, or a part
            arrived with no ``Content-Length`` to find its end by.
        PiKVMError: PiKVM became unreachable, or the connection broke
            mid-stream.
    """
    params: dict[str, Any] = {}
    if key is not None:
        params["key"] = key
    if extra_headers:
        params["extra_headers"] = 1
    if zero_data:
        params["zero_data"] = 1
    async with self._stream(
        "GET",
        "/streamer/stream",
        params=params or None,
        timeout=timeout,
    ) as response:
        reader = _MultipartReader(_boundary_of(response))
        # No chunk size: httpx would then hand out whole pieces of one and
        # hold the remainder back until that much more arrived. A frame
        # that had arrived whole waited on the next read's worth of bytes,
        # and the close delimiter reached the reader only once enough
        # followed it — whatever was left to fill the piece, up to 65535
        # bytes of an epilogue RFC 2046 §5.1.1 says is not there at all.
        # Unchunked, each read goes to the reader as it comes off the
        # socket (#176).
        async for chunk in response.aiter_bytes():
            for headers, data in reader.feed(chunk):
                payload: dict[str, Any] = {"data": data, "headers": headers}
                payload.update(_meta_from_headers(headers, _FRAME_HEADERS))
                yield self._validate(MJPEGFrame, payload, "/streamer/stream")
            if reader.closed:
                # The body said it had ended. Reading on would be waiting
                # for a stream that is over — and anything that did
                # arrive is epilogue the reader now drops anyway.
                break

set_params(*, quality=None, desired_fps=None, resolution=None, h264_bitrate=None, h264_gop=None, timeout=None) async

Change the streamer parameters.

kvmd applies these asynchronously — the call returns once the change is queued, and StreamerState.applied is what the running streamer ended up with. Read it back to confirm: a value outside the device's own limits is accepted with HTTP 200 and then dropped silently, so only re-reading the state shows what happened. What is rejected outright is a parameter the device does not have at all — the ones it has are the keys present in StreamerState.params.

Asynchronously here means about a second: kvmd holds the batch open for further writes, then applies it and restarts the streamer, so video drops for a moment. Until that happens neither params nor applied moves — both describe the streamer that is still running.

That lag has a sharp edge. kvmd compares each incoming value against the running streamer and queues only what differs, so writing the old value back does not cancel a pending change: it is equal to what is running, so it is dropped, and the pending change lands a moment later. Undoing a write means waiting for it to take and then writing the old value — by which time it differs again.

Parameters:

Name Type Description Default
quality int | None

JPEG quality, 1 to 100. Unsupported on devices with no adjustable encoder.

None
desired_fps int | None

Target frame rate, 0 to 120 for kvmd, and within StreamerLimits.desired_fps to actually take effect.

None
resolution str | None

Capture resolution as "WIDTHxHEIGHT", one of StreamerLimits.available_resolutions. Only on resolution-capable hardware.

None
h264_bitrate int | None

H.264 bitrate in kbps, 25 to 20000 for kvmd, and within StreamerLimits.h264_bitrate to take effect.

None
h264_gop int | None

H.264 group-of-pictures size, 0 to 60 for kvmd, and within StreamerLimits.h264_gop to take effect.

None
timeout float | None

Per-call timeout in seconds.

None

Raises:

Type Description
ConfigurationError

If no parameter is given at all.

APIError

The device does not have one of these parameters (HTTP 400, e.g. StreamerH264NotSupported), or a value is outside the range kvmd validates against.

Source code in src/aiopikvm/resources/streamer.py
async def set_params(
    self,
    *,
    quality: int | None = None,
    desired_fps: int | None = None,
    resolution: str | None = None,
    h264_bitrate: int | None = None,
    h264_gop: int | None = None,
    timeout: float | None = None,
) -> None:
    """Change the streamer parameters.

    kvmd applies these asynchronously — the call returns once the change
    is queued, and [`StreamerState.applied`][aiopikvm.StreamerState] is
    what the running streamer ended up with. Read it back to confirm: a
    value outside the device's own limits is accepted with HTTP 200 and
    then dropped silently, so only re-reading the state shows what
    happened. What is rejected outright is a parameter the device does not
    have at all — the ones it has are the keys present in
    [`StreamerState.params`][aiopikvm.StreamerState].

    Asynchronously here means about a second: kvmd holds the batch open
    for further writes, then applies it and **restarts the streamer**, so
    video drops for a moment. Until that happens neither ``params`` nor
    ``applied`` moves — both describe the streamer that is still running.

    That lag has a sharp edge. kvmd compares each incoming value against
    the *running* streamer and queues only what differs, so writing the
    old value back does not cancel a pending change: it is equal to what
    is running, so it is dropped, and the pending change lands a moment
    later. Undoing a write means waiting for it to take and then writing
    the old value — by which time it differs again.

    Args:
        quality: JPEG quality, 1 to 100. Unsupported on devices with no
            adjustable encoder.
        desired_fps: Target frame rate, 0 to 120 for kvmd, and within
            [`StreamerLimits.desired_fps`][aiopikvm.StreamerLimits] to
            actually take effect.
        resolution: Capture resolution as ``"WIDTHxHEIGHT"``, one of
            [`StreamerLimits.available_resolutions`][aiopikvm.StreamerLimits].
            Only on resolution-capable hardware.
        h264_bitrate: H.264 bitrate in kbps, 25 to 20000 for kvmd, and
            within
            [`StreamerLimits.h264_bitrate`][aiopikvm.StreamerLimits] to
            take effect.
        h264_gop: H.264 group-of-pictures size, 0 to 60 for kvmd, and
            within [`StreamerLimits.h264_gop`][aiopikvm.StreamerLimits] to
            take effect.
        timeout: Per-call timeout in seconds.

    Raises:
        ConfigurationError: If no parameter is given at all.
        APIError: The device does not have one of these parameters
            (HTTP 400, e.g. ``StreamerH264NotSupported``), or a value is
            outside the range kvmd validates against.
    """
    params: dict[str, Any] = {
        name: value
        for name, value in (
            ("quality", quality),
            ("desired_fps", desired_fps),
            ("resolution", resolution),
            ("h264_bitrate", h264_bitrate),
            ("h264_gop", h264_gop),
        )
        if value is not None
    }
    if not params:
        raise ConfigurationError("set_params() needs at least one parameter")
    await self._post("/api/streamer/set_params", params=params, timeout=timeout)

reset(*, timeout=None) async

Restart the streamer process.

The standard recovery for a pipeline that has frozen or wedged its capture device. Video drops for a moment while ustreamer restarts.

Parameters:

Name Type Description Default
timeout float | None

Per-call timeout in seconds.

None
Source code in src/aiopikvm/resources/streamer.py
async def reset(self, *, timeout: float | None = None) -> None:
    """Restart the streamer process.

    The standard recovery for a pipeline that has frozen or wedged its
    capture device. Video drops for a moment while ustreamer restarts.

    Args:
        timeout: Per-call timeout in seconds.
    """
    await self._post("/api/streamer/reset", timeout=timeout)

snapshot(*, allow_offline=False, save=False, load=False, preview=False, preview_max_width=None, preview_max_height=None, preview_quality=None, timeout=None) async

Take a JPEG screenshot.

Without allow_offline, kvmd returns HTTP 503 whenever the video source is not online (host asleep, HDMI unplugged, etc.). Passing allow_offline=True makes kvmd return a "NO LIVE VIDEO" placeholder JPEG instead, and the returned SnapshotImage.online says which one arrived. The flag has no effect when the streamer process is fully stopped (no UI clients) — the call still fails with HTTP 503, unless load is used.

Parameters:

Name Type Description Default
allow_offline bool

When True, accept a placeholder frame if the video source is offline.

False
save bool

Also store this frame as the device's saved snapshot, where it shows up in StreamerState.snapshot and survives the streamer being stopped. Ignored together with load, which returns before anything is saved.

False
load bool

Return the saved snapshot instead of capturing a new one. Works while the streamer is stopped, which is the point.

False
preview bool

Have kvmd scale the image down before sending it. The reported width and height still describe the source frame, not the scaled data.

False
preview_max_width int | None

Width bound for the preview. Leaving both bounds unset gives a fifth of the source size; setting only this one leaves the height at the source height.

None
preview_max_height int | None

Height bound for the preview.

None
preview_quality int | None

JPEG quality of the preview, 1 to 100.

None
timeout float | None

Per-call timeout in seconds.

None

Returns:

Type Description
SnapshotImage

The JPEG together with the metadata ustreamer reports for it.

Raises:

Type Description
UnavailableError

The video source is offline and allow_offline was not set, the streamer process is stopped, or load was used with nothing saved (HTTP 503).

Source code in src/aiopikvm/resources/streamer.py
async def snapshot(
    self,
    *,
    allow_offline: bool = False,
    save: bool = False,
    load: bool = False,
    preview: bool = False,
    preview_max_width: int | None = None,
    preview_max_height: int | None = None,
    preview_quality: int | None = None,
    timeout: float | None = None,
) -> SnapshotImage:
    """Take a JPEG screenshot.

    Without ``allow_offline``, kvmd returns HTTP 503 whenever the video
    source is not online (host asleep, HDMI unplugged, etc.). Passing
    ``allow_offline=True`` makes kvmd return a "NO LIVE VIDEO" placeholder
    JPEG instead, and the returned
    [`SnapshotImage.online`][aiopikvm.SnapshotImage] says which one
    arrived. The flag has no effect when the streamer process is fully
    stopped (no UI clients) — the call still fails with HTTP 503, unless
    ``load`` is used.

    Args:
        allow_offline: When ``True``, accept a placeholder frame if the
            video source is offline.
        save: Also store this frame as the device's saved snapshot, where
            it shows up in
            [`StreamerState.snapshot`][aiopikvm.StreamerState] and
            survives the streamer being stopped. Ignored together with
            ``load``, which returns before anything is saved.
        load: Return the saved snapshot instead of capturing a new one.
            Works while the streamer is stopped, which is the point.
        preview: Have kvmd scale the image down before sending it. The
            reported ``width`` and ``height`` still describe the source
            frame, not the scaled data.
        preview_max_width: Width bound for the preview. Leaving *both*
            bounds unset gives a fifth of the source size; setting only
            this one leaves the height at the source height.
        preview_max_height: Height bound for the preview.
        preview_quality: JPEG quality of the preview, 1 to 100.
        timeout: Per-call timeout in seconds.

    Returns:
        The JPEG together with the metadata ustreamer reports for it.

    Raises:
        UnavailableError: The video source is offline and
            ``allow_offline`` was not set, the streamer process is
            stopped, or ``load`` was used with nothing saved (HTTP 503).
    """
    params: dict[str, Any] = {}
    if allow_offline:
        params["allow_offline"] = 1
    if save:
        params["save"] = 1
    if load:
        params["load"] = 1
    if preview:
        params["preview"] = 1
    if preview_max_width is not None:
        params["preview_max_width"] = preview_max_width
    if preview_max_height is not None:
        params["preview_max_height"] = preview_max_height
    if preview_quality is not None:
        params["preview_quality"] = preview_quality
    response = await self._get_raw(
        "/api/streamer/snapshot",
        params=params or None,
        accept="image/jpeg",
        timeout=timeout,
    )
    return self._snapshot_image(response)

delete_snapshot() async

Delete the cached snapshot.

Source code in src/aiopikvm/resources/streamer.py
async def delete_snapshot(self) -> None:
    """Delete the cached snapshot."""
    await self._delete("/api/streamer/snapshot")

get_ocr_info() async

Get OCR capability metadata (enabled flag, available languages).

Returns:

Type Description
OCRInfo

Installed OCR languages and the default selection.

Source code in src/aiopikvm/resources/streamer.py
async def get_ocr_info(self) -> OCRInfo:
    """Get OCR capability metadata (enabled flag, available languages).

    Returns:
        Installed OCR languages and the default selection.
    """
    result = await self._get("/api/streamer/ocr")
    ocr = result.get("ocr") if isinstance(result, dict) else None
    return self._validate(OCRInfo, ocr, "/api/streamer/ocr")

ocr(*, langs=None, left=None, top=None, right=None, bottom=None, allow_offline=False, timeout=30.0) async

Perform OCR on the current screen.

Sends GET /api/streamer/snapshot?ocr=1 — the kvmd snapshot endpoint with the ocr flag, which returns recognized text as text/plain instead of a JPEG.

Parameters:

Name Type Description Default
langs list[str] | None

Tesseract language codes (e.g. ["eng"], ["eng", "rus"]). When omitted the kvmd default is used. Available languages can be queried via get_ocr_info().

None
left int | None

Left edge of the region to read, in pixels. Cropping is what makes OCR quick: Tesseract needs 10-20 s for a full screen.

None
top int | None

Top edge of the region to read.

None
right int | None

Right edge of the region to read.

None
bottom int | None

Bottom edge of the region to read.

None
allow_offline bool

When True, run OCR on the "NO LIVE VIDEO" placeholder if the video source is offline; otherwise kvmd returns HTTP 503. No effect if the streamer process is fully stopped.

False
timeout float

Per-call timeout in seconds. OCR runs Tesseract on the Pi CPU and is intrinsically slow (10-20 s for full-screen), so the default is wider than the client-level default.

30.0

Returns:

Type Description
str

Recognized text.

Source code in src/aiopikvm/resources/streamer.py
async def ocr(
    self,
    *,
    langs: list[str] | None = None,
    left: int | None = None,
    top: int | None = None,
    right: int | None = None,
    bottom: int | None = None,
    allow_offline: bool = False,
    timeout: float = 30.0,
) -> str:
    """Perform OCR on the current screen.

    Sends ``GET /api/streamer/snapshot?ocr=1`` — the kvmd snapshot
    endpoint with the ``ocr`` flag, which returns recognized text as
    ``text/plain`` instead of a JPEG.

    Args:
        langs: Tesseract language codes (e.g. ``["eng"]``,
            ``["eng", "rus"]``). When omitted the kvmd default is used.
            Available languages can be queried via
            [`get_ocr_info()`][aiopikvm.resources.streamer.StreamerResource.get_ocr_info].
        left: Left edge of the region to read, in pixels. Cropping is what
            makes OCR quick: Tesseract needs 10-20 s for a full screen.
        top: Top edge of the region to read.
        right: Right edge of the region to read.
        bottom: Bottom edge of the region to read.
        allow_offline: When ``True``, run OCR on the "NO LIVE VIDEO"
            placeholder if the video source is offline; otherwise
            kvmd returns HTTP 503. No effect if the streamer process
            is fully stopped.
        timeout: Per-call timeout in seconds. OCR runs Tesseract on the
            Pi CPU and is intrinsically slow (10-20 s for full-screen),
            so the default is wider than the client-level default.

    Returns:
        Recognized text.
    """
    params: dict[str, Any] = {"ocr": 1}
    if langs:
        params["ocr_langs"] = ",".join(langs)
    for name, value in (
        ("ocr_left", left),
        ("ocr_top", top),
        ("ocr_right", right),
        ("ocr_bottom", bottom),
    ):
        if value is not None:
            params[name] = value
    if allow_offline:
        params["allow_offline"] = 1
    response = await self._get_raw(
        "/api/streamer/snapshot",
        params=params,
        accept="text/plain",
        timeout=timeout,
    )
    return response.text