Skip to content

MSDResource

MSDResource

Mass Storage Device management for PiKVM.

Source code in src/aiopikvm/resources/msd.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
class MSDResource(BaseResource):
    """Mass Storage Device management for PiKVM."""

    async def get_state(self) -> MSDState:
        """Get the current MSD state.

        Returns:
            Current MSD subsystem state.
        """
        return await self._get_model("/api/msd", MSDState)

    async def set_params(
        self,
        *,
        image: str | None = None,
        cdrom: bool | None = None,
        rw: bool | None = None,
    ) -> None:
        """Set MSD parameters.

        Args:
            image: Name of a stored image to put in the drive, or ``""`` to
                eject the current one. Names come from the storage listing;
                a URL selects a remote image instead. Omit to leave the
                current selection alone.
            cdrom: Emulate CD-ROM drive.
            rw: Allow read-write access.
        """
        params: dict[str, str | int] = {}
        if image is not None:
            params["image"] = image
        if cdrom is not None:
            params["cdrom"] = int(cdrom)
        if rw is not None:
            params["rw"] = int(rw)
        await self._post("/api/msd/set_params", params=params)

    async def set_connected(self, connected: bool) -> None:
        """Set the MSD connection state.

        Args:
            connected: Whether MSD should be connected to the host.
        """
        await self._post("/api/msd/set_connected", params={"connected": int(connected)})

    async def upload(
        self,
        name: str,
        data: bytes | AsyncIterator[bytes],
        *,
        size: int | None = None,
        prefix: str | None = None,
        remove_incomplete: bool | None = None,
        timeout: float | None = None,
    ) -> MSDUpload:
        """Upload a disk image.

        Args:
            name: Image file name. kvmd runs it through its own file-name
                validator and stores it under the result, so read the name
                back from the return value rather than assuming this one.
            data: Image data as bytes or an async byte iterator.
            size: Total image size in bytes. Required for an iterator and
                ignored for bytes. kvmd reads the size from
                ``Content-Length`` and rejects a chunked upload outright, so
                a streamed image has to declare its length up front. It must
                match the data exactly: an undercount makes kvmd store a
                truncated image and mark it ``complete``.
            prefix: Subdirectory of the storage to write into, joined onto
                *name* by kvmd. It has to already exist: kvmd creates the
                image's ``.incomplete`` marker before it creates the
                directory, so a prefix that is not there yet fails on an
                unhandled ``FileNotFoundError`` — a plain-text HTTP 500 with
                no error block, which reaches the caller as an
                [`APIError`][aiopikvm.APIError] carrying only the status.
            remove_incomplete: Whether kvmd deletes a partially written image
                if the connection breaks. Leave unset for the kvmd default,
                which is to keep it, listed with ``complete=False``.
            timeout: Per-call timeout in seconds. Images are large and the
                client default of 10 s is meant for state calls.

        Returns:
            What kvmd wrote: the stored ``name``, the ``size`` the write was
            opened for, and how much was ``written``.

        Raises:
            ConfigurationError: If *data* is an iterator and *size* is
                missing, negative, or disagrees with the bytes it yields.
            APIError: If kvmd refuses the write — an image of that name is
                already in storage, the name does not pass its validator, or
                the prefix directory does not exist.
            ResponseError: If the body is not the write info it documents.
            PiKVMError: If PiKVM is unreachable.
        """
        if isinstance(data, bytes):
            length = len(data)
            content: bytes | httpx.AsyncByteStream = data
        else:
            if size is None:
                raise ConfigurationError(
                    "upload() needs the size of a streamed image: kvmd takes "
                    "it from Content-Length and rejects a chunked body"
                )
            if size < 0:
                raise ConfigurationError(f"upload() got a negative size: {size}")
            length = size
            content = _AsyncStream(data, size)
        params: dict[str, Any] = {"image": name}
        if prefix is not None:
            params["prefix"] = prefix
        if remove_incomplete is not None:
            params["remove_incomplete"] = int(remove_incomplete)
        result = await self._post(
            _WRITE_PATH,
            params=params,
            content=content,
            headers={
                "Content-Type": "application/octet-stream",
                # httpx would frame an iterator as Transfer-Encoding: chunked,
                # which leaves kvmd with content_length=None and a 400.
                "Content-Length": str(length),
            },
            timeout=timeout,
        )
        return self._write_info(result, _WRITE_PATH)

    async def upload_remote(
        self,
        url: str,
        *,
        name: str | None = None,
        prefix: str | None = None,
        insecure: bool | None = None,
        remove_incomplete: bool | None = None,
        connect_timeout: float | None = None,
        timeout: float | httpx.Timeout | None = None,
    ) -> MSDUpload:
        """Download a disk image straight into MSD storage and wait for it.

        The transfer happens between PiKVM and *url*; the image never passes
        through this client. Progress is read from kvmd's own stream, so this
        call lasts as long as the download does — see
        [`upload_remote_progress()`][aiopikvm.resources.msd.MSDResource.upload_remote_progress]
        to watch it go by.

        Args:
            url: Remote image URL. kvmd's validator accepts ``http`` and
                ``https`` only.
            name: Name to store the image under. kvmd defaults it to the
                remote's own: the ``filename`` of its ``Content-Disposition``
                if it sends a usable one, otherwise the last segment of the
                URL path — and it refuses the whole call if neither is a name
                it will accept.
            prefix: Subdirectory of the storage, with the same
                already-has-to-exist caveat as in
                [`upload()`][aiopikvm.resources.msd.MSDResource.upload].
            insecure: Skip TLS verification of the remote — kvmd's own fetch,
                not this client's connection to PiKVM.
            remove_incomplete: Whether kvmd deletes the partial image when
                the download fails. Worth turning on here: a failed remote
                download otherwise leaves an incomplete image occupying the
                name, and the retry is refused for that reason.
            connect_timeout: kvmd's ``timeout`` parameter, in seconds — how
                long *it* waits to connect to *url*. It does not bound the
                download: kvmd puts no limit on the total, and allows a week
                between chunks. Defaults to kvmd's own 10 s; values below 0.1
                are refused.
            timeout: Override this client's timeout for the request. By
                default the read timeout is disabled, since the response
                stays open for the length of the download, while connect,
                write and pool keep their client-level values.

        Returns:
            The last progress record, whose ``name`` is what kvmd stored and
            whose ``written`` equals ``size`` on a completed download.

        Raises:
            APIError: If kvmd refuses before it starts streaming — an
                unusable URL, an origin that answers anything but 200 or
                sends no ``Content-Length``, an unreachable host, or a name
                already in storage — or if the download itself fails, which
                kvmd reports as the last record of an HTTP 200 stream.
            ResponseError: If a record is not the envelope it documents, or
                the stream carries none at all.
            PiKVMError: If PiKVM is unreachable, or the connection breaks
                before kvmd has said why.
        """
        last: MSDUpload | None = None
        async for record in self.upload_remote_progress(
            url,
            name=name,
            prefix=prefix,
            insecure=insecure,
            remove_incomplete=remove_incomplete,
            connect_timeout=connect_timeout,
            timeout=timeout,
        ):
            last = record
        if last is None:
            raise ResponseError(
                f"{_WRITE_REMOTE_PATH} answered without a single progress "
                "record; kvmd sends one before the first byte and one when "
                "the download ends"
            )
        return last

    async def upload_remote_progress(
        self,
        url: str,
        *,
        name: str | None = None,
        prefix: str | None = None,
        insecure: bool | None = None,
        remove_incomplete: bool | None = None,
        connect_timeout: float | None = None,
        timeout: float | httpx.Timeout | None = None,
    ) -> AsyncIterator[MSDUpload]:
        """Download a disk image from a URL, reporting progress as it goes.

        kvmd answers this endpoint with ``application/x-ndjson``: one
        envelope per line, sent before the first byte arrives, about once a
        second while the download runs, and once more when it ends. Each one
        is yielded here as it lands, so ``written / size`` tracks a transfer
        that can take hours.

        Iterating to the end is what waits for the download. Stopping early
        closes the connection, and kvmd gives up on the transfer as soon as
        the next record it writes finds it gone — leaving the partial image
        behind or deleting it, according to *remove_incomplete*. Stop through
        ``contextlib.aclosing`` so that happens where you decide rather than
        whenever the generator is collected.

        A failed download is *not* an error status. kvmd has already sent HTTP
        200 by then, so it writes the failure as one last record and lets the
        connection break without closing the body properly. This raises that
        record as an [`APIError`][aiopikvm.APIError] when it arrives, which is
        before the broken connection surfaces.

        Args:
            url: Remote image URL, ``http`` or ``https``.
            name: Name to store the image under; defaults to the remote's.
            prefix: Subdirectory of the storage, which has to already exist.
            insecure: Skip TLS verification of the remote.
            remove_incomplete: Whether kvmd deletes the partial image when
                the download fails.
            connect_timeout: How long kvmd waits to connect to *url*.
            timeout: Override this client's timeout for the request; the read
                timeout is disabled by default.

        Yields:
            One record per line kvmd sends, in order.

        Raises:
            APIError: If kvmd refuses before streaming, or reports the
                download as failed inside the stream.
            ResponseError: If a line is not the envelope it documents.
            PiKVMError: If PiKVM is unreachable, or the connection breaks
                before kvmd has said why.
        """
        params: dict[str, Any] = {"url": url}
        if name is not None:
            params["image"] = name
        if prefix is not None:
            params["prefix"] = prefix
        if insecure is not None:
            params["insecure"] = int(insecure)
        if remove_incomplete is not None:
            params["remove_incomplete"] = int(remove_incomplete)
        if connect_timeout is not None:
            params["timeout"] = connect_timeout
        async with self._stream(
            "POST",
            _WRITE_REMOTE_PATH,
            params=params,
            headers={"Accept": "application/x-ndjson"},
            timeout=timeout,
        ) as response:
            async for line in response.aiter_lines():
                if line.strip():
                    yield self._write_record(line)

    def _write_record(self, line: str) -> MSDUpload:
        """Parse one line of the ``write_remote`` stream.

        Args:
            line: One line of the NDJSON body, without its terminator.

        Returns:
            The progress it carries.

        Raises:
            ResponseError: If the line is not a JSON object, or holds no
                write info.
            APIError: If the record reports the download as failed.
        """
        try:
            body = json.loads(line)
        except ValueError as exc:
            raise ResponseError(
                f"{_WRITE_REMOTE_PATH} sent a line that is not JSON: {line[:200]}"
            ) from exc
        return self._write_info(
            self._unwrap(body, _WRITE_REMOTE_PATH), _WRITE_REMOTE_PATH
        )

    def _write_info(self, result: Any, path: str) -> MSDUpload:
        """Pull the write info out of an unwrapped ``result`` payload.

        Args:
            result: The ``result`` field of a write response envelope.
            path: URL path it came from, for the error message.

        Returns:
            The validated write info.

        Raises:
            ResponseError: If ``result`` holds no ``image`` block, or the
                block does not match [`MSDUpload`][aiopikvm.MSDUpload].
        """
        image = result.get("image") if isinstance(result, dict) else None
        if image is None:
            raise ResponseError(
                f"{path} returned no image block; kvmd answers a write with "
                f'{{"image": {{"name": ..., "size": ..., "written": ...}}}}'
            )
        return self._validate(MSDUpload, image, path)

    async def download(
        self,
        name: str,
        *,
        compress: Compression = "",
        chunk_size: int = 65536,
        timeout: float | httpx.Timeout | None = None,
    ) -> AsyncIterator[bytes]:
        """Stream a stored image back from the device.

        Args:
            name: Name of the stored image to read.
            compress: Compression kvmd applies on the fly, one of
                [`Compression`][aiopikvm.resources.msd.Compression]. The
                default sends the image verbatim; a compressed response
                carries no ``Content-Length``, so the size is unknown until it
                ends.
            chunk_size: Size of the chunks yielded, in bytes.
            timeout: Override the request timeout. By default the read
                timeout is disabled — an image takes far longer to transfer
                than the client default allows — while connect, write and
                pool keep their client-level values.

        Yields:
            Chunks of the image, in order.

        Raises:
            APIError: If kvmd refuses the read, all of it HTTP 400: no image
                of that name in storage, a compression mode it does not
                know, an MSD that is not set up (``MsdOfflineError``), or a
                drive still handed to the host, which it cannot read from
                underneath (``MsdConnectedError``).
            BusyError: If the MSD is busy with another operation (409).
            PiKVMError: If PiKVM is unreachable, or the connection breaks
                part-way through the image.
        """
        params: dict[str, Any] = {"image": name}
        if compress:
            params["compress"] = compress
        async with self._stream(
            "GET",
            "/api/msd/read",
            params=params,
            headers={"Accept": "application/octet-stream"},
            timeout=timeout,
        ) as response:
            async for chunk in response.aiter_bytes(chunk_size):
                yield chunk

    async def remove(self, name: str) -> None:
        """Remove a disk image.

        The file is gone when this returns, but the listing kvmd checks a
        write against is rebuilt from the storage a moment later. Uploading
        the same name immediately afterwards is refused as already existing;
        poll [`get_state()`][aiopikvm.resources.msd.MSDResource.get_state]
        until ``storage.images`` has dropped it.

        Args:
            name: Image file name to remove, as it appears in
                ``storage.images`` — including the subdirectory, if it was
                written under one.

        Raises:
            APIError: If no image of that name is in storage, or it is in the
                drive and cannot be removed.
            PiKVMError: If PiKVM is unreachable.
        """
        await self._post("/api/msd/remove", params={"image": name})

    async def reset(self) -> None:
        """Reset the MSD subsystem."""
        await self._post("/api/msd/reset")

get_state() async

Get the current MSD state.

Returns:

Type Description
MSDState

Current MSD subsystem state.

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

    Returns:
        Current MSD subsystem state.
    """
    return await self._get_model("/api/msd", MSDState)

set_params(*, image=None, cdrom=None, rw=None) async

Set MSD parameters.

Parameters:

Name Type Description Default
image str | None

Name of a stored image to put in the drive, or "" to eject the current one. Names come from the storage listing; a URL selects a remote image instead. Omit to leave the current selection alone.

None
cdrom bool | None

Emulate CD-ROM drive.

None
rw bool | None

Allow read-write access.

None
Source code in src/aiopikvm/resources/msd.py
async def set_params(
    self,
    *,
    image: str | None = None,
    cdrom: bool | None = None,
    rw: bool | None = None,
) -> None:
    """Set MSD parameters.

    Args:
        image: Name of a stored image to put in the drive, or ``""`` to
            eject the current one. Names come from the storage listing;
            a URL selects a remote image instead. Omit to leave the
            current selection alone.
        cdrom: Emulate CD-ROM drive.
        rw: Allow read-write access.
    """
    params: dict[str, str | int] = {}
    if image is not None:
        params["image"] = image
    if cdrom is not None:
        params["cdrom"] = int(cdrom)
    if rw is not None:
        params["rw"] = int(rw)
    await self._post("/api/msd/set_params", params=params)

set_connected(connected) async

Set the MSD connection state.

Parameters:

Name Type Description Default
connected bool

Whether MSD should be connected to the host.

required
Source code in src/aiopikvm/resources/msd.py
async def set_connected(self, connected: bool) -> None:
    """Set the MSD connection state.

    Args:
        connected: Whether MSD should be connected to the host.
    """
    await self._post("/api/msd/set_connected", params={"connected": int(connected)})

upload(name, data, *, size=None, prefix=None, remove_incomplete=None, timeout=None) async

Upload a disk image.

Parameters:

Name Type Description Default
name str

Image file name. kvmd runs it through its own file-name validator and stores it under the result, so read the name back from the return value rather than assuming this one.

required
data bytes | AsyncIterator[bytes]

Image data as bytes or an async byte iterator.

required
size int | None

Total image size in bytes. Required for an iterator and ignored for bytes. kvmd reads the size from Content-Length and rejects a chunked upload outright, so a streamed image has to declare its length up front. It must match the data exactly: an undercount makes kvmd store a truncated image and mark it complete.

None
prefix str | None

Subdirectory of the storage to write into, joined onto name by kvmd. It has to already exist: kvmd creates the image's .incomplete marker before it creates the directory, so a prefix that is not there yet fails on an unhandled FileNotFoundError — a plain-text HTTP 500 with no error block, which reaches the caller as an APIError carrying only the status.

None
remove_incomplete bool | None

Whether kvmd deletes a partially written image if the connection breaks. Leave unset for the kvmd default, which is to keep it, listed with complete=False.

None
timeout float | None

Per-call timeout in seconds. Images are large and the client default of 10 s is meant for state calls.

None

Returns:

Type Description
MSDUpload

What kvmd wrote: the stored name, the size the write was opened for, and how much was written.

Raises:

Type Description
ConfigurationError

If data is an iterator and size is missing, negative, or disagrees with the bytes it yields.

APIError

If kvmd refuses the write — an image of that name is already in storage, the name does not pass its validator, or the prefix directory does not exist.

ResponseError

If the body is not the write info it documents.

PiKVMError

If PiKVM is unreachable.

Source code in src/aiopikvm/resources/msd.py
async def upload(
    self,
    name: str,
    data: bytes | AsyncIterator[bytes],
    *,
    size: int | None = None,
    prefix: str | None = None,
    remove_incomplete: bool | None = None,
    timeout: float | None = None,
) -> MSDUpload:
    """Upload a disk image.

    Args:
        name: Image file name. kvmd runs it through its own file-name
            validator and stores it under the result, so read the name
            back from the return value rather than assuming this one.
        data: Image data as bytes or an async byte iterator.
        size: Total image size in bytes. Required for an iterator and
            ignored for bytes. kvmd reads the size from
            ``Content-Length`` and rejects a chunked upload outright, so
            a streamed image has to declare its length up front. It must
            match the data exactly: an undercount makes kvmd store a
            truncated image and mark it ``complete``.
        prefix: Subdirectory of the storage to write into, joined onto
            *name* by kvmd. It has to already exist: kvmd creates the
            image's ``.incomplete`` marker before it creates the
            directory, so a prefix that is not there yet fails on an
            unhandled ``FileNotFoundError`` — a plain-text HTTP 500 with
            no error block, which reaches the caller as an
            [`APIError`][aiopikvm.APIError] carrying only the status.
        remove_incomplete: Whether kvmd deletes a partially written image
            if the connection breaks. Leave unset for the kvmd default,
            which is to keep it, listed with ``complete=False``.
        timeout: Per-call timeout in seconds. Images are large and the
            client default of 10 s is meant for state calls.

    Returns:
        What kvmd wrote: the stored ``name``, the ``size`` the write was
        opened for, and how much was ``written``.

    Raises:
        ConfigurationError: If *data* is an iterator and *size* is
            missing, negative, or disagrees with the bytes it yields.
        APIError: If kvmd refuses the write — an image of that name is
            already in storage, the name does not pass its validator, or
            the prefix directory does not exist.
        ResponseError: If the body is not the write info it documents.
        PiKVMError: If PiKVM is unreachable.
    """
    if isinstance(data, bytes):
        length = len(data)
        content: bytes | httpx.AsyncByteStream = data
    else:
        if size is None:
            raise ConfigurationError(
                "upload() needs the size of a streamed image: kvmd takes "
                "it from Content-Length and rejects a chunked body"
            )
        if size < 0:
            raise ConfigurationError(f"upload() got a negative size: {size}")
        length = size
        content = _AsyncStream(data, size)
    params: dict[str, Any] = {"image": name}
    if prefix is not None:
        params["prefix"] = prefix
    if remove_incomplete is not None:
        params["remove_incomplete"] = int(remove_incomplete)
    result = await self._post(
        _WRITE_PATH,
        params=params,
        content=content,
        headers={
            "Content-Type": "application/octet-stream",
            # httpx would frame an iterator as Transfer-Encoding: chunked,
            # which leaves kvmd with content_length=None and a 400.
            "Content-Length": str(length),
        },
        timeout=timeout,
    )
    return self._write_info(result, _WRITE_PATH)

upload_remote(url, *, name=None, prefix=None, insecure=None, remove_incomplete=None, connect_timeout=None, timeout=None) async

Download a disk image straight into MSD storage and wait for it.

The transfer happens between PiKVM and url; the image never passes through this client. Progress is read from kvmd's own stream, so this call lasts as long as the download does — see upload_remote_progress() to watch it go by.

Parameters:

Name Type Description Default
url str

Remote image URL. kvmd's validator accepts http and https only.

required
name str | None

Name to store the image under. kvmd defaults it to the remote's own: the filename of its Content-Disposition if it sends a usable one, otherwise the last segment of the URL path — and it refuses the whole call if neither is a name it will accept.

None
prefix str | None

Subdirectory of the storage, with the same already-has-to-exist caveat as in upload().

None
insecure bool | None

Skip TLS verification of the remote — kvmd's own fetch, not this client's connection to PiKVM.

None
remove_incomplete bool | None

Whether kvmd deletes the partial image when the download fails. Worth turning on here: a failed remote download otherwise leaves an incomplete image occupying the name, and the retry is refused for that reason.

None
connect_timeout float | None

kvmd's timeout parameter, in seconds — how long it waits to connect to url. It does not bound the download: kvmd puts no limit on the total, and allows a week between chunks. Defaults to kvmd's own 10 s; values below 0.1 are refused.

None
timeout float | Timeout | None

Override this client's timeout for the request. By default the read timeout is disabled, since the response stays open for the length of the download, while connect, write and pool keep their client-level values.

None

Returns:

Type Description
MSDUpload

The last progress record, whose name is what kvmd stored and whose written equals size on a completed download.

Raises:

Type Description
APIError

If kvmd refuses before it starts streaming — an unusable URL, an origin that answers anything but 200 or sends no Content-Length, an unreachable host, or a name already in storage — or if the download itself fails, which kvmd reports as the last record of an HTTP 200 stream.

ResponseError

If a record is not the envelope it documents, or the stream carries none at all.

PiKVMError

If PiKVM is unreachable, or the connection breaks before kvmd has said why.

Source code in src/aiopikvm/resources/msd.py
async def upload_remote(
    self,
    url: str,
    *,
    name: str | None = None,
    prefix: str | None = None,
    insecure: bool | None = None,
    remove_incomplete: bool | None = None,
    connect_timeout: float | None = None,
    timeout: float | httpx.Timeout | None = None,
) -> MSDUpload:
    """Download a disk image straight into MSD storage and wait for it.

    The transfer happens between PiKVM and *url*; the image never passes
    through this client. Progress is read from kvmd's own stream, so this
    call lasts as long as the download does — see
    [`upload_remote_progress()`][aiopikvm.resources.msd.MSDResource.upload_remote_progress]
    to watch it go by.

    Args:
        url: Remote image URL. kvmd's validator accepts ``http`` and
            ``https`` only.
        name: Name to store the image under. kvmd defaults it to the
            remote's own: the ``filename`` of its ``Content-Disposition``
            if it sends a usable one, otherwise the last segment of the
            URL path — and it refuses the whole call if neither is a name
            it will accept.
        prefix: Subdirectory of the storage, with the same
            already-has-to-exist caveat as in
            [`upload()`][aiopikvm.resources.msd.MSDResource.upload].
        insecure: Skip TLS verification of the remote — kvmd's own fetch,
            not this client's connection to PiKVM.
        remove_incomplete: Whether kvmd deletes the partial image when
            the download fails. Worth turning on here: a failed remote
            download otherwise leaves an incomplete image occupying the
            name, and the retry is refused for that reason.
        connect_timeout: kvmd's ``timeout`` parameter, in seconds — how
            long *it* waits to connect to *url*. It does not bound the
            download: kvmd puts no limit on the total, and allows a week
            between chunks. Defaults to kvmd's own 10 s; values below 0.1
            are refused.
        timeout: Override this client's timeout for the request. By
            default the read timeout is disabled, since the response
            stays open for the length of the download, while connect,
            write and pool keep their client-level values.

    Returns:
        The last progress record, whose ``name`` is what kvmd stored and
        whose ``written`` equals ``size`` on a completed download.

    Raises:
        APIError: If kvmd refuses before it starts streaming — an
            unusable URL, an origin that answers anything but 200 or
            sends no ``Content-Length``, an unreachable host, or a name
            already in storage — or if the download itself fails, which
            kvmd reports as the last record of an HTTP 200 stream.
        ResponseError: If a record is not the envelope it documents, or
            the stream carries none at all.
        PiKVMError: If PiKVM is unreachable, or the connection breaks
            before kvmd has said why.
    """
    last: MSDUpload | None = None
    async for record in self.upload_remote_progress(
        url,
        name=name,
        prefix=prefix,
        insecure=insecure,
        remove_incomplete=remove_incomplete,
        connect_timeout=connect_timeout,
        timeout=timeout,
    ):
        last = record
    if last is None:
        raise ResponseError(
            f"{_WRITE_REMOTE_PATH} answered without a single progress "
            "record; kvmd sends one before the first byte and one when "
            "the download ends"
        )
    return last

upload_remote_progress(url, *, name=None, prefix=None, insecure=None, remove_incomplete=None, connect_timeout=None, timeout=None) async

Download a disk image from a URL, reporting progress as it goes.

kvmd answers this endpoint with application/x-ndjson: one envelope per line, sent before the first byte arrives, about once a second while the download runs, and once more when it ends. Each one is yielded here as it lands, so written / size tracks a transfer that can take hours.

Iterating to the end is what waits for the download. Stopping early closes the connection, and kvmd gives up on the transfer as soon as the next record it writes finds it gone — leaving the partial image behind or deleting it, according to remove_incomplete. Stop through contextlib.aclosing so that happens where you decide rather than whenever the generator is collected.

A failed download is not an error status. kvmd has already sent HTTP 200 by then, so it writes the failure as one last record and lets the connection break without closing the body properly. This raises that record as an APIError when it arrives, which is before the broken connection surfaces.

Parameters:

Name Type Description Default
url str

Remote image URL, http or https.

required
name str | None

Name to store the image under; defaults to the remote's.

None
prefix str | None

Subdirectory of the storage, which has to already exist.

None
insecure bool | None

Skip TLS verification of the remote.

None
remove_incomplete bool | None

Whether kvmd deletes the partial image when the download fails.

None
connect_timeout float | None

How long kvmd waits to connect to url.

None
timeout float | Timeout | None

Override this client's timeout for the request; the read timeout is disabled by default.

None

Yields:

Type Description
AsyncIterator[MSDUpload]

One record per line kvmd sends, in order.

Raises:

Type Description
APIError

If kvmd refuses before streaming, or reports the download as failed inside the stream.

ResponseError

If a line is not the envelope it documents.

PiKVMError

If PiKVM is unreachable, or the connection breaks before kvmd has said why.

Source code in src/aiopikvm/resources/msd.py
async def upload_remote_progress(
    self,
    url: str,
    *,
    name: str | None = None,
    prefix: str | None = None,
    insecure: bool | None = None,
    remove_incomplete: bool | None = None,
    connect_timeout: float | None = None,
    timeout: float | httpx.Timeout | None = None,
) -> AsyncIterator[MSDUpload]:
    """Download a disk image from a URL, reporting progress as it goes.

    kvmd answers this endpoint with ``application/x-ndjson``: one
    envelope per line, sent before the first byte arrives, about once a
    second while the download runs, and once more when it ends. Each one
    is yielded here as it lands, so ``written / size`` tracks a transfer
    that can take hours.

    Iterating to the end is what waits for the download. Stopping early
    closes the connection, and kvmd gives up on the transfer as soon as
    the next record it writes finds it gone — leaving the partial image
    behind or deleting it, according to *remove_incomplete*. Stop through
    ``contextlib.aclosing`` so that happens where you decide rather than
    whenever the generator is collected.

    A failed download is *not* an error status. kvmd has already sent HTTP
    200 by then, so it writes the failure as one last record and lets the
    connection break without closing the body properly. This raises that
    record as an [`APIError`][aiopikvm.APIError] when it arrives, which is
    before the broken connection surfaces.

    Args:
        url: Remote image URL, ``http`` or ``https``.
        name: Name to store the image under; defaults to the remote's.
        prefix: Subdirectory of the storage, which has to already exist.
        insecure: Skip TLS verification of the remote.
        remove_incomplete: Whether kvmd deletes the partial image when
            the download fails.
        connect_timeout: How long kvmd waits to connect to *url*.
        timeout: Override this client's timeout for the request; the read
            timeout is disabled by default.

    Yields:
        One record per line kvmd sends, in order.

    Raises:
        APIError: If kvmd refuses before streaming, or reports the
            download as failed inside the stream.
        ResponseError: If a line is not the envelope it documents.
        PiKVMError: If PiKVM is unreachable, or the connection breaks
            before kvmd has said why.
    """
    params: dict[str, Any] = {"url": url}
    if name is not None:
        params["image"] = name
    if prefix is not None:
        params["prefix"] = prefix
    if insecure is not None:
        params["insecure"] = int(insecure)
    if remove_incomplete is not None:
        params["remove_incomplete"] = int(remove_incomplete)
    if connect_timeout is not None:
        params["timeout"] = connect_timeout
    async with self._stream(
        "POST",
        _WRITE_REMOTE_PATH,
        params=params,
        headers={"Accept": "application/x-ndjson"},
        timeout=timeout,
    ) as response:
        async for line in response.aiter_lines():
            if line.strip():
                yield self._write_record(line)

download(name, *, compress='', chunk_size=65536, timeout=None) async

Stream a stored image back from the device.

Parameters:

Name Type Description Default
name str

Name of the stored image to read.

required
compress Compression

Compression kvmd applies on the fly, one of Compression. The default sends the image verbatim; a compressed response carries no Content-Length, so the size is unknown until it ends.

''
chunk_size int

Size of the chunks yielded, in bytes.

65536
timeout float | Timeout | None

Override the request timeout. By default the read timeout is disabled — an image takes far longer to transfer than the client default allows — while connect, write and pool keep their client-level values.

None

Yields:

Type Description
AsyncIterator[bytes]

Chunks of the image, in order.

Raises:

Type Description
APIError

If kvmd refuses the read, all of it HTTP 400: no image of that name in storage, a compression mode it does not know, an MSD that is not set up (MsdOfflineError), or a drive still handed to the host, which it cannot read from underneath (MsdConnectedError).

BusyError

If the MSD is busy with another operation (409).

PiKVMError

If PiKVM is unreachable, or the connection breaks part-way through the image.

Source code in src/aiopikvm/resources/msd.py
async def download(
    self,
    name: str,
    *,
    compress: Compression = "",
    chunk_size: int = 65536,
    timeout: float | httpx.Timeout | None = None,
) -> AsyncIterator[bytes]:
    """Stream a stored image back from the device.

    Args:
        name: Name of the stored image to read.
        compress: Compression kvmd applies on the fly, one of
            [`Compression`][aiopikvm.resources.msd.Compression]. The
            default sends the image verbatim; a compressed response
            carries no ``Content-Length``, so the size is unknown until it
            ends.
        chunk_size: Size of the chunks yielded, in bytes.
        timeout: Override the request timeout. By default the read
            timeout is disabled — an image takes far longer to transfer
            than the client default allows — while connect, write and
            pool keep their client-level values.

    Yields:
        Chunks of the image, in order.

    Raises:
        APIError: If kvmd refuses the read, all of it HTTP 400: no image
            of that name in storage, a compression mode it does not
            know, an MSD that is not set up (``MsdOfflineError``), or a
            drive still handed to the host, which it cannot read from
            underneath (``MsdConnectedError``).
        BusyError: If the MSD is busy with another operation (409).
        PiKVMError: If PiKVM is unreachable, or the connection breaks
            part-way through the image.
    """
    params: dict[str, Any] = {"image": name}
    if compress:
        params["compress"] = compress
    async with self._stream(
        "GET",
        "/api/msd/read",
        params=params,
        headers={"Accept": "application/octet-stream"},
        timeout=timeout,
    ) as response:
        async for chunk in response.aiter_bytes(chunk_size):
            yield chunk

remove(name) async

Remove a disk image.

The file is gone when this returns, but the listing kvmd checks a write against is rebuilt from the storage a moment later. Uploading the same name immediately afterwards is refused as already existing; poll get_state() until storage.images has dropped it.

Parameters:

Name Type Description Default
name str

Image file name to remove, as it appears in storage.images — including the subdirectory, if it was written under one.

required

Raises:

Type Description
APIError

If no image of that name is in storage, or it is in the drive and cannot be removed.

PiKVMError

If PiKVM is unreachable.

Source code in src/aiopikvm/resources/msd.py
async def remove(self, name: str) -> None:
    """Remove a disk image.

    The file is gone when this returns, but the listing kvmd checks a
    write against is rebuilt from the storage a moment later. Uploading
    the same name immediately afterwards is refused as already existing;
    poll [`get_state()`][aiopikvm.resources.msd.MSDResource.get_state]
    until ``storage.images`` has dropped it.

    Args:
        name: Image file name to remove, as it appears in
            ``storage.images`` — including the subdirectory, if it was
            written under one.

    Raises:
        APIError: If no image of that name is in storage, or it is in the
            drive and cannot be removed.
        PiKVMError: If PiKVM is unreachable.
    """
    await self._post("/api/msd/remove", params={"image": name})

reset() async

Reset the MSD subsystem.

Source code in src/aiopikvm/resources/msd.py
async def reset(self) -> None:
    """Reset the MSD subsystem."""
    await self._post("/api/msd/reset")

Compression = Literal['', 'none', 'lzma', 'zstd']

How MSDResource.download() may ask kvmd to compress an image.

"" and "none" are the same thing and send the image verbatim; "lzma" produces what .xz holds and "zstd" what .zst does. kvmd compresses on the fly on the Pi's own CPU, so the two real modes trade transfer size against how fast the device can feed the connection.

kvmd lowercases the value before it looks, so only the canonical spelling is typed. A mode it does not know is HTTP 400.