From 72000f630ea3462f4655c1414c3f301d37bd194c Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Tue, 22 Sep 2026 16:25:36 -0700 Subject: [PATCH 01/19] fix(udp): cap the payload at the server's advertised buffer `SMPUDPTransport.max_unencoded_size` overrode the base implementation with the MSS alone, so the MCUmgr parameters that `SMPClient` reads on connect never reached the UDP transport: `initialize(buf_size)` stored the value and nothing read it. Zephyr's UDP SMP transport receives each request as a single datagram into one MCUmgr buffer of `CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE` bytes, the value advertised as `buf_size`. A request larger than that cannot be received. The defaults (2048-byte buffer, 1500-byte MTU) hide this. A build that lowers the buffer, e.g. to its 384-byte non-UDP default, does not. The payload is now `min(MSS, buf_size)`. Before the params are known, `buf_size or mtu` makes that the MSS, so nothing changes for a server that does not advertise them. Verified: `camas check` green; the new parametrized test fails on the 384-byte case without the fix. https://github.com/zephyrproject-rtos/zephyr/blob/70be2ff0b565a3313128f5577f51cfeb3ebcf602/subsys/mgmt/mcumgr/grp/os_mgmt/src/os_mgmt.c#L551-L554 https://github.com/zephyrproject-rtos/zephyr/blob/70be2ff0b565a3313128f5577f51cfeb3ebcf602/subsys/mgmt/mcumgr/transport/Kconfig#L33-L58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/udp.py | 5 +++-- tests/test_smp_udp_transport.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/smpclient/transport/udp.py b/src/smpclient/transport/udp.py index 9e4e568..af38d93 100644 --- a/src/smpclient/transport/udp.py +++ b/src/smpclient/transport/udp.py @@ -137,7 +137,8 @@ def max_unencoded_size(self) -> int: """Maximum UDP payload size (MSS) to avoid fragmentation. Subtracts IPv4/IPv6 and UDP header overhead from MTU per RFC 8085 section 3.2. - The IP version is auto-detected after connection. + The IP version is auto-detected after connection. Once the server's MCUmgr + parameters are known, the payload is also capped at its advertised buffer. """ overhead = IPV6_UDP_OVERHEAD if self._is_ipv6 else IPV4_UDP_OVERHEAD - return self._mtu - overhead + return min(self._mtu - overhead, self._smp_server_transport_buffer_size or self._mtu) diff --git a/tests/test_smp_udp_transport.py b/tests/test_smp_udp_transport.py index 6096b59..845fb70 100644 --- a/tests/test_smp_udp_transport.py +++ b/tests/test_smp_udp_transport.py @@ -132,6 +132,17 @@ def test_max_unencoded_size_custom_mtu() -> None: assert t.max_unencoded_size == 484 +@pytest.mark.parametrize( + "buf_size, expected", + [(384, 384), (1472, 1472), (2048, 1472)], +) +def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expected: int) -> None: + """Zephyr copies each datagram into one `buf_size` buffer, so neither bound may be exceeded.""" + t = SMPUDPTransport(mtu=1500) + t.initialize(buf_size) + assert t.max_unencoded_size == expected + + @pytest.mark.asyncio async def test_ipv4_detection_real_socket() -> None: """Test IPv4 auto-detection with real socket connection.""" From 40c6d36cd2b3ed70bb43c7a1b6f6ab3510f4f701 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 13:56:01 -0700 Subject: [PATCH 02/19] refactor(client): move the request/response exchange into smpclient._request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verbatim move with no behavior change: the TypeVars, the `success`/`error` narrowers, `wrapping_sequence`, the validation diagnostics, and the body of `SMPClient.request`, which becomes `_request.exchange(transport, request, sequence, timeout_s)`. `SMPClient.request` delegates to it, and `smpclient` re-exports every public name unchanged. The next commit needs this: a transport reads the server's MCUmgr parameters while it connects, before any `SMPClient` exists, so the exchange must sit below the client. Review with `git show --color-moved`; 252 of the 284 changed lines are moved. `smpclient` imports `_request` as a module (`from smpclient import _request`), so the package attribute stays the submodule. An `import ... as _request` of the function would shadow it and break `mock.patch("smpclient._request.…")`. The function is named `exchange` so that its `request` parameter doesn't shadow it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/__init__.py | 174 ++++------------------------------ src/smpclient/_request.py | 191 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 154 deletions(-) create mode 100644 src/smpclient/_request.py diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index c471166..25ce025 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -37,101 +37,40 @@ from __future__ import annotations -import asyncio -import itertools import logging import traceback from collections.abc import AsyncIterator, Iterator from hashlib import sha256 from types import TracebackType -from typing import TYPE_CHECKING, Any, Final, TypeVar, Union, cast +from typing import TYPE_CHECKING, Final, TypeVar import msgspec from smp import SMPRequest -from smp import error as smperror from smp import header as smpheader from smp import message as smpmsg from smp.file_management import FileDownloadRequest, FileUploadRequest from smp.image_management import ImageUploadWriteRequest from smp.os_management import MCUMgrParametersReadRequest from smp.user import intercreate as smpic -from typing_extensions import TypeIs, assert_never - -from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException +from typing_extensions import assert_never + +from smpclient import _request +from smpclient._request import TEr1 as TEr1 +from smpclient._request import TEr2 as TEr2 +from smpclient._request import TRep as TRep +from smpclient._request import error as error +from smpclient._request import error_v1 as error_v1 +from smpclient._request import error_v2 as error_v2 +from smpclient._request import success as success +from smpclient._request import wrapping_sequence as wrapping_sequence +from smpclient.exceptions import SMPUploadError from smpclient.transport import SMPTransport if TYPE_CHECKING: from types_bits import u8 -try: - from asyncio import timeout # type: ignore -except ImportError: # backport for Python3.10 and below - from async_timeout import timeout # type: ignore - logger = logging.getLogger(__name__) -TEr1 = TypeVar("TEr1", bound=smperror.ErrorV1) -"""Type of SMP Error V1.""" - -TEr2 = TypeVar("TEr2", bound=smperror.ErrorV2) -"""Type of SMP Error V2.""" - -TRep = TypeVar("TRep", bound=Union[smpmsg.ReadResponse, smpmsg.WriteResponse]) -"""Type of successful SMP Response (ReadResponse or WriteResponse).""" - - -def error_v1(response: smpmsg.Response) -> TypeIs[smperror.ErrorV1]: - """`TypeIs` that returns `True` if the `response` is an `ErrorV1`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is an `ErrorV1`. - """ - return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V1 - - -def error_v2(response: smpmsg.Response) -> TypeIs[smperror.ErrorV2[Any]]: - """`TypeIs` that returns `True` if the `response` is an `ErrorV2`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is an `ErrorV2`. - """ - return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V2 - - -def error( - response: smpmsg.Response, -) -> TypeIs[Union[smperror.ErrorV1, smperror.ErrorV2[Any]]]: - """`TypeIs` that returns `True` if the `response` is an `ErrorV1` or `ErrorV2`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is an `ErrorV1` or `ErrorV2`. - """ - return error_v1(response) or error_v2(response) - - -def success( - response: smpmsg.Response, -) -> TypeIs[Union[smpmsg.ReadResponse, smpmsg.WriteResponse]]: - """`TypeIs` that returns `True` if the `response` is a successful `Response`. - - Args: - response: The response to check. - - Returns: - `True` if the `response` is a successful `Response`. - """ - return response.RESPONSE_TYPE == smpmsg.ResponseType.SUCCESS - - TUploadRequest = TypeVar( "TUploadRequest", ImageUploadWriteRequest, @@ -141,47 +80,6 @@ def success( """A single-shot upload request whose `data` field is filled to maximize throughput.""" -def wrapping_sequence() -> Iterator[u8]: - """The default SMP sequence space: `0x00`-`0xFF`, wrapping.""" - return cast("Iterator[u8]", itertools.cycle(range(0x100))) - - -def _hexdump(frame: bytes) -> str: - """Format `frame` as an offset/hex/printable-ASCII dump for readable debug logging.""" - - def row(offset: int) -> str: - chunk: Final = frame[offset : offset + 16] - columns: Final = " ".join(f"{byte:02x}" for byte in chunk) - printable: Final = "".join(chr(byte) if 0x20 <= byte <= 0x7E else "." for byte in chunk) - return f"\t{offset:04x} {columns:<47} {printable}" - - return "\n".join(row(offset) for offset in range(0, len(frame), 16)) - - -def _validation_failure( - header: smpheader.Header, - frame: bytes, - errors: tuple[tuple[type[smpmsg.Response], msgspec.DecodeError], ...], -) -> tuple[str, str]: - """Return the `(summary, details)` describing why `frame` matched none of `errors`' types.""" - summary: Final = ( - "\nFrame could not be parsed as any of:\n" - f"\t{[response.__name__ for response, _ in errors]}\n" - ) - details: Final = "\n".join( - ( - f"Header:\n\t{header}", - f"Frame:\n{_hexdump(frame)}", - "Errors:", - *( - f"\tCould not be parsed as {response.__name__}: {error}" - for response, error in errors - ), - ) - ) - return summary, details - - class SMPClient: """Create a client to the SMP server `address`, using `transport`. @@ -300,45 +198,13 @@ async def request( assert_never(response) ``` - """ - timeout_s = timeout_s if timeout_s is not None else self._timeout_s - - request_frame: Final = request.to_frame(next(self._sequence)) - - try: - async with timeout(timeout_s): - frame = await self._transport.send_and_receive(bytes(request_frame)) - except asyncio.TimeoutError: - timeout_message: Final = f"Timeout ({timeout_s}s) waiting for request {request}" - logger.error(timeout_message) - raise TimeoutError(timeout_message) - - header = smpheader.Header.loads(frame[: smpheader.Header.SIZE]) - - if header.sequence != request_frame.header.sequence: - raise SMPBadSequence( - f"Bad sequence {header.sequence}, expected {request_frame.header.sequence}" - ) - - # `SMPMalformed` and `SMPMismatchedGroupId` are not caught: they fail all three - # candidates identically, so they are transport errors rather than a mismatch. - errors: list[tuple[type[smpmsg.Response], msgspec.DecodeError]] = [] - try: - return request._Response.loads(frame).data # type: ignore[return-value] - except msgspec.DecodeError as error: - errors.append((request._Response, error)) - try: - return request._ErrorV1.loads(frame).data - except msgspec.DecodeError as error: - errors.append((request._ErrorV1, error)) - try: - return request._ErrorV2.loads(frame).data - except msgspec.DecodeError as error: - errors.append((request._ErrorV2, error)) - - summary, details = _validation_failure(header, frame, tuple(errors)) - logger.error(summary + details) - raise SMPValidationException(summary, details) from None + """ # noqa: DOC502 + return await _request.exchange( + self._transport, + request, + next(self._sequence), + timeout_s if timeout_s is not None else self._timeout_s, + ) async def upload( self, diff --git a/src/smpclient/_request.py b/src/smpclient/_request.py new file mode 100644 index 0000000..2656f71 --- /dev/null +++ b/src/smpclient/_request.py @@ -0,0 +1,191 @@ +"""The SMP request/response exchange over a live `SMPTransport`.""" + +from __future__ import annotations + +import asyncio +import itertools +import logging +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any, Final, TypeVar, Union, cast + +import msgspec +from smp import SMPRequest +from smp import error as smperror +from smp import header as smpheader +from smp import message as smpmsg +from typing_extensions import TypeIs + +from smpclient.exceptions import SMPBadSequence, SMPValidationException +from smpclient.transport import SMPTransport + +if TYPE_CHECKING: + from types_bits import u8 + +try: + from asyncio import timeout # type: ignore +except ImportError: # backport for Python3.10 and below + from async_timeout import timeout # type: ignore + +logger = logging.getLogger(__name__) + +TEr1 = TypeVar("TEr1", bound=smperror.ErrorV1) +"""Type of SMP Error V1.""" + +TEr2 = TypeVar("TEr2", bound=smperror.ErrorV2) +"""Type of SMP Error V2.""" + +TRep = TypeVar("TRep", bound=Union[smpmsg.ReadResponse, smpmsg.WriteResponse]) +"""Type of successful SMP Response (ReadResponse or WriteResponse).""" + + +def error_v1(response: smpmsg.Response) -> TypeIs[smperror.ErrorV1]: + """`TypeIs` that returns `True` if the `response` is an `ErrorV1`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is an `ErrorV1`. + """ + return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V1 + + +def error_v2(response: smpmsg.Response) -> TypeIs[smperror.ErrorV2[Any]]: + """`TypeIs` that returns `True` if the `response` is an `ErrorV2`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is an `ErrorV2`. + """ + return response.RESPONSE_TYPE == smpmsg.ResponseType.ERROR_V2 + + +def error( + response: smpmsg.Response, +) -> TypeIs[Union[smperror.ErrorV1, smperror.ErrorV2[Any]]]: + """`TypeIs` that returns `True` if the `response` is an `ErrorV1` or `ErrorV2`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is an `ErrorV1` or `ErrorV2`. + """ + return error_v1(response) or error_v2(response) + + +def success( + response: smpmsg.Response, +) -> TypeIs[Union[smpmsg.ReadResponse, smpmsg.WriteResponse]]: + """`TypeIs` that returns `True` if the `response` is a successful `Response`. + + Args: + response: The response to check. + + Returns: + `True` if the `response` is a successful `Response`. + """ + return response.RESPONSE_TYPE == smpmsg.ResponseType.SUCCESS + + +def wrapping_sequence() -> Iterator[u8]: + """The default SMP sequence space: `0x00`-`0xFF`, wrapping.""" + return cast("Iterator[u8]", itertools.cycle(range(0x100))) + + +def _hexdump(frame: bytes) -> str: + """Format `frame` as an offset/hex/printable-ASCII dump for readable debug logging.""" + + def row(offset: int) -> str: + chunk: Final = frame[offset : offset + 16] + columns: Final = " ".join(f"{byte:02x}" for byte in chunk) + printable: Final = "".join(chr(byte) if 0x20 <= byte <= 0x7E else "." for byte in chunk) + return f"\t{offset:04x} {columns:<47} {printable}" + + return "\n".join(row(offset) for offset in range(0, len(frame), 16)) + + +def _validation_failure( + header: smpheader.Header, + frame: bytes, + errors: tuple[tuple[type[smpmsg.Response], msgspec.DecodeError], ...], +) -> tuple[str, str]: + """Return the `(summary, details)` describing why `frame` matched none of `errors`' types.""" + summary: Final = ( + "\nFrame could not be parsed as any of:\n" + f"\t{[response.__name__ for response, _ in errors]}\n" + ) + details: Final = "\n".join( + ( + f"Header:\n\t{header}", + f"Frame:\n{_hexdump(frame)}", + "Errors:", + *( + f"\tCould not be parsed as {response.__name__}: {error}" + for response, error in errors + ), + ) + ) + return summary, details + + +async def exchange( + transport: SMPTransport, + request: SMPRequest[TRep, TEr1, TEr2], + sequence: u8, + timeout_s: float, +) -> TRep | TEr1 | TEr2: + """Send `request` as SMP sequence `sequence` and return the typed Response or Error. + + Args: + transport: the live transport to exchange the request over + request: the `SMPRequest` to send + sequence: the SMP sequence number to send `request` as + timeout_s: the timeout for the exchange in seconds + + Returns: + The typed and validated Response or Error + + Raises: + TimeoutError: if the request times out + SMPBadSequence: if the response sequence does not match the request sequence + SMPValidationException: if the response cannot be parsed as a Response or Error + """ + request_frame: Final = request.to_frame(sequence) + + try: + async with timeout(timeout_s): + frame = await transport.send_and_receive(bytes(request_frame)) + except asyncio.TimeoutError: + timeout_message: Final = f"Timeout ({timeout_s}s) waiting for request {request}" + logger.error(timeout_message) + raise TimeoutError(timeout_message) + + header = smpheader.Header.loads(frame[: smpheader.Header.SIZE]) + + if header.sequence != request_frame.header.sequence: + raise SMPBadSequence( + f"Bad sequence {header.sequence}, expected {request_frame.header.sequence}" + ) + + # `SMPMalformed` and `SMPMismatchedGroupId` are not caught: they fail all three + # candidates identically, so they are transport errors rather than a mismatch. + errors: list[tuple[type[smpmsg.Response], msgspec.DecodeError]] = [] + try: + return request._Response.loads(frame).data # type: ignore[return-value] + except msgspec.DecodeError as error: + errors.append((request._Response, error)) + try: + return request._ErrorV1.loads(frame).data + except msgspec.DecodeError as error: + errors.append((request._ErrorV1, error)) + try: + return request._ErrorV2.loads(frame).data + except msgspec.DecodeError as error: + errors.append((request._ErrorV2, error)) + + summary, details = _validation_failure(header, frame, tuple(errors)) + logger.error(summary + details) + raise SMPValidationException(summary, details) from None From a353b5b54d36997243189bd2ecb7853622138aca Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:13:22 -0700 Subject: [PATCH 03/19] breaking(transport): transports own their address and lifecycle; SMPClient never opens a link `SMPClient(transport, address, timeout_s)` becomes `SMPClient(transport, *, timeout_s, sequence)`. `connect()`, `disconnect()`, `address`, `__aenter__`/`__aexit__` and `_initialize()` are removed. The client only sends and receives over a transport that is already open. It holds the `SMPTransport` Protocol, which no longer has `connect` or `disconnect`, so it can't control the transport's side effects. Each transport now takes its address in the constructor (#58), along with `connect_timeout_s` and `sequence`. `connect()` and `disconnect()` take no arguments, and their bodies are unchanged: - serial: the old `connect` body becomes `_open()`; `connect()` is `_open()` then `negotiate()` - UDP: `SMPUDPTransport(address, port=1337, *, mtu, ...)`. The port is a real parameter, the point of #58. - bleak: `connect()` wraps `_connect(address, timeout_s)`, which is unchanged - bumble: `connect()` is the old flow, reading the address from `self`. `use_connection` becomes `borrow`, and the module helper `borrowed_connection()` becomes the method `borrowed()`. A private base, `_ConnectableTransport`, adds the encouraged bracket, `async with transport.connected():`. It connects, yields the transport, and disconnects best-effort. The primitives remain for lifetimes a lexical scope can't express, e.g. a standing link held for an application's lifetime. The MCUmgr parameters read moves out of `SMPClient._initialize` into the transport's `negotiate()`, with the same warnings and the same fallback on an error or a timeout (`_request.read_mcumgr_parameters`). `negotiate()` runs inside `connect()` and `borrow()`, and it is public, for re-negotiating: the integration harness uses it after a server boots, and a borrowed link can negotiate at all. Behavior is unchanged here: the read is still unconditional. The next commit makes it conditional on each transport's fragmentation strategy. `connect()` is all-or-nothing on every transport: a failed or cancelled negotiation closes the link it just opened. Tests move to address-first constructors and argument-free `connect()`. A `skip_negotiation` fixture answers the params read with `None`, so tests that drive `connect()` over mocked I/O don't wait for a server. The integration harness enters `transport.connected()` and re-negotiates after the echo wait, where it used to call `client._initialize()`. Its skip for a UDP fixture on a non-default port is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/ble/helloworld.py | 3 +- examples/ble/imagestate.py | 3 +- examples/ble/mcumgrparameters.py | 3 +- examples/ble/upgrade.py | 6 +- examples/ble/upload.py | 7 +- examples/udp/helloworld.py | 3 +- examples/usb/download_file.py | 3 +- examples/usb/helloworld.py | 3 +- examples/usb/upgrade.py | 30 +++-- examples/usb/upload_file.py | 3 +- src/smpclient/__init__.py | 66 +---------- src/smpclient/_request.py | 37 +++++- src/smpclient/transport/__init__.py | 71 +++++++++--- src/smpclient/transport/ble.py | 44 +++++-- src/smpclient/transport/bumble/__init__.py | 69 +++++++---- src/smpclient/transport/bumble/__main__.py | 11 +- src/smpclient/transport/serial/common.py | 43 +++++-- src/smpclient/transport/serial/encoded.py | 28 ++++- src/smpclient/transport/serial/unencoded.py | 19 +++- src/smpclient/transport/udp.py | 49 ++++++-- tests/conftest.py | 14 +++ tests/extensions/test_intercreate.py | 4 +- tests/integration/conftest.py | 74 +++++------- tests/integration/servers.py | 18 +-- tests/integration/test_fragmentation.py | 9 +- tests/integration/test_serial_recovery.py | 5 +- tests/test_smp_ble_transport.py | 50 ++++---- tests/test_smp_bumble_transport.py | 94 ++++++++------- tests/test_smp_client.py | 64 +++++------ tests/test_smp_serial_raw_transport.py | 83 +++++++------- tests/test_smp_serial_transport.py | 120 +++++++++++--------- tests/test_smp_udp_transport.py | 47 +++++--- 32 files changed, 658 insertions(+), 425 deletions(-) create mode 100644 tests/conftest.py diff --git a/examples/ble/helloworld.py b/examples/ble/helloworld.py index b9c0341..24be1d1 100644 --- a/examples/ble/helloworld.py +++ b/examples/ble/helloworld.py @@ -16,7 +16,8 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPClient(SMPBLETransport(), smp_servers[0].address) as client: + async with SMPBLETransport(smp_servers[0].address).connected() as transport: + client = SMPClient(transport) print("OK") print("Sending request...", end="", flush=True) diff --git a/examples/ble/imagestate.py b/examples/ble/imagestate.py index f9b8f30..a919a82 100644 --- a/examples/ble/imagestate.py +++ b/examples/ble/imagestate.py @@ -16,7 +16,8 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPClient(SMPBLETransport(), smp_servers[0].address) as client: + async with SMPBLETransport(smp_servers[0].address).connected() as transport: + client = SMPClient(transport) print("OK") print("Sending request...", end="", flush=True) diff --git a/examples/ble/mcumgrparameters.py b/examples/ble/mcumgrparameters.py index 57d0134..250356d 100644 --- a/examples/ble/mcumgrparameters.py +++ b/examples/ble/mcumgrparameters.py @@ -16,7 +16,8 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPClient(SMPBLETransport(), smp_servers[0].address) as client: + async with SMPBLETransport(smp_servers[0].address).connected() as transport: + client = SMPClient(transport) print("OK") print(f"Client MTU is {client._transport.mtu}B") print(f"Client max unencoded size is {client._transport.max_unencoded_size}B") diff --git a/examples/ble/upgrade.py b/examples/ble/upgrade.py index 1b68d19..8a48f22 100644 --- a/examples/ble/upgrade.py +++ b/examples/ble/upgrade.py @@ -64,7 +64,8 @@ async def main() -> None: print("OK") print("Connecting to A SMP DUT...", end="", flush=True) - async with SMPClient(SMPBLETransport(), a_smp_dut.name or a_smp_dut.address) as client: + async with SMPBLETransport(a_smp_dut.name or a_smp_dut.address).connected() as transport: + client = SMPClient(transport) print("OK") async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: @@ -119,7 +120,8 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: b_smp_dut = cast(BLEDevice, b_smp_dut) print("Connecting to B SMP DUT...", end="", flush=True) - async with SMPClient(SMPBLETransport(), b_smp_dut.name or b_smp_dut.address) as client: + async with SMPBLETransport(b_smp_dut.name or b_smp_dut.address).connected() as transport: + client = SMPClient(transport) print("OK") print() diff --git a/examples/ble/upload.py b/examples/ble/upload.py index 025cb92..a810139 100644 --- a/examples/ble/upload.py +++ b/examples/ble/upload.py @@ -30,9 +30,10 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPClient( - SMPBLETransport(), smp_servers[0].name or smp_servers[0].address - ) as client: + async with SMPBLETransport( + smp_servers[0].name or smp_servers[0].address + ).connected() as transport: + client = SMPClient(transport) print("OK") print("Sending request...", end="", flush=True) diff --git a/examples/udp/helloworld.py b/examples/udp/helloworld.py index 799f8db..77894ce 100644 --- a/examples/udp/helloworld.py +++ b/examples/udp/helloworld.py @@ -18,7 +18,8 @@ async def main() -> None: parser.add_argument("address", help="The IP address to connect to") address = parser.parse_args().address - async with SMPClient(SMPUDPTransport(), address) as client: + async with SMPUDPTransport(address).connected() as transport: + client = SMPClient(transport) print("OK") print("Sending request...", end="", flush=True) diff --git a/examples/usb/download_file.py b/examples/usb/download_file.py index 6f6e683..aa751aa 100644 --- a/examples/usb/download_file.py +++ b/examples/usb/download_file.py @@ -16,7 +16,8 @@ async def main() -> None: port = args.port file_location = args.file_location - async with SMPClient(SMPSerialTransport(), port) as client: + async with SMPSerialTransport(port).connected() as transport: + client = SMPClient(transport) start_s = time.time() file_data = await client.download_file(file_location) end_s = time.time() diff --git a/examples/usb/helloworld.py b/examples/usb/helloworld.py index 2373dd4..eb16b71 100644 --- a/examples/usb/helloworld.py +++ b/examples/usb/helloworld.py @@ -15,7 +15,8 @@ async def main() -> None: parser.add_argument("port", help="The serial port to connect to") port = parser.parse_args().port - async with SMPClient(SMPSerialTransport(), port) as client: + async with SMPSerialTransport(port).connected() as transport: + client = SMPClient(transport) print("OK") print("Sending request...", end="", flush=True) diff --git a/examples/usb/upgrade.py b/examples/usb/upgrade.py index 9cc0e45..f238a00 100644 --- a/examples/usb/upgrade.py +++ b/examples/usb/upgrade.py @@ -105,15 +105,14 @@ async def main() -> None: await asyncio.sleep(1) print("Connecting to SMP DUT...", end="", flush=True) - async with SMPClient( - SMPSerialTransport( - fragmentation_strategy=BufferParams( - line_length=line_length, - line_buffers=line_buffers, - ) - ), + async with SMPSerialTransport( port_a.device, - ) as client: + fragmentation_strategy=BufferParams( + line_length=line_length, + line_buffers=line_buffers, + ), + ).connected() as transport: + client = SMPClient(transport) print("OK") async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: @@ -185,15 +184,14 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: print(f"OK - found DUT B at {port_b.device}") print("Connecting to B SMP DUT...", end="", flush=True) - async with SMPClient( - SMPSerialTransport( - fragmentation_strategy=BufferParams( - line_length=line_length, - line_buffers=line_buffers, - ) - ), + async with SMPSerialTransport( port_b.device, - ) as client: + fragmentation_strategy=BufferParams( + line_length=line_length, + line_buffers=line_buffers, + ), + ).connected() as transport: + client = SMPClient(transport) print("OK") print() diff --git a/examples/usb/upload_file.py b/examples/usb/upload_file.py index d5aadeb..01df75b 100644 --- a/examples/usb/upload_file.py +++ b/examples/usb/upload_file.py @@ -64,7 +64,8 @@ async def main() -> None: Etiam elit velit, posuere ut pulvinar ac, condimentum eget justo. Fusce a erat velit. Vivamus imperdiet ultrices orci in hendrerit. """ - async with SMPClient(SMPSerialTransport(), port) as client: + async with SMPSerialTransport(port).connected() as transport: + client = SMPClient(transport) start_s = time.time() async for offset in client.upload_file(file_data=file_data, file_path=file_path): print( diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index 25ce025..8996be9 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -38,10 +38,8 @@ from __future__ import annotations import logging -import traceback from collections.abc import AsyncIterator, Iterator from hashlib import sha256 -from types import TracebackType from typing import TYPE_CHECKING, Final, TypeVar import msgspec @@ -50,7 +48,6 @@ from smp import message as smpmsg from smp.file_management import FileDownloadRequest, FileUploadRequest from smp.image_management import ImageUploadWriteRequest -from smp.os_management import MCUMgrParametersReadRequest from smp.user import intercreate as smpic from typing_extensions import assert_never @@ -81,7 +78,7 @@ class SMPClient: - """Create a client to the SMP server `address`, using `transport`. + """Create a client to the SMP server at the other end of the live `transport`. This class provides a high-level interface to an SMP server. Other than the `request` method, all methods are abstractions of common SMP routines, @@ -91,8 +88,7 @@ class SMPClient: the response or error. Args: - transport: the `SMPTransport` to use - address: the address of the SMP server, see `smpclient.transport` for details + transport: the connected `SMPTransport`; the client never opens or closes it timeout_s: the default timeout in seconds for SMP requests sequence: this client's SMP sequence space; defaults to `wrapping_sequence()` @@ -104,7 +100,8 @@ class SMPClient: from smpclient.transport.ble import SMPBLETransport async def main(): - async with SMPClient(SMPBLETransport(), "00:11:22:33:44:55") as client: + async with SMPBLETransport("00:11:22:33:44:55").connected() as transport: + client = SMPClient(transport) response = await client.request(EchoWriteRequest(d="Hello, World!")) if success(response): @@ -120,30 +117,14 @@ async def main(): def __init__( # noqa: DOC301 self, transport: SMPTransport, - address: str, + *, timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, ): self._transport: Final = transport - self._address: Final = address self._timeout_s = timeout_s self._sequence: Final = wrapping_sequence() if sequence is None else sequence - async def connect(self, connect_timeout_s: float | None = None) -> None: - """Connect to the SMP server. - - Args: - connect_timeout_s: the timeout for the connection attempt in seconds - """ - connect_timeout_s = connect_timeout_s if connect_timeout_s is not None else self._timeout_s - - await self._transport.connect(self._address, connect_timeout_s) - await self._initialize(self._timeout_s) - - async def disconnect(self) -> None: - """Disconnect from the SMP server.""" - await self._transport.disconnect() - async def request( self, request: SMPRequest[TRep, TEr1, TEr2], timeout_s: float | None = None ) -> TRep | TEr1 | TEr2: @@ -410,25 +391,6 @@ async def download_file( logger.info("Download complete") return file_data - @property - def address(self) -> str: - """The SMP server address.""" - return self._address - - async def __aenter__(self) -> "SMPClient": - await self.connect() - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - tb: TracebackType | None, - ) -> None: - if exc_value is not None: - logger.error(f"Exception in SMPClient:\n{traceback.format_exc()}") - await self.disconnect() - @staticmethod def _cbor_integer_size(integer: int) -> int: """CBOR integers are packed as small as possible.""" @@ -485,21 +447,3 @@ def _maximize_upload_packet(self, request: TUploadRequest, data: bytes) -> TUplo data_size: Final = min(max_data_size, len(data) - request.off) return msgspec.structs.replace(request, data=data[request.off : request.off + data_size]) - - async def _initialize(self, timeout_s: float | None = None) -> None: - """Gather initialization information from the SMP server.""" - timeout_s = timeout_s if timeout_s is not None else self._timeout_s - - try: - mcumgr_parameters = await self.request( - MCUMgrParametersReadRequest(), timeout_s=timeout_s - ) - if success(mcumgr_parameters): - logger.debug(f"MCUMgr parameters: {mcumgr_parameters}") - self._transport.initialize(mcumgr_parameters.buf_size) - elif error(mcumgr_parameters): - logger.warning(f"Error reading MCUMgr parameters: {mcumgr_parameters}") - else: - assert_never(mcumgr_parameters) - except TimeoutError: - logger.warning("Timeout waiting for MCUMgr parameters") diff --git a/src/smpclient/_request.py b/src/smpclient/_request.py index 2656f71..f2324cb 100644 --- a/src/smpclient/_request.py +++ b/src/smpclient/_request.py @@ -13,14 +13,16 @@ from smp import error as smperror from smp import header as smpheader from smp import message as smpmsg -from typing_extensions import TypeIs +from smp.os_management import MCUMgrParametersReadRequest, MCUMgrParametersReadResponse +from typing_extensions import TypeIs, assert_never from smpclient.exceptions import SMPBadSequence, SMPValidationException -from smpclient.transport import SMPTransport if TYPE_CHECKING: from types_bits import u8 + from smpclient.transport import SMPTransport + try: from asyncio import timeout # type: ignore except ImportError: # backport for Python3.10 and below @@ -189,3 +191,34 @@ async def exchange( summary, details = _validation_failure(header, frame, tuple(errors)) logger.error(summary + details) raise SMPValidationException(summary, details) from None + + +async def read_mcumgr_parameters( + transport: SMPTransport, sequence: u8, timeout_s: float +) -> MCUMgrParametersReadResponse | None: + """Read the server's MCUmgr parameters over `transport`. + + Args: + transport: the live transport to read the parameters over + sequence: the SMP sequence number to send the request as + timeout_s: the timeout for the exchange in seconds + + Returns: + The parameters, or `None` (with a warning) if the server answers with an error or + not at all + """ + try: + response: Final = await exchange( + transport, MCUMgrParametersReadRequest(), sequence, timeout_s + ) + except TimeoutError: + logger.warning("Timeout waiting for MCUMgr parameters") + return None + if success(response): + logger.debug(f"MCUMgr parameters: {response}") + return response + elif error(response): + logger.warning(f"Error reading MCUMgr parameters: {response}") + return None + else: + assert_never(response) diff --git a/src/smpclient/transport/__init__.py b/src/smpclient/transport/__init__.py index 100004c..e536bdc 100644 --- a/src/smpclient/transport/__init__.py +++ b/src/smpclient/transport/__init__.py @@ -1,8 +1,22 @@ """Simple Management Protocol (SMP) Client Transport Protocol.""" -from typing import Final, Protocol +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Final, Protocol from uuid import UUID +from typing_extensions import Self + +from smpclient import _request + +if TYPE_CHECKING: + from types_bits import u8 + +logger: Final = logging.getLogger(__name__) + SMP_SERVICE_UUID: Final = UUID("8D53DC1D-1DB7-4CD3-868B-8A527460AA84") """The 128-bit GATT service UUID for an SMP server. @@ -22,19 +36,6 @@ class SMPTransport(Protocol): _smp_server_transport_buffer_size: int | None = None """The SMP server transport buffer size, in 8-bit bytes.""" - async def connect(self, address: str, timeout_s: float) -> None: # pragma: no cover - """Connect the `SMPTransport`. - - Args: - address: The SMP server address. - timeout_s: The connection timeout in seconds. - """ - ... - - async def disconnect(self) -> None: # pragma: no cover - """Disconnect the `SMPTransport`.""" - ... - async def send(self, data: bytes) -> None: # pragma: no cover """Send the encoded `SMPRequest` `data`. @@ -90,3 +91,45 @@ def max_unencoded_size(self) -> int: # pragma: no cover # concurrent write needs to be tracked very carefully! return self._smp_server_transport_buffer_size or self.mtu + + +class _ConnectableTransport(SMPTransport, Protocol): + """An `SMPTransport` that opens and closes its own link. + + `SMPClient` sees only the `SMPTransport` part. Prefer the `connected()` bracket; + `connect()` and `disconnect()` are for a lifetime that a lexical scope can't express. + """ + + _sequence: Iterator[u8] + """The SMP sequence space that the MCUmgr parameters read draws from.""" + + _connect_timeout_s: float + """Bounds establishing the link, including reading the MCUmgr parameters.""" + + async def connect(self) -> None: # pragma: no cover + """Open the link, then `negotiate()`.""" + ... + + async def disconnect(self) -> None: # pragma: no cover + """Close the link.""" + ... + + async def negotiate(self) -> None: + """Adopt the server's MCUmgr parameters, if it provides them.""" + params: Final = await _request.read_mcumgr_parameters( + self, next(self._sequence), self._connect_timeout_s + ) + if params is not None: + self.initialize(params.buf_size) + + @asynccontextmanager + async def connected(self) -> AsyncIterator[Self]: + """Open the link for the duration of the `async with`, then close it.""" + await self.connect() + try: + yield self + finally: + try: + await self.disconnect() + except Exception as e: + logger.warning(f"Error during disconnect: {e}") diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index 54b6107..eeada26 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -1,11 +1,13 @@ """A Bluetooth Low Energy (BLE) SMPTransport.""" +from __future__ import annotations + import asyncio import logging import re import sys -from collections.abc import Coroutine -from typing import Any, Final, Protocol, TypeAlias, TypeGuard, TypeVar +from collections.abc import Coroutine, Iterator +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, TypeGuard, TypeVar from uuid import UUID try: @@ -21,14 +23,18 @@ from smp import header as smphdr from typing_extensions import override +from smpclient import _request from smpclient.exceptions import SMPClientException from smpclient.transport import ( SMP_CHARACTERISTIC_UUID, SMP_SERVICE_UUID, - SMPTransport, SMPTransportDisconnected, + _ConnectableTransport, ) +if TYPE_CHECKING: + from types_bits import u8 + if sys.platform == "linux": from bleak.backends.bluezdbus.client import BleakClientBlueZDBus else: # stub for mypy @@ -82,10 +88,30 @@ class SMPBLETransportNotSMPServer(SMPBLETransportException): _T = TypeVar("_T") -class SMPBLETransport(SMPTransport): +class SMPBLETransport(_ConnectableTransport): """A Bluetooth Low Energy (BLE) SMPTransport.""" - def __init__(self, winrt: WinRTClientArgs = {}) -> None: + def __init__( + self, + address: str, + *, + winrt: WinRTClientArgs = {}, + connect_timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, + ) -> None: + """Initialize the BLE transport; `connect()` scans for and connects to `address`. + + Args: + address: The device's MAC address, macOS UUID, or advertised name. + winrt: WinRT backend arguments, e.g. `use_cached_services`. + connect_timeout_s: Bounds scanning and connecting, and reading the server's + MCUmgr parameters. + sequence: The SMP sequence space the MCUmgr parameters read draws from; + defaults to `wrapping_sequence()`. + """ + self._address: Final = address + self._connect_timeout_s = connect_timeout_s + self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._buffer = bytearray() self._notify_condition = asyncio.Condition() self._disconnected_event = asyncio.Event() @@ -98,9 +124,13 @@ def __init__(self, winrt: WinRTClientArgs = {}) -> None: logger.debug(f"Initialized {self.__class__.__name__}") @override - async def connect(self, address: str, timeout_s: float) -> None: + async def connect(self) -> None: try: - await asyncio.wait_for(self._connect(address, timeout_s), timeout=timeout_s) + await asyncio.wait_for( + self._connect(self._address, self._connect_timeout_s), + timeout=self._connect_timeout_s, + ) + await self.negotiate() except (Exception, asyncio.CancelledError): await self._best_effort_disconnect() raise diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index 9e64ac5..b04d7db 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -1,10 +1,13 @@ """A bumble-backed `SMPTransport` driving an external HCI controller over GATT.""" +from __future__ import annotations + import asyncio import logging +from collections.abc import Iterator from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import AsyncIterator, Final, NamedTuple, Protocol, TypeAlias +from typing import TYPE_CHECKING, AsyncIterator, Final, NamedTuple, Protocol, TypeAlias from uuid import UUID try: @@ -25,15 +28,19 @@ raise from smp import header as smphdr -from typing_extensions import assert_never, override +from typing_extensions import Self, assert_never, override +from smpclient import _request from smpclient.exceptions import SMPClientException from smpclient.transport import ( SMP_CHARACTERISTIC_UUID, SMP_SERVICE_UUID, - SMPTransport, SMPTransportDisconnected, + _ConnectableTransport, ) + +if TYPE_CHECKING: + from types_bits import u8 from smpclient.transport.bumble.device import ( DEFAULT_HCI_TRANSPORT, DEFAULT_HOST_ADDRESS, @@ -122,11 +129,12 @@ class ConnectedBorrowed(NamedTuple): _State: TypeAlias = Disconnected | Connecting | Connected | ConnectedBorrowed -class SMPBumbleTransport(SMPTransport): +class SMPBumbleTransport(_ConnectableTransport): """An `SMPTransport` backed by Google's bumble Bluetooth stack.""" def __init__( self, + address: str, *, hci: str = DEFAULT_HCI_TRANSPORT, host_address: Address = DEFAULT_HOST_ADDRESS, @@ -136,10 +144,13 @@ def __init__( pair_on_connect: PairingDelegate | None = None, pair_timeout_s: float = DEFAULT_PAIR_TIMEOUT_S, settle_s: float = DEFAULT_POST_PAIR_SETTLE_S, + connect_timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, ) -> None: """Initialize the bumble transport. Args: + address: The peer's BD_ADDR, or an advertised name to scan for. hci: The bumble HCI transport spec, e.g. `"usb:0"` or `"tcp-client:host:port"`. See bumble's `open_transport()` for the full list of supported schemes. @@ -160,7 +171,14 @@ def __init__( `pair_on_connect` and `pair()`. settle_s: Wait between successful pair and proceeding (or disconnecting) so the peer can finalize bonding. + connect_timeout_s: Bounds scanning for a name, and reading the server's + MCUmgr parameters. + sequence: The SMP sequence space the MCUmgr parameters read draws from; + defaults to `wrapping_sequence()`. """ + self._address: Final = address + self._connect_timeout_s = connect_timeout_s + self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._hci: Final = hci self._host_address: Final = host_address self._host_name: Final = host_name @@ -186,7 +204,7 @@ def __init__( logger.debug(f"Initialized {self.__class__.__name__}(hci={hci!r})") @override - async def connect(self, address: str, timeout_s: float) -> None: + async def connect(self) -> None: if not isinstance(self._state, Disconnected): raise SMPBumbleTransportException( f"connect() called while in state {type(self._state).__name__}" @@ -221,7 +239,9 @@ async def connect(self, address: str, timeout_s: float) -> None: ) await self._state.device.power_on() - target = await _resolve_target(self._state.device, address, timeout_s) + target = await _resolve_target( + self._state.device, self._address, self._connect_timeout_s + ) logger.info(f"Connecting to {target}") self._state.connection = await self._state.device.connect(Address(target)) self._state.connection.on(Connection.EVENT_DISCONNECTION, self._on_disconnection) @@ -263,6 +283,7 @@ async def connect(self, address: str, timeout_s: float) -> None: max_write=max_write, ) logger.info(f"Connected to {target}, max_write={max_write}") + await self.negotiate() except Exception: logger.exception("connect() failed; tearing down partial state") await self.disconnect() @@ -331,16 +352,16 @@ async def scan( async with bumble_device(hci=hci) as device: return await scan_for_devices(device, timeout_s, mode, service_uuid=service_uuid) - async def use_connection( + async def borrow( self, connection: Connection, *, peer: Peer | None = None, ) -> None: - """Adopt a caller-owned `Connection`; `disconnect()` only unsubscribes.""" + """Adopt a caller-owned `Connection`, then `negotiate()`; `disconnect()` only unsubscribes.""" if not isinstance(self._state, Disconnected): raise SMPBumbleTransportException( - f"use_connection() called while in state {type(self._state).__name__}" + f"borrow() called while in state {type(self._state).__name__}" ) while not self._notifications.empty(): @@ -362,6 +383,21 @@ async def use_connection( max_write=max_write, ) logger.info(f"Borrowing connection to {connection.peer_address}, max_write={max_write}") + await self.negotiate() + + @asynccontextmanager + async def borrowed( + self, + connection: Connection, + *, + peer: Peer | None = None, + ) -> AsyncIterator[Self]: + """Borrow the caller's `connection` for the duration of the `async with`.""" + try: + await self.borrow(connection, peer=peer) + yield self + finally: + await self.disconnect() async def bonded_devices(self) -> tuple[str, ...]: """Return the BD_ADDRs of peers currently in the keystore.""" @@ -625,18 +661,3 @@ def _find_smp_characteristic(peer: Peer) -> CharacteristicProxy[bytes]: f"SMP characteristic {SMP_CHARACTERISTIC_UUID} not found on peer" ) return characteristics[0] - - -@asynccontextmanager -async def borrowed_connection( - transport: SMPBumbleTransport, - connection: Connection, - *, - peer: Peer | None = None, -) -> AsyncIterator[SMPBumbleTransport]: - """`async with`-friendly wrapper around `use_connection()` + `disconnect()`.""" - await transport.use_connection(connection, peer=peer) - try: - yield transport - finally: - await transport.disconnect() diff --git a/src/smpclient/transport/bumble/__main__.py b/src/smpclient/transport/bumble/__main__.py index 06e3892..af177bc 100644 --- a/src/smpclient/transport/bumble/__main__.py +++ b/src/smpclient/transport/bumble/__main__.py @@ -93,10 +93,13 @@ async def _pair(args: _PairArgs) -> int: async def _echo(args: _EchoArgs) -> int: - async with SMPClient( - SMPBumbleTransport(hci=args.hci), args.address, timeout_s=args.timeout - ) as client: - response = await client.request(EchoWriteRequest(d=args.message)) + transport: Final = SMPBumbleTransport( + args.address, hci=args.hci, connect_timeout_s=args.timeout + ) + async with transport.connected(): + response = await SMPClient(transport, timeout_s=args.timeout).request( + EchoWriteRequest(d=args.message) + ) if success(response): print(response.r) return 0 diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index aa0866e..96f7288 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -1,10 +1,13 @@ """Shared connection management for the encoded and unencoded serial transports.""" +from __future__ import annotations + import asyncio import logging +from collections.abc import Iterator from contextlib import contextmanager from time import monotonic -from typing import Final, Generator, final +from typing import TYPE_CHECKING, Final, Generator, final try: from serial import Serial, SerialException @@ -16,12 +19,16 @@ raise from typing_extensions import override -from smpclient.transport import SMPTransport, SMPTransportDisconnected +from smpclient import _request +from smpclient.transport import SMPTransportDisconnected, _ConnectableTransport + +if TYPE_CHECKING: + from types_bits import u8 logger = logging.getLogger(__name__) -class _SerialTransportBase(SMPTransport): +class _SerialTransportBase(_ConnectableTransport): """Connection-management base class for serial-port-backed SMP transports. Holds the `pyserial` `Serial` instance, the open/retry connect loop, disconnect, @@ -30,7 +37,7 @@ class _SerialTransportBase(SMPTransport): Subclasses implement `send` and `receive` with their framing of choice, may override `_reset_state` to clear per-connection state on `connect`, and may - override `connect` to back the transport with a byte pipe other than a local + override `_open` to back the transport with a byte pipe other than a local serial port (e.g. an emulator's `socket://` chardev). """ @@ -39,6 +46,9 @@ class _SerialTransportBase(SMPTransport): def __init__( self, + port: str, + connect_timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, baudrate: int = 115200, bytesize: int = 8, parity: str = "N", @@ -54,6 +64,11 @@ def __init__( """Initialize the underlying `pyserial` `Serial` instance. Args: + port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. + connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr + parameters. + sequence: The SMP sequence space the MCUmgr parameters read draws from; + defaults to `wrapping_sequence()`. baudrate: The baudrate of the serial connection. OK to ignore for USB CDC ACM. bytesize: The number of data bits. @@ -69,6 +84,9 @@ def __init__( opened in exclusive access mode if it is already open in exclusive access mode. """ + self._port: Final = port + self._connect_timeout_s = connect_timeout_s + self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._conn: Final = Serial( baudrate=baudrate, bytesize=bytesize, @@ -87,12 +105,21 @@ def _reset_state(self) -> None: """Reset any per-connection state. Subclasses override as needed.""" @override - async def connect(self, address: str, timeout_s: float) -> None: + async def connect(self) -> None: + await self._open() + try: + await self.negotiate() + except (Exception, asyncio.CancelledError): + self._conn.close() + raise + + async def _open(self) -> None: + """Open the port, retrying until `connect_timeout_s`.""" self._reset_state() - self._conn.port = address + self._conn.port = self._port logger.debug(f"Connecting to {self._conn.port=}") start_time: Final = monotonic() - while monotonic() - start_time <= timeout_s: + while monotonic() - start_time <= self._connect_timeout_s: try: self._conn.open() self._conn.reset_input_buffer() @@ -105,7 +132,7 @@ async def connect(self, address: str, timeout_s: float) -> None: ) await asyncio.sleep(self._CONNECTION_RETRY_INTERVAL_S) - raise TimeoutError(f"Failed to connect to {address=}") + raise TimeoutError(f"Failed to connect to {self._port=}") @final @override diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index adbe673..2c0ccb3 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -19,18 +19,24 @@ default), `BufferSize`, and `BufferParams`. """ +from __future__ import annotations + import asyncio import logging import math import warnings +from collections.abc import Iterator from enum import IntEnum, unique -from typing import Final, NamedTuple, TypeAlias +from typing import TYPE_CHECKING, Final, NamedTuple, TypeAlias from smp import packet as smppacket from typing_extensions import assert_never, deprecated, overload, override from smpclient.transport.serial.common import _SerialTransportBase +if TYPE_CHECKING: + from types_bits import u8 + logger = logging.getLogger(__name__) @@ -190,8 +196,11 @@ class BufferState(IntEnum): @overload def __init__( self, + port: str, fragmentation_strategy: FragmentationStrategy = ..., *, + connect_timeout_s: float = ..., + sequence: Iterator[u8] | None = ..., baudrate: int = ..., bytesize: int = ..., parity: str = ..., @@ -212,10 +221,13 @@ def __init__( ) def __init__( self, + port: str, *, max_smp_encoded_frame_size: int = ..., line_length: int = ..., line_buffers: int = ..., + connect_timeout_s: float = ..., + sequence: Iterator[u8] | None = ..., baudrate: int = ..., bytesize: int = ..., parity: str = ..., @@ -236,11 +248,14 @@ def __init__( ) def __init__( self, + port: str, max_smp_encoded_frame_size: int, line_length: int = ..., line_buffers: int = ..., /, *, + connect_timeout_s: float = ..., + sequence: Iterator[u8] | None = ..., baudrate: int = ..., bytesize: int = ..., parity: str = ..., @@ -256,11 +271,14 @@ def __init__( def __init__( # noqa: DOC301 self, + port: str, fragmentation_strategy: FragmentationStrategy | int | None = None, line_length: int | None = None, line_buffers: int | None = None, *, max_smp_encoded_frame_size: int | None = None, + connect_timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, baudrate: int = 115200, bytesize: int = 8, parity: str = "N", @@ -276,6 +294,7 @@ def __init__( # noqa: DOC301 """Initialize the serial transport. Args: + port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. fragmentation_strategy: how to size SMP messages; one of `Auto` (default), `BufferSize`, or `BufferParams`. line_length: Deprecated; pass `BufferParams(line_length=...)` (or `BufferSize`). @@ -283,6 +302,10 @@ def __init__( # noqa: DOC301 max_smp_encoded_frame_size: Deprecated, but still honored for backward compatibility -- it drives `mtu` exactly as in 7.1.0. Prefer an explicit `BufferSize(buf_size=...)` (decoded netbuf) for new code. + connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr + parameters. + sequence: The SMP sequence space the MCUmgr parameters read draws from; + defaults to `wrapping_sequence()`. baudrate: The baudrate of the serial connection. OK to ignore for USB CDC ACM. bytesize: The number of data bits. @@ -298,6 +321,9 @@ def __init__( # noqa: DOC301 """ super().__init__( + port, + connect_timeout_s, + sequence, baudrate=baudrate, bytesize=bytesize, parity=parity, diff --git a/src/smpclient/transport/serial/unencoded.py b/src/smpclient/transport/serial/unencoded.py index 6cd488b..c36ae12 100644 --- a/src/smpclient/transport/serial/unencoded.py +++ b/src/smpclient/transport/serial/unencoded.py @@ -11,9 +11,12 @@ `smpclient.transport.serial.encoded`. """ +from __future__ import annotations + import asyncio import logging -from typing import Final +from collections.abc import Iterator +from typing import TYPE_CHECKING, Final from smp import header as smphdr from typing_extensions import override @@ -22,15 +25,21 @@ from smpclient.transport.serial.common import _SerialTransportBase from smpclient.transport.serial.framing import SerialFraming +if TYPE_CHECKING: + from types_bits import u8 + logger = logging.getLogger(__name__) class SMPSerialRawTransport(_SerialTransportBase): def __init__( self, + port: str, mtu: int = 384, *, framing: SerialFraming | None = None, + connect_timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, baudrate: int = 115200, bytesize: int = 8, parity: str = "N", @@ -46,12 +55,17 @@ def __init__( """Initialize the raw serial transport. Args: + port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. mtu: The maximum size of one SMP message (header + payload), in bytes. A serial link has no MTU of its own, but the SMP server's receive buffer does -- this should match the server's `CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE` (Zephyr default 384). framing: optional wire framing for each SMP message (e.g. `Cobs()`); `None` sends the bare `[header][payload]`. + connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr + parameters. + sequence: The SMP sequence space the MCUmgr parameters read draws from; + defaults to `wrapping_sequence()`. baudrate: The baudrate of the serial connection. OK to ignore for USB CDC ACM. bytesize: The number of data bits. @@ -68,6 +82,9 @@ def __init__( exclusive access mode. """ super().__init__( + port, + connect_timeout_s, + sequence, baudrate=baudrate, bytesize=bytesize, parity=parity, diff --git a/src/smpclient/transport/udp.py b/src/smpclient/transport/udp.py index af38d93..67160fc 100644 --- a/src/smpclient/transport/udp.py +++ b/src/smpclient/transport/udp.py @@ -1,17 +1,24 @@ """A UDP SMPTransport for Network connections like Wi-Fi or Ethernet.""" +from __future__ import annotations + import asyncio import logging +from collections.abc import Iterator from socket import AF_INET6 -from typing import Final +from typing import TYPE_CHECKING, Final from smp import header as smphdr from typing_extensions import override +from smpclient import _request from smpclient.exceptions import SMPClientException -from smpclient.transport import SMPTransport +from smpclient.transport import _ConnectableTransport from smpclient.transport._udp_client import Addr, UDPClient +if TYPE_CHECKING: + from types_bits import u8 + logger = logging.getLogger(__name__) IPV4_HEADER_SIZE: Final = 20 @@ -36,30 +43,56 @@ PMTU to avoid fragmentation.""" -class SMPUDPTransport(SMPTransport): - def __init__(self, mtu: int = 1500) -> None: +class SMPUDPTransport(_ConnectableTransport): + def __init__( + self, + address: str, + port: int = 1337, + *, + mtu: int = 1500, + connect_timeout_s: float = 2.5, + sequence: Iterator[u8] | None = None, + ) -> None: """Initialize the SMP UDP transport. Args: + address: The server's IPv4 or IPv6 address, or a host name. + port: The server's SMP UDP port. mtu: The Maximum Transmission Unit (MTU) of the link layer in bytes. IP and UDP header overhead will be subtracted to calculate the maximum UDP payload size (MSS) to avoid fragmentation per RFC 8085 section 3.2. + connect_timeout_s: Bounds connecting, and reading the server's MCUmgr + parameters. + sequence: The SMP sequence space the MCUmgr parameters read draws from; + defaults to `wrapping_sequence()`. """ + self._address: Final = address + self._port: Final = port + self._connect_timeout_s = connect_timeout_s + self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._mtu = mtu self._is_ipv6 = False self._client: Final = UDPClient() @override - async def connect(self, address: str, timeout_s: float, port: int = 1337) -> None: - logger.debug(f"Connecting to {address=} {port=}") - await asyncio.wait_for(self._client.connect(Addr(host=address, port=port)), timeout_s) + async def connect(self) -> None: + logger.debug(f"Connecting to {self._address=} {self._port=}") + await asyncio.wait_for( + self._client.connect(Addr(host=self._address, port=self._port)), + self._connect_timeout_s, + ) if sock := self._client._transport.get_extra_info('socket'): self._is_ipv6 = sock.family == AF_INET6 logger.debug(f"Detected {'IPv6' if self._is_ipv6 else 'IPv4'} connection") - logger.info(f"Connected to {address=} {port=}") + logger.info(f"Connected to {self._address=} {self._port=}") + try: + await self.negotiate() + except (Exception, asyncio.CancelledError): + await self.disconnect() + raise @override async def disconnect(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..52b377c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures shared by the unit tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def skip_negotiation() -> Generator[AsyncMock, Any, None]: + """Answer every transport's MCUmgr parameters read with `None`, as a server without them.""" + with patch("smpclient._request.read_mcumgr_parameters", AsyncMock(return_value=None)) as read: + yield read diff --git a/tests/extensions/test_intercreate.py b/tests/extensions/test_intercreate.py index 00d7c16..ac41116 100644 --- a/tests/extensions/test_intercreate.py +++ b/tests/extensions/test_intercreate.py @@ -23,8 +23,8 @@ async def test_upload_hello_world_bin_encoded(mock_mtu: PropertyMock) -> None: ) as f: image = f.read() - m = SMPSerialTransport() - s = ICUploadClient(m, "address") + m = SMPSerialTransport("/dev/ttyACM0") + s = ICUploadClient(m) assert s._transport.mtu == 127 assert s._transport.max_unencoded_size < 127 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 645e250..91c6450 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -15,7 +15,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from pathlib import Path -from typing import NamedTuple +from typing import Final, NamedTuple import pytest import pytest_asyncio @@ -26,7 +26,6 @@ from smpclient import SMPClient, success from smpclient.exceptions import SMPBadSequence -from smpclient.transport import SMPTransport from smpclient.transport.serial import SMPSerialRawTransport, SMPSerialTransport from smpclient.transport.udp import SMPUDPTransport from tests.integration.servers import ( @@ -46,10 +45,14 @@ _READY_PROBE = "smpclient-integration-ready" +FixtureTransport = SMPSerialTransport | SMPSerialRawTransport | SMPUDPTransport + + class ConnectedServer(NamedTuple): - """A live `SMPClient`, the `ServerFixture` it is connected to, and its `Endpoint`.""" + """A live `SMPClient`, its transport, its `ServerFixture`, and its `Endpoint`.""" client: SMPClient + transport: FixtureTransport fixture: ServerFixture endpoint: Endpoint @@ -71,18 +74,14 @@ def fixture_params( ] -_SMP_UDP_DEFAULT_PORT = 1337 -"""`SMPUDPTransport.connect`'s default port; `SMPClient.connect` cannot override it.""" - - -def _build_transport(fixture: ServerFixture, endpoint: Endpoint) -> tuple[SMPTransport, str]: +def _build_transport(fixture: ServerFixture, endpoint: Endpoint) -> FixtureTransport: match endpoint: case PtyEndpoint(pty): match fixture.transport: case "serial" | "shell": - return SMPSerialTransport(), pty + return SMPSerialTransport(pty) case "serial_raw": - return SMPSerialRawTransport(), pty + return SMPSerialRawTransport(pty) case "udp": pytest.fail("UDP fixtures do not present as a PTY serial endpoint") case _ as unreachable: @@ -90,20 +89,15 @@ def _build_transport(fixture: ServerFixture, endpoint: Endpoint) -> tuple[SMPTra case SocketSerialEndpoint(url): match fixture.transport: case "serial" | "shell": - return QemuSocketSerialTransport(url), url + return QemuSocketSerialTransport(url) case "serial_raw": - return QemuSocketSerialRawTransport(url), url + return QemuSocketSerialRawTransport(url) case "udp": pytest.fail("UDP fixtures do not present as a socket serial endpoint") case _ as unreachable: assert_never(unreachable) case UdpEndpoint(host, port): - if port != _SMP_UDP_DEFAULT_PORT: - pytest.skip( - f"UDP fixture port {port} is unreachable: SMPClient.connect cannot pass a " - f"non-default UDP port (only {_SMP_UDP_DEFAULT_PORT})" - ) - return SMPUDPTransport(), host + return SMPUDPTransport(host, port) case _: assert_never(endpoint) @@ -146,7 +140,7 @@ async def echoes(c: SMPClient) -> bool: return success(response) and response.r == _READY_PROBE if not await _poll_until_answering(client, echoes, attempts=attempts): - raise TimeoutError(f"{client.address} never answered an echo") + raise TimeoutError("the SMP server never answered an echo") def signed_image(fixture: ServerFixture) -> Path: @@ -233,16 +227,16 @@ def assert_chunks_maximized( @asynccontextmanager async def reboot_into_recovery( - app_client: SMPClient, + app: ConnectedServer, transport: SMPSerialTransport | SMPSerialRawTransport, - address: str, ) -> AsyncIterator[SMPClient]: """Reboot the device into MCUboot serial recovery and yield a recovery-connected client. - The app at `app_client` reboots via `os reset boot_mode=BOOTLOADER` (smp 4.1.0); - `transport` then connects to the bootloader on the same serial endpoint, probed until - it answers (the recovery server speaks the img group, not echo). + The app at `app` reboots via `os reset boot_mode=BOOTLOADER` (smp 4.1.0) and its link + closes; `transport` then connects to the bootloader on the same serial endpoint, probed + until it answers (the recovery server speaks the img group, not echo). """ + app_client: Final = app.client assert success(await app_client.request(ImageStatesReadRequest())) try: assert success( @@ -252,40 +246,32 @@ async def reboot_into_recovery( ) except TimeoutError: pass # some servers reset before sending the response - await app_client.disconnect() + await app.transport.disconnect() await asyncio.sleep(2.0) # let MCUboot serial recovery come up async def lists_images(c: SMPClient) -> bool: return success(await c.request(ImageStatesReadRequest(), timeout_s=1.0)) - bootloader = SMPClient(transport, address) - await bootloader.connect() - try: + async with transport.connected(): + bootloader = SMPClient(transport) if not await _poll_until_answering(bootloader, lists_images, interval_s=0.2): pytest.fail("MCUboot serial recovery SMP server never answered") yield bootloader - finally: - await bootloader.disconnect() @asynccontextmanager async def connected(fixture: ServerFixture) -> AsyncIterator[ConnectedServer]: """Launch `fixture`, connect an `SMPClient`, and wait until the server answers.""" async with serve(fixture) as endpoint: - transport, address = _build_transport(fixture, endpoint) - client = SMPClient(transport, address) - await client.connect() - await _wait_until_answering(client) - # Re-initialize in case the first MCUMgr parameter read raced server boot. - await client._initialize() - try: - yield ConnectedServer(client, fixture, endpoint) - finally: - # Tolerant: a recovery test may have rebooted the server out from under us. - try: - await client.disconnect() - except Exception as e: - logger.debug(f"disconnect during teardown failed: {e}") + transport = _build_transport(fixture, endpoint) + # Tolerant on exit: `connected()` closes best-effort, and a recovery test may have + # rebooted the server out from under us. + async with transport.connected(): + client = SMPClient(transport) + await _wait_until_answering(client) + # Re-negotiate in case the first MCUMgr parameter read raced server boot. + await transport.negotiate() + yield ConnectedServer(client, transport, fixture, endpoint) @pytest_asyncio.fixture(params=fixture_params()) diff --git a/tests/integration/servers.py b/tests/integration/servers.py index edde8d1..a45e241 100644 --- a/tests/integration/servers.py +++ b/tests/integration/servers.py @@ -327,7 +327,7 @@ async def _connect_socket_chardev( class QemuSocketSerialTransport(SMPSerialTransport): """`SMPSerialTransport` whose byte pipe is a TCP socket (an emulator's serial chardev). - Only `connect` differs -- it binds a `socket://` chardev instead of a local serial + Only `_open` differs -- it binds a `socket://` chardev instead of a local serial port, sidestepping the PTY held-byte quirk of an emulated UART. Framing, fragmentation, `send`, and `receive` are inherited unchanged, so the suite exercises the real transport rather than a copy of it. @@ -339,31 +339,31 @@ def __init__( # noqa: DOC301 fragmentation_strategy: FragmentationStrategy | None = None, ) -> None: if fragmentation_strategy is None: - super().__init__() + super().__init__(url) else: - super().__init__(fragmentation_strategy=fragmentation_strategy) + super().__init__(url, fragmentation_strategy=fragmentation_strategy) self._url: Final = url @override - async def connect(self, address: str, timeout_s: float) -> None: - await _connect_socket_chardev(self, self._url, timeout_s) + async def _open(self) -> None: + await _connect_socket_chardev(self, self._url, self._connect_timeout_s) class QemuSocketSerialRawTransport(SMPSerialRawTransport): """`SMPSerialRawTransport` whose byte pipe is a TCP socket (an emulator's serial chardev). - The raw counterpart of `QemuSocketSerialTransport`: only `connect` differs; the raw + The raw counterpart of `QemuSocketSerialTransport`: only `_open` differs; the raw `[header][payload]` framing, `send`, and `receive` are inherited from `SMPSerialRawTransport` unchanged. """ def __init__(self, url: str, mtu: int = 384, framing: SerialFraming | None = None) -> None: # noqa: DOC301 - super().__init__(mtu=mtu, framing=framing) + super().__init__(url, mtu=mtu, framing=framing) self._url: Final = url @override - async def connect(self, address: str, timeout_s: float) -> None: - await _connect_socket_chardev(self, self._url, timeout_s, _PacedSocketChardev) + async def _open(self) -> None: + await _connect_socket_chardev(self, self._url, self._connect_timeout_s, _PacedSocketChardev) def _verify_sha256(artifact: Path) -> str | None: diff --git a/tests/integration/test_fragmentation.py b/tests/integration/test_fragmentation.py index a028cc3..9a3221a 100644 --- a/tests/integration/test_fragmentation.py +++ b/tests/integration/test_fragmentation.py @@ -125,11 +125,10 @@ async def test_non_default_line_length(fixture: ServerFixture) -> None: async with serve(fixture) as endpoint: assert isinstance(endpoint, PtyEndpoint) transport = SMPSerialTransport( - fragmentation_strategy=BufferParams(line_length=512, line_buffers=1) + endpoint.pty, fragmentation_strategy=BufferParams(line_length=512, line_buffers=1) ) - client = SMPClient(transport, endpoint.pty) - await client.connect() - try: + async with transport.connected(): + client = SMPClient(transport) await _wait_until_answering(client) assert transport._line_length == 512 @@ -141,5 +140,3 @@ async def test_non_default_line_length(fixture: ServerFixture) -> None: response = await client.request(request, timeout_s=10.0) assert success(response) assert response.r == text - finally: - await client.disconnect() diff --git a/tests/integration/test_serial_recovery.py b/tests/integration/test_serial_recovery.py index aae2b39..4188e42 100644 --- a/tests/integration/test_serial_recovery.py +++ b/tests/integration/test_serial_recovery.py @@ -170,8 +170,9 @@ async def test_upload_to_mcuboot_recovery(variant: _Recovery, fixture: ServerFix assert isinstance(cs.endpoint, SocketSerialEndpoint) transport = _build_transport(variant, cs.endpoint.url) - async with reboot_into_recovery(cs.client, transport, cs.endpoint.url) as bootloader: - await bootloader._initialize() # negotiate buf_size (a no-op for explicit BufferSize) + async with reboot_into_recovery(cs, transport) as bootloader: + # Re-negotiate in case the first read raced the bootloader coming up. + await transport.negotiate() params = await bootloader.request(MCUMgrParametersReadRequest(), timeout_s=2.0) assert success(params) diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index a911e9d..2eb6097 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -31,8 +31,14 @@ def __new__(cls, *args, **kwargs) -> "MockBleakClient": # type: ignore return client +pytestmark = pytest.mark.usefixtures("skip_negotiation") + +ADDRESS = "00:00:00:00:00:00" +"""An address; bleak is mocked, so nothing is scanned for.""" + + def test_constructor() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) assert t._buffer == bytearray() assert isinstance(t._notify_condition, asyncio.Condition) @@ -85,17 +91,19 @@ async def test_connect( mock_find_device_by_address: MagicMock, ) -> None: # assert that it searches by name if MAC or UUID is not provided - await SMPBLETransport().connect("device name", 1.0) + await SMPBLETransport("device name", connect_timeout_s=1.0).connect() mock_find_device_by_name.assert_called_once_with("device name", timeout=1.0) mock_find_device_by_name.reset_mock() # assert that it searches by MAC if MAC is provided - await SMPBLETransport().connect("00:00:00:00:00:00", 1.0) + await SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=1.0).connect() mock_find_device_by_address.assert_called_once_with("00:00:00:00:00:00", timeout=1.0) mock_find_device_by_address.reset_mock() # assert that it searches by UUID if UUID is provided - await SMPBLETransport().connect(UUID("00000000-0000-4000-8000-000000000000").hex, 1.0) + await SMPBLETransport( + UUID("00000000-0000-4000-8000-000000000000").hex, connect_timeout_s=1.0 + ).connect() mock_find_device_by_address.assert_called_once_with( "00000000000040008000000000000000", timeout=1.0 ) @@ -104,15 +112,15 @@ async def test_connect( # assert that it raises an exception if the device is not found mock_find_device_by_address.return_value = None with pytest.raises(SMPBLETransportDeviceNotFound): - await SMPBLETransport().connect("00:00:00:00:00:00", 1.0) + await SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=1.0).connect() mock_find_device_by_address.reset_mock() # assert that connect is awaited - t = SMPBLETransport() - await t.connect("name", 1.0) + t = SMPBLETransport("name", connect_timeout_s=1.0) + await t.connect() t._client = cast(MagicMock, t._client) t._client.reset_mock() - await t.connect("name", 1.0) + await t.connect() t._client.connect.assert_awaited_once_with() # these are hard to mock now because the _client is created in the connect method @@ -140,7 +148,7 @@ async def test_connect( @pytest.mark.asyncio async def test_disconnect() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) t._client = MagicMock(spec=BleakClient) await t.disconnect() t._client.disconnect.assert_awaited_once_with() @@ -148,7 +156,7 @@ async def test_disconnect() -> None: @pytest.mark.asyncio async def test_send() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) t._client = MagicMock(spec=BleakClient) t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) t._smp_characteristic.max_write_without_response_size = 20 @@ -160,7 +168,7 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_receive() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) t._client = MagicMock(spec=BleakClient) t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) t._smp_characteristic.uuid = str(SMP_CHARACTERISTIC_UUID) @@ -191,7 +199,7 @@ async def fragmented_notifies() -> None: @pytest.mark.asyncio async def test_send_and_receive() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) t.send = AsyncMock() # type: ignore t.receive = AsyncMock() # type: ignore await t.send_and_receive(b"Hello pytest!") @@ -200,14 +208,14 @@ async def test_send_and_receive() -> None: def test_max_unencoded_size() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) t._client = MagicMock(spec=BleakClient) t._max_write_without_response_size = 42 assert t.max_unencoded_size == 42 def test_max_unencoded_size_mcumgr_param() -> None: - t = SMPBLETransport() + t = SMPBLETransport(ADDRESS) t._client = MagicMock(spec=BleakClient) t._smp_server_transport_buffer_size = 9001 assert t.max_unencoded_size == 9001 @@ -251,7 +259,7 @@ async def test_connect_raises_on_peer_disconnect_during_start_notify( When the peer disconnects mid-`start_notify` (e.g. failed pairing), `connect()` must surface `SMPTransportDisconnected` rather than hang. """ - t = SMPBLETransport() + t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=5.0) async def _trip_disconnect_callback() -> None: # Wait until the transport reaches start_notify and clears the event, @@ -261,7 +269,7 @@ async def _trip_disconnect_callback() -> None: await asyncio.sleep(0) # let start_notify await begin t._set_disconnected_event(t._client) - connect_task = asyncio.create_task(t.connect("00:00:00:00:00:00", 5.0)) + connect_task = asyncio.create_task(t.connect()) trip_task = asyncio.create_task(_trip_disconnect_callback()) with pytest.raises(SMPTransportDisconnected): @@ -281,10 +289,10 @@ async def _trip_disconnect_callback() -> None: async def test_connect_raises_on_timeout_during_start_notify( _mock_find_device_by_address: MagicMock, ) -> None: - """`connect()` must honor `timeout_s` even when `start_notify` hangs.""" - t = SMPBLETransport() + """`connect()` must honor `connect_timeout_s` even when `start_notify` hangs.""" + t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=0.05) with pytest.raises(asyncio.TimeoutError): - await t.connect("00:00:00:00:00:00", 0.05) + await t.connect() t._client.disconnect.assert_awaited() # type: ignore[attr-defined] @@ -298,10 +306,10 @@ async def test_connect_does_not_leak_tasks_on_external_cancel( _mock_find_device_by_address: MagicMock, ) -> None: """Caller-driven cancellation must not leave `_await_or_disconnect` sub-tasks running.""" - t = SMPBLETransport() + t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=60.0) tasks_before = {id(task) for task in asyncio.all_tasks()} - connect_task = asyncio.create_task(t.connect("00:00:00:00:00:00", 60.0)) + connect_task = asyncio.create_task(t.connect()) while t._disconnected_event.is_set(): await asyncio.sleep(0) # wait until BleakClient.connect() returned await asyncio.sleep(0) # let start_notify await begin diff --git a/tests/test_smp_bumble_transport.py b/tests/test_smp_bumble_transport.py index 31d9692..a7e1e6f 100644 --- a/tests/test_smp_bumble_transport.py +++ b/tests/test_smp_bumble_transport.py @@ -4,6 +4,7 @@ import logging import os import tempfile +from contextlib import nullcontext from pathlib import Path from typing import cast from unittest.mock import AsyncMock, MagicMock, patch @@ -46,6 +47,11 @@ PairingTimedOut, ) +pytestmark = pytest.mark.usefixtures("skip_negotiation") + +ADDRESS = "AA:BB:CC:DD:EE:FF" +"""A BD_ADDR; the bumble stack is mocked, so nothing is connected to.""" + def test_smp_uuids_match_ble_transport() -> None: """Bumble must use the same SMP UUIDs as the bleak-backed transport.""" @@ -67,7 +73,7 @@ def test_smp_uuids_match_ble_transport() -> None: def test_constructor_defaults() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) assert isinstance(t._state, Disconnected) assert t._hci == DEFAULT_HCI_TRANSPORT assert t._host_name == DEFAULT_HOST_NAME @@ -76,27 +82,27 @@ def test_constructor_defaults() -> None: @pytest.mark.asyncio async def test_send_in_disconnected_state_raises() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) with pytest.raises(SMPBumbleTransportException): await t.send(b"x") def test_mtu_in_disconnected_state_raises() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) with pytest.raises(SMPBumbleTransportException): _ = t.mtu @pytest.mark.asyncio async def test_pair_in_disconnected_state_raises() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) with pytest.raises(SMPBumbleTransportException): await t.pair(NoInputNoOutput()) @pytest.mark.asyncio async def test_pair_in_borrowed_state_raises() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) t._state = ConnectedBorrowed( connection=MagicMock(), peer=MagicMock(), smp_characteristic=MagicMock(), max_write=20 ) @@ -106,21 +112,21 @@ async def test_pair_in_borrowed_state_raises() -> None: @pytest.mark.asyncio async def test_disconnect_in_disconnected_state_is_noop() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) await t.disconnect() assert isinstance(t._state, Disconnected) @pytest.mark.asyncio async def test_connect_while_connected_raises() -> None: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) t._state = Connecting() with pytest.raises(SMPBumbleTransportException, match="Connecting"): - await t.connect("00:11:22:33:44:55", 5.0) + await t.connect() def _make_connected(max_write: int = 244) -> tuple[SMPBumbleTransport, MagicMock]: - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) smp_char = MagicMock() smp_char.write_value = AsyncMock() t._state = Connected( @@ -410,7 +416,7 @@ def test_disconnect_sentinel_is_namedtuple() -> None: class _MockBumbleEnvironment: - """Builds the mock bumble stack required by `SMPBumbleTransport.connect()`.""" + """Builds the mock bumble stack required by `SMPBumbleTransport.connect()` and `borrow()`.""" def __init__(self, *, with_bond: bool = False) -> None: self.transport = MagicMock() @@ -484,8 +490,8 @@ def _device_with_hci(*_args: object, **_kwargs: object) -> MagicMock: async def test_connect_transitions_to_connected_state( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport() - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + t = SMPBumbleTransport(ADDRESS) + await t.connect() assert isinstance(t._state, Connected) assert t._state.max_write == 247 - ATT_WRITE_OVERHEAD bumble_env.device.power_on.assert_awaited_once() @@ -515,8 +521,8 @@ async def test_connect_proactively_encrypts_when_bonded( "smpclient.transport.bumble.resolve_keystore", lambda _s, namespace: env.keystore, ) - t = SMPBumbleTransport() - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + t = SMPBumbleTransport(ADDRESS) + await t.connect() env.connection.encrypt.assert_awaited_once() @@ -543,8 +549,8 @@ async def _connect_snapshotting(*args: object, **kwargs: object) -> MagicMock: bumble_env.device.connect = _connect_snapshotting - t = SMPBumbleTransport(pair_on_connect=delegate) - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + t = SMPBumbleTransport(ADDRESS, pair_on_connect=delegate) + await t.connect() assert factory_set_at["value"], ( "pairing_config_factory must be set before device.connect() returns" ) @@ -563,7 +569,7 @@ async def _pair_and_encrypt() -> None: bumble_env.connection.pair.side_effect = _pair_and_encrypt delegate = NoInputNoOutput() - t = SMPBumbleTransport(pair_on_connect=delegate, settle_s=0.0) + t = SMPBumbleTransport(ADDRESS, pair_on_connect=delegate, settle_s=0.0) captured: dict[str, object] = {} original_on = bumble_env.connection.on @@ -585,7 +591,7 @@ async def _emit_security_request_after_connect(*args: object, **kwargs: object) bumble_env.device.connect = _emit_security_request_after_connect - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + await t.connect() bumble_env.connection.pair.assert_awaited_once() assert isinstance(t._state, Connected) @@ -602,7 +608,7 @@ async def _pair_and_encrypt() -> None: bumble_env.connection.pair.side_effect = _pair_and_encrypt - t = SMPBumbleTransport(pair_on_connect=NoInputNoOutput(), settle_s=0.0) + t = SMPBumbleTransport(ADDRESS, pair_on_connect=NoInputNoOutput(), settle_s=0.0) t._state = Connecting(connection=bumble_env.connection, device=bumble_env.device) t._pair_lock = asyncio.Lock() t._pair_result = None @@ -620,9 +626,9 @@ async def test_connect_failure_tears_down_partial_state( bumble_env: _MockBumbleEnvironment, ) -> None: bumble_env.smp_char.subscribe.side_effect = RuntimeError("boom") - t = SMPBumbleTransport() + t = SMPBumbleTransport(ADDRESS) with pytest.raises(RuntimeError, match="boom"): - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + await t.connect() assert isinstance(t._state, Disconnected) bumble_env.connection.disconnect.assert_awaited() bumble_env.device.power_off.assert_awaited() @@ -633,8 +639,8 @@ async def test_connect_failure_tears_down_partial_state( async def test_disconnect_owned_tears_down_everything( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport() - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + t = SMPBumbleTransport(ADDRESS) + await t.connect() await t.disconnect() assert isinstance(t._state, Disconnected) bumble_env.smp_char.unsubscribe.assert_awaited() @@ -644,11 +650,11 @@ async def test_disconnect_owned_tears_down_everything( @pytest.mark.asyncio -async def test_use_connection_borrowed_only_unsubscribes_on_disconnect( +async def test_borrow_borrowed_only_unsubscribes_on_disconnect( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport() - await t.use_connection(bumble_env.connection) + t = SMPBumbleTransport(ADDRESS) + await t.borrow(bumble_env.connection) assert isinstance(t._state, ConnectedBorrowed) await t.disconnect() bumble_env.smp_char.unsubscribe.assert_awaited() @@ -658,23 +664,23 @@ async def test_use_connection_borrowed_only_unsubscribes_on_disconnect( @pytest.mark.asyncio -async def test_use_connection_skips_discover_when_services_present( +async def test_borrow_skips_discover_when_services_present( bumble_env: _MockBumbleEnvironment, ) -> None: bumble_env.peer.services = [MagicMock()] - t = SMPBumbleTransport() - await t.use_connection(bumble_env.connection, peer=bumble_env.peer) + t = SMPBumbleTransport(ADDRESS) + await t.borrow(bumble_env.connection, peer=bumble_env.peer) bumble_env.peer.discover_all.assert_not_called() @pytest.mark.asyncio -async def test_use_connection_while_connected_raises( +async def test_borrow_while_connected_raises( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport() - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + t = SMPBumbleTransport(ADDRESS) + await t.connect() with pytest.raises(SMPBumbleTransportException): - await t.use_connection(bumble_env.connection) + await t.borrow(bumble_env.connection) @pytest.mark.asyncio @@ -1055,11 +1061,14 @@ async def test_cli_echo_success(capsys: pytest.CaptureFixture[str]) -> None: response = MagicMock() response.r = "pong" client = MagicMock() - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) client.request = AsyncMock(return_value=response) + transport = MagicMock() + transport.connected.return_value = nullcontext() with ( + patch( + "smpclient.transport.bumble.__main__.SMPBumbleTransport", return_value=transport + ) as transport_class, patch("smpclient.transport.bumble.__main__.SMPClient", return_value=client), patch("smpclient.transport.bumble.__main__.success", return_value=True), patch("smpclient.transport.bumble.__main__.error", return_value=False), @@ -1068,6 +1077,8 @@ async def test_cli_echo_success(capsys: pytest.CaptureFixture[str]) -> None: _EchoArgs(hci="usb:0", address="AA:BB:CC:DD:EE:FF", message="ping", timeout=5.0) ) assert rc == 0 + transport_class.assert_called_once_with("AA:BB:CC:DD:EE:FF", hci="usb:0", connect_timeout_s=5.0) + transport.connected.assert_called_once_with() assert "pong" in capsys.readouterr().out @@ -1077,11 +1088,12 @@ async def test_cli_echo_returns_1_on_error(capsys: pytest.CaptureFixture[str]) - response = MagicMock() client = MagicMock() - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=None) client.request = AsyncMock(return_value=response) + transport = MagicMock() + transport.connected.return_value = nullcontext() with ( + patch("smpclient.transport.bumble.__main__.SMPBumbleTransport", return_value=transport), patch("smpclient.transport.bumble.__main__.SMPClient", return_value=client), patch("smpclient.transport.bumble.__main__.success", return_value=False), patch("smpclient.transport.bumble.__main__.error", return_value=True), @@ -1192,8 +1204,8 @@ def _post_pair_encrypted() -> None: bumble_env.connection.pair = AsyncMock(side_effect=_post_pair_encrypted) - t = SMPBumbleTransport(pair_on_connect=NoInputNoOutput(), settle_s=0.0) - await t.connect("AA:BB:CC:DD:EE:FF", 5.0) + t = SMPBumbleTransport(ADDRESS, pair_on_connect=NoInputNoOutput(), settle_s=0.0) + await t.connect() bumble_env.connection.pair.assert_awaited_once() assert isinstance(t._state, Connected) @@ -1208,9 +1220,9 @@ async def test_resolve_target_raises_when_no_device_with_name( "smpclient.transport.bumble.scan_for_devices", AsyncMock(return_value=()), ) - t = SMPBumbleTransport() + t = SMPBumbleTransport("UnknownName", connect_timeout_s=0.1) with pytest.raises(SMPBumbleTransportDeviceNotFound): - await t.connect("UnknownName", 0.1) + await t.connect() # Suppress unused-imports warnings for symbols re-exported for downstream code. diff --git a/tests/test_smp_client.py b/tests/test_smp_client.py index 0570a3e..8e76cfb 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -47,6 +47,9 @@ SMPSerialTransport, ) +PORT = "/dev/ttyACM0" +"""A port name for transports that are never opened.""" + FRAME_OVERHEAD = smppacket.FRAME_LENGTH_STRUCT.size + smppacket.CRC16_STRUCT.size """The SMP serial frame's 2-byte length + 2-byte CRC16 that share the decoded buffer.""" @@ -79,8 +82,6 @@ class SMPMockTransport: """Satisfies the `SMPTransport` `Protocol`.""" def __init__(self) -> None: - self.connect = AsyncMock() - self.disconnect = AsyncMock() self.send = AsyncMock() self.receive = AsyncMock() self._smp_server_transport_buffer_size: int | None = None @@ -126,26 +127,14 @@ def sent_frame(m: SMPMockTransport) -> Any: def test_constructor() -> None: m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) assert s._transport is m - assert s._address == "address" - - -@pytest.mark.asyncio -async def test_connect() -> None: - m = SMPMockTransport() - s = SMPClient(m, "address", 5.0) - s._initialize = AsyncMock() # type: ignore - await s.connect() - - m.connect.assert_awaited_once_with("address", 5.0) - s._initialize.assert_awaited_once_with(5.0) @pytest.mark.asyncio async def test_request() -> None: m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) req = ResetWriteRequest() m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) @@ -209,7 +198,7 @@ async def test_request() -> None: async def test_request_unparseable_frame() -> None: """A frame matching none of the Response/ErrorV1/ErrorV2 types raises with diagnostics.""" m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) req = ResetWriteRequest() # Same group, so the frame reaches the decoders -- but `r` is a field of none of @@ -238,7 +227,7 @@ def test_wrapping_sequence() -> None: async def test_injected_sequence() -> None: """The sequence space is injectable, so a test can pin what goes on the wire.""" m = SMPMockTransport() - s = SMPClient(m, "address", sequence=iter((7, 9))) + s = SMPClient(m, sequence=iter((7, 9))) m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) for expected in (7, 9): @@ -254,7 +243,7 @@ async def test_request_mismatched_group_propagates() -> None: instead of being collected as one more parse failure. """ m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) m.receive.return_value = bytes(ImageUploadWriteResponse(off=0).to_frame(sequence=0)) @@ -270,7 +259,7 @@ async def test_request_truncated_payload_is_diagnosed() -> None: decode chain catches the wider type. """ m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) truncated = b"\xbf\x61\x72" # an indefinite-length map that simply stops m.receive.return_value = ( @@ -295,7 +284,7 @@ async def test_request_truncated_payload_is_diagnosed() -> None: @pytest.mark.asyncio async def test_upload() -> None: m = SMPMockTransport() - s = SMPClient(m, "address", 2.5) + s = SMPClient(m, timeout_s=2.5) s.request = AsyncMock() # type: ignore @@ -368,7 +357,7 @@ async def test_upload_hello_world_bin( image = f.read() m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) accumulated_image = bytearray([]) @@ -403,12 +392,13 @@ async def test_upload_hello_world_bin_encoded( pytest.skip("The line buffer size is too small") m = SMPSerialTransport( + PORT, fragmentation_strategy=BufferParams( line_length=line_length, line_buffers=line_buffers, - ) + ), ) - s = SMPClient(m, "address") + s = SMPClient(m) # MTU is line_length * line_buffers, which may be <= max_smp_encoded_frame_size # due to integer division assert s._transport.mtu == line_length * line_buffers @@ -468,8 +458,8 @@ async def test_upload_hello_world_bin_raw(mtu: int) -> None: ) as f: image = f.read() - m = SMPSerialRawTransport(mtu=mtu) - s = SMPClient(m, "address") + m = SMPSerialRawTransport(PORT, mtu=mtu) + s = SMPClient(m) assert s._transport.mtu == mtu assert s._transport.max_unencoded_size == mtu, "The raw transport has no encoding overhead" @@ -509,7 +499,7 @@ async def mock_request( @pytest.mark.asyncio async def test_upload_file() -> None: m = SMPMockTransport() - s = SMPClient(m, "address", 2.5) + s = SMPClient(m, timeout_s=2.5) s.request = AsyncMock() # type: ignore @@ -581,7 +571,7 @@ async def test_file_upload_test_txt( data = f.read() m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) accumulated_data = bytearray([]) @@ -615,7 +605,7 @@ async def test_file_upload_test_255_bytes_file( data = f.read() m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) accumulated_data = bytearray([]) @@ -648,12 +638,13 @@ async def test_file_upload_test_encoded(max_smp_encoded_frame_size: int, line_bu pytest.skip("The line buffer size is too small") m = SMPSerialTransport( + PORT, fragmentation_strategy=BufferParams( line_length=line_length, line_buffers=line_buffers, - ) + ), ) - s = SMPClient(m, "address") + s = SMPClient(m) # MTU is line_length * line_buffers, which may be <= max_smp_encoded_frame_size # due to integer division assert s._transport.mtu == line_length * line_buffers @@ -707,7 +698,7 @@ async def mock_request( @pytest.mark.asyncio async def test_download_file() -> None: m = SMPMockTransport() - s = SMPClient(m, "address", 2.5) + s = SMPClient(m, timeout_s=2.5) s.request = AsyncMock() # type: ignore @@ -804,7 +795,7 @@ async def test_download_file() -> None: @pytest.mark.asyncio async def test_download_file_error_first() -> None: m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) s.request = AsyncMock() # type: ignore @@ -822,7 +813,7 @@ async def test_download_file_error_first() -> None: @pytest.mark.asyncio async def test_download_file_no_len_first() -> None: m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) s.request = AsyncMock() # type: ignore @@ -841,7 +832,7 @@ async def test_download_file_no_len_first() -> None: @pytest.mark.asyncio async def test_download_file_error_not_first() -> None: m = SMPMockTransport() - s = SMPClient(m, "address") + s = SMPClient(m) s.request = AsyncMock() # type: ignore @@ -878,8 +869,7 @@ def test_maximize_upload_packet_fills_decoded_buffer( lines arrive. The unified generic handles both `ImageUploadWriteRequest` and `FileUploadRequest`. """ client = SMPClient( - SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size)), - "address", + SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) ) max_unencoded_size = client._transport.max_unencoded_size assert max_unencoded_size == buf_size - FRAME_OVERHEAD diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index 5703efd..671db25 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -18,6 +18,11 @@ from smpclient.transport.serial import Cobs, SMPSerialRawTransport from smpclient.transport.serial.framing.cobs import cobs_encode +pytestmark = pytest.mark.usefixtures("skip_negotiation") + +PORT = "/dev/ttyUSB0" +"""A port name; `Serial` is mocked, so nothing is opened.""" + @pytest.fixture(autouse=True) def mock_serial() -> Generator[None, Any, None]: @@ -26,13 +31,13 @@ def mock_serial() -> Generator[None, Any, None]: def test_constructor() -> None: - t = SMPSerialRawTransport(mtu=512) + t = SMPSerialRawTransport(PORT, mtu=512) assert t.mtu == 512 assert t.max_unencoded_size == 512 def test_constructor_defaults() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT) assert t.mtu == 384 @@ -40,11 +45,11 @@ def test_constructor_defaults() -> None: async def test_connect_disconnect() -> None: ports: list[str] = ["COM2", "/dev/ttyACM0", "/dev/ttyUSB0"] - t = SMPSerialRawTransport() - t._conn.read_all = MagicMock(return_value=b"") # type: ignore - for p in ports: - await asyncio.wait_for(t.connect(p, 1.0), timeout=1.0) + t = SMPSerialRawTransport(p, connect_timeout_s=1.0) + t._conn.read_all = MagicMock(return_value=b"") # type: ignore + + await asyncio.wait_for(t.connect(), timeout=1.0) t._conn.open.assert_called_once() # type: ignore assert t._conn.port == p @@ -57,16 +62,16 @@ async def test_connect_disconnect() -> None: @pytest.mark.asyncio async def test_connect_retries_until_timeout() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT, connect_timeout_s=0.1) t._conn.open = MagicMock(side_effect=SerialException("nope")) # type: ignore with pytest.raises(TimeoutError): - await asyncio.wait_for(t.connect("/dev/ttyUSB0", 0.1), timeout=2.0) + await asyncio.wait_for(t.connect(), timeout=2.0) @pytest.mark.asyncio async def test_send() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT) t._conn.write = MagicMock() # type: ignore p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore @@ -81,7 +86,7 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_send_waits_for_tx_drain() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT) t._conn.write = MagicMock() # type: ignore p = PropertyMock(side_effect=(1, 0)) type(t._conn).out_waiting = p # type: ignore @@ -92,14 +97,14 @@ async def test_send_waits_for_tx_drain() -> None: @pytest.mark.asyncio async def test_send_too_large_raises() -> None: - t = SMPSerialRawTransport(mtu=16) + t = SMPSerialRawTransport(PORT, mtu=16) with pytest.raises(ValueError): await t.send(b"\x00" * 32) @pytest.mark.asyncio async def test_send_disconnected_raises() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT) t._conn.write = MagicMock(side_effect=SerialException("disconnected")) # type: ignore with pytest.raises(SMPTransportDisconnected): @@ -108,8 +113,8 @@ async def test_send_disconnected_raises() -> None: @pytest.mark.asyncio async def test_receive_single_packet() -> None: - t = SMPSerialRawTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT) + await t.connect() m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) t._conn.read_all = MagicMock(side_effect=[bytes(m)]) # type: ignore @@ -122,8 +127,8 @@ async def test_receive_single_packet() -> None: @pytest.mark.asyncio async def test_receive_fragmented() -> None: - t = SMPSerialRawTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT) + await t.connect() m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) fragments = [ @@ -142,8 +147,8 @@ async def test_receive_fragmented() -> None: @pytest.mark.asyncio async def test_receive_byte_at_a_time() -> None: - t = SMPSerialRawTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT) + await t.connect() m = EchoWriteResponse(r="Hi").to_frame(sequence=0) t._conn.read_all = MagicMock( # type: ignore @@ -158,8 +163,8 @@ async def test_receive_byte_at_a_time() -> None: @pytest.mark.asyncio async def test_receive_consecutive_messages() -> None: - t = SMPSerialRawTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT) + await t.connect() m1 = EchoWriteResponse(r="SMP Message 1").to_frame(sequence=0) m2 = EchoWriteResponse(r="SMP Message 2").to_frame(sequence=1) @@ -181,8 +186,8 @@ async def test_receive_overrun_raises() -> None: SMP is strictly request/response; the server should never send unsolicited bytes. """ - t = SMPSerialRawTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT) + await t.connect() m = EchoWriteResponse(r="Hello!").to_frame(sequence=0) t._conn.read_all = MagicMock(side_effect=[bytes(m) + b"\x00\x01\x02"]) # type: ignore @@ -195,8 +200,8 @@ async def test_receive_overrun_raises() -> None: @pytest.mark.asyncio async def test_receive_polls_when_nothing_available() -> None: - t = SMPSerialRawTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT) + await t.connect() m = EchoWriteResponse(r="ok").to_frame(sequence=0) t._conn.read_all = MagicMock(side_effect=[b"", b"", bytes(m)]) # type: ignore @@ -215,8 +220,8 @@ async def test_receive_oversized_header_raises() -> None: Defensive bound against noisy or corrupted UART traffic that would otherwise cause an unbounded wait. """ - t = SMPSerialRawTransport(mtu=64) - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT, mtu=64) + await t.connect() bogus_header = smphdr.Header( op=smphdr.OP.WRITE_RSP, @@ -237,7 +242,7 @@ async def test_receive_oversized_header_raises() -> None: @pytest.mark.asyncio async def test_receive_disconnected_raises() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT) t._conn.read_all = MagicMock(side_effect=SerialException("disconnected")) # type: ignore with pytest.raises(SMPTransportDisconnected): @@ -246,7 +251,7 @@ async def test_receive_disconnected_raises() -> None: @pytest.mark.asyncio async def test_send_and_receive() -> None: - t = SMPSerialRawTransport() + t = SMPSerialRawTransport(PORT) t.send = AsyncMock() # type: ignore t.receive = AsyncMock() # type: ignore @@ -258,7 +263,7 @@ async def test_send_and_receive() -> None: @pytest.mark.asyncio async def test_send_with_cobs_framing_encodes() -> None: - t = SMPSerialRawTransport(framing=Cobs()) + t = SMPSerialRawTransport(PORT, framing=Cobs()) t._conn.write = MagicMock() # type: ignore p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore @@ -272,8 +277,8 @@ async def test_send_with_cobs_framing_encodes() -> None: @pytest.mark.asyncio async def test_receive_with_cobs_framing_decodes() -> None: - t = SMPSerialRawTransport(framing=Cobs()) - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT, framing=Cobs()) + await t.connect() m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) (wire,) = Cobs().encode(bytes(m)) @@ -286,8 +291,8 @@ async def test_receive_with_cobs_framing_decodes() -> None: @pytest.mark.asyncio async def test_receive_with_cobs_framing_fragmented() -> None: - t = SMPSerialRawTransport(framing=Cobs()) - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT, framing=Cobs()) + await t.connect() m = EchoWriteResponse(r="fragment me across reads").to_frame(sequence=0) (wire,) = Cobs().encode(bytes(m)) @@ -304,8 +309,8 @@ async def test_receive_two_cobs_frames_in_one_read() -> None: The next receive returns it without consulting read_all again. """ - t = SMPSerialRawTransport(framing=Cobs()) - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT, framing=Cobs()) + await t.connect() m1 = EchoWriteResponse(r="first").to_frame(sequence=0) m2 = EchoWriteResponse(r="second").to_frame(sequence=1) @@ -327,8 +332,8 @@ async def test_receive_cobs_framing_resyncs_past_corrupt_frame() -> None: The two frames carry *different* payloads, so a decoder that wrongly accepted the corrupt frame would surface `dropped`, not `recovered`. """ - t = SMPSerialRawTransport(framing=Cobs()) - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT, framing=Cobs()) + await t.connect() dropped = EchoWriteResponse(r="dropped").to_frame(sequence=0) recovered = EchoWriteResponse(r="recovered").to_frame(sequence=1) @@ -351,8 +356,8 @@ async def test_receive_framed_yields_so_an_outer_timeout_can_fire() -> None: `_read_all` is synchronous, so the loop must yield each iteration; otherwise an outer `asyncio.timeout` could never fire on a wrong-baud / wrong-protocol / noisy peer. """ - t = SMPSerialRawTransport(framing=Cobs()) - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialRawTransport(PORT, framing=Cobs()) + await t.connect() m = EchoWriteResponse(r="never valid").to_frame(sequence=0) corrupt = cobs_encode(bytes(m) + CRC16_STRUCT.pack(crc16_func(bytes(m)) ^ 0xFFFF)) + b"\x00" diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index b12ce9b..21cc230 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -23,6 +23,11 @@ SMPSerialTransport, ) +pytestmark = pytest.mark.usefixtures("skip_negotiation") + +PORT = "/dev/ttyACM0" +"""A port name; `Serial` is mocked, so nothing is opened.""" + FRAME_OVERHEAD = smppacket.FRAME_LENGTH_STRUCT.size + smppacket.CRC16_STRUCT.size """The SMP serial frame's 2-byte length + 2-byte CRC16 that share the decoded buffer.""" @@ -35,14 +40,16 @@ def mock_serial() -> Generator[None, Any, None]: def test_constructor() -> None: # Test with Auto() (default): conservative 7.1.0-equivalent 128 * 2 budget pre-init - t = SMPSerialTransport() + t = SMPSerialTransport(PORT) assert t.mtu == 256 # 128 * 2, the conservative default before server params are read assert t._line_length == 128 assert t._line_buffers == 2 assert t._max_smp_encoded_frame_size == 256 # Test with BufferParams - t = SMPSerialTransport(fragmentation_strategy=BufferParams(line_length=128, line_buffers=4)) + t = SMPSerialTransport( + PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=4) + ) assert t.mtu == 512 # 128 * 4 assert t._line_length == 128 assert t._line_buffers == 4 @@ -50,7 +57,7 @@ def test_constructor() -> None: assert t.max_unencoded_size < 512 # Test with BufferSize: fills the decoded buffer (buf_size - 4), like Auto - t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=1024)) + t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) assert t.mtu == 1024 assert t._line_length == 128 assert t._max_smp_encoded_frame_size == 1024 @@ -61,11 +68,11 @@ def test_constructor() -> None: async def test_connect_disconnect() -> None: ports: list[str] = ["COM2", "/dev/ttyACM0", "/dev/ttyUSB0"] - t = SMPSerialTransport() - t._conn.read_all = MagicMock(return_value=b"") # type: ignore - for p in ports: - await asyncio.wait_for(t.connect(p, 1.0), timeout=1.0) + t = SMPSerialTransport(p, connect_timeout_s=1.0) + t._conn.read_all = MagicMock(return_value=b"") # type: ignore + + await asyncio.wait_for(t.connect(), timeout=1.0) t._conn.open.assert_called_once() # type: ignore assert t._conn.port == p @@ -78,7 +85,7 @@ async def test_connect_disconnect() -> None: @pytest.mark.asyncio async def test_send() -> None: - t = SMPSerialTransport() + t = SMPSerialTransport(PORT) t._conn.write = MagicMock() # type: ignore p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore @@ -99,7 +106,7 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_receive() -> None: - t = SMPSerialTransport() + t = SMPSerialTransport(PORT) m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) p = [p for p in smppacket.encode(bytes(m), t.max_unencoded_size)] t._read_one_smp_packet = AsyncMock(side_effect=p) # type: ignore @@ -119,8 +126,8 @@ async def test_receive() -> None: @pytest.mark.asyncio async def test_read_one_smp_packet() -> None: - t = SMPSerialTransport() - await t.connect("COM2", timeout_s=1.0) + t = SMPSerialTransport(PORT) + await t.connect() m1 = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) m2 = EchoWriteResponse(r="Hello computer!").to_frame(sequence=1) @@ -157,7 +164,7 @@ async def test_read_one_smp_packet() -> None: @pytest.mark.asyncio async def test_send_and_receive() -> None: - t = SMPSerialTransport() + t = SMPSerialTransport(PORT) t.send = AsyncMock() # type: ignore t.receive = AsyncMock() # type: ignore @@ -169,7 +176,7 @@ async def test_send_and_receive() -> None: @pytest.mark.asyncio async def test_receive_timeout() -> None: - t = SMPSerialTransport(timeout=0.1) + t = SMPSerialTransport(PORT, timeout=0.1) t._read_one_smp_packet = AsyncMock(side_effect=TimeoutError) # type: ignore with pytest.raises(TimeoutError): @@ -178,8 +185,8 @@ async def test_receive_timeout() -> None: @pytest.mark.asyncio async def test_only_serial_data_no_smp() -> None: - t = SMPSerialTransport() - await t.connect("/dev/ttyACM0", timeout_s=1.0) + t = SMPSerialTransport(PORT) + await t.connect() t._conn.read_all = MagicMock( # type: ignore side_effect=[ @@ -215,8 +222,8 @@ async def test_only_serial_data_no_smp() -> None: @pytest.mark.asyncio async def test_only_smp_data_no_serial() -> None: - t = SMPSerialTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialTransport(PORT) + await t.connect() m1 = EchoWriteResponse(r="SMP Message 1").to_frame(sequence=0) m2 = EchoWriteResponse(r="SMP Message 2").to_frame(sequence=1) @@ -239,8 +246,8 @@ async def test_only_smp_data_no_serial() -> None: @pytest.mark.asyncio async def test_serial_and_smp_data() -> None: - t = SMPSerialTransport() - await t.connect("/dev/ttyUSB0", timeout_s=1.0) + t = SMPSerialTransport(PORT) + await t.connect() m1 = EchoWriteResponse(r="SMP1").to_frame(sequence=0) m2 = EchoWriteResponse(r="SMP2").to_frame(sequence=1) @@ -283,7 +290,7 @@ async def test_serial_and_smp_data() -> None: @pytest.mark.asyncio async def test_not_connected_exception_handling() -> None: - t = SMPSerialTransport() + t = SMPSerialTransport(PORT) t._conn.is_open = False t._conn.read_all = MagicMock(side_effect=SerialException("Not connected")) # type: ignore @@ -293,7 +300,7 @@ async def test_not_connected_exception_handling() -> None: def test_initialize_with_auto() -> None: """Test that Auto mode updates parameters based on server's buffer size.""" - t = SMPSerialTransport() # Uses Auto() by default + t = SMPSerialTransport(PORT) # Uses Auto() by default # Before initialize, uses the conservative 7.1.0-equivalent 128 * 2 defaults assert t._line_length == 128 @@ -313,7 +320,9 @@ def test_initialize_with_auto() -> None: def test_initialize_with_buffer_params() -> None: """Test that BufferParams mode doesn't change user-specified parameters.""" - t = SMPSerialTransport(fragmentation_strategy=BufferParams(line_length=128, line_buffers=2)) + t = SMPSerialTransport( + PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=2) + ) # Before initialize assert t._line_length == 128 @@ -331,10 +340,11 @@ def test_initialize_with_buffer_params() -> None: def test_initialize_with_buffer_params_warning(caplog: pytest.LogCaptureFixture) -> None: """Test that a warning is logged when user's params exceed server buffer size.""" t = SMPSerialTransport( + PORT, fragmentation_strategy=BufferParams( line_length=128, line_buffers=4, # 128 * 4 = 512 - ) + ), ) with caplog.at_level(logging.WARNING): @@ -346,7 +356,7 @@ def test_initialize_with_buffer_params_warning(caplog: pytest.LogCaptureFixture) def test_buffer_size() -> None: """BufferSize fills the decoded reassembly buffer: max message == buf_size - 4.""" for buf_size in (96, 256, 384, 512, 1024, 2048): - t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size)) + t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) assert t.mtu == buf_size assert t._line_length == 128 assert t.max_unencoded_size == buf_size - FRAME_OVERHEAD @@ -354,9 +364,9 @@ def test_buffer_size() -> None: def test_buffer_size_matches_initialized_auto() -> None: """BufferSize(n) is equivalent to Auto initialized with buf_size n.""" - auto = SMPSerialTransport() + auto = SMPSerialTransport(PORT) auto.initialize(1024) - told = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=1024)) + told = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) assert told.max_unencoded_size == auto.max_unencoded_size == 1024 - FRAME_OVERHEAD assert told.mtu == auto.mtu == 1024 @@ -365,7 +375,7 @@ def test_buffer_size_matches_initialized_auto() -> None: def test_buffer_size_small_line_length() -> None: """A server with a sub-128 per-line buffer keeps the full decoded-buffer payload.""" - t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=384, line_length=64)) + t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=384, line_length=64)) assert t._line_length == 64 assert t.max_unencoded_size == 384 - FRAME_OVERHEAD @@ -373,15 +383,17 @@ def test_buffer_size_small_line_length() -> None: def test_line_buffers_never_misleading_zero() -> None: """Sub-line-length decoded buffers report >= 1 line buffer, never a misleading 0.""" # BufferSize with a buffer smaller than one line still reports at least one line buffer. - assert SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=96))._line_buffers == 1 + assert ( + SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=96))._line_buffers == 1 + ) # Auto initialized against a sub-line-length server buffer, likewise. - auto_small = SMPSerialTransport() + auto_small = SMPSerialTransport(PORT) auto_small.initialize(96) assert auto_small._line_buffers == 1 # A non-multiple server buffer floors to a sane count and still fills buf_size - overhead. - auto_400 = SMPSerialTransport() + auto_400 = SMPSerialTransport(PORT) auto_400.initialize(400) assert auto_400._line_buffers == 400 // 128 # 3 assert auto_400.max_unencoded_size == 400 - FRAME_OVERHEAD @@ -413,8 +425,8 @@ async def test_decoded_buffer_strategies_put_full_encoded_frame_on_the_wire() -> """ expected_encoded = {384: 527, 512: 702, 1024: 1404, 2048: 2801} for buf_size, encoded_size in expected_encoded.items(): - told = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size)) - auto = SMPSerialTransport() + told = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) + auto = SMPSerialTransport(PORT) auto.initialize(buf_size) for t in (told, auto): @@ -425,7 +437,7 @@ async def test_decoded_buffer_strategies_put_full_encoded_frame_on_the_wire() -> def test_initialize_with_buffer_size_warning(caplog: pytest.LogCaptureFixture) -> None: """A BufferSize larger than the server's advertised buffer warns; the manual size wins.""" - t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=1024)) + t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) with caplog.at_level(logging.WARNING): t.initialize(512) @@ -446,7 +458,7 @@ def test_fragmentation_strategy_alias() -> None: [ pytest.param( lambda: SMPSerialTransport( - max_smp_encoded_frame_size=512, line_length=128, line_buffers=4 + PORT, max_smp_encoded_frame_size=512, line_length=128, line_buffers=4 ), 512, 128, @@ -454,7 +466,7 @@ def test_fragmentation_strategy_alias() -> None: id="kw-frame-ll-lb", ), pytest.param( - lambda: SMPSerialTransport(line_length=64, line_buffers=4), + lambda: SMPSerialTransport(PORT, line_length=64, line_buffers=4), 256, # max_smp_encoded_frame_size defaults to the 7.1.0 256 64, 4, @@ -462,13 +474,13 @@ def test_fragmentation_strategy_alias() -> None: ), # 7.1.0 positional layout was (max_smp_encoded_frame_size, line_length, line_buffers), # with the line_length=128, line_buffers=2 defaults. - pytest.param(lambda: SMPSerialTransport(256), 256, 128, 2, id="pos-frame"), - pytest.param(lambda: SMPSerialTransport(256, 128, 2), 256, 128, 2, id="pos-triple"), + pytest.param(lambda: SMPSerialTransport(PORT, 256), 256, 128, 2, id="pos-frame"), + pytest.param(lambda: SMPSerialTransport(PORT, 256, 128, 2), 256, 128, 2, id="pos-triple"), # A frame size larger than line_length * line_buffers still drives mtu (as in 7.1.0), # rather than being silently downgraded to the 128 * 2 == 256 budget. - pytest.param(lambda: SMPSerialTransport(512), 512, 128, 2, id="pos-frame-gt-budget"), + pytest.param(lambda: SMPSerialTransport(PORT, 512), 512, 128, 2, id="pos-frame-gt-budget"), pytest.param( - lambda: SMPSerialTransport(max_smp_encoded_frame_size=1024), + lambda: SMPSerialTransport(PORT, max_smp_encoded_frame_size=1024), 1024, 128, 2, @@ -506,7 +518,7 @@ def test_deprecated_frame_size_matches_7_1_0_throughput( (which halved, or worse, the per-request payload for upgraders). """ with pytest.warns(DeprecationWarning): - t = SMPSerialTransport(max_smp_encoded_frame_size=frame_size) + t = SMPSerialTransport(PORT, max_smp_encoded_frame_size=frame_size) assert t.mtu == expected_mtu assert t.max_unencoded_size == expected_max_unencoded @@ -514,9 +526,11 @@ def test_deprecated_frame_size_matches_7_1_0_throughput( def test_deprecated_params_match_equivalent_buffer_params() -> None: """A *consistent* deprecated call (frame == line_length*line_buffers) equals its BufferParams.""" with pytest.warns(DeprecationWarning): - legacy = SMPSerialTransport(max_smp_encoded_frame_size=512, line_length=128, line_buffers=4) + legacy = SMPSerialTransport( + PORT, max_smp_encoded_frame_size=512, line_length=128, line_buffers=4 + ) modern = SMPSerialTransport( - fragmentation_strategy=BufferParams(line_length=128, line_buffers=4) + PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=4) ) assert legacy.mtu == modern.mtu # 512 == 128 * 4 @@ -532,13 +546,15 @@ def test_deprecated_frame_size_mismatch_is_logged(caplog: pytest.LogCaptureFixtu explicit max_smp_encoded_frame_size; this reproduces that, rather than downgrading mtu. """ with caplog.at_level(logging.WARNING), pytest.warns(DeprecationWarning): - t = SMPSerialTransport(max_smp_encoded_frame_size=512, line_length=128, line_buffers=2) + t = SMPSerialTransport( + PORT, max_smp_encoded_frame_size=512, line_length=128, line_buffers=2 + ) assert any("is not equal to" in record.message for record in caplog.records) assert t.mtu == 512 # the explicit frame size wins, as in 7.1.0 (not 128 * 2 == 256) caplog.clear() with caplog.at_level(logging.ERROR), pytest.warns(DeprecationWarning): - t = SMPSerialTransport(max_smp_encoded_frame_size=64, line_length=128, line_buffers=2) + t = SMPSerialTransport(PORT, max_smp_encoded_frame_size=64, line_length=128, line_buffers=2) assert any( record.levelno == logging.ERROR and "is less than" in record.message for record in caplog.records @@ -549,11 +565,13 @@ def test_deprecated_frame_size_mismatch_is_logged(caplog: pytest.LogCaptureFixtu @pytest.mark.parametrize( "make", [ - pytest.param(lambda: SMPSerialTransport(), id="auto-default"), - pytest.param(lambda: SMPSerialTransport(fragmentation_strategy=Auto()), id="auto-explicit"), - pytest.param(lambda: SMPSerialTransport(BufferSize(buf_size=1024)), id="buffersize"), + pytest.param(lambda: SMPSerialTransport(PORT), id="auto-default"), + pytest.param( + lambda: SMPSerialTransport(PORT, fragmentation_strategy=Auto()), id="auto-explicit" + ), + pytest.param(lambda: SMPSerialTransport(PORT, BufferSize(buf_size=1024)), id="buffersize"), pytest.param( - lambda: SMPSerialTransport(BufferParams(line_length=128, line_buffers=4)), + lambda: SMPSerialTransport(PORT, BufferParams(line_length=128, line_buffers=4)), id="bufferparams", ), ], @@ -590,7 +608,7 @@ def test_explicit_strategy_wins_over_stray_legacy_args(caplog: pytest.LogCapture def test_invalid_strategy_raises_value_error(strategy: FragmentationStrategy) -> None: """The modern API rejects sizes that would hang the encoder or yield a non-positive payload.""" with pytest.raises(ValueError): - SMPSerialTransport(fragmentation_strategy=strategy) + SMPSerialTransport(PORT, fragmentation_strategy=strategy) @pytest.mark.parametrize( @@ -605,12 +623,12 @@ def test_invalid_strategy_raises_value_error(strategy: FragmentationStrategy) -> ) def test_valid_strategy_does_not_raise(strategy: FragmentationStrategy) -> None: """Valid strategies construct and report a positive max_unencoded_size.""" - t = SMPSerialTransport(fragmentation_strategy=strategy) + t = SMPSerialTransport(PORT, fragmentation_strategy=strategy) assert t.max_unencoded_size > 0 def test_auto_rejects_tiny_server_buffer() -> None: """Auto raises if the server advertises a buffer too small to hold a framed message.""" - t = SMPSerialTransport() + t = SMPSerialTransport(PORT) with pytest.raises(ValueError, match="frame overhead"): t.initialize(FRAME_OVERHEAD) # buf_size == overhead -> zero-byte payload diff --git a/tests/test_smp_udp_transport.py b/tests/test_smp_udp_transport.py index 4f2a03c..0f77bb3 100644 --- a/tests/test_smp_udp_transport.py +++ b/tests/test_smp_udp_transport.py @@ -11,34 +11,51 @@ from smpclient.transport._udp_client import Addr, UDPClient from smpclient.transport.udp import IPV4_UDP_OVERHEAD, IPV6_UDP_OVERHEAD, SMPUDPTransport +pytestmark = pytest.mark.usefixtures("skip_negotiation") + +ADDRESS = "192.168.0.1" +"""An address; the UDP client is mocked or never connected.""" + def test_init() -> None: - t = SMPUDPTransport() + t = SMPUDPTransport(ADDRESS) assert t.mtu == 1500 assert isinstance(t._client, UDPClient) - t = SMPUDPTransport(mtu=512) + t = SMPUDPTransport(ADDRESS, mtu=512) assert t.mtu == 512 @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_connect(_: MagicMock) -> None: - t = SMPUDPTransport() + t = SMPUDPTransport("192.168.0.1", connect_timeout_s=0.001) t._client = cast(MagicMock, t._client) # type: ignore # Mock _transport for IPv4/IPv6 detection t._client._transport = MagicMock() t._client._transport.get_extra_info.return_value = None - await t.connect("192.168.0.1", 0.001) + await t.connect() t._client.connect.assert_awaited_once_with(Addr(host="192.168.0.1", port=1337)) +@patch("smpclient.transport.udp.UDPClient", autospec=True) +@pytest.mark.asyncio +async def test_connect_port(_: MagicMock) -> None: + t = SMPUDPTransport("192.168.0.1", 1338) + t._client = cast(MagicMock, t._client) # type: ignore + t._client._transport = MagicMock() + t._client._transport.get_extra_info.return_value = None + + await t.connect() + t._client.connect.assert_awaited_once_with(Addr(host="192.168.0.1", port=1338)) + + @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_disconnect(_: MagicMock) -> None: - t = SMPUDPTransport() + t = SMPUDPTransport(ADDRESS) t._client = cast(MagicMock, t._client) # type: ignore t._client._protocol = MagicMock() @@ -59,7 +76,7 @@ async def test_disconnect(_: MagicMock) -> None: @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_send(_: MagicMock) -> None: - t = SMPUDPTransport() + t = SMPUDPTransport(ADDRESS) t._client.send = cast(MagicMock, t._client.send) # type: ignore await t.send(b"hello") @@ -79,7 +96,7 @@ async def test_send(_: MagicMock) -> None: @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_receive(_: MagicMock) -> None: - t = SMPUDPTransport() + t = SMPUDPTransport(ADDRESS) t._client.receive = AsyncMock() # type: ignore message = bytes(EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0)) # type: ignore # noqa @@ -110,7 +127,7 @@ async def test_send_and_receive() -> None: patch("smpclient.transport.udp.SMPUDPTransport.send") as send_mock, patch("smpclient.transport.udp.SMPUDPTransport.receive") as receive_mock, ): - t = SMPUDPTransport() + t = SMPUDPTransport(ADDRESS) message: Final = b"hello" await t.send_and_receive(message) send_mock.assert_awaited_once_with(message) @@ -119,7 +136,7 @@ async def test_send_and_receive() -> None: def test_max_unencoded_size_ipv4() -> None: """Test MSS calculation for IPv4 (default).""" - t = SMPUDPTransport(mtu=1500) + t = SMPUDPTransport(ADDRESS, mtu=1500) # Before connection, defaults to IPv4 assert t.max_unencoded_size == 1500 - IPV4_UDP_OVERHEAD assert t.max_unencoded_size == 1472 @@ -127,7 +144,7 @@ def test_max_unencoded_size_ipv4() -> None: def test_max_unencoded_size_custom_mtu() -> None: """Test MSS calculation with custom MTU.""" - t = SMPUDPTransport(mtu=512) + t = SMPUDPTransport(ADDRESS, mtu=512) assert t.max_unencoded_size == 512 - IPV4_UDP_OVERHEAD assert t.max_unencoded_size == 484 @@ -138,7 +155,7 @@ def test_max_unencoded_size_custom_mtu() -> None: ) def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expected: int) -> None: """Zephyr copies each datagram into one `buf_size` buffer, so neither bound may be exceeded.""" - t = SMPUDPTransport(mtu=1500) + t = SMPUDPTransport(ADDRESS, mtu=1500) t.initialize(buf_size) assert t.max_unencoded_size == expected @@ -146,10 +163,10 @@ def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expected: int @pytest.mark.asyncio async def test_ipv4_detection_real_socket() -> None: """Test IPv4 auto-detection with real socket connection.""" - t = SMPUDPTransport(mtu=1500) + t = SMPUDPTransport("127.0.0.1", mtu=1500, connect_timeout_s=1.0) # Create a real UDP connection to localhost IPv4 - await t.connect("127.0.0.1", 1.0) + await t.connect() assert t._is_ipv6 is False assert t.max_unencoded_size == 1500 - IPV4_UDP_OVERHEAD @@ -161,10 +178,10 @@ async def test_ipv4_detection_real_socket() -> None: @pytest.mark.asyncio async def test_ipv6_detection_real_socket() -> None: """Test IPv6 auto-detection with real socket connection.""" - t = SMPUDPTransport(mtu=1500) + t = SMPUDPTransport("::1", mtu=1500, connect_timeout_s=1.0) # Create a real UDP connection to localhost IPv6 - await t.connect("::1", 1.0) + await t.connect() assert t._is_ipv6 is True assert t.max_unencoded_size == 1500 - IPV6_UDP_OVERHEAD From 78c58eee9464e943a2b2d98b79ffefd79de2b8d6 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:26:47 -0700 Subject: [PATCH 04/19] breaking(transport): per-transport fragmentation strategies; negotiate only when asked Each transport now declares how it sizes SMP messages with its own fragmentation strategy union, and reads the server's MCUmgr parameters only when that strategy asks for them. A pinned strategy never issues the read, so a server without the params command, like mcuboot serial recovery, never sees it. The shared vocabulary lives in `smpclient.transport`: - `Auto`: read `buf_size` while connecting. On a timeout or an error response, warn and fall back to the transport's conservative default. - `BufferSize(buf_size)`: a known server buffer; nothing is read. - `Unfragmented`: GATT only. Like `Auto`, but one message per write, for a server built without `CONFIG_MCUMGR_TRANSPORT_BT_REASSEMBLY`. The per-transport unions use the prefixed names: - `SerialFragmentationStrategy = Auto | BufferSize | BufferParams` (serial's own `BufferSize(buf_size, line_length)` and `BufferParams` are unchanged from main) - `RawSerialFragmentationStrategy = Auto | BufferSize` - `UDPFragmentationStrategy = Auto | BufferSize`, always capped at the MSS - `GATTFragmentationStrategy = Auto | Unfragmented | BufferSize`, shared by `SMPBLETransport` and `SMPBumbleTransport` through a `_GATTTransport` mixin `SMPTransport.initialize()` and `_smp_server_transport_buffer_size` are gone. Each transport's `negotiate()` matches its strategy exhaustively and stores `_negotiated_buf_size`; `max_unencoded_size` is derived from the strategy. Breaking: - `smpclient.transport.serial.FragmentationStrategy` is renamed `SerialFragmentationStrategy`. - `Auto` moves to `smpclient.transport`, since every transport uses it. - `SMPSerialRawTransport(port, mtu=384)` becomes `SMPSerialRawTransport(port, fragmentation_strategy=Auto())`; pin the old behavior with `BufferSize(384)`. Its `mtu` now reports `max_unencoded_size`, one whole message. - The serial "pinned size exceeds the server's buffer" warnings are removed: a pinned strategy no longer reads the parameters it would compare against. Tests: `tests/support.py` adds `advertise(buf_size)`, which patches the params read, and `negotiated(transport, buf_size)`. Each transport tests that a pinned strategy never reads, and how `Auto` and `Unfragmented` cap the size. The integration raw transport defaults to `Auto()`, so the suite exercises negotiation against the real fixtures (229 passed, 101 skipped, the same as before). Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/__init__.py | 88 ++++++++++++---- src/smpclient/transport/ble.py | 10 +- src/smpclient/transport/bumble/__init__.py | 10 +- src/smpclient/transport/serial/__init__.py | 8 +- src/smpclient/transport/serial/encoded.py | 109 +++++++------------- src/smpclient/transport/serial/unencoded.py | 51 +++++++-- src/smpclient/transport/udp.py | 46 +++++++-- tests/integration/servers.py | 16 ++- tests/integration/test_serial_recovery.py | 3 +- tests/support.py | 29 ++++++ tests/test_smp_ble_transport.py | 30 +++++- tests/test_smp_bumble_transport.py | 39 ++++++- tests/test_smp_client.py | 5 +- tests/test_smp_serial_raw_transport.py | 25 ++++- tests/test_smp_serial_transport.py | 92 +++++++---------- tests/test_smp_udp_transport.py | 20 +++- 16 files changed, 395 insertions(+), 186 deletions(-) create mode 100644 tests/support.py diff --git a/src/smpclient/transport/__init__.py b/src/smpclient/transport/__init__.py index e536bdc..816d0c6 100644 --- a/src/smpclient/transport/__init__.py +++ b/src/smpclient/transport/__init__.py @@ -5,10 +5,10 @@ import logging from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Final, Protocol +from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypeAlias from uuid import UUID -from typing_extensions import Self +from typing_extensions import Self, assert_never, override from smpclient import _request @@ -32,10 +32,30 @@ class SMPTransportDisconnected(Exception): """Raised when the SMP transport is disconnected.""" -class SMPTransport(Protocol): - _smp_server_transport_buffer_size: int | None = None - """The SMP server transport buffer size, in 8-bit bytes.""" +class Auto(NamedTuple): + """Size messages from the server's MCUmgr parameters, read while the transport connects.""" + + +class BufferSize(NamedTuple): + """Size messages from a known server buffer; the server's parameters are not read.""" + + buf_size: int + """The server's SMP reassembly buffer (`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`).""" + + +class Unfragmented(NamedTuple): + """Like `Auto`, but one SMP message per GATT write. + + For a server that does not reassemble a message split across writes (Zephyr without + `CONFIG_MCUMGR_TRANSPORT_BT_REASSEMBLY`). + """ + +GATTFragmentationStrategy: TypeAlias = Auto | Unfragmented | BufferSize +"""How a GATT transport (`ble`, `bumble`) sizes SMP messages.""" + + +class SMPTransport(Protocol): async def send(self, data: bytes) -> None: # pragma: no cover """Send the encoded `SMPRequest` `data`. @@ -63,14 +83,6 @@ async def send_and_receive(self, data: bytes) -> bytes: # pragma: no cover """ ... - def initialize(self, smp_server_transport_buffer_size: int) -> None: # pragma: no cover - """Initialize the `SMPTransport` with the server transport buffer size. - - Args: - smp_server_transport_buffer_size: The SMP server transport buffer size, in 8-bit bytes. - """ - self._smp_server_transport_buffer_size = smp_server_transport_buffer_size - @property def mtu(self) -> int: # pragma: no cover """The Maximum Transmission Unit (MTU) in 8-bit bytes.""" @@ -89,8 +101,7 @@ def max_unencoded_size(self) -> int: # pragma: no cover # an error in some write, then some of the writes that have already been # sent out are no longer valid. That is, the response to each # concurrent write needs to be tracked very carefully! - - return self._smp_server_transport_buffer_size or self.mtu + ... class _ConnectableTransport(SMPTransport, Protocol): @@ -106,6 +117,9 @@ class _ConnectableTransport(SMPTransport, Protocol): _connect_timeout_s: float """Bounds establishing the link, including reading the MCUmgr parameters.""" + _negotiated_buf_size: int | None = None + """The server's advertised `buf_size`, once a fragmentation strategy that asks has read it.""" + async def connect(self) -> None: # pragma: no cover """Open the link, then `negotiate()`.""" ... @@ -114,13 +128,16 @@ async def disconnect(self) -> None: # pragma: no cover """Close the link.""" ... - async def negotiate(self) -> None: - """Adopt the server's MCUmgr parameters, if it provides them.""" + async def negotiate(self) -> None: # pragma: no cover + """Adopt the server's MCUmgr parameters, if the fragmentation strategy asks for them.""" + ... + + async def _read_buf_size(self) -> int | None: + """The server's advertised `buf_size`, or `None` if it doesn't provide one.""" params: Final = await _request.read_mcumgr_parameters( self, next(self._sequence), self._connect_timeout_s ) - if params is not None: - self.initialize(params.buf_size) + return None if params is None else params.buf_size @asynccontextmanager async def connected(self) -> AsyncIterator[Self]: @@ -133,3 +150,36 @@ async def connected(self) -> AsyncIterator[Self]: await self.disconnect() except Exception as e: logger.warning(f"Error during disconnect: {e}") + + +class _GATTTransport(_ConnectableTransport): + """A `_ConnectableTransport` that writes SMP messages to a GATT characteristic.""" + + _fragmentation_strategy: GATTFragmentationStrategy + + @override + async def negotiate(self) -> None: + match self._fragmentation_strategy: + case Auto() | Unfragmented(): + self._negotiated_buf_size = await self._read_buf_size() + case BufferSize(): + pass + case _ as unreachable: + assert_never(unreachable) + + @property + @override + def max_unencoded_size(self) -> int: + match self._fragmentation_strategy: + case Auto(): + return self.mtu if self._negotiated_buf_size is None else self._negotiated_buf_size + case Unfragmented(): + return ( + self.mtu + if self._negotiated_buf_size is None + else min(self.mtu, self._negotiated_buf_size) + ) + case BufferSize(buf_size=buf_size): + return buf_size + case _ as unreachable: + assert_never(unreachable) diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index eeada26..c908724 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -28,8 +28,10 @@ from smpclient.transport import ( SMP_CHARACTERISTIC_UUID, SMP_SERVICE_UUID, + Auto, + GATTFragmentationStrategy, SMPTransportDisconnected, - _ConnectableTransport, + _GATTTransport, ) if TYPE_CHECKING: @@ -88,7 +90,7 @@ class SMPBLETransportNotSMPServer(SMPBLETransportException): _T = TypeVar("_T") -class SMPBLETransport(_ConnectableTransport): +class SMPBLETransport(_GATTTransport): """A Bluetooth Low Energy (BLE) SMPTransport.""" def __init__( @@ -96,6 +98,7 @@ def __init__( address: str, *, winrt: WinRTClientArgs = {}, + fragmentation_strategy: GATTFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, ) -> None: @@ -104,12 +107,15 @@ def __init__( Args: address: The device's MAC address, macOS UUID, or advertised name. winrt: WinRT backend arguments, e.g. `use_cached_services`. + fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or + `BufferSize`. connect_timeout_s: Bounds scanning and connecting, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; defaults to `wrapping_sequence()`. """ self._address: Final = address + self._fragmentation_strategy = fragmentation_strategy self._connect_timeout_s = connect_timeout_s self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._buffer = bytearray() diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index b04d7db..b69547d 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -35,8 +35,10 @@ from smpclient.transport import ( SMP_CHARACTERISTIC_UUID, SMP_SERVICE_UUID, + Auto, + GATTFragmentationStrategy, SMPTransportDisconnected, - _ConnectableTransport, + _GATTTransport, ) if TYPE_CHECKING: @@ -129,7 +131,7 @@ class ConnectedBorrowed(NamedTuple): _State: TypeAlias = Disconnected | Connecting | Connected | ConnectedBorrowed -class SMPBumbleTransport(_ConnectableTransport): +class SMPBumbleTransport(_GATTTransport): """An `SMPTransport` backed by Google's bumble Bluetooth stack.""" def __init__( @@ -144,6 +146,7 @@ def __init__( pair_on_connect: PairingDelegate | None = None, pair_timeout_s: float = DEFAULT_PAIR_TIMEOUT_S, settle_s: float = DEFAULT_POST_PAIR_SETTLE_S, + fragmentation_strategy: GATTFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, ) -> None: @@ -171,12 +174,15 @@ def __init__( `pair_on_connect` and `pair()`. settle_s: Wait between successful pair and proceeding (or disconnecting) so the peer can finalize bonding. + fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or + `BufferSize`. connect_timeout_s: Bounds scanning for a name, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; defaults to `wrapping_sequence()`. """ self._address: Final = address + self._fragmentation_strategy = fragmentation_strategy self._connect_timeout_s = connect_timeout_s self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._hci: Final = hci diff --git a/src/smpclient/transport/serial/__init__.py b/src/smpclient/transport/serial/__init__.py index eaa3cfc..cfeec5b 100644 --- a/src/smpclient/transport/serial/__init__.py +++ b/src/smpclient/transport/serial/__init__.py @@ -3,11 +3,15 @@ In addition to UART, these transports can be used with USB CDC ACM and CAN. """ -from smpclient.transport.serial.encoded import Auto as Auto from smpclient.transport.serial.encoded import BufferParams as BufferParams from smpclient.transport.serial.encoded import BufferSize as BufferSize -from smpclient.transport.serial.encoded import FragmentationStrategy as FragmentationStrategy +from smpclient.transport.serial.encoded import ( + SerialFragmentationStrategy as SerialFragmentationStrategy, +) from smpclient.transport.serial.encoded import SMPSerialTransport as SMPSerialTransport from smpclient.transport.serial.framing import SerialFraming as SerialFraming from smpclient.transport.serial.framing.cobs import Cobs as Cobs +from smpclient.transport.serial.unencoded import ( + RawSerialFragmentationStrategy as RawSerialFragmentationStrategy, +) from smpclient.transport.serial.unencoded import SMPSerialRawTransport as SMPSerialRawTransport diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index 2c0ccb3..db40b3e 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -15,7 +15,7 @@ `smpclient.transport.serial.unencoded`. The transport fills that decoded buffer for best throughput; how it learns the buffer -size is the `fragmentation_strategy` (`FragmentationStrategy`) -- see `Auto` (the +size is the `fragmentation_strategy` (`SerialFragmentationStrategy`) -- see `Auto` (the default), `BufferSize`, and `BufferParams`. """ @@ -32,6 +32,7 @@ from smp import packet as smppacket from typing_extensions import assert_never, deprecated, overload, override +from smpclient.transport import Auto from smpclient.transport.serial.common import _SerialTransportBase if TYPE_CHECKING: @@ -101,18 +102,6 @@ def _encoded_budget(mtu: int, line_buffers: int) -> int: """ -class Auto(NamedTuple): - """Discover the server's reassembly buffer from its MCUmgr params. - - On connect the client reads the server's `buf_size` - (`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`) -- the decoded SMP frame reassembly - buffer -- and sends messages up to `buf_size - 4` (the frame length and CRC16 - share that buffer), filling it for best throughput. Before the params are read, - or when the server does not support the params command, a single 128-byte line - buffer is assumed. - """ - - class BufferSize(NamedTuple): """Manually specify the server's decoded reassembly buffer size. @@ -151,8 +140,13 @@ class BufferParams(NamedTuple): """The number of encoded line buffers the budget spans.""" -FragmentationStrategy: TypeAlias = Auto | BufferSize | BufferParams -"""How `SMPSerialTransport` sizes SMP messages: `Auto`, `BufferSize`, or `BufferParams`.""" +SerialFragmentationStrategy: TypeAlias = Auto | BufferSize | BufferParams +"""How `SMPSerialTransport` sizes SMP messages: `Auto`, `BufferSize`, or `BufferParams`. + +With `Auto`, connecting reads the server's `buf_size` (the decoded reassembly buffer) and +the transport sends messages up to `buf_size - 4`, filling that buffer; until the parameters +are read, or if the server doesn't provide them, it assumes a conservative line budget. +""" class _LegacyParams(NamedTuple): @@ -162,7 +156,7 @@ class _LegacyParams(NamedTuple): byte-for-byte. Unlike `BufferParams`, `mtu` is the *explicit* `max_smp_encoded_frame_size` (independent of `line_length * line_buffers`, exactly as 7.1.0 stored it), while the per-line framing still spans `line_buffers`. Not part - of the public `FragmentationStrategy` API -- prefer `Auto`, `BufferSize`, or + of the public `SerialFragmentationStrategy` API -- prefer `Auto`, `BufferSize`, or `BufferParams`. """ @@ -197,7 +191,7 @@ class BufferState(IntEnum): def __init__( self, port: str, - fragmentation_strategy: FragmentationStrategy = ..., + fragmentation_strategy: SerialFragmentationStrategy = ..., *, connect_timeout_s: float = ..., sequence: Iterator[u8] | None = ..., @@ -272,7 +266,7 @@ def __init__( def __init__( # noqa: DOC301 self, port: str, - fragmentation_strategy: FragmentationStrategy | int | None = None, + fragmentation_strategy: SerialFragmentationStrategy | int | None = None, line_length: int | None = None, line_buffers: int | None = None, *, @@ -354,7 +348,7 @@ def __init__( # noqa: DOC301 @staticmethod def _resolve_fragmentation_strategy( - fragmentation_strategy: FragmentationStrategy | int | None, + fragmentation_strategy: SerialFragmentationStrategy | int | None, max_smp_encoded_frame_size: int | None, line_length: int | None, line_buffers: int | None, @@ -420,7 +414,7 @@ def _resolve_fragmentation_strategy( ) @staticmethod - def _validate_strategy(strategy: FragmentationStrategy) -> None: + def _validate_strategy(strategy: SerialFragmentationStrategy) -> None: """Raise `ValueError` for a modern strategy that cannot carry a message. Guards `BufferSize`/`BufferParams` against configs that would otherwise fail far @@ -428,7 +422,7 @@ def _validate_strategy(strategy: FragmentationStrategy) -> None: would emit empty packets forever), a `buf_size` at or below the frame overhead, or an encoded budget too small for a single byte. The deprecated 7.1.0 params are intentionally *not* validated -- `_LegacyParams` reproduces 7.1.0 behavior, latent - edge cases and all. `Auto` defers to `initialize`, where the server's advertised + edge cases and all. `Auto` defers to `negotiate`, where the server's advertised buffer size is known. """ match strategy: @@ -497,8 +491,8 @@ def _line_buffers(self) -> int: """ match self._fragmentation_strategy: case Auto(): - if self._smp_server_transport_buffer_size is not None: - return max(1, self._smp_server_transport_buffer_size // self._line_length) + if self._negotiated_buf_size is not None: + return max(1, self._negotiated_buf_size // self._line_length) return _LEGACY_LINE_BUFFERS case BufferSize(buf_size=buf_size): return max(1, buf_size // self._line_length) @@ -514,8 +508,8 @@ def _max_smp_encoded_frame_size(self) -> int: """The configured buffer size that the MTU reports.""" match self._fragmentation_strategy: case Auto(): - if self._smp_server_transport_buffer_size is not None: - return self._smp_server_transport_buffer_size + if self._negotiated_buf_size is not None: + return self._negotiated_buf_size return self._line_length * self._line_buffers case BufferSize(buf_size=buf_size): return buf_size @@ -527,50 +521,27 @@ def _max_smp_encoded_frame_size(self) -> int: assert_never(unreachable) @override - def initialize(self, smp_server_transport_buffer_size: int) -> None: - """Initialize with the server's buffer size from MCUMGR_PARAM. - - Args: - smp_server_transport_buffer_size: The server's CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE - - Raises: - ValueError: in `Auto` mode, if the server's advertised buffer is too small to - hold a framed message (`<= ` the frame overhead). - """ - super().initialize(smp_server_transport_buffer_size) - + async def negotiate(self) -> None: + """For `Auto`, adopt the server's `buf_size`; `ValueError` if it can't hold a frame.""" match self._fragmentation_strategy: case Auto(): - if smp_server_transport_buffer_size <= _FRAME_OVERHEAD: - raise ValueError( - f"server buffer size ({smp_server_transport_buffer_size}) must exceed " - f"the {_FRAME_OVERHEAD}-byte frame overhead to carry a message" - ) - logger.info( - f"Auto-configured from server buf_size={smp_server_transport_buffer_size}: " - f"mtu={self.mtu}, max_unencoded_size={self.max_unencoded_size}, " - f"line_length={self._line_length}" - ) - case BufferSize(buf_size=buf_size): - if buf_size > smp_server_transport_buffer_size: - logger.warning( - f"BufferSize buf_size ({buf_size}) exceeds the server's advertised " - f"buffer size ({smp_server_transport_buffer_size})" - ) - case BufferParams(line_length=line_length, line_buffers=line_buffers): - calculated_size = line_length * line_buffers - if calculated_size > smp_server_transport_buffer_size: - logger.warning( - f"BufferParams (line_length={line_length} * " - f"line_buffers={line_buffers} = {calculated_size}) " - f"exceeds server buffer size ({smp_server_transport_buffer_size})" - ) - case _LegacyParams(max_smp_encoded_frame_size=frame_size): - if frame_size > smp_server_transport_buffer_size: - logger.warning( - f"deprecated max_smp_encoded_frame_size ({frame_size}) exceeds the " - f"server's advertised buffer size ({smp_server_transport_buffer_size})" - ) + match await self._read_buf_size(): + case None: + pass + case buf_size if buf_size <= _FRAME_OVERHEAD: + raise ValueError( + f"server buffer size ({buf_size}) must exceed the " + f"{_FRAME_OVERHEAD}-byte frame overhead to carry a message" + ) + case buf_size: + self._negotiated_buf_size = buf_size + logger.info( + f"Auto-configured from server buf_size={buf_size}: " + f"mtu={self.mtu}, max_unencoded_size={self.max_unencoded_size}, " + f"line_length={self._line_length}" + ) + case BufferSize() | BufferParams() | _LegacyParams(): + pass case _ as unreachable: assert_never(unreachable) @@ -761,8 +732,8 @@ def max_unencoded_size(self) -> int: """ match self._fragmentation_strategy: case Auto(): - if self._smp_server_transport_buffer_size is not None: - return self._smp_server_transport_buffer_size - _FRAME_OVERHEAD + if self._negotiated_buf_size is not None: + return self._negotiated_buf_size - _FRAME_OVERHEAD return self._encoded_budget_max_unencoded_size() case BufferSize(buf_size=buf_size): return buf_size - _FRAME_OVERHEAD diff --git a/src/smpclient/transport/serial/unencoded.py b/src/smpclient/transport/serial/unencoded.py index c36ae12..98edb53 100644 --- a/src/smpclient/transport/serial/unencoded.py +++ b/src/smpclient/transport/serial/unencoded.py @@ -16,12 +16,13 @@ import asyncio import logging from collections.abc import Iterator -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, TypeAlias from smp import header as smphdr -from typing_extensions import override +from typing_extensions import assert_never, override from smpclient.exceptions import SMPClientException +from smpclient.transport import Auto, BufferSize from smpclient.transport.serial.common import _SerialTransportBase from smpclient.transport.serial.framing import SerialFraming @@ -31,11 +32,19 @@ logger = logging.getLogger(__name__) +_DEFAULT_BUF_SIZE: Final = 384 +"""The server buffer `Auto` assumes until it reads the MCUmgr parameters: Zephyr's default +`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`.""" + +RawSerialFragmentationStrategy: TypeAlias = Auto | BufferSize +"""How `SMPSerialRawTransport` sizes SMP messages: `Auto` or `BufferSize`.""" + + class SMPSerialRawTransport(_SerialTransportBase): def __init__( self, port: str, - mtu: int = 384, + fragmentation_strategy: RawSerialFragmentationStrategy = Auto(), *, framing: SerialFraming | None = None, connect_timeout_s: float = 2.5, @@ -56,10 +65,9 @@ def __init__( Args: port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. - mtu: The maximum size of one SMP message (header + payload), in - bytes. A serial link has no MTU of its own, but the SMP - server's receive buffer does -- this should match the server's - `CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE` (Zephyr default 384). + fragmentation_strategy: How to size one SMP message (header + payload): `Auto` + or `BufferSize`. A serial link has no MTU of its own, but the SMP server's + receive buffer (`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`) does. framing: optional wire framing for each SMP message (e.g. `Cobs()`); `None` sends the bare `[header][payload]`. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr @@ -97,7 +105,7 @@ def __init__( inter_byte_timeout=inter_byte_timeout, exclusive=exclusive, ) - self._mtu: Final = mtu + self._fragmentation_strategy: Final = fragmentation_strategy self._framing: Final = framing logger.debug(f"Initialized {self.__class__.__name__}") @@ -190,7 +198,32 @@ async def _poll_read_into(self, buf: bytearray) -> None: else: await asyncio.sleep(self._POLLING_INTERVAL_S) + @override + async def negotiate(self) -> None: + match self._fragmentation_strategy: + case Auto(): + self._negotiated_buf_size = await self._read_buf_size() + case BufferSize(): + pass + case _ as unreachable: + assert_never(unreachable) + @property @override def mtu(self) -> int: - return self._mtu + return self.max_unencoded_size + + @property + @override + def max_unencoded_size(self) -> int: + match self._fragmentation_strategy: + case Auto(): + return ( + _DEFAULT_BUF_SIZE + if self._negotiated_buf_size is None + else self._negotiated_buf_size + ) + case BufferSize(buf_size=buf_size): + return buf_size + case _ as unreachable: + assert_never(unreachable) diff --git a/src/smpclient/transport/udp.py b/src/smpclient/transport/udp.py index 67160fc..21455ce 100644 --- a/src/smpclient/transport/udp.py +++ b/src/smpclient/transport/udp.py @@ -6,14 +6,14 @@ import logging from collections.abc import Iterator from socket import AF_INET6 -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, TypeAlias from smp import header as smphdr -from typing_extensions import override +from typing_extensions import assert_never, override from smpclient import _request from smpclient.exceptions import SMPClientException -from smpclient.transport import _ConnectableTransport +from smpclient.transport import Auto, BufferSize, _ConnectableTransport from smpclient.transport._udp_client import Addr, UDPClient if TYPE_CHECKING: @@ -43,6 +43,14 @@ PMTU to avoid fragmentation.""" +UDPFragmentationStrategy: TypeAlias = Auto | BufferSize +"""How `SMPUDPTransport` sizes SMP messages: `Auto` or `BufferSize`. + +Either way a message never exceeds one datagram's payload (the MSS): the server receives +each request as a single datagram into a single buffer. +""" + + class SMPUDPTransport(_ConnectableTransport): def __init__( self, @@ -50,6 +58,7 @@ def __init__( port: int = 1337, *, mtu: int = 1500, + fragmentation_strategy: UDPFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, ) -> None: @@ -61,6 +70,7 @@ def __init__( mtu: The Maximum Transmission Unit (MTU) of the link layer in bytes. IP and UDP header overhead will be subtracted to calculate the maximum UDP payload size (MSS) to avoid fragmentation per RFC 8085 section 3.2. + fragmentation_strategy: How to size SMP messages: `Auto` or `BufferSize`. connect_timeout_s: Bounds connecting, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; @@ -71,6 +81,7 @@ def __init__( self._connect_timeout_s = connect_timeout_s self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._mtu = mtu + self._fragmentation_strategy: Final = fragmentation_strategy self._is_ipv6 = False self._client: Final = UDPClient() @@ -164,14 +175,33 @@ async def send_and_receive(self, data: bytes) -> bytes: def mtu(self) -> int: return self._mtu + @override + async def negotiate(self) -> None: + match self._fragmentation_strategy: + case Auto(): + self._negotiated_buf_size = await self._read_buf_size() + case BufferSize(): + pass + case _ as unreachable: + assert_never(unreachable) + @property @override def max_unencoded_size(self) -> int: - """Maximum UDP payload size (MSS) to avoid fragmentation. + """Maximum UDP payload size (MSS) to avoid fragmentation, capped at the server's buffer. Subtracts IPv4/IPv6 and UDP header overhead from MTU per RFC 8085 section 3.2. - The IP version is auto-detected after connection. Once the server's MCUmgr - parameters are known, the payload is also capped at its advertised buffer. + The IP version is auto-detected after connection. """ - overhead = IPV6_UDP_OVERHEAD if self._is_ipv6 else IPV4_UDP_OVERHEAD - return min(self._mtu - overhead, self._smp_server_transport_buffer_size or self._mtu) + mss: Final = self._mtu - (IPV6_UDP_OVERHEAD if self._is_ipv6 else IPV4_UDP_OVERHEAD) + match self._fragmentation_strategy: + case Auto(): + return ( + mss + if self._negotiated_buf_size is None + else min(mss, self._negotiated_buf_size) + ) + case BufferSize(buf_size=buf_size): + return min(mss, buf_size) + case _ as unreachable: + assert_never(unreachable) diff --git a/tests/integration/servers.py b/tests/integration/servers.py index a45e241..debdc76 100644 --- a/tests/integration/servers.py +++ b/tests/integration/servers.py @@ -39,9 +39,10 @@ from serial.urlhandler.protocol_socket import Serial as _SocketSerial from typing_extensions import override -from smpclient.transport import SMPTransportDisconnected +from smpclient.transport import Auto, SMPTransportDisconnected from smpclient.transport.serial import ( - FragmentationStrategy, + RawSerialFragmentationStrategy, + SerialFragmentationStrategy, SerialFraming, SMPSerialRawTransport, SMPSerialTransport, @@ -336,7 +337,7 @@ class QemuSocketSerialTransport(SMPSerialTransport): def __init__( # noqa: DOC301 self, url: str, - fragmentation_strategy: FragmentationStrategy | None = None, + fragmentation_strategy: SerialFragmentationStrategy | None = None, ) -> None: if fragmentation_strategy is None: super().__init__(url) @@ -357,8 +358,13 @@ class QemuSocketSerialRawTransport(SMPSerialRawTransport): `SMPSerialRawTransport` unchanged. """ - def __init__(self, url: str, mtu: int = 384, framing: SerialFraming | None = None) -> None: # noqa: DOC301 - super().__init__(url, mtu=mtu, framing=framing) + def __init__( # noqa: DOC301 + self, + url: str, + fragmentation_strategy: RawSerialFragmentationStrategy = Auto(), + framing: SerialFraming | None = None, + ) -> None: + super().__init__(url, fragmentation_strategy, framing=framing) self._url: Final = url @override diff --git a/tests/integration/test_serial_recovery.py b/tests/integration/test_serial_recovery.py index 4188e42..ebcbf41 100644 --- a/tests/integration/test_serial_recovery.py +++ b/tests/integration/test_serial_recovery.py @@ -33,7 +33,8 @@ from typing_extensions import assert_never from smpclient import success -from smpclient.transport.serial import Auto, BufferSize, Cobs, SMPSerialTransport +from smpclient.transport import Auto +from smpclient.transport.serial import BufferSize, Cobs, SMPSerialTransport from smpclient.transport.serial.encoded import _FRAME_OVERHEAD from tests.integration.conftest import ( RECOVERY_UPLOAD_TIMEOUT_S, diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..16397df --- /dev/null +++ b/tests/support.py @@ -0,0 +1,29 @@ +"""Helpers shared by the transport tests.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import TypeVar +from unittest.mock import AsyncMock, patch + +from smp.os_management import MCUMgrParametersReadResponse + +from smpclient.transport import _ConnectableTransport + +_T = TypeVar("_T", bound=_ConnectableTransport) + + +@contextmanager +def advertise(buf_size: int) -> Iterator[AsyncMock]: + """Answer every transport's MCUmgr parameters read as a server whose buffer is `buf_size`.""" + with patch( + "smpclient._request.read_mcumgr_parameters", + AsyncMock(return_value=MCUMgrParametersReadResponse(buf_size=buf_size, buf_count=1)), + ) as read: + yield read + + +async def negotiated(transport: _T, buf_size: int) -> _T: + """`transport`, after negotiating against a server whose buffer is `buf_size`.""" + with advertise(buf_size): + await transport.negotiate() + return transport diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index 2eb6097..4ebb451 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -11,7 +11,7 @@ from bleak.backends.device import BLEDevice from smp.os_management import EchoWriteResponse -from smpclient.transport import SMPTransportDisconnected +from smpclient.transport import BufferSize, SMPTransportDisconnected, Unfragmented from smpclient.transport.ble import ( MAC_ADDRESS_PATTERN, SMP_CHARACTERISTIC_UUID, @@ -20,6 +20,7 @@ SMPBLETransport, SMPBLETransportDeviceNotFound, ) +from tests.support import advertise, negotiated class MockBleakClient: @@ -214,11 +215,32 @@ def test_max_unencoded_size() -> None: assert t.max_unencoded_size == 42 -def test_max_unencoded_size_mcumgr_param() -> None: +@pytest.mark.asyncio +async def test_max_unencoded_size_mcumgr_param() -> None: t = SMPBLETransport(ADDRESS) t._client = MagicMock(spec=BleakClient) - t._smp_server_transport_buffer_size = 9001 - assert t.max_unencoded_size == 9001 + t._max_write_without_response_size = 42 + assert (await negotiated(t, 9001)).max_unencoded_size == 9001 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("buf_size, expected", [(9001, 42), (30, 30)]) +async def test_unfragmented_caps_at_the_write_size(buf_size: int, expected: int) -> None: + """One message per write: never more than one write, nor more than the server holds.""" + t = SMPBLETransport(ADDRESS, fragmentation_strategy=Unfragmented()) + t._client = MagicMock(spec=BleakClient) + t._max_write_without_response_size = 42 + assert (await negotiated(t, buf_size)).max_unencoded_size == expected + + +@pytest.mark.asyncio +async def test_buffer_size_never_reads() -> None: + t = SMPBLETransport(ADDRESS, fragmentation_strategy=BufferSize(512)) + t._client = MagicMock(spec=BleakClient) + with advertise(9001) as read: + await t.negotiate() + read.assert_not_awaited() + assert t.max_unencoded_size == 512 class _HangingBleakClient: diff --git a/tests/test_smp_bumble_transport.py b/tests/test_smp_bumble_transport.py index a7e1e6f..354394e 100644 --- a/tests/test_smp_bumble_transport.py +++ b/tests/test_smp_bumble_transport.py @@ -11,7 +11,13 @@ import pytest -from smpclient.transport import SMPTransportDisconnected +from smpclient.transport import ( + Auto, + BufferSize, + GATTFragmentationStrategy, + SMPTransportDisconnected, + Unfragmented, +) from smpclient.transport.bumble import ( ATT_WRITE_OVERHEAD, SMP_CHARACTERISTIC_UUID, @@ -46,6 +52,7 @@ PairingSucceeded, PairingTimedOut, ) +from tests.support import advertise, negotiated pytestmark = pytest.mark.usefixtures("skip_negotiation") @@ -125,8 +132,10 @@ async def test_connect_while_connected_raises() -> None: await t.connect() -def _make_connected(max_write: int = 244) -> tuple[SMPBumbleTransport, MagicMock]: - t = SMPBumbleTransport(ADDRESS) +def _make_connected( + max_write: int = 244, fragmentation_strategy: GATTFragmentationStrategy = Auto() +) -> tuple[SMPBumbleTransport, MagicMock]: + t = SMPBumbleTransport(ADDRESS, fragmentation_strategy=fragmentation_strategy) smp_char = MagicMock() smp_char.write_value = AsyncMock() t._state = Connected( @@ -141,6 +150,30 @@ def _make_connected(max_write: int = 244) -> tuple[SMPBumbleTransport, MagicMock return t, smp_char +@pytest.mark.asyncio +async def test_auto_adopts_the_server_buffer() -> None: + t, _ = _make_connected(max_write=244) + assert t.max_unencoded_size == 244 + assert (await negotiated(t, 2048)).max_unencoded_size == 2048 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("buf_size, expected", [(2048, 244), (128, 128)]) +async def test_unfragmented_caps_at_the_write_size(buf_size: int, expected: int) -> None: + """One message per write: never more than one write, nor more than the server holds.""" + t, _ = _make_connected(max_write=244, fragmentation_strategy=Unfragmented()) + assert (await negotiated(t, buf_size)).max_unencoded_size == expected + + +@pytest.mark.asyncio +async def test_buffer_size_never_reads() -> None: + t, _ = _make_connected(max_write=244, fragmentation_strategy=BufferSize(512)) + with advertise(2048) as read: + await t.negotiate() + read.assert_not_awaited() + assert t.max_unencoded_size == 512 + + @pytest.mark.asyncio async def test_send_chunks_data_to_max_write() -> None: t, smp_char = _make_connected(max_write=4) diff --git a/tests/test_smp_client.py b/tests/test_smp_client.py index 8e76cfb..5a4ef98 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -39,6 +39,7 @@ ) from smpclient import SMPClient, error, error_v1, error_v2, success, wrapping_sequence +from smpclient import transport as smptransport from smpclient.exceptions import SMPBadSequence, SMPUploadError, SMPValidationException from smpclient.transport.serial import ( BufferParams, @@ -84,8 +85,6 @@ class SMPMockTransport: def __init__(self) -> None: self.send = AsyncMock() self.receive = AsyncMock() - self._smp_server_transport_buffer_size: int | None = None - self.initialize = AsyncMock() self._mtu = 0 self._max_unencoded_size = 0 self.sequence_offset = 0 @@ -458,7 +457,7 @@ async def test_upload_hello_world_bin_raw(mtu: int) -> None: ) as f: image = f.read() - m = SMPSerialRawTransport(PORT, mtu=mtu) + m = SMPSerialRawTransport(PORT, fragmentation_strategy=smptransport.BufferSize(mtu)) s = SMPClient(m) assert s._transport.mtu == mtu assert s._transport.max_unencoded_size == mtu, "The raw transport has no encoding overhead" diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index 671db25..9691447 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -14,9 +14,10 @@ from smp.packet import CRC16_STRUCT, crc16_func from smpclient.exceptions import SMPClientException -from smpclient.transport import SMPTransportDisconnected +from smpclient.transport import BufferSize, SMPTransportDisconnected from smpclient.transport.serial import Cobs, SMPSerialRawTransport from smpclient.transport.serial.framing.cobs import cobs_encode +from tests.support import advertise, negotiated pytestmark = pytest.mark.usefixtures("skip_negotiation") @@ -31,7 +32,7 @@ def mock_serial() -> Generator[None, Any, None]: def test_constructor() -> None: - t = SMPSerialRawTransport(PORT, mtu=512) + t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(512)) assert t.mtu == 512 assert t.max_unencoded_size == 512 @@ -41,6 +42,22 @@ def test_constructor_defaults() -> None: assert t.mtu == 384 +@pytest.mark.asyncio +async def test_negotiate_with_auto() -> None: + """`Auto` adopts the server's buffer: the whole message rides in it, with no framing.""" + t = await negotiated(SMPSerialRawTransport(PORT), 1024) + assert t.mtu == t.max_unencoded_size == 1024 + + +@pytest.mark.asyncio +async def test_negotiate_never_reads_for_buffer_size() -> None: + t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(512)) + with advertise(1024) as read: + await t.negotiate() + read.assert_not_awaited() + assert t.max_unencoded_size == 512 + + @pytest.mark.asyncio async def test_connect_disconnect() -> None: ports: list[str] = ["COM2", "/dev/ttyACM0", "/dev/ttyUSB0"] @@ -97,7 +114,7 @@ async def test_send_waits_for_tx_drain() -> None: @pytest.mark.asyncio async def test_send_too_large_raises() -> None: - t = SMPSerialRawTransport(PORT, mtu=16) + t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(16)) with pytest.raises(ValueError): await t.send(b"\x00" * 32) @@ -220,7 +237,7 @@ async def test_receive_oversized_header_raises() -> None: Defensive bound against noisy or corrupted UART traffic that would otherwise cause an unbounded wait. """ - t = SMPSerialRawTransport(PORT, mtu=64) + t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(64)) await t.connect() bogus_header = smphdr.Header( diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 21cc230..6eac294 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -14,14 +14,14 @@ from smp import packet as smppacket from smp.os_management import EchoWriteRequest, EchoWriteResponse -from smpclient.transport import SMPTransportDisconnected +from smpclient.transport import Auto, SMPTransportDisconnected from smpclient.transport.serial import ( - Auto, BufferParams, BufferSize, - FragmentationStrategy, + SerialFragmentationStrategy, SMPSerialTransport, ) +from tests.support import advertise, negotiated pytestmark = pytest.mark.usefixtures("skip_negotiation") @@ -298,17 +298,18 @@ async def test_not_connected_exception_handling() -> None: await t.receive() -def test_initialize_with_auto() -> None: +@pytest.mark.asyncio +async def test_negotiate_with_auto() -> None: """Test that Auto mode updates parameters based on server's buffer size.""" t = SMPSerialTransport(PORT) # Uses Auto() by default - # Before initialize, uses the conservative 7.1.0-equivalent 128 * 2 defaults + # Before negotiating, uses the conservative 7.1.0-equivalent 128 * 2 defaults assert t._line_length == 128 assert t._line_buffers == 2 assert t._max_smp_encoded_frame_size == 256 - # After initialize with server buffer size - t.initialize(512) + # After negotiating against the server buffer size + await negotiated(t, 512) assert t._line_length == 128 assert t._line_buffers == 512 // 128 # 4 assert t._max_smp_encoded_frame_size == 512 @@ -318,39 +319,36 @@ def test_initialize_with_auto() -> None: assert t.max_unencoded_size == 512 - FRAME_OVERHEAD -def test_initialize_with_buffer_params() -> None: +@pytest.mark.asyncio +async def test_negotiate_with_buffer_params() -> None: """Test that BufferParams mode doesn't change user-specified parameters.""" t = SMPSerialTransport( PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=2) ) - # Before initialize + # Before negotiating assert t._line_length == 128 assert t._line_buffers == 2 assert t._max_smp_encoded_frame_size == 256 # 128 * 2 - # After initialize - parameters should NOT change - t.initialize(512) + # After negotiating - parameters should NOT change + await negotiated(t, 512) assert t._line_length == 128 assert t._line_buffers == 2 assert t._max_smp_encoded_frame_size == 256 assert t.mtu == 256 -def test_initialize_with_buffer_params_warning(caplog: pytest.LogCaptureFixture) -> None: - """Test that a warning is logged when user's params exceed server buffer size.""" - t = SMPSerialTransport( - PORT, - fragmentation_strategy=BufferParams( - line_length=128, - line_buffers=4, # 128 * 4 = 512 - ), - ) - - with caplog.at_level(logging.WARNING): - t.initialize(256) # Server buffer (256) is smaller than calculated size (512) - - assert any("exceeds server buffer size" in record.message for record in caplog.records) +@pytest.mark.asyncio +async def test_negotiate_never_reads_for_pinned_strategies() -> None: + """A pinned strategy knows its size, so negotiating never reads the server's params.""" + for strategy in (BufferParams(line_length=128, line_buffers=4), BufferSize(buf_size=1024)): + t = SMPSerialTransport(PORT, fragmentation_strategy=strategy) + max_unencoded_size = t.max_unencoded_size + with advertise(256) as read: + await t.negotiate() + read.assert_not_awaited() + assert t.max_unencoded_size == max_unencoded_size def test_buffer_size() -> None: @@ -362,10 +360,11 @@ def test_buffer_size() -> None: assert t.max_unencoded_size == buf_size - FRAME_OVERHEAD -def test_buffer_size_matches_initialized_auto() -> None: - """BufferSize(n) is equivalent to Auto initialized with buf_size n.""" +@pytest.mark.asyncio +async def test_buffer_size_matches_negotiated_auto() -> None: + """BufferSize(n) is equivalent to Auto negotiated against buf_size n.""" auto = SMPSerialTransport(PORT) - auto.initialize(1024) + await negotiated(auto, 1024) told = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) assert told.max_unencoded_size == auto.max_unencoded_size == 1024 - FRAME_OVERHEAD @@ -380,21 +379,22 @@ def test_buffer_size_small_line_length() -> None: assert t.max_unencoded_size == 384 - FRAME_OVERHEAD -def test_line_buffers_never_misleading_zero() -> None: +@pytest.mark.asyncio +async def test_line_buffers_never_misleading_zero() -> None: """Sub-line-length decoded buffers report >= 1 line buffer, never a misleading 0.""" # BufferSize with a buffer smaller than one line still reports at least one line buffer. assert ( SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=96))._line_buffers == 1 ) - # Auto initialized against a sub-line-length server buffer, likewise. + # Auto negotiated against a sub-line-length server buffer, likewise. auto_small = SMPSerialTransport(PORT) - auto_small.initialize(96) + await negotiated(auto_small, 96) assert auto_small._line_buffers == 1 # A non-multiple server buffer floors to a sane count and still fills buf_size - overhead. auto_400 = SMPSerialTransport(PORT) - auto_400.initialize(400) + await negotiated(auto_400, 400) assert auto_400._line_buffers == 400 // 128 # 3 assert auto_400.max_unencoded_size == 400 - FRAME_OVERHEAD @@ -427,7 +427,7 @@ async def test_decoded_buffer_strategies_put_full_encoded_frame_on_the_wire() -> for buf_size, encoded_size in expected_encoded.items(): told = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) auto = SMPSerialTransport(PORT) - auto.initialize(buf_size) + await negotiated(auto, buf_size) for t in (told, auto): on_wire = await _frame_on_the_wire(t, b"\x5a" * t.max_unencoded_size) @@ -435,22 +435,9 @@ async def test_decoded_buffer_strategies_put_full_encoded_frame_on_the_wire() -> assert len(on_wire) > buf_size # more encoded bytes on the wire than the buffer holds -def test_initialize_with_buffer_size_warning(caplog: pytest.LogCaptureFixture) -> None: - """A BufferSize larger than the server's advertised buffer warns; the manual size wins.""" - t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) - - with caplog.at_level(logging.WARNING): - t.initialize(512) - - assert any( - "exceeds the server's advertised buffer size" in record.message for record in caplog.records - ) - assert t.max_unencoded_size == 1024 - FRAME_OVERHEAD - - def test_fragmentation_strategy_alias() -> None: - """`FragmentationStrategy` is the union of the three strategy types.""" - assert set(get_args(FragmentationStrategy)) == {Auto, BufferSize, BufferParams} + """`SerialFragmentationStrategy` is the union of the three strategy types.""" + assert set(get_args(SerialFragmentationStrategy)) == {Auto, BufferSize, BufferParams} @pytest.mark.parametrize( @@ -605,7 +592,7 @@ def test_explicit_strategy_wins_over_stray_legacy_args(caplog: pytest.LogCapture pytest.param(BufferParams(line_length=128, line_buffers=0), id="bufferparams-zero-buffers"), ], ) -def test_invalid_strategy_raises_value_error(strategy: FragmentationStrategy) -> None: +def test_invalid_strategy_raises_value_error(strategy: SerialFragmentationStrategy) -> None: """The modern API rejects sizes that would hang the encoder or yield a non-positive payload.""" with pytest.raises(ValueError): SMPSerialTransport(PORT, fragmentation_strategy=strategy) @@ -621,14 +608,15 @@ def test_invalid_strategy_raises_value_error(strategy: FragmentationStrategy) -> pytest.param(BufferParams(line_length=128, line_buffers=1), id="bufferparams"), ], ) -def test_valid_strategy_does_not_raise(strategy: FragmentationStrategy) -> None: +def test_valid_strategy_does_not_raise(strategy: SerialFragmentationStrategy) -> None: """Valid strategies construct and report a positive max_unencoded_size.""" t = SMPSerialTransport(PORT, fragmentation_strategy=strategy) assert t.max_unencoded_size > 0 -def test_auto_rejects_tiny_server_buffer() -> None: +@pytest.mark.asyncio +async def test_auto_rejects_tiny_server_buffer() -> None: """Auto raises if the server advertises a buffer too small to hold a framed message.""" t = SMPSerialTransport(PORT) with pytest.raises(ValueError, match="frame overhead"): - t.initialize(FRAME_OVERHEAD) # buf_size == overhead -> zero-byte payload + await negotiated(t, FRAME_OVERHEAD) # buf_size == overhead -> zero-byte payload diff --git a/tests/test_smp_udp_transport.py b/tests/test_smp_udp_transport.py index 0f77bb3..6005dc2 100644 --- a/tests/test_smp_udp_transport.py +++ b/tests/test_smp_udp_transport.py @@ -8,8 +8,10 @@ from smp.os_management import EchoWriteResponse from smpclient.exceptions import SMPClientException +from smpclient.transport import BufferSize from smpclient.transport._udp_client import Addr, UDPClient from smpclient.transport.udp import IPV4_UDP_OVERHEAD, IPV6_UDP_OVERHEAD, SMPUDPTransport +from tests.support import advertise, negotiated pytestmark = pytest.mark.usefixtures("skip_negotiation") @@ -153,10 +155,22 @@ def test_max_unencoded_size_custom_mtu() -> None: "buf_size, expected", [(384, 384), (1472, 1472), (2048, 1472)], ) -def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expected: int) -> None: +@pytest.mark.asyncio +async def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expected: int) -> None: """Zephyr copies each datagram into one `buf_size` buffer, so neither bound may be exceeded.""" - t = SMPUDPTransport(ADDRESS, mtu=1500) - t.initialize(buf_size) + t = await negotiated(SMPUDPTransport(ADDRESS, mtu=1500), buf_size) + assert t.max_unencoded_size == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("buf_size, expected", [(384, 384), (1472, 1472), (2048, 1472)]) +async def test_buffer_size_is_capped_by_the_mss_and_never_reads( + buf_size: int, expected: int +) -> None: + t = SMPUDPTransport(ADDRESS, mtu=1500, fragmentation_strategy=BufferSize(buf_size)) + with advertise(4096) as read: + await t.negotiate() + read.assert_not_awaited() assert t.max_unencoded_size == expected From 9d00fd4100e0ff2ba8a0ba86c51b18a4bac7e2ec Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:33:53 -0700 Subject: [PATCH 05/19] breaking(serial): pyserial settings move into SerialOptions The eleven pyserial keyword arguments are replaced by one `options: SerialOptions = SerialOptions()` on `SMPSerialTransport` (all three constructor overloads and the implementation) and on `SMPSerialRawTransport`. The settings are declared once, in `smpclient.transport.serial.common`, and exported from `smpclient.transport.serial`; they are no longer repeated across the four encoded signatures, the raw signature, and the base. SMPSerialTransport(port, baudrate=9600) SMPSerialTransport(port, options=SerialOptions(baudrate=9600)) `test_serial_options_lock_pyserial` locks the field names, their order, and their defaults to `inspect.signature(serial.Serial)`. The one deliberate difference is `baudrate`: 115200 here, 9600 in pyserial. pyserial is effectively unmaintained, so drift isn't expected, but the test fails loudly if it happens. A renamed field and a changed default were each confirmed to fail the test. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/serial/__init__.py | 1 + src/smpclient/transport/serial/common.py | 81 +++++++++++---------- src/smpclient/transport/serial/encoded.py | 75 ++----------------- src/smpclient/transport/serial/unencoded.py | 41 +---------- tests/test_smp_serial_transport.py | 25 ++++++- 5 files changed, 77 insertions(+), 146 deletions(-) diff --git a/src/smpclient/transport/serial/__init__.py b/src/smpclient/transport/serial/__init__.py index cfeec5b..6341d0b 100644 --- a/src/smpclient/transport/serial/__init__.py +++ b/src/smpclient/transport/serial/__init__.py @@ -3,6 +3,7 @@ In addition to UART, these transports can be used with USB CDC ACM and CAN. """ +from smpclient.transport.serial.common import SerialOptions as SerialOptions from smpclient.transport.serial.encoded import BufferParams as BufferParams from smpclient.transport.serial.encoded import BufferSize as BufferSize from smpclient.transport.serial.encoded import ( diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index 96f7288..90f354f 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -7,7 +7,7 @@ from collections.abc import Iterator from contextlib import contextmanager from time import monotonic -from typing import TYPE_CHECKING, Final, Generator, final +from typing import TYPE_CHECKING, Final, Generator, NamedTuple, final try: from serial import Serial, SerialException @@ -28,6 +28,44 @@ logger = logging.getLogger(__name__) +class SerialOptions(NamedTuple): + """The `pyserial` port settings, named as `serial.Serial` names them.""" + + baudrate: int = 115200 + """The baudrate of the serial connection. OK to ignore for USB CDC ACM.""" + + bytesize: int = 8 + """The number of data bits.""" + + parity: str = "N" + """The parity setting.""" + + stopbits: float = 1 + """The number of stop bits.""" + + timeout: float | None = None + """The read timeout.""" + + xonxoff: bool = False + """Enable software flow control.""" + + rtscts: bool = False + """Enable hardware (RTS/CTS) flow control.""" + + write_timeout: float | None = None + """The write timeout.""" + + dsrdtr: bool = False + """Enable hardware (DSR/DTR) flow control.""" + + inter_byte_timeout: float | None = None + """The inter-byte timeout.""" + + exclusive: bool | None = None + """Set exclusive access mode (POSIX only). A port cannot be opened in exclusive access + mode if it is already open in exclusive access mode.""" + + class _SerialTransportBase(_ConnectableTransport): """Connection-management base class for serial-port-backed SMP transports. @@ -49,17 +87,7 @@ def __init__( port: str, connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, - baudrate: int = 115200, - bytesize: int = 8, - parity: str = "N", - stopbits: float = 1, - timeout: float | None = None, - xonxoff: bool = False, - rtscts: bool = False, - write_timeout: float | None = None, - dsrdtr: bool = False, - inter_byte_timeout: float | None = None, - exclusive: bool | None = None, + options: SerialOptions = SerialOptions(), ) -> None: """Initialize the underlying `pyserial` `Serial` instance. @@ -69,37 +97,12 @@ def __init__( parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; defaults to `wrapping_sequence()`. - baudrate: The baudrate of the serial connection. OK to ignore for - USB CDC ACM. - bytesize: The number of data bits. - parity: The parity setting. - stopbits: The number of stop bits. - timeout: The read timeout. - xonxoff: Enable software flow control. - rtscts: Enable hardware (RTS/CTS) flow control. - write_timeout: The write timeout. - dsrdtr: Enable hardware (DSR/DTR) flow control. - inter_byte_timeout: The inter-byte timeout. - exclusive: Set exclusive access mode (POSIX only). A port cannot be - opened in exclusive access mode if it is already open in - exclusive access mode. + options: The `pyserial` port settings. """ self._port: Final = port self._connect_timeout_s = connect_timeout_s self._sequence = _request.wrapping_sequence() if sequence is None else sequence - self._conn: Final = Serial( - baudrate=baudrate, - bytesize=bytesize, - parity=parity, - stopbits=stopbits, - timeout=timeout, - xonxoff=xonxoff, - rtscts=rtscts, - write_timeout=write_timeout, - dsrdtr=dsrdtr, - inter_byte_timeout=inter_byte_timeout, - exclusive=exclusive, - ) + self._conn: Final = Serial(**options._asdict()) def _reset_state(self) -> None: """Reset any per-connection state. Subclasses override as needed.""" diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index db40b3e..f9802a8 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -33,7 +33,7 @@ from typing_extensions import assert_never, deprecated, overload, override from smpclient.transport import Auto -from smpclient.transport.serial.common import _SerialTransportBase +from smpclient.transport.serial.common import SerialOptions, _SerialTransportBase if TYPE_CHECKING: from types_bits import u8 @@ -195,17 +195,7 @@ def __init__( *, connect_timeout_s: float = ..., sequence: Iterator[u8] | None = ..., - baudrate: int = ..., - bytesize: int = ..., - parity: str = ..., - stopbits: float = ..., - timeout: float | None = ..., - xonxoff: bool = ..., - rtscts: bool = ..., - write_timeout: float | None = ..., - dsrdtr: bool = ..., - inter_byte_timeout: float | None = ..., - exclusive: bool | None = ..., + options: SerialOptions = ..., ) -> None: ... @overload @@ -222,17 +212,7 @@ def __init__( line_buffers: int = ..., connect_timeout_s: float = ..., sequence: Iterator[u8] | None = ..., - baudrate: int = ..., - bytesize: int = ..., - parity: str = ..., - stopbits: float = ..., - timeout: float | None = ..., - xonxoff: bool = ..., - rtscts: bool = ..., - write_timeout: float | None = ..., - dsrdtr: bool = ..., - inter_byte_timeout: float | None = ..., - exclusive: bool | None = ..., + options: SerialOptions = ..., ) -> None: ... @overload @@ -250,17 +230,7 @@ def __init__( *, connect_timeout_s: float = ..., sequence: Iterator[u8] | None = ..., - baudrate: int = ..., - bytesize: int = ..., - parity: str = ..., - stopbits: float = ..., - timeout: float | None = ..., - xonxoff: bool = ..., - rtscts: bool = ..., - write_timeout: float | None = ..., - dsrdtr: bool = ..., - inter_byte_timeout: float | None = ..., - exclusive: bool | None = ..., + options: SerialOptions = ..., ) -> None: ... def __init__( # noqa: DOC301 @@ -273,17 +243,7 @@ def __init__( # noqa: DOC301 max_smp_encoded_frame_size: int | None = None, connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, - baudrate: int = 115200, - bytesize: int = 8, - parity: str = "N", - stopbits: float = 1, - timeout: float | None = None, - xonxoff: bool = False, - rtscts: bool = False, - write_timeout: float | None = None, - dsrdtr: bool = False, - inter_byte_timeout: float | None = None, - exclusive: bool | None = None, + options: SerialOptions = SerialOptions(), ) -> None: """Initialize the serial transport. @@ -300,35 +260,14 @@ def __init__( # noqa: DOC301 parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; defaults to `wrapping_sequence()`. - baudrate: The baudrate of the serial connection. OK to ignore for - USB CDC ACM. - bytesize: The number of data bits. - parity: The parity setting. - stopbits: The number of stop bits. - timeout: The read timeout. - xonxoff: Enable software flow control. - rtscts: Enable hardware (RTS/CTS) flow control. - write_timeout: The write timeout. - dsrdtr: Enable hardware (DSR/DTR) flow control. - inter_byte_timeout: The inter-byte timeout. - exclusive: The exclusive access timeout. + options: The `pyserial` port settings. """ super().__init__( port, connect_timeout_s, sequence, - baudrate=baudrate, - bytesize=bytesize, - parity=parity, - stopbits=stopbits, - timeout=timeout, - xonxoff=xonxoff, - rtscts=rtscts, - write_timeout=write_timeout, - dsrdtr=dsrdtr, - inter_byte_timeout=inter_byte_timeout, - exclusive=exclusive, + options, ) self._fragmentation_strategy: Final = self._resolve_fragmentation_strategy( diff --git a/src/smpclient/transport/serial/unencoded.py b/src/smpclient/transport/serial/unencoded.py index 98edb53..701d2d9 100644 --- a/src/smpclient/transport/serial/unencoded.py +++ b/src/smpclient/transport/serial/unencoded.py @@ -23,7 +23,7 @@ from smpclient.exceptions import SMPClientException from smpclient.transport import Auto, BufferSize -from smpclient.transport.serial.common import _SerialTransportBase +from smpclient.transport.serial.common import SerialOptions, _SerialTransportBase from smpclient.transport.serial.framing import SerialFraming if TYPE_CHECKING: @@ -49,17 +49,7 @@ def __init__( framing: SerialFraming | None = None, connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, - baudrate: int = 115200, - bytesize: int = 8, - parity: str = "N", - stopbits: float = 1, - timeout: float | None = None, - xonxoff: bool = False, - rtscts: bool = False, - write_timeout: float | None = None, - dsrdtr: bool = False, - inter_byte_timeout: float | None = None, - exclusive: bool | None = None, + options: SerialOptions = SerialOptions(), ) -> None: """Initialize the raw serial transport. @@ -74,36 +64,13 @@ def __init__( parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; defaults to `wrapping_sequence()`. - baudrate: The baudrate of the serial connection. OK to ignore for - USB CDC ACM. - bytesize: The number of data bits. - parity: The parity setting. - stopbits: The number of stop bits. - timeout: The read timeout. - xonxoff: Enable software flow control. - rtscts: Enable hardware (RTS/CTS) flow control. - write_timeout: The write timeout. - dsrdtr: Enable hardware (DSR/DTR) flow control. - inter_byte_timeout: The inter-byte timeout. - exclusive: Set exclusive access mode (POSIX only). A port cannot be - opened in exclusive access mode if it is already open in - exclusive access mode. + options: The `pyserial` port settings. """ super().__init__( port, connect_timeout_s, sequence, - baudrate=baudrate, - bytesize=bytesize, - parity=parity, - stopbits=stopbits, - timeout=timeout, - xonxoff=xonxoff, - rtscts=rtscts, - write_timeout=write_timeout, - dsrdtr=dsrdtr, - inter_byte_timeout=inter_byte_timeout, - exclusive=exclusive, + options, ) self._fragmentation_strategy: Final = fragmentation_strategy self._framing: Final = framing diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 6eac294..2313535 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -3,13 +3,15 @@ from __future__ import annotations import asyncio +import inspect import logging import warnings from collections.abc import Callable, Generator -from typing import Any, get_args +from typing import Any, Final, get_args from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest +import serial from serial import SerialException from smp import packet as smppacket from smp.os_management import EchoWriteRequest, EchoWriteResponse @@ -19,6 +21,7 @@ BufferParams, BufferSize, SerialFragmentationStrategy, + SerialOptions, SMPSerialTransport, ) from tests.support import advertise, negotiated @@ -64,6 +67,24 @@ def test_constructor() -> None: assert t.max_unencoded_size == 1024 - FRAME_OVERHEAD +def test_serial_options_lock_pyserial() -> None: + """`SerialOptions` is `serial.Serial`'s settings, in order, with its defaults but the baudrate.""" + pyserial_defaults: Final = { + name: parameter.default + for name, parameter in inspect.signature(serial.Serial).parameters.items() + if name != "port" and parameter.kind is not inspect.Parameter.VAR_KEYWORD + } + assert tuple(pyserial_defaults) == SerialOptions._fields + assert {**pyserial_defaults, "baudrate": 115200} == SerialOptions()._asdict() + + +def test_options_configure_the_port() -> None: + options: Final = SerialOptions(baudrate=9600, rtscts=True, exclusive=True) + with patch("smpclient.transport.serial.common.Serial") as serial_class: + SMPSerialTransport(PORT, options=options) + serial_class.assert_called_once_with(**options._asdict()) + + @pytest.mark.asyncio async def test_connect_disconnect() -> None: ports: list[str] = ["COM2", "/dev/ttyACM0", "/dev/ttyUSB0"] @@ -176,7 +197,7 @@ async def test_send_and_receive() -> None: @pytest.mark.asyncio async def test_receive_timeout() -> None: - t = SMPSerialTransport(PORT, timeout=0.1) + t = SMPSerialTransport(PORT, options=SerialOptions(timeout=0.1)) t._read_one_smp_packet = AsyncMock(side_effect=TimeoutError) # type: ignore with pytest.raises(TimeoutError): From 9a5eb94108919cba9caa33421fd16957632fb187 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:35:11 -0700 Subject: [PATCH 06/19] fix(serial): open the port off the event loop; don't leak it when the flush fails `_open()` now runs pyserial's blocking `open()` and `reset_input_buffer()` in `asyncio.to_thread`, so a slow open (USB CDC ACM still enumerating, for example) no longer stalls the event loop. The leak, which is pre-existing on main: `reset_input_buffer()` was inside the retry `try`. When the flush raised `SerialException` after a successful `open()`, the loop called `open()` again on the open port. pyserial refuses that with another `SerialException`, so the loop spun until `connect_timeout_s` and raised `TimeoutError`, leaving the first fd open. Only `open()` is retried now (`try/except/else`), and `connect()` wraps `_open()` too in its all-or-nothing `except (Exception, CancelledError): close()`. A failed flush, or a cancellation during the open, closes the port and re-raises. A cancellation that lands while the worker thread is still inside `open()` cannot interrupt that thread. The best-effort `close()` runs either way, and is a no-op if the open hadn't finished. Tests: `test_connect_closes_the_port_when_the_flush_fails` fails on the previous code. `test_connect_closes_the_port_when_cancelled_while_negotiating` covers the cancel path. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/serial/common.py | 13 ++++++----- tests/test_smp_serial_raw_transport.py | 29 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index 90f354f..2d82846 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -109,31 +109,32 @@ def _reset_state(self) -> None: @override async def connect(self) -> None: - await self._open() try: + await self._open() await self.negotiate() except (Exception, asyncio.CancelledError): self._conn.close() raise async def _open(self) -> None: - """Open the port, retrying until `connect_timeout_s`.""" + """Open the port off the event loop, retrying until `connect_timeout_s`.""" self._reset_state() self._conn.port = self._port logger.debug(f"Connecting to {self._conn.port=}") start_time: Final = monotonic() while monotonic() - start_time <= self._connect_timeout_s: try: - self._conn.open() - self._conn.reset_input_buffer() - logger.debug(f"Connected to {self._conn.port=}") - return + await asyncio.to_thread(self._conn.open) except SerialException as e: logger.debug( f"Failed to connect to {self._conn.port=}: {e}, " f"retrying in {self._CONNECTION_RETRY_INTERVAL_S} seconds" ) await asyncio.sleep(self._CONNECTION_RETRY_INTERVAL_S) + else: + await asyncio.to_thread(self._conn.reset_input_buffer) + logger.debug(f"Connected to {self._conn.port=}") + return raise TimeoutError(f"Failed to connect to {self._port=}") diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index 9691447..c8ee8f6 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -86,6 +86,35 @@ async def test_connect_retries_until_timeout() -> None: await asyncio.wait_for(t.connect(), timeout=2.0) +@pytest.mark.asyncio +async def test_connect_closes_the_port_when_the_flush_fails() -> None: + """The flush is outside the retry, which would reopen the open port until the timeout.""" + t = SMPSerialRawTransport(PORT) + t._conn.reset_input_buffer = MagicMock(side_effect=SerialException("flush")) # type: ignore + + with pytest.raises(SerialException): + await t.connect() + + t._conn.open.assert_called_once() # type: ignore + t._conn.close.assert_called_once() # type: ignore + + +@pytest.mark.asyncio +async def test_connect_closes_the_port_when_cancelled_while_negotiating() -> None: + t = SMPSerialRawTransport(PORT) + + with ( + patch( + "smpclient._request.read_mcumgr_parameters", + AsyncMock(side_effect=asyncio.CancelledError), + ), + pytest.raises(asyncio.CancelledError), + ): + await t.connect() + + t._conn.close.assert_called_once() # type: ignore + + @pytest.mark.asyncio async def test_send() -> None: t = SMPSerialRawTransport(PORT) From c8ccc0299e6459d7a30a169f44a469cefcf0ef79 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:44:57 -0700 Subject: [PATCH 07/19] feat(serial): borrow a caller's open port; the harness borrows its socket chardev `_SerialTransportBase` gains the same borrow primitives the GATT transports have: - `await t.borrow(port)` adopts a caller's open `SerialPort`, clears the framing state, then runs `negotiate()`. It is all-or-nothing: if negotiation raises or is cancelled, the transport reverts to its own port. - `async with t.borrowed(port):` wraps borrow and disconnect in a bracket. - `disconnect()` returns a borrowed port without closing it; the acquirer releases. It still closes the transport's own `Serial`. `SerialPort` is the Protocol for the four members the transports use: `port`, `out_waiting`, `write`, and `read_all`. `serial.Serial` satisfies it, and so does a `serial_for_url` port that reports `out_waiting`. Both it and `SerialOptions` are exported from `smpclient.transport.serial`. Which port is live is a sum type, `_Link = _Owned | _Borrowed(port)`. `_conn` becomes a property that matches on it, and `_serial` is the transport's own `Serial`, still constructed closed in `__init__`. The unit tests' `t._conn. = MagicMock(...)` assignments still land on the owned mock, so they keep working unchanged. Integration harness: the `QemuSocketSerialTransport` and `QemuSocketSerialRawTransport` subclasses are gone. They overrode `_open` and replaced the `Final` `_conn` with `object.__setattr__`. Now `socket_link(transport, url)` opens the emulator's `socket://` chardev (paced for the raw transport), lends it with `transport.borrowed()`, and closes it on exit, so the suite drives the real public API. `ConnectedServer` carries its link as an `AsyncExitStack`, and `reboot_into_recovery` releases it with `link.aclose()` before opening the recovery link it is handed. Integration: 229 passed, 101 skipped, the same as before, with every socket fixture going through `borrowed()`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/serial/__init__.py | 1 + src/smpclient/transport/serial/common.py | 108 ++++++++++++++++---- tests/integration/conftest.py | 47 ++++----- tests/integration/servers.py | 112 ++++++--------------- tests/integration/test_serial_recovery.py | 15 ++- tests/test_smp_serial_raw_transport.py | 46 ++++++++- tests/test_smp_serial_transport.py | 16 ++- 7 files changed, 210 insertions(+), 135 deletions(-) diff --git a/src/smpclient/transport/serial/__init__.py b/src/smpclient/transport/serial/__init__.py index 6341d0b..499e924 100644 --- a/src/smpclient/transport/serial/__init__.py +++ b/src/smpclient/transport/serial/__init__.py @@ -4,6 +4,7 @@ """ from smpclient.transport.serial.common import SerialOptions as SerialOptions +from smpclient.transport.serial.common import SerialPort as SerialPort from smpclient.transport.serial.encoded import BufferParams as BufferParams from smpclient.transport.serial.encoded import BufferSize as BufferSize from smpclient.transport.serial.encoded import ( diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index 2d82846..62b39cf 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -4,10 +4,10 @@ import asyncio import logging -from collections.abc import Iterator -from contextlib import contextmanager +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager from time import monotonic -from typing import TYPE_CHECKING, Final, Generator, NamedTuple, final +from typing import TYPE_CHECKING, Final, Generator, NamedTuple, Protocol, TypeAlias, final try: from serial import Serial, SerialException @@ -17,17 +17,46 @@ "Serial transport requires the 'serial' extra. Use smpclient[serial]" ) from e raise -from typing_extensions import override +from typing_extensions import Self, assert_never, override from smpclient import _request from smpclient.transport import SMPTransportDisconnected, _ConnectableTransport if TYPE_CHECKING: + from _typeshed import ReadableBuffer from types_bits import u8 logger = logging.getLogger(__name__) +class SerialPort(Protocol): + """The part of an open `pyserial` port that the serial transports use. + + Satisfied by `serial.Serial`, and by a `serial.serial_for_url` port that reports + `out_waiting`. + """ + + @property + def port(self) -> str | None: ... # pragma: no cover + @property + def out_waiting(self) -> int: ... # pragma: no cover + def write(self, b: ReadableBuffer, /) -> int | None: ... # pragma: no cover + def read_all(self) -> bytes | None: ... # pragma: no cover + + +class _Owned(NamedTuple): + """The link is the transport's own `Serial`, which `connect()` opens.""" + + +class _Borrowed(NamedTuple): + """The link is a caller's open port, which the caller closes.""" + + port: SerialPort + + +_Link: TypeAlias = _Owned | _Borrowed + + class SerialOptions(NamedTuple): """The `pyserial` port settings, named as `serial.Serial` names them.""" @@ -69,14 +98,12 @@ class SerialOptions(NamedTuple): class _SerialTransportBase(_ConnectableTransport): """Connection-management base class for serial-port-backed SMP transports. - Holds the `pyserial` `Serial` instance, the open/retry connect loop, disconnect, - and the small TX/RX helpers that wrap `SerialException` into - `SMPTransportDisconnected`. + Holds the `pyserial` `Serial` instance, the open/retry connect loop, borrowing a + caller's open port (e.g. an emulator's `socket://` chardev), disconnect, and the small + TX/RX helpers that wrap `SerialException` into `SMPTransportDisconnected`. - Subclasses implement `send` and `receive` with their framing of choice, may - override `_reset_state` to clear per-connection state on `connect`, and may - override `_open` to back the transport with a byte pipe other than a local - serial port (e.g. an emulator's `socket://` chardev). + Subclasses implement `send` and `receive` with their framing of choice, and may + override `_reset_state` to clear per-connection state on `connect` and `borrow`. """ _POLLING_INTERVAL_S: Final = 0.005 @@ -102,7 +129,18 @@ def __init__( self._port: Final = port self._connect_timeout_s = connect_timeout_s self._sequence = _request.wrapping_sequence() if sequence is None else sequence - self._conn: Final = Serial(**options._asdict()) + self._serial: Final = Serial(**options._asdict()) + self._link: _Link = _Owned() + + @property + def _conn(self) -> SerialPort: + match self._link: + case _Owned(): + return self._serial + case _Borrowed(port=port): + return port + case _ as unreachable: + assert_never(unreachable) def _reset_state(self) -> None: """Reset any per-connection state. Subclasses override as needed.""" @@ -113,27 +151,46 @@ async def connect(self) -> None: await self._open() await self.negotiate() except (Exception, asyncio.CancelledError): - self._conn.close() + self._serial.close() + raise + + async def borrow(self, port: SerialPort) -> None: + """Adopt the caller's open `port`, then `negotiate()`; `disconnect()` leaves it open.""" + self._reset_state() + self._link = _Borrowed(port) + try: + await self.negotiate() + except (Exception, asyncio.CancelledError): + self._link = _Owned() raise + @asynccontextmanager + async def borrowed(self, port: SerialPort) -> AsyncIterator[Self]: + """Borrow the caller's open `port` for the duration of the `async with`.""" + await self.borrow(port) + try: + yield self + finally: + await self.disconnect() + async def _open(self) -> None: """Open the port off the event loop, retrying until `connect_timeout_s`.""" self._reset_state() - self._conn.port = self._port - logger.debug(f"Connecting to {self._conn.port=}") + self._serial.port = self._port + logger.debug(f"Connecting to {self._serial.port=}") start_time: Final = monotonic() while monotonic() - start_time <= self._connect_timeout_s: try: - await asyncio.to_thread(self._conn.open) + await asyncio.to_thread(self._serial.open) except SerialException as e: logger.debug( - f"Failed to connect to {self._conn.port=}: {e}, " + f"Failed to connect to {self._serial.port=}: {e}, " f"retrying in {self._CONNECTION_RETRY_INTERVAL_S} seconds" ) await asyncio.sleep(self._CONNECTION_RETRY_INTERVAL_S) else: - await asyncio.to_thread(self._conn.reset_input_buffer) - logger.debug(f"Connected to {self._conn.port=}") + await asyncio.to_thread(self._serial.reset_input_buffer) + logger.debug(f"Connected to {self._serial.port=}") return raise TimeoutError(f"Failed to connect to {self._port=}") @@ -141,9 +198,16 @@ async def _open(self) -> None: @final @override async def disconnect(self) -> None: - logger.debug(f"Disconnecting from {self._conn.port=}") - self._conn.close() - logger.debug(f"Disconnected from {self._conn.port=}") + match self._link: + case _Owned(): + logger.debug(f"Disconnecting from {self._serial.port=}") + self._serial.close() + logger.debug(f"Disconnected from {self._serial.port=}") + case _Borrowed(port=port): + logger.debug(f"Returning the borrowed {port.port=}") + self._link = _Owned() + case _ as unreachable: + assert_never(unreachable) @final @override diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 91c6450..32cf7c1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,7 +13,7 @@ import logging import re from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import asynccontextmanager +from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Final, NamedTuple @@ -32,12 +32,11 @@ FIXTURES, Endpoint, PtyEndpoint, - QemuSocketSerialRawTransport, - QemuSocketSerialTransport, ServerFixture, SocketSerialEndpoint, UdpEndpoint, serve, + socket_link, ) logger = logging.getLogger(__name__) @@ -49,12 +48,13 @@ class ConnectedServer(NamedTuple): - """A live `SMPClient`, its transport, its `ServerFixture`, and its `Endpoint`.""" + """A live `SMPClient`, its transport, its `ServerFixture`, `Endpoint`, and open link.""" client: SMPClient transport: FixtureTransport fixture: ServerFixture endpoint: Endpoint + link: AsyncExitStack def fixture_params( @@ -74,14 +74,16 @@ def fixture_params( ] -def _build_transport(fixture: ServerFixture, endpoint: Endpoint) -> FixtureTransport: +def _link( + fixture: ServerFixture, endpoint: Endpoint +) -> AbstractAsyncContextManager[FixtureTransport]: match endpoint: case PtyEndpoint(pty): match fixture.transport: case "serial" | "shell": - return SMPSerialTransport(pty) + return SMPSerialTransport(pty).connected() case "serial_raw": - return SMPSerialRawTransport(pty) + return SMPSerialRawTransport(pty).connected() case "udp": pytest.fail("UDP fixtures do not present as a PTY serial endpoint") case _ as unreachable: @@ -89,15 +91,15 @@ def _build_transport(fixture: ServerFixture, endpoint: Endpoint) -> FixtureTrans case SocketSerialEndpoint(url): match fixture.transport: case "serial" | "shell": - return QemuSocketSerialTransport(url) + return socket_link(SMPSerialTransport(url), url) case "serial_raw": - return QemuSocketSerialRawTransport(url) + return socket_link(SMPSerialRawTransport(url), url) case "udp": pytest.fail("UDP fixtures do not present as a socket serial endpoint") case _ as unreachable: assert_never(unreachable) case UdpEndpoint(host, port): - return SMPUDPTransport(host, port) + return SMPUDPTransport(host, port).connected() case _: assert_never(endpoint) @@ -228,13 +230,13 @@ def assert_chunks_maximized( @asynccontextmanager async def reboot_into_recovery( app: ConnectedServer, - transport: SMPSerialTransport | SMPSerialRawTransport, + recovery: AbstractAsyncContextManager[SMPSerialTransport | SMPSerialRawTransport], ) -> AsyncIterator[SMPClient]: """Reboot the device into MCUboot serial recovery and yield a recovery-connected client. The app at `app` reboots via `os reset boot_mode=BOOTLOADER` (smp 4.1.0) and its link - closes; `transport` then connects to the bootloader on the same serial endpoint, probed - until it answers (the recovery server speaks the img group, not echo). + closes; the `recovery` link then opens to the bootloader on the same serial endpoint, + probed until it answers (the recovery server speaks the img group, not echo). """ app_client: Final = app.client assert success(await app_client.request(ImageStatesReadRequest())) @@ -246,13 +248,13 @@ async def reboot_into_recovery( ) except TimeoutError: pass # some servers reset before sending the response - await app.transport.disconnect() + await app.link.aclose() await asyncio.sleep(2.0) # let MCUboot serial recovery come up async def lists_images(c: SMPClient) -> bool: return success(await c.request(ImageStatesReadRequest(), timeout_s=1.0)) - async with transport.connected(): + async with recovery as transport: bootloader = SMPClient(transport) if not await _poll_until_answering(bootloader, lists_images, interval_s=0.2): pytest.fail("MCUboot serial recovery SMP server never answered") @@ -262,16 +264,15 @@ async def lists_images(c: SMPClient) -> bool: @asynccontextmanager async def connected(fixture: ServerFixture) -> AsyncIterator[ConnectedServer]: """Launch `fixture`, connect an `SMPClient`, and wait until the server answers.""" - async with serve(fixture) as endpoint: - transport = _build_transport(fixture, endpoint) + async with serve(fixture) as endpoint, AsyncExitStack() as link: # Tolerant on exit: `connected()` closes best-effort, and a recovery test may have # rebooted the server out from under us. - async with transport.connected(): - client = SMPClient(transport) - await _wait_until_answering(client) - # Re-negotiate in case the first MCUMgr parameter read raced server boot. - await transport.negotiate() - yield ConnectedServer(client, transport, fixture, endpoint) + transport = await link.enter_async_context(_link(fixture, endpoint)) + client = SMPClient(transport) + await _wait_until_answering(client) + # Re-negotiate in case the first MCUMgr parameter read raced server boot. + await transport.negotiate() + yield ConnectedServer(client, transport, fixture, endpoint, link) @pytest_asyncio.fixture(params=fixture_params()) diff --git a/tests/integration/servers.py b/tests/integration/servers.py index debdc76..ac553f1 100644 --- a/tests/integration/servers.py +++ b/tests/integration/servers.py @@ -33,20 +33,14 @@ from contextlib import asynccontextmanager, closing from hashlib import sha256 from pathlib import Path -from typing import TYPE_CHECKING, Final, Literal, NamedTuple +from typing import TYPE_CHECKING, Final, Literal, NamedTuple, TypeVar import serial as pyserial from serial.urlhandler.protocol_socket import Serial as _SocketSerial -from typing_extensions import override +from typing_extensions import assert_never, override -from smpclient.transport import Auto, SMPTransportDisconnected -from smpclient.transport.serial import ( - RawSerialFragmentationStrategy, - SerialFragmentationStrategy, - SerialFraming, - SMPSerialRawTransport, - SMPSerialTransport, -) +from smpclient.transport import SMPTransportDisconnected +from smpclient.transport.serial import SMPSerialRawTransport, SMPSerialTransport if TYPE_CHECKING: from _typeshed import ReadableBuffer @@ -287,89 +281,47 @@ def write(self, b: ReadableBuffer, /) -> int: FIXTURES: Final = _load_fixtures() -async def _connect_socket_chardev( - transport: SMPSerialTransport | SMPSerialRawTransport, - url: str, - timeout_s: float, - chardev: type[_SocketChardev] = _SocketChardev, -) -> None: - """Back `transport` with an emulator's `socket://` serial chardev, retrying until it accepts. +_CHARDEV_CONNECT_TIMEOUT_S: Final = 2.5 +_CHARDEV_RETRY_INTERVAL_S: Final = 0.5 - Replaces the `Final` pyserial `_conn` with a socket-backed `Serial`, sidestepping the - PTY held-byte quirk of an emulated UART. Shared by the encoded and raw socket - transports, which differ only in their on-wire framing. +_LinkedTransport = TypeVar("_LinkedTransport", bound=SMPSerialTransport | SMPSerialRawTransport) - Args: - transport: the socket-backed serial transport whose `_conn` to (re)bind. - url: the emulator's `socket://host:port` chardev URL. - timeout_s: how long to keep retrying before the socket must have accepted. - chardev: the chardev class to bind; `_PacedSocketChardev` for the raw transport. - Raises: - TimeoutError: if the emulator's serial socket never accepts within `timeout_s`. - """ - transport._reset_state() +def _chardev_for(transport: SMPSerialTransport | SMPSerialRawTransport) -> type[_SocketChardev]: + match transport: + case SMPSerialRawTransport(): + return _PacedSocketChardev + case SMPSerialTransport(): + return _SocketChardev + case _ as unreachable: + assert_never(unreachable) + + +async def _open_socket_chardev(url: str, chardev: type[_SocketChardev]) -> _SocketChardev: + """Open an emulator's `socket://` serial chardev, retrying until it accepts.""" loop = asyncio.get_running_loop() - deadline = loop.time() + timeout_s + deadline = loop.time() + _CHARDEV_CONNECT_TIMEOUT_S while True: try: - conn = chardev(url, timeout=0, write_timeout=_WRITE_TIMEOUT_S) + return chardev(url, timeout=0, write_timeout=_WRITE_TIMEOUT_S) except (OSError, pyserial.SerialException) as e: if loop.time() >= deadline: raise TimeoutError(f"emulator serial socket {url} never accepted: {e}") - await asyncio.sleep(transport._CONNECTION_RETRY_INTERVAL_S) - continue - # `_conn` is `Final` on the base class; replace it for the socket backend. - object.__setattr__(transport, "_conn", conn) - logger.debug(f"Connected to {url}") - return - + await asyncio.sleep(_CHARDEV_RETRY_INTERVAL_S) -class QemuSocketSerialTransport(SMPSerialTransport): - """`SMPSerialTransport` whose byte pipe is a TCP socket (an emulator's serial chardev). - - Only `_open` differs -- it binds a `socket://` chardev instead of a local serial - port, sidestepping the PTY held-byte quirk of an emulated UART. Framing, - fragmentation, `send`, and `receive` are inherited unchanged, so the suite exercises - the real transport rather than a copy of it. - """ - def __init__( # noqa: DOC301 - self, - url: str, - fragmentation_strategy: SerialFragmentationStrategy | None = None, - ) -> None: - if fragmentation_strategy is None: - super().__init__(url) - else: - super().__init__(url, fragmentation_strategy=fragmentation_strategy) - self._url: Final = url - - @override - async def _open(self) -> None: - await _connect_socket_chardev(self, self._url, self._connect_timeout_s) - - -class QemuSocketSerialRawTransport(SMPSerialRawTransport): - """`SMPSerialRawTransport` whose byte pipe is a TCP socket (an emulator's serial chardev). +@asynccontextmanager +async def socket_link(transport: _LinkedTransport, url: str) -> AsyncIterator[_LinkedTransport]: + """Lend `transport` an emulator's `socket://` serial chardev, closing it on exit. - The raw counterpart of `QemuSocketSerialTransport`: only `_open` differs; the raw - `[header][payload]` framing, `send`, and `receive` are inherited from - `SMPSerialRawTransport` unchanged. + The socket sidesteps the PTY held-byte quirk of an emulated UART. The transport + borrows it, so its framing, fragmentation, `send`, and `receive` run unchanged -- the + suite exercises the real transport rather than a copy of it. """ - - def __init__( # noqa: DOC301 - self, - url: str, - fragmentation_strategy: RawSerialFragmentationStrategy = Auto(), - framing: SerialFraming | None = None, - ) -> None: - super().__init__(url, fragmentation_strategy, framing=framing) - self._url: Final = url - - @override - async def _open(self) -> None: - await _connect_socket_chardev(self, self._url, self._connect_timeout_s, _PacedSocketChardev) + with closing(await _open_socket_chardev(url, _chardev_for(transport))) as chardev: + async with transport.borrowed(chardev): + logger.debug(f"Borrowing {url}") + yield transport def _verify_sha256(artifact: Path) -> str | None: diff --git a/tests/integration/test_serial_recovery.py b/tests/integration/test_serial_recovery.py index ebcbf41..728fd01 100644 --- a/tests/integration/test_serial_recovery.py +++ b/tests/integration/test_serial_recovery.py @@ -34,7 +34,7 @@ from smpclient import success from smpclient.transport import Auto -from smpclient.transport.serial import BufferSize, Cobs, SMPSerialTransport +from smpclient.transport.serial import BufferSize, Cobs, SMPSerialRawTransport, SMPSerialTransport from smpclient.transport.serial.encoded import _FRAME_OVERHEAD from tests.integration.conftest import ( RECOVERY_UPLOAD_TIMEOUT_S, @@ -46,15 +46,14 @@ ) from tests.integration.servers import ( FIXTURES, - QemuSocketSerialRawTransport, - QemuSocketSerialTransport, ServerFixture, SocketSerialEndpoint, + socket_link, ) pytestmark = [pytest.mark.integration, pytest.mark.asyncio] -_SocketTransport = QemuSocketSerialTransport | QemuSocketSerialRawTransport +_SocketTransport = SMPSerialTransport | SMPSerialRawTransport class Console(NamedTuple): @@ -103,11 +102,11 @@ def _fixture(variant: _Recovery) -> tuple[str, str]: def _build_transport(variant: _Recovery, url: str) -> _SocketTransport: match variant: case Console(strategy=strategy): - return QemuSocketSerialTransport(url, fragmentation_strategy=strategy) + return SMPSerialTransport(url, fragmentation_strategy=strategy) case Raw(): - return QemuSocketSerialRawTransport(url) + return SMPSerialRawTransport(url) case RawCobs(): - return QemuSocketSerialRawTransport(url, framing=Cobs()) + return SMPSerialRawTransport(url, framing=Cobs()) case _ as unreachable: assert_never(unreachable) @@ -171,7 +170,7 @@ async def test_upload_to_mcuboot_recovery(variant: _Recovery, fixture: ServerFix assert isinstance(cs.endpoint, SocketSerialEndpoint) transport = _build_transport(variant, cs.endpoint.url) - async with reboot_into_recovery(cs, transport) as bootloader: + async with reboot_into_recovery(cs, socket_link(transport, cs.endpoint.url)) as bootloader: # Re-negotiate in case the first read raced the bootloader coming up. await transport.negotiate() diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index c8ee8f6..26fc4c2 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Generator -from typing import Any +from typing import Any, Final from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest @@ -115,6 +115,50 @@ async def test_connect_closes_the_port_when_cancelled_while_negotiating() -> Non t._conn.close.assert_called_once() # type: ignore +@pytest.mark.asyncio +async def test_borrowed_uses_the_port_and_leaves_it_open() -> None: + port: Final = MagicMock(out_waiting=0) + t = SMPSerialRawTransport(PORT) + r = EchoWriteRequest(d="Hello pytest!").to_frame(sequence=0) + + async with t.borrowed(port) as borrowed: + assert borrowed is t + await t.send(bytes(r)) + + port.write.assert_called_once_with(bytes(r)) + port.close.assert_not_called() + t._serial.open.assert_not_called() # type: ignore + t._serial.close.assert_not_called() # type: ignore + assert t._conn is t._serial + + +@pytest.mark.asyncio +async def test_borrow_negotiates_the_fragmentation_strategy() -> None: + t = SMPSerialRawTransport(PORT) + + with advertise(2048) as read_mcumgr_parameters: + await t.borrow(MagicMock()) + + read_mcumgr_parameters.assert_awaited_once() + assert t.max_unencoded_size == 2048 + + +@pytest.mark.asyncio +async def test_borrow_reverts_to_the_owned_port_when_negotiation_fails() -> None: + t = SMPSerialRawTransport(PORT) + + with ( + patch( + "smpclient._request.read_mcumgr_parameters", + AsyncMock(side_effect=asyncio.CancelledError), + ), + pytest.raises(asyncio.CancelledError), + ): + await t.borrow(MagicMock()) + + assert t._conn is t._serial + + @pytest.mark.asyncio async def test_send() -> None: t = SMPSerialRawTransport(PORT) diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 2313535..0613118 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -125,6 +125,20 @@ async def test_send() -> None: assert p.call_count == 2 # called twice since out buffer was not drained on first call +@pytest.mark.asyncio +async def test_borrowed_receives_from_the_port_and_leaves_it_open() -> None: + m: Final = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) + port: Final = MagicMock(read_all=MagicMock(side_effect=smppacket.encode(bytes(m), 8))) + t = SMPSerialTransport(PORT) + + async with t.borrowed(port): + assert await t.receive() == bytes(m) + + port.close.assert_not_called() + t._serial.open.assert_not_called() # type: ignore + assert t._conn is t._serial + + @pytest.mark.asyncio async def test_receive() -> None: t = SMPSerialTransport(PORT) @@ -312,7 +326,7 @@ async def test_serial_and_smp_data() -> None: @pytest.mark.asyncio async def test_not_connected_exception_handling() -> None: t = SMPSerialTransport(PORT) - t._conn.is_open = False + t._serial.is_open = False t._conn.read_all = MagicMock(side_effect=SerialException("Not connected")) # type: ignore with pytest.raises(SMPTransportDisconnected): From 03306eae7bd22465493448f6c4d31b4a5caea778 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:49:43 -0700 Subject: [PATCH 08/19] feat(ble): borrow a caller's connected BleakClient `SMPBLETransport` gains the borrow primitives the other transports have: - `await t.borrow(client)` adopts a caller's connected `BleakClient`, finds the SMP characteristic, sizes writes to the link, subscribes, and then runs `negotiate()`. It is all-or-nothing: on failure or cancellation the transport returns the client and re-raises. - `async with t.borrowed(client):` wraps borrow and disconnect in a bracket. - `disconnect()` on a borrowed client unsubscribes and never disconnects it; the acquirer releases. The `stop_notify` is bounded by `connect_timeout_s`, and a failure is logged rather than raised, so returning the client can't hang or mask the caller's error when the owner has already dropped the link. The part of `_connect()` after the link comes up is now `_start_smp()`, shared by connect and borrow, and it clears the receive buffer. Ownership is a sum type, `_Link = _Owned | _Borrowed(client)`. `_active_client` matches on it; `_client` stays the transport's own client, so the unit tests that assign it keep working. Disconnect detection: bleak takes `disconnected_callback` only when the client is constructed, and the owner holds it. So `_until_disconnected()` waits on the transport's event when it owns the client, and polls `client.is_connected` every 100 ms when it borrows one. The poll runs only inside a receive or GATT wait. There is no watcher task, so nothing outlives the primitive that started it. `_notify_or_disconnect` now reaps its two sub-tasks in a `finally`, like `_await_or_disconnect`. Before, cancelling a waiting `receive()` leaked both tasks; with a borrowed client, that would leave a poll loop running for as long as the owner's link stayed up. The old `except CancelledError: pass` around the reaping `gather` also swallowed a cancellation of the waiter itself; a positional `gather(..., return_exceptions=True)` doesn't. `test_borrowed_receive_leaves_no_task_polling_when_cancelled` fails without the `finally`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/ble.py | 129 +++++++++++++++++++++++++------- tests/test_smp_ble_transport.py | 108 +++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 28 deletions(-) diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index c908724..dbf8b80 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -6,8 +6,9 @@ import logging import re import sys -from collections.abc import Coroutine, Iterator -from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias, TypeGuard, TypeVar +from collections.abc import AsyncIterator, Coroutine, Iterator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeAlias, TypeGuard, TypeVar from uuid import UUID try: @@ -21,7 +22,7 @@ raise ImportError("BLE transport requires the 'ble' extra. Use smpclient[ble]") from e raise from smp import header as smphdr -from typing_extensions import override +from typing_extensions import Self, assert_never, override from smpclient import _request from smpclient.exceptions import SMPClientException @@ -89,6 +90,22 @@ class SMPBLETransportNotSMPServer(SMPBLETransportException): _T = TypeVar("_T") +_BORROWED_DISCONNECT_POLL_S: Final = 0.1 +"""How often a wait on a borrowed client checks `is_connected`; its owner holds the callback.""" + + +class _Owned(NamedTuple): + """The link is the transport's own `BleakClient`, which `connect()` creates.""" + + +class _Borrowed(NamedTuple): + """The link is a caller's connected `BleakClient`, which the caller disconnects.""" + + client: BleakClient + + +_Link: TypeAlias = _Owned | _Borrowed + class SMPBLETransport(_GATTTransport): """A Bluetooth Low Energy (BLE) SMPTransport.""" @@ -123,6 +140,7 @@ def __init__( self._disconnected_event = asyncio.Event() self._disconnected_event.set() self._winrt = winrt + self._link: _Link = _Owned() self._max_write_without_response_size = 20 """Initially set to BLE minimum; may be mutated by the `connect()` method.""" @@ -164,8 +182,43 @@ async def _connect(self, address: str, timeout_s: float) -> None: await self._client.connect() self._disconnected_event.clear() logger.debug(f"Connected to {device=}") + await self._start_smp() - smp_characteristic = self._client.services.get_characteristic(SMP_CHARACTERISTIC_UUID) + async def borrow(self, client: BleakClient) -> None: + """Adopt the caller's connected `client`, then `negotiate()`; `disconnect()` leaves it up.""" + self._link = _Borrowed(client) + try: + await self._start_smp() + await self.negotiate() + except (Exception, asyncio.CancelledError): + await self.disconnect() + raise + + @asynccontextmanager + async def borrowed(self, client: BleakClient) -> AsyncIterator[Self]: + """Borrow the caller's connected `client` for the duration of the `async with`.""" + await self.borrow(client) + try: + yield self + finally: + await self.disconnect() + + @property + def _active_client(self) -> BleakClient: + match self._link: + case _Owned(): + return self._client + case _Borrowed(client=client): + return client + case _ as unreachable: + assert_never(unreachable) + + async def _start_smp(self) -> None: + """Find the SMP characteristic, size writes to the link, and subscribe to it.""" + self._buffer.clear() + smp_characteristic = self._active_client.services.get_characteristic( + SMP_CHARACTERISTIC_UUID + ) if smp_characteristic is None: raise SMPBLETransportNotSMPServer("Missing the SMP characteristic UUID.") @@ -173,7 +226,7 @@ async def _connect(self, address: str, timeout_s: float) -> None: logger.info(f"{smp_characteristic.max_write_without_response_size=}") self._max_write_without_response_size = smp_characteristic.max_write_without_response_size if ( - self._winrt_backend(self._client._backend) + self._winrt_backend(self._active_client._backend) and self._max_write_without_response_size == 20 ): # https://github.com/hbldh/bleak/pull/1552#issuecomment-2105573291 @@ -182,38 +235,51 @@ async def _connect(self, address: str, timeout_s: float) -> None: ) await asyncio.sleep(2) smp_characteristic._max_write_without_response_size = ( # pyright: ignore[reportAttributeAccessIssue] - self._client._backend._session.max_pdu_size - 3 # type: ignore + self._active_client._backend._session.max_pdu_size - 3 # type: ignore ) self._max_write_without_response_size = ( smp_characteristic.max_write_without_response_size ) logger.warning(f"{smp_characteristic.max_write_without_response_size=}") - elif self._bluez_backend(self._client._backend): + elif self._bluez_backend(self._active_client._backend): logger.debug("Getting MTU from BlueZ backend") - await self._client._backend._acquire_mtu() - logger.debug(f"Got MTU: {self._client.mtu_size}") - self._max_write_without_response_size = self._client.mtu_size - 3 + await self._active_client._backend._acquire_mtu() + logger.debug(f"Got MTU: {self._active_client.mtu_size}") + self._max_write_without_response_size = self._active_client.mtu_size - 3 logger.info(f"{self._max_write_without_response_size=}") self._smp_characteristic = smp_characteristic logger.debug(f"Starting notify on {SMP_CHARACTERISTIC_UUID=}") await self._await_or_disconnect( - self._client.start_notify(SMP_CHARACTERISTIC_UUID, self._notify_callback) + self._active_client.start_notify(SMP_CHARACTERISTIC_UUID, self._notify_callback) ) logger.debug(f"Started notify on {SMP_CHARACTERISTIC_UUID=}") @override async def disconnect(self) -> None: - logger.debug(f"Disonnecting from {self._client.address}") - await self._client.disconnect() - logger.debug(f"Disconnected from {self._client.address}") + match self._link: + case _Owned(): + logger.debug(f"Disonnecting from {self._client.address}") + await self._client.disconnect() + logger.debug(f"Disconnected from {self._client.address}") + case _Borrowed(client=client): + logger.debug(f"Returning the borrowed client for {client.address}") + self._link = _Owned() + try: + await asyncio.wait_for( + client.stop_notify(SMP_CHARACTERISTIC_UUID), timeout=self._connect_timeout_s + ) + except Exception as e: + logger.warning(f"Error unsubscribing from the borrowed client: {e}") + case _ as unreachable: + assert_never(unreachable) @override async def send(self, data: bytes) -> None: logger.debug(f"Sending {len(data)} bytes, {self.mtu=}") for offset in range(0, len(data), self.mtu): - await self._client.write_gatt_char( + await self._active_client.write_gatt_char( self._smp_characteristic, data[offset : offset + self.mtu], response=False ) logger.debug(f"Sent {len(data)} bytes") @@ -291,21 +357,30 @@ def _set_disconnected_event(self, client: BleakClient) -> None: logger.warning(f"Disconnected from {client.address}") self._disconnected_event.set() + async def _until_disconnected(self) -> None: + match self._link: + case _Owned(): + await self._disconnected_event.wait() + case _Borrowed(client=client): + while client.is_connected: + await asyncio.sleep(_BORROWED_DISCONNECT_POLL_S) + case _ as unreachable: + assert_never(unreachable) + async def _notify_or_disconnect(self) -> None: - disconnected_task: Final = asyncio.create_task(self._disconnected_event.wait()) + disconnected_task: Final = asyncio.create_task(self._until_disconnected()) notify_task: Final = asyncio.create_task(self._notify_condition.wait()) - done, pending = await asyncio.wait( - (disconnected_task, notify_task), return_when=asyncio.FIRST_COMPLETED - ) - for task in pending: - task.cancel() try: - await asyncio.gather(*pending) - except asyncio.CancelledError: - pass + done, _ = await asyncio.wait( + (disconnected_task, notify_task), return_when=asyncio.FIRST_COMPLETED + ) + finally: + for task in (disconnected_task, notify_task): + task.cancel() + await asyncio.gather(disconnected_task, notify_task, return_exceptions=True) if disconnected_task in done: raise SMPTransportDisconnected( - f"{self.__class__.__name__} disconnected from {self._client.address}" + f"{self.__class__.__name__} disconnected from {self._active_client.address}" ) async def _await_or_disconnect(self, coro: Coroutine[Any, Any, _T]) -> _T: @@ -316,7 +391,7 @@ async def _await_or_disconnect(self, coro: Coroutine[Any, Any, _T]) -> _T: https://github.com/intercreate/smpmgr/issues/97. """ op_task: Final = asyncio.create_task(coro) - disconnected_task: Final = asyncio.create_task(self._disconnected_event.wait()) + disconnected_task: Final = asyncio.create_task(self._until_disconnected()) try: done, _ = await asyncio.wait( (op_task, disconnected_task), return_when=asyncio.FIRST_COMPLETED @@ -328,7 +403,7 @@ async def _await_or_disconnect(self, coro: Coroutine[Any, Any, _T]) -> _T: await asyncio.gather(op_task, disconnected_task, return_exceptions=True) if disconnected_task in done: raise SMPTransportDisconnected( - f"{self.__class__.__name__} disconnected from {self._client.address}" + f"{self.__class__.__name__} disconnected from {self._active_client.address}" ) return op_task.result() diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index 4ebb451..5e43b2c 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -1,7 +1,7 @@ """Tests for `SMPBLETransport`.""" import asyncio -from typing import cast +from typing import Final, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID @@ -9,6 +9,7 @@ from bleak import BleakClient from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice +from bleak.exc import BleakError from smp.os_management import EchoWriteResponse from smpclient.transport import BufferSize, SMPTransportDisconnected, Unfragmented @@ -155,6 +156,111 @@ async def test_disconnect() -> None: t._client.disconnect.assert_awaited_once_with() +def _borrowable_client(max_write: int = 244) -> MagicMock: + """A caller's connected `BleakClient` that serves the SMP characteristic.""" + client = MagicMock(spec=BleakClient, name="BorrowedBleakClient") + client._backend = MockBleakClient.Backend() + client.address = ADDRESS + client.is_connected = True + client.services.get_characteristic.return_value = MagicMock( + spec=BleakGATTCharacteristic, max_write_without_response_size=max_write + ) + return client + + +@pytest.mark.asyncio +async def test_borrowed_subscribes_and_leaves_the_client_connected() -> None: + client: Final = _borrowable_client(max_write=244) + t = SMPBLETransport(ADDRESS) + + async with t.borrowed(client) as borrowed: + assert borrowed is t + assert t.mtu == 244 + await t.send(b"Hello pytest!") + + client.start_notify.assert_awaited_once_with(SMP_CHARACTERISTIC_UUID, t._notify_callback) + client.write_gatt_char.assert_awaited_once_with( + t._smp_characteristic, b"Hello pytest!", response=False + ) + client.stop_notify.assert_awaited_once_with(SMP_CHARACTERISTIC_UUID) + client.disconnect.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_borrowed_receive_raises_once_the_client_disconnects() -> None: + """The owner holds the client's disconnect callback, so a borrowed wait polls instead.""" + client: Final = _borrowable_client() + t = SMPBLETransport(ADDRESS) + + async with t.borrowed(client): + client.is_connected = False + with pytest.raises(SMPTransportDisconnected): + await asyncio.wait_for(t.receive(), timeout=1.0) + + +@pytest.mark.asyncio +async def test_borrowed_receive_leaves_no_task_polling_when_cancelled() -> None: + t = SMPBLETransport(ADDRESS) + + async with t.borrowed(_borrowable_client()): + tasks_before: Final = asyncio.all_tasks() + receive: Final = asyncio.create_task(t.receive()) + await asyncio.sleep(0.01) # the receive is waiting on a notify or a disconnect + receive.cancel() + with pytest.raises(asyncio.CancelledError): + await receive + + assert asyncio.all_tasks() == tasks_before + + +@pytest.mark.asyncio +async def test_borrow_negotiates_the_fragmentation_strategy() -> None: + t = SMPBLETransport(ADDRESS) + + with advertise(2048) as read_mcumgr_parameters: + await t.borrow(_borrowable_client()) + + read_mcumgr_parameters.assert_awaited_once() + assert t.max_unencoded_size == 2048 + + +@pytest.mark.asyncio +async def test_borrow_returns_the_client_when_negotiation_fails() -> None: + client: Final = _borrowable_client() + t = SMPBLETransport(ADDRESS) + + with ( + patch( + "smpclient._request.read_mcumgr_parameters", + AsyncMock(side_effect=asyncio.CancelledError), + ), + pytest.raises(asyncio.CancelledError), + ): + await t.borrow(client) + + client.stop_notify.assert_awaited_once_with(SMP_CHARACTERISTIC_UUID) + client.disconnect.assert_not_awaited() + + +async def _never_returns(*_args: object) -> None: + await asyncio.Event().wait() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "stop_notify", [BleakError("Not connected"), _never_returns], ids=["raises", "hangs"] +) +async def test_returning_a_borrowed_client_survives_its_unsubscribe(stop_notify: object) -> None: + client: Final = _borrowable_client() + client.stop_notify.side_effect = stop_notify + t = SMPBLETransport(ADDRESS, connect_timeout_s=0.1) + + async with t.borrowed(client): + pass + + client.disconnect.assert_not_awaited() + + @pytest.mark.asyncio async def test_send() -> None: t = SMPBLETransport(ADDRESS) From 0ecf44c3a9ae9646c7679dd4e43c9cb3905c8127 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:51:11 -0700 Subject: [PATCH 09/19] feat(ble): pick the BlueZ adapter to scan and connect with `SMPBLETransport(address, bluez=BlueZClientArgs(adapter="hci1"))` scans for the device and connects to it on that adapter, instead of bleak's default. The options are bleak's own type, so the `BlueZClientArgs` passes to `BleakClient`, and its keys, a subset of `BlueZScannerArgs`, pass to `find_device_by_address` and `find_device_by_name`. `SMPBLETransport.scan()` takes `bluez: BlueZScannerArgs` too. Both default to `{}`, which is bleak's default adapter, so behavior is unchanged. This sits beside the existing `winrt=WinRTClientArgs(...)`, which already carries #90's `use_cached_services`. bleak is pinned `>=3.0.2`, and the BlueZ adapter args date from 3.0. Closes #103 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/ble.py | 23 +++++++++++++++------- tests/test_smp_ble_transport.py | 35 ++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index dbf8b80..ec28f76 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -13,6 +13,7 @@ try: from bleak import BleakClient, BleakScanner + from bleak.args.bluez import BlueZClientArgs, BlueZScannerArgs from bleak.args.winrt import WinRTClientArgs from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient @@ -115,6 +116,7 @@ def __init__( address: str, *, winrt: WinRTClientArgs = {}, + bluez: BlueZClientArgs = {}, fragmentation_strategy: GATTFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, @@ -124,6 +126,7 @@ def __init__( Args: address: The device's MAC address, macOS UUID, or advertised name. winrt: WinRT backend arguments, e.g. `use_cached_services`. + bluez: BlueZ backend arguments, e.g. the `adapter` to scan and connect with. fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or `BufferSize`. connect_timeout_s: Bounds scanning and connecting, and reading the server's @@ -140,6 +143,7 @@ def __init__( self._disconnected_event = asyncio.Event() self._disconnected_event.set() self._winrt = winrt + self._bluez: Final = bluez self._link: _Link = _Owned() self._max_write_without_response_size = 20 @@ -162,9 +166,13 @@ async def connect(self) -> None: async def _connect(self, address: str, timeout_s: float) -> None: logger.debug(f"Scanning for {address=}") device: BLEDevice | None = ( - await BleakScanner.find_device_by_address(address, timeout=timeout_s) + await BleakScanner.find_device_by_address( + address, timeout=timeout_s, bluez=BlueZScannerArgs(**self._bluez) + ) if MAC_ADDRESS_PATTERN.match(address) or UUID_PATTERN.match(address) - else await BleakScanner.find_device_by_name(address, timeout=timeout_s) + else await BleakScanner.find_device_by_name( + address, timeout=timeout_s, bluez=BlueZScannerArgs(**self._bluez) + ) ) if type(device) is BLEDevice: @@ -172,6 +180,7 @@ async def _connect(self, address: str, timeout_s: float) -> None: device, services=(str(SMP_SERVICE_UUID),), winrt=self._winrt, + bluez=self._bluez, timeout=timeout_s, disconnected_callback=self._set_disconnected_event, ) @@ -329,12 +338,12 @@ def mtu(self) -> int: return self._max_write_without_response_size @staticmethod - async def scan(timeout: int = 5) -> list[BLEDevice]: - """Scan for BLE devices.""" + async def scan(timeout: int = 5, bluez: BlueZScannerArgs = {}) -> list[BLEDevice]: + """Scan for BLE devices, on the BlueZ `adapter` if `bluez` names one.""" logger.debug(f"Scanning for BLE devices for {timeout} seconds") - devices: Final = await BleakScanner(service_uuids=[str(SMP_SERVICE_UUID)]).discover( - timeout=timeout, return_adv=True - ) + devices: Final = await BleakScanner( + service_uuids=[str(SMP_SERVICE_UUID)], bluez=bluez + ).discover(timeout=timeout, return_adv=True) smp_servers: Final = [ d for d, a in devices.values() if SMP_SERVICE_UUID in {UUID(u) for u in a.service_uuids} ] diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index 5e43b2c..a9b16de 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -94,12 +94,12 @@ async def test_connect( ) -> None: # assert that it searches by name if MAC or UUID is not provided await SMPBLETransport("device name", connect_timeout_s=1.0).connect() - mock_find_device_by_name.assert_called_once_with("device name", timeout=1.0) + mock_find_device_by_name.assert_called_once_with("device name", timeout=1.0, bluez={}) mock_find_device_by_name.reset_mock() # assert that it searches by MAC if MAC is provided await SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=1.0).connect() - mock_find_device_by_address.assert_called_once_with("00:00:00:00:00:00", timeout=1.0) + mock_find_device_by_address.assert_called_once_with("00:00:00:00:00:00", timeout=1.0, bluez={}) mock_find_device_by_address.reset_mock() # assert that it searches by UUID if UUID is provided @@ -107,7 +107,7 @@ async def test_connect( UUID("00000000-0000-4000-8000-000000000000").hex, connect_timeout_s=1.0 ).connect() mock_find_device_by_address.assert_called_once_with( - "00000000000040008000000000000000", timeout=1.0 + "00000000000040008000000000000000", timeout=1.0, bluez={} ) mock_find_device_by_address.reset_mock() @@ -148,6 +148,35 @@ async def test_connect( t._client.start_notify.assert_called_once_with(SMP_CHARACTERISTIC_UUID, t._notify_callback) +@patch( + "smpclient.transport.ble.BleakScanner.find_device_by_address", + return_value=BLEDevice(ADDRESS, "name", None), +) +@patch("smpclient.transport.ble.BleakClient", side_effect=MockBleakClient) +@pytest.mark.asyncio +async def test_connect_scans_and_connects_with_the_bluez_adapter( + mock_bleak_client: MagicMock, mock_find_device_by_address: MagicMock +) -> None: + await SMPBLETransport(ADDRESS, bluez={"adapter": "hci1"}, connect_timeout_s=1.0).connect() + + mock_find_device_by_address.assert_called_once_with( + ADDRESS, timeout=1.0, bluez={"adapter": "hci1"} + ) + assert mock_bleak_client.call_args.kwargs["bluez"] == {"adapter": "hci1"} + + +@patch("smpclient.transport.ble.BleakScanner") +@pytest.mark.asyncio +async def test_scan_uses_the_bluez_adapter(mock_bleak_scanner: MagicMock) -> None: + mock_bleak_scanner.return_value.discover = AsyncMock(return_value={}) + + assert await SMPBLETransport.scan(bluez={"adapter": "hci1"}) == [] + + mock_bleak_scanner.assert_called_once_with( + service_uuids=[str(SMP_SERVICE_UUID)], bluez={"adapter": "hci1"} + ) + + @pytest.mark.asyncio async def test_disconnect() -> None: t = SMPBLETransport(ADDRESS) From 2bd75544c1233fbf3379a017f3e4428f22dadccd Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:53:08 -0700 Subject: [PATCH 10/19] breaking(bumble): bond management moves to module functions `SMPBumbleTransport.bonded_devices()`, `.clear_bond(address)`, and `.clear_bonds()` become module functions in `smpclient.transport.bumble`: await bonded_devices(keystore=..., host_address=...) await clear_bond(address, keystore=..., host_address=...) await clear_bonds(keystore=..., host_address=...) They only ever read the transport's `keystore` and `host_address`, the keystore namespace, and never its link. So listing or clearing bonds no longer means building a transport for a device address you don't plan to connect to. The defaults match the transport's, `Tempfile()` and `DEFAULT_HOST_ADDRESS`, so a call with none of the options sees the same bonds a default transport writes. The private `_standalone_keystore()` helper is gone. The functions had no tests; `test_bond_functions_manage_the_hosts_bonds` seeds a keystore, then covers list, per-host isolation, clearing one bond, and clearing all. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/bumble/__init__.py | 47 +++++++++++++--------- tests/test_smp_bumble_transport.py | 30 +++++++++++++- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index b69547d..6c590f1 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -15,7 +15,6 @@ from bumble.device import Connection, Device, Peer from bumble.gatt_client import CharacteristicProxy from bumble.hci import Address, HCI_ErrorCode - from bumble.keys import KeyStore from bumble.pairing import PairingConfig, PairingDelegate from bumble.smp import AuthReq from bumble.transport import open_transport @@ -405,23 +404,6 @@ async def borrowed( finally: await self.disconnect() - async def bonded_devices(self) -> tuple[str, ...]: - """Return the BD_ADDRs of peers currently in the keystore.""" - return tuple(addr for addr, _keys in await self._standalone_keystore().get_all()) - - async def clear_bond(self, address: str) -> None: - """Delete the bond for `address` from the keystore.""" - await self._standalone_keystore().delete(address) - logger.info(f"Cleared bond for {address}") - - async def clear_bonds(self) -> None: - """Delete every bond from the keystore.""" - await self._standalone_keystore().delete_all() - logger.info("Cleared all bonds") - - def _standalone_keystore(self) -> KeyStore: - return resolve_keystore(self._keystore, namespace=str(self._host_address)) - async def pair( self, delegate: PairingDelegate, @@ -597,6 +579,35 @@ async def _teardown_borrowed(self) -> None: logger.warning(f"remove_listener(EVENT_DISCONNECTION) failed: {e}") +async def bonded_devices( + *, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS +) -> tuple[str, ...]: + """Return the BD_ADDRs of peers in the keystore that `host_address` bonds with.""" + return tuple( + addr + for addr, _keys in await resolve_keystore(keystore, namespace=str(host_address)).get_all() + ) + + +async def clear_bond( + address: str, + *, + keystore: KeystoreStrategy = Tempfile(), + host_address: Address = DEFAULT_HOST_ADDRESS, +) -> None: + """Delete the bond for `address` from the keystore that `host_address` bonds with.""" + await resolve_keystore(keystore, namespace=str(host_address)).delete(address) + logger.info(f"Cleared bond for {address}") + + +async def clear_bonds( + *, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS +) -> None: + """Delete every bond from the keystore that `host_address` bonds with.""" + await resolve_keystore(keystore, namespace=str(host_address)).delete_all() + logger.info("Cleared all bonds") + + async def _resolve_target(device: Device, address: str, timeout_s: float) -> str: if MAC_ADDRESS_PATTERN.match(address): return address diff --git a/tests/test_smp_bumble_transport.py b/tests/test_smp_bumble_transport.py index 354394e..afd36d5 100644 --- a/tests/test_smp_bumble_transport.py +++ b/tests/test_smp_bumble_transport.py @@ -6,10 +6,12 @@ import tempfile from contextlib import nullcontext from pathlib import Path -from typing import cast +from typing import Final, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest +from bumble.hci import Address +from bumble.keys import PairingKeys from smpclient.transport import ( Auto, @@ -31,8 +33,15 @@ SMPBumbleTransportNotSMPServer, _DisconnectSentinel, _find_smp_characteristic, + bonded_devices, + clear_bond, + clear_bonds, +) +from smpclient.transport.bumble.device import ( + DEFAULT_HCI_TRANSPORT, + DEFAULT_HOST_ADDRESS, + DEFAULT_HOST_NAME, ) -from smpclient.transport.bumble.device import DEFAULT_HCI_TRANSPORT, DEFAULT_HOST_NAME from smpclient.transport.bumble.keystore import ( Custom, ExistingCustom, @@ -414,6 +423,23 @@ def test_keystore_local_rejects_path_separators() -> None: resolve(Local("nested/bonds.json"), namespace="aa:bb:cc:dd:ee:ff") +@pytest.mark.asyncio +async def test_bond_functions_manage_the_hosts_bonds(tmp_path: Path) -> None: + keystore: Final = Custom(tmp_path / "bonds.json") + peers: Final = ("11:11:11:11:11:11", "22:22:22:22:22:22", "33:33:33:33:33:33") + for peer in peers: + await resolve(keystore, namespace=str(DEFAULT_HOST_ADDRESS)).update(peer, PairingKeys()) + + assert await bonded_devices(keystore=keystore) == peers + assert await bonded_devices(keystore=keystore, host_address=Address("F0:F1:F2:F3:F4:F5")) == () + + await clear_bond(peers[1], keystore=keystore) + assert await bonded_devices(keystore=keystore) == (peers[0], peers[2]) + + await clear_bonds(keystore=keystore) + assert await bonded_devices(keystore=keystore) == () + + def test_find_smp_characteristic_raises_when_service_missing() -> None: peer = MagicMock() peer.get_services_by_uuid.return_value = [] From bdc979e93594049aef1164db20a79938a60c772d Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 14:55:07 -0700 Subject: [PATCH 11/19] fix(bumble): release on cancel, return borrowed links whole, unsubscribe only ourselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lifecycle gaps from the #144 review: - A cancelled `connect()` leaked its partial state. The teardown arm was `except Exception`, which `CancelledError` bypasses, so a cancel mid `device.connect()` left the state at `Connecting` with the HCI transport open. Every later `connect()` then raised "called while in state Connecting". A `CancelledError` arm now tears down and re-raises, logged at debug: a cancel is the caller's decision, not an error. This is pre-existing on main. - `borrow()` was not all-or-nothing. If `negotiate()` raised or was cancelled, the transport stayed `ConnectedBorrowed`, subscribed, with its disconnection listener attached. It now returns the connection (`disconnect()` → `_teardown_borrowed`) and re-raises, like serial and bleak `borrow()`. - Returning a borrowed connection called `smp_characteristic.unsubscribe()` with no subscriber. bumble reads that as "drop every subscriber" and writes the CCCD to zero, cutting off the owner's own notifications on the shared characteristic. It now passes `self._on_notification`. bumble keys subscriber proxies by the subscriber, and a bound method compares equal each time it's looked up, so only this transport's proxy is removed, and the CCCD is cleared only if no subscriber is left. The owned teardown still unsubscribes everything; it owns the whole link. Each new test fails on the previous code. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/bumble/__init__.py | 12 +++++- tests/test_smp_bumble_transport.py | 48 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index 6c590f1..eb6bc00 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -289,6 +289,10 @@ async def connect(self) -> None: ) logger.info(f"Connected to {target}, max_write={max_write}") await self.negotiate() + except asyncio.CancelledError: + logger.debug("connect() cancelled; tearing down partial state") + await self.disconnect() + raise except Exception: logger.exception("connect() failed; tearing down partial state") await self.disconnect() @@ -388,7 +392,11 @@ async def borrow( max_write=max_write, ) logger.info(f"Borrowing connection to {connection.peer_address}, max_write={max_write}") - await self.negotiate() + try: + await self.negotiate() + except (Exception, asyncio.CancelledError): + await self.disconnect() + raise @asynccontextmanager async def borrowed( @@ -568,7 +576,7 @@ async def _next_chunk(self) -> bytes: async def _teardown_borrowed(self) -> None: assert isinstance(self._state, ConnectedBorrowed) try: - await self._state.smp_characteristic.unsubscribe() + await self._state.smp_characteristic.unsubscribe(self._on_notification) except Exception as e: logger.warning(f"smp_characteristic.unsubscribe failed: {e}") try: diff --git a/tests/test_smp_bumble_transport.py b/tests/test_smp_bumble_transport.py index afd36d5..97b891b 100644 --- a/tests/test_smp_bumble_transport.py +++ b/tests/test_smp_bumble_transport.py @@ -716,12 +716,58 @@ async def test_borrow_borrowed_only_unsubscribes_on_disconnect( await t.borrow(bumble_env.connection) assert isinstance(t._state, ConnectedBorrowed) await t.disconnect() - bumble_env.smp_char.unsubscribe.assert_awaited() + bumble_env.smp_char.unsubscribe.assert_awaited_once_with(t._on_notification) bumble_env.connection.disconnect.assert_not_called() bumble_env.device.power_off.assert_not_called() bumble_env.transport.close.assert_not_called() +@pytest.mark.asyncio +async def test_borrow_returns_the_connection_when_negotiation_fails( + bumble_env: _MockBumbleEnvironment, +) -> None: + t = SMPBumbleTransport(ADDRESS) + + with ( + patch( + "smpclient._request.read_mcumgr_parameters", + AsyncMock(side_effect=asyncio.CancelledError), + ), + pytest.raises(asyncio.CancelledError), + ): + await t.borrow(bumble_env.connection) + + assert isinstance(t._state, Disconnected) + bumble_env.smp_char.unsubscribe.assert_awaited_once_with(t._on_notification) + bumble_env.connection.disconnect.assert_not_called() + + +@pytest.mark.asyncio +async def test_connect_tears_down_when_cancelled( + bumble_env: _MockBumbleEnvironment, caplog: pytest.LogCaptureFixture +) -> None: + connecting: Final = asyncio.Event() + + async def connect_until_cancelled(*_args: object, **_kwargs: object) -> MagicMock: + connecting.set() + await asyncio.Event().wait() + return bumble_env.connection + + bumble_env.device.connect = AsyncMock(side_effect=connect_until_cancelled) + t = SMPBumbleTransport(ADDRESS) + + connect: Final = asyncio.create_task(t.connect()) + await connecting.wait() + connect.cancel() + with pytest.raises(asyncio.CancelledError): + await connect + + assert isinstance(t._state, Disconnected) + bumble_env.device.power_off.assert_awaited_once() + bumble_env.transport.close.assert_awaited_once() + assert not [r for r in caplog.records if r.levelno >= logging.ERROR] + + @pytest.mark.asyncio async def test_borrow_skips_discover_when_services_present( bumble_env: _MockBumbleEnvironment, From d35531e289ca9cbae80188bfd2ad4f7da7028442 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 15:06:37 -0700 Subject: [PATCH 12/19] fix(ble): the link sum type carries the client; a closed transport raises SMPTransportDisconnected `_Link` is now `_Closed | _Owned(client) | _Borrowed(client)`, and the separate `_client` attribute is gone. `connect()` creates `_Owned(BleakClient(...))`; `borrow()` makes `_Borrowed(client)`; and `disconnect()` ends in `_Closed()` from any state, so it stays idempotent. This closes a gap that was also on `main`. Once a transport that only ever borrowed had returned its client, `disconnect()` switched back to the old `_Owned()` marker, and `_active_client` read a `self._client` that only `connect()` assigns. `send()`/`receive()` raised `AttributeError: _client` instead of `SMPTransportDisconnected`; the same was true on `main` for a transport that never connected. With the client inside the variant, "no client" is its own case: - `_active_client` raises `SMPTransportDisconnected` on `_Closed`. - `_until_disconnected` returns at once on `_Closed`. - `_best_effort_disconnect` delegates to `disconnect()`, dropping its defensive `getattr`. - `_set_disconnected_event` still rejects a callback from a client other than the owned one. After our own `disconnect()` the link is `_Closed`, so bleak's callback for that disconnect is accepted. Tests inject `t._link = _Owned(client)`, or read the owned client with `_owned_client(t)`, instead of assigning `t._client`. Four sizing tests dropped a client assignment they never used. `test_a_returned_borrow_raises_disconnected` fails on the previous code with the `AttributeError`, and `test_disconnect` now also checks idempotence and a send after close. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/ble.py | 69 ++++++++++++++++++++------------- tests/test_smp_ble_transport.py | 66 +++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 47 deletions(-) diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index ec28f76..652d204 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -95,8 +95,14 @@ class SMPBLETransportNotSMPServer(SMPBLETransportException): """How often a wait on a borrowed client checks `is_connected`; its owner holds the callback.""" +class _Closed(NamedTuple): + """No link: not yet connected, disconnected, or a borrowed client returned.""" + + class _Owned(NamedTuple): - """The link is the transport's own `BleakClient`, which `connect()` creates.""" + """The link is the transport's own `BleakClient`, which `connect()` created.""" + + client: BleakClient class _Borrowed(NamedTuple): @@ -105,7 +111,7 @@ class _Borrowed(NamedTuple): client: BleakClient -_Link: TypeAlias = _Owned | _Borrowed +_Link: TypeAlias = _Closed | _Owned | _Borrowed class SMPBLETransport(_GATTTransport): @@ -144,7 +150,7 @@ def __init__( self._disconnected_event.set() self._winrt = winrt self._bluez: Final = bluez - self._link: _Link = _Owned() + self._link: _Link = _Closed() self._max_write_without_response_size = 20 """Initially set to BLE minimum; may be mutated by the `connect()` method.""" @@ -176,19 +182,21 @@ async def _connect(self, address: str, timeout_s: float) -> None: ) if type(device) is BLEDevice: - self._client = BleakClient( - device, - services=(str(SMP_SERVICE_UUID),), - winrt=self._winrt, - bluez=self._bluez, - timeout=timeout_s, - disconnected_callback=self._set_disconnected_event, + self._link = _Owned( + BleakClient( + device, + services=(str(SMP_SERVICE_UUID),), + winrt=self._winrt, + bluez=self._bluez, + timeout=timeout_s, + disconnected_callback=self._set_disconnected_event, + ) ) else: raise SMPBLETransportDeviceNotFound(f"Device '{address}' not found") logger.debug(f"Found device: {device=}, connecting...") - await self._client.connect() + await self._active_client.connect() self._disconnected_event.clear() logger.debug(f"Connected to {device=}") await self._start_smp() @@ -215,9 +223,9 @@ async def borrowed(self, client: BleakClient) -> AsyncIterator[Self]: @property def _active_client(self) -> BleakClient: match self._link: - case _Owned(): - return self._client - case _Borrowed(client=client): + case _Closed(): + raise SMPTransportDisconnected(f"{self.__class__.__name__} is not connected") + case _Owned(client=client) | _Borrowed(client=client): return client case _ as unreachable: assert_never(unreachable) @@ -268,13 +276,16 @@ async def _start_smp(self) -> None: @override async def disconnect(self) -> None: match self._link: - case _Owned(): - logger.debug(f"Disonnecting from {self._client.address}") - await self._client.disconnect() - logger.debug(f"Disconnected from {self._client.address}") + case _Closed(): + pass + case _Owned(client=client): + logger.debug(f"Disonnecting from {client.address}") + self._link = _Closed() + await client.disconnect() + logger.debug(f"Disconnected from {client.address}") case _Borrowed(client=client): logger.debug(f"Returning the borrowed client for {client.address}") - self._link = _Owned() + self._link = _Closed() try: await asyncio.wait_for( client.stop_notify(SMP_CHARACTERISTIC_UUID), timeout=self._connect_timeout_s @@ -359,15 +370,22 @@ def _winrt_backend(client_backend: _ClientBackend) -> TypeGuard[BleakClientWinRT return client_backend.__class__.__name__ == "BleakClientWinRT" def _set_disconnected_event(self, client: BleakClient) -> None: - if client is not self._client: - raise SMPBLETransportException( - f"Unexpected client disconnected: {client=}, {self._client=}" - ) + match self._link: + case _Owned(client=owned) if owned is not client: + raise SMPBLETransportException( + f"Unexpected client disconnected: {client=}, {owned=}" + ) + case _Closed() | _Owned() | _Borrowed(): + pass + case _ as unreachable: + assert_never(unreachable) logger.warning(f"Disconnected from {client.address}") self._disconnected_event.set() async def _until_disconnected(self) -> None: match self._link: + case _Closed(): + pass case _Owned(): await self._disconnected_event.wait() case _Borrowed(client=client): @@ -418,10 +436,7 @@ async def _await_or_disconnect(self, coro: Coroutine[Any, Any, _T]) -> _T: async def _best_effort_disconnect(self) -> None: """Best-effort cleanup after a failed `connect()`; never raises.""" - client: Final = getattr(self, "_client", None) - if client is None: - return try: - await client.disconnect() + await self.disconnect() except Exception: logger.warning("Best-effort disconnect after failed connect raised", exc_info=True) diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index a9b16de..e9a1341 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -20,6 +20,7 @@ UUID_PATTERN, SMPBLETransport, SMPBLETransportDeviceNotFound, + _Owned, ) from tests.support import advertise, negotiated @@ -120,10 +121,7 @@ async def test_connect( # assert that connect is awaited t = SMPBLETransport("name", connect_timeout_s=1.0) await t.connect() - t._client = cast(MagicMock, t._client) - t._client.reset_mock() - await t.connect() - t._client.connect.assert_awaited_once_with() + _owned_client(t).connect.assert_awaited_once_with() # these are hard to mock now because the _client is created in the connect method # reenable these after the SMPTransport Protocol is updated to take address @@ -145,7 +143,9 @@ async def test_connect( # assert t._smp_characteristic is m # assert that SMP characteristic notifications are started - t._client.start_notify.assert_called_once_with(SMP_CHARACTERISTIC_UUID, t._notify_callback) + _owned_client(t).start_notify.assert_called_once_with( + SMP_CHARACTERISTIC_UUID, t._notify_callback + ) @patch( @@ -179,10 +179,24 @@ async def test_scan_uses_the_bluez_adapter(mock_bleak_scanner: MagicMock) -> Non @pytest.mark.asyncio async def test_disconnect() -> None: + client: Final = MagicMock(spec=BleakClient) t = SMPBLETransport(ADDRESS) - t._client = MagicMock(spec=BleakClient) + t._link = _Owned(client) + await t.disconnect() - t._client.disconnect.assert_awaited_once_with() + await t.disconnect() + + client.disconnect.assert_awaited_once_with() + with pytest.raises(SMPTransportDisconnected): + await t.send(b"Hello pytest!") + + +def _owned_client(t: SMPBLETransport) -> MagicMock: + match t._link: + case _Owned(client=client): + return cast(MagicMock, client) + case _: + pytest.fail(f"expected an owned link, got {t._link}") def _borrowable_client(max_write: int = 244) -> MagicMock: @@ -215,6 +229,19 @@ async def test_borrowed_subscribes_and_leaves_the_client_connected() -> None: client.disconnect.assert_not_awaited() +@pytest.mark.asyncio +async def test_a_returned_borrow_raises_disconnected() -> None: + t = SMPBLETransport(ADDRESS) + async with t.borrowed(_borrowable_client()): + pass + + with pytest.raises(SMPTransportDisconnected): + await t.send(b"Hello pytest!") + with pytest.raises(SMPTransportDisconnected): + await asyncio.wait_for(t.receive(), timeout=1.0) + await t.disconnect() + + @pytest.mark.asyncio async def test_borrowed_receive_raises_once_the_client_disconnects() -> None: """The owner holds the client's disconnect callback, so a borrowed wait polls instead.""" @@ -292,12 +319,13 @@ async def test_returning_a_borrowed_client_survives_its_unsubscribe(stop_notify: @pytest.mark.asyncio async def test_send() -> None: + client: Final = MagicMock(spec=BleakClient) t = SMPBLETransport(ADDRESS) - t._client = MagicMock(spec=BleakClient) + t._link = _Owned(client) t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) t._smp_characteristic.max_write_without_response_size = 20 await t.send(b"Hello pytest!") - t._client.write_gatt_char.assert_awaited_once_with( + client.write_gatt_char.assert_awaited_once_with( t._smp_characteristic, b"Hello pytest!", response=False ) @@ -305,7 +333,7 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_receive() -> None: t = SMPBLETransport(ADDRESS) - t._client = MagicMock(spec=BleakClient) + t._link = _Owned(MagicMock(spec=BleakClient)) t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) t._smp_characteristic.uuid = str(SMP_CHARACTERISTIC_UUID) t._disconnected_event.clear() # pretend t.connect() was successful @@ -345,7 +373,6 @@ async def test_send_and_receive() -> None: def test_max_unencoded_size() -> None: t = SMPBLETransport(ADDRESS) - t._client = MagicMock(spec=BleakClient) t._max_write_without_response_size = 42 assert t.max_unencoded_size == 42 @@ -353,7 +380,6 @@ def test_max_unencoded_size() -> None: @pytest.mark.asyncio async def test_max_unencoded_size_mcumgr_param() -> None: t = SMPBLETransport(ADDRESS) - t._client = MagicMock(spec=BleakClient) t._max_write_without_response_size = 42 assert (await negotiated(t, 9001)).max_unencoded_size == 9001 @@ -363,7 +389,6 @@ async def test_max_unencoded_size_mcumgr_param() -> None: async def test_unfragmented_caps_at_the_write_size(buf_size: int, expected: int) -> None: """One message per write: never more than one write, nor more than the server holds.""" t = SMPBLETransport(ADDRESS, fragmentation_strategy=Unfragmented()) - t._client = MagicMock(spec=BleakClient) t._max_write_without_response_size = 42 assert (await negotiated(t, buf_size)).max_unencoded_size == expected @@ -371,7 +396,6 @@ async def test_unfragmented_caps_at_the_write_size(buf_size: int, expected: int) @pytest.mark.asyncio async def test_buffer_size_never_reads() -> None: t = SMPBLETransport(ADDRESS, fragmentation_strategy=BufferSize(512)) - t._client = MagicMock(spec=BleakClient) with advertise(9001) as read: await t.negotiate() read.assert_not_awaited() @@ -418,39 +442,41 @@ async def test_connect_raises_on_peer_disconnect_during_start_notify( """ t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=5.0) - async def _trip_disconnect_callback() -> None: + async def _trip_disconnect_callback() -> MagicMock: # Wait until the transport reaches start_notify and clears the event, # then simulate the bleak `disconnected_callback` firing. while t._disconnected_event.is_set(): await asyncio.sleep(0) await asyncio.sleep(0) # let start_notify await begin - t._set_disconnected_event(t._client) + client: Final = _owned_client(t) + t._set_disconnected_event(client) + return client connect_task = asyncio.create_task(t.connect()) trip_task = asyncio.create_task(_trip_disconnect_callback()) with pytest.raises(SMPTransportDisconnected): await connect_task - await trip_task # `_best_effort_disconnect` should have been called to release the client. - t._client.disconnect.assert_awaited() # type: ignore[attr-defined] + (await trip_task).disconnect.assert_awaited() @patch( "smpclient.transport.ble.BleakScanner.find_device_by_address", return_value=BLEDevice("00:00:00:00:00:00", "name", None), ) -@patch("smpclient.transport.ble.BleakClient", new=_HangingBleakClient) +@patch("smpclient.transport.ble.BleakClient", return_value=_HangingBleakClient()) @pytest.mark.asyncio async def test_connect_raises_on_timeout_during_start_notify( + mock_bleak_client: MagicMock, _mock_find_device_by_address: MagicMock, ) -> None: """`connect()` must honor `connect_timeout_s` even when `start_notify` hangs.""" t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=0.05) with pytest.raises(asyncio.TimeoutError): await t.connect() - t._client.disconnect.assert_awaited() # type: ignore[attr-defined] + mock_bleak_client.return_value.disconnect.assert_awaited() @patch( From 5d587912202964fb77eae2a6ed24643e4d962e1e Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 15:14:36 -0700 Subject: [PATCH 13/19] test(serial): lock SerialOptions to SerialBase, which declares pyserial's settings everywhere The lock test read `inspect.signature(serial.Serial)`, which only works where `serial.Serial` inherits `SerialBase.__init__`: POSIX. On Windows, `serial.Serial` is `serialwin32.Serial`, whose `__init__(self, *args, **kwargs)` sets up the overlapped handles and forwards to `SerialBase.__init__`. The signature there reads `('args',)`, which failed every Windows job on #144. `SerialBase` in `serial.serialutil` declares the settings on every platform, so the test reads its signature, and first asserts that `serial.Serial` subclasses it. The transport already passes `**options._asdict()` through `serial.Serial` to `SerialBase`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_smp_serial_transport.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 0613118..5ecc883 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -13,6 +13,7 @@ import pytest import serial from serial import SerialException +from serial.serialutil import SerialBase from smp import packet as smppacket from smp.os_management import EchoWriteRequest, EchoWriteResponse @@ -68,10 +69,14 @@ def test_constructor() -> None: def test_serial_options_lock_pyserial() -> None: - """`SerialOptions` is `serial.Serial`'s settings, in order, with its defaults but the baudrate.""" + """`SerialOptions` is pyserial's settings, in order, with its defaults but the baudrate. + + The settings are `SerialBase`'s: on Windows, `serial.Serial.__init__` is `*args, **kwargs`. + """ + assert issubclass(serial.Serial, SerialBase) pyserial_defaults: Final = { name: parameter.default - for name, parameter in inspect.signature(serial.Serial).parameters.items() + for name, parameter in inspect.signature(SerialBase).parameters.items() if name != "port" and parameter.kind is not inspect.Parameter.VAR_KEYWORD } assert tuple(pyserial_defaults) == SerialOptions._fields From a1c2b1666428f2ff2c5f13e36815f0ea1601ba60 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 15:55:33 -0700 Subject: [PATCH 14/19] breaking(serial): drop the deprecated 7.1.0 sizing params Per review on #144: the overloads existed only for backwards compatibility, and this is the breaking release, so they go now rather than in the follow-up planned earlier. `SMPSerialTransport(port, fragmentation_strategy=Auto(), *, ...)` is now the only signature. Removed: - the three `__init__` overloads, including the two `@deprecated` ones for `max_smp_encoded_frame_size`/`line_length`/`line_buffers` - `_LegacyParams` and `_ResolvedStrategy` - `_resolve_fragmentation_strategy` - the `_LEGACY_FRAME_SIZE` constant and `_LEGACY_PARAMS_DEPRECATION` - the `_LegacyParams` match arms in the sizing properties - the six tests covering the 7.1.0 reproduction The constructor now validates the strategy directly. `_LEGACY_LINE_BUFFERS` survives as `_AUTO_LINE_BUFFERS`, the line buffers `Auto` assumes before the server's parameters are read. `typing_extensions.deprecated` is no longer used, and the pyproject comment about typing-extensions' minimum no longer names it. Migration: `max_smp_encoded_frame_size=n, line_length=l, line_buffers=b` becomes `BufferParams(line_length=l, line_buffers=b)`, or better, `BufferSize(buf_size=...)` for the server's decoded buffer. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- src/smpclient/transport/serial/encoded.py | 202 ++-------------------- tests/test_smp_serial_transport.py | 145 +--------------- 3 files changed, 16 insertions(+), 333 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c5d9db3..752d004 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "smp @ git+https://github.com/JPHutchins/smp@screaming-goblin", "msgspec>=0.21.1", "intelhex>=2.3.0", -# `TypeIs` landed in 4.10; `override`/`assert_never`/`deprecated` are older +# `TypeIs` landed in 4.10; `override`/`assert_never` are older "typing-extensions>=4.10", "async-timeout>=5.0.1; python_version < '3.11'", ] diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index f9802a8..a4ef6bb 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -24,13 +24,12 @@ import asyncio import logging import math -import warnings from collections.abc import Iterator from enum import IntEnum, unique from typing import TYPE_CHECKING, Final, NamedTuple, TypeAlias from smp import packet as smppacket -from typing_extensions import assert_never, deprecated, overload, override +from typing_extensions import assert_never, override from smpclient.transport import Auto from smpclient.transport.serial.common import SerialOptions, _SerialTransportBase @@ -60,11 +59,8 @@ def _base64_max(size: int) -> int: _DEFAULT_LINE_LENGTH: Final = 128 """The SMP serial line length convention: base64 chars per line on the wire.""" -_LEGACY_LINE_BUFFERS: Final = 2 -"""The 7.1.0 default `line_buffers`, preserved for the deprecated constructor params.""" - -_LEGACY_FRAME_SIZE: Final = 256 -"""The 7.1.0 default `max_smp_encoded_frame_size`, preserved for the deprecated params.""" +_AUTO_LINE_BUFFERS: Final = 2 +"""The line buffers `Auto` assumes until it reads the server's parameters.""" _MIN_LINE_LENGTH: Final = 8 """The smallest `line_length` that can carry a base64 payload. @@ -91,17 +87,6 @@ def _encoded_budget(mtu: int, line_buffers: int) -> int: return _base64_max(mtu) - packet_framing_size -_LEGACY_PARAMS_DEPRECATION: Final = ( - "max_smp_encoded_frame_size, line_length, and line_buffers are deprecated; pass a " - "fragmentation_strategy (Auto, BufferSize, or BufferParams) instead." -) -"""The runtime `DeprecationWarning` message. - -The `@deprecated` overload decorators must repeat this text as a string *literal* -- -PEP 702 type checkers ignore a name reference -- so keep the two in sync. -""" - - class BufferSize(NamedTuple): """Manually specify the server's decoded reassembly buffer size. @@ -149,31 +134,6 @@ class BufferParams(NamedTuple): """ -class _LegacyParams(NamedTuple): - """The deprecated 7.1.0 `(max_smp_encoded_frame_size, line_length, line_buffers)` sizing. - - Constructed only by the deprecated constructor params; it reproduces 7.1.0 - byte-for-byte. Unlike `BufferParams`, `mtu` is the *explicit* - `max_smp_encoded_frame_size` (independent of `line_length * line_buffers`, exactly - as 7.1.0 stored it), while the per-line framing still spans `line_buffers`. Not part - of the public `SerialFragmentationStrategy` API -- prefer `Auto`, `BufferSize`, or - `BufferParams`. - """ - - max_smp_encoded_frame_size: int - """The encoded frame size that `mtu` reports verbatim (7.1.0 semantics).""" - - line_length: int - """The maximum length of one fragment (line) on the wire.""" - - line_buffers: int - """The number of encoded line buffers the framing budget spans.""" - - -_ResolvedStrategy: TypeAlias = Auto | BufferSize | BufferParams | _LegacyParams -"""The internal strategy a constructor call resolves to (adds the deprecated `_LegacyParams`).""" - - class SMPSerialTransport(_SerialTransportBase): @unique class BufferState(IntEnum): @@ -187,60 +147,11 @@ class BufferState(IntEnum): `_buffer` is being parsed as serial data. """ - @overload - def __init__( - self, - port: str, - fragmentation_strategy: SerialFragmentationStrategy = ..., - *, - connect_timeout_s: float = ..., - sequence: Iterator[u8] | None = ..., - options: SerialOptions = ..., - ) -> None: ... - - @overload - @deprecated( - "max_smp_encoded_frame_size, line_length, and line_buffers are deprecated; pass a " - "fragmentation_strategy (Auto, BufferSize, or BufferParams) instead." - ) - def __init__( - self, - port: str, - *, - max_smp_encoded_frame_size: int = ..., - line_length: int = ..., - line_buffers: int = ..., - connect_timeout_s: float = ..., - sequence: Iterator[u8] | None = ..., - options: SerialOptions = ..., - ) -> None: ... - - @overload - @deprecated( - "max_smp_encoded_frame_size, line_length, and line_buffers are deprecated; pass a " - "fragmentation_strategy (Auto, BufferSize, or BufferParams) instead." - ) def __init__( self, port: str, - max_smp_encoded_frame_size: int, - line_length: int = ..., - line_buffers: int = ..., - /, - *, - connect_timeout_s: float = ..., - sequence: Iterator[u8] | None = ..., - options: SerialOptions = ..., - ) -> None: ... - - def __init__( # noqa: DOC301 - self, - port: str, - fragmentation_strategy: SerialFragmentationStrategy | int | None = None, - line_length: int | None = None, - line_buffers: int | None = None, + fragmentation_strategy: SerialFragmentationStrategy = Auto(), *, - max_smp_encoded_frame_size: int | None = None, connect_timeout_s: float = 2.5, sequence: Iterator[u8] | None = None, options: SerialOptions = SerialOptions(), @@ -249,13 +160,7 @@ def __init__( # noqa: DOC301 Args: port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. - fragmentation_strategy: how to size SMP messages; one of `Auto` - (default), `BufferSize`, or `BufferParams`. - line_length: Deprecated; pass `BufferParams(line_length=...)` (or `BufferSize`). - line_buffers: Deprecated; pass `BufferParams(line_buffers=...)`. - max_smp_encoded_frame_size: Deprecated, but still honored for backward - compatibility -- it drives `mtu` exactly as in 7.1.0. Prefer an explicit - `BufferSize(buf_size=...)` (decoded netbuf) for new code. + fragmentation_strategy: how to size SMP messages. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from; @@ -270,9 +175,8 @@ def __init__( # noqa: DOC301 options, ) - self._fragmentation_strategy: Final = self._resolve_fragmentation_strategy( - fragmentation_strategy, max_smp_encoded_frame_size, line_length, line_buffers - ) + self._validate_strategy(fragmentation_strategy) + self._fragmentation_strategy: Final = fragmentation_strategy self._smp_packet_queue: asyncio.Queue[bytes] = asyncio.Queue() """Contains full SMP packets.""" @@ -285,73 +189,6 @@ def __init__( # noqa: DOC301 logger.debug(f"Initialized {self.__class__.__name__}") - @staticmethod - def _resolve_fragmentation_strategy( - fragmentation_strategy: SerialFragmentationStrategy | int | None, - max_smp_encoded_frame_size: int | None, - line_length: int | None, - line_buffers: int | None, - ) -> _ResolvedStrategy: - """Normalize the constructor inputs into a fragmentation strategy. - - An explicit `fragmentation_strategy` always wins; it is validated, and any - stray deprecated args passed alongside it are logged and ignored. Otherwise - the deprecated 7.1.0 params -- `max_smp_encoded_frame_size`, `line_length`, - `line_buffers`, or a legacy positional `int` frame size -- reproduce 7.1.0 - exactly via `_LegacyParams` (`mtu == max_smp_encoded_frame_size`, defaulting to - the 7.1.0 256/128/2) and emit a `DeprecationWarning`. A frame size that - disagrees with `line_length * line_buffers` is logged at the level 7.1.0 used, - but -- as in 7.1.0 -- the explicit frame size still drives `mtu`. - """ - if not isinstance(fragmentation_strategy, int) and fragmentation_strategy is not None: - ignored: Final = { - name: value - for name, value in ( - ("max_smp_encoded_frame_size", max_smp_encoded_frame_size), - ("line_length", line_length), - ("line_buffers", line_buffers), - ) - if value is not None - } - if ignored: - logger.warning( - f"explicit fragmentation_strategy={fragmentation_strategy!r} takes " - f"precedence; ignoring deprecated {ignored}" - ) - SMPSerialTransport._validate_strategy(fragmentation_strategy) - return fragmentation_strategy - - legacy_frame: Final = ( - max_smp_encoded_frame_size - if max_smp_encoded_frame_size is not None - else (fragmentation_strategy if isinstance(fragmentation_strategy, int) else None) - ) - if legacy_frame is None and line_length is None and line_buffers is None: - return Auto() - - warnings.warn(_LEGACY_PARAMS_DEPRECATION, DeprecationWarning, stacklevel=3) - resolved_frame: Final = _LEGACY_FRAME_SIZE if legacy_frame is None else legacy_frame - resolved_line_length: Final = _DEFAULT_LINE_LENGTH if line_length is None else line_length - resolved_line_buffers: Final = ( - _LEGACY_LINE_BUFFERS if line_buffers is None else line_buffers - ) - budget: Final = resolved_line_length * resolved_line_buffers - if resolved_frame < budget: - logger.error( - f"max_smp_encoded_frame_size={resolved_frame} is less than " - f"line_length={resolved_line_length} * line_buffers={resolved_line_buffers}!" - ) - elif resolved_frame != budget: - logger.warning( - f"max_smp_encoded_frame_size={resolved_frame} is not equal to " - f"line_length={resolved_line_length} * line_buffers={resolved_line_buffers}!" - ) - return _LegacyParams( - max_smp_encoded_frame_size=resolved_frame, - line_length=resolved_line_length, - line_buffers=resolved_line_buffers, - ) - @staticmethod def _validate_strategy(strategy: SerialFragmentationStrategy) -> None: """Raise `ValueError` for a modern strategy that cannot carry a message. @@ -359,10 +196,8 @@ def _validate_strategy(strategy: SerialFragmentationStrategy) -> None: Guards `BufferSize`/`BufferParams` against configs that would otherwise fail far downstream: a `line_length` too small for `smppacket.encode` to make progress (it would emit empty packets forever), a `buf_size` at or below the frame overhead, or - an encoded budget too small for a single byte. The deprecated 7.1.0 params are - intentionally *not* validated -- `_LegacyParams` reproduces 7.1.0 behavior, latent - edge cases and all. `Auto` defers to `negotiate`, where the server's advertised - buffer size is known. + an encoded budget too small for a single byte. `Auto` defers to `negotiate`, where + the server's advertised buffer size is known. """ match strategy: case Auto(): @@ -413,8 +248,6 @@ def _line_length(self) -> int: return line_length case BufferParams(line_length=line_length): return line_length - case _LegacyParams(line_length=line_length): - return line_length case _ as unreachable: assert_never(unreachable) @@ -422,23 +255,21 @@ def _line_length(self) -> int: def _line_buffers(self) -> int: """The number of encoded line buffers spanned by the configured budget. - Meaningful for `BufferParams`/legacy params, where it sets the encoded budget. + Meaningful for `BufferParams`, where it sets the encoded budget. For the decoded-netbuf strategies (`Auto`/`BufferSize`) it is a diagnostic line count, clamped to at least 1 (never the misleading `0` of a sub-`line_length` - buffer); `Auto` falls back to the conservative legacy default until the server's + buffer); `Auto` falls back to a conservative default until the server's params are read. """ match self._fragmentation_strategy: case Auto(): if self._negotiated_buf_size is not None: return max(1, self._negotiated_buf_size // self._line_length) - return _LEGACY_LINE_BUFFERS + return _AUTO_LINE_BUFFERS case BufferSize(buf_size=buf_size): return max(1, buf_size // self._line_length) case BufferParams(line_buffers=line_buffers): return line_buffers - case _LegacyParams(line_buffers=line_buffers): - return line_buffers case _ as unreachable: assert_never(unreachable) @@ -454,8 +285,6 @@ def _max_smp_encoded_frame_size(self) -> int: return buf_size case BufferParams(line_length=line_length, line_buffers=line_buffers): return line_length * line_buffers - case _LegacyParams(max_smp_encoded_frame_size=frame_size): - return frame_size case _ as unreachable: assert_never(unreachable) @@ -479,7 +308,7 @@ async def negotiate(self) -> None: f"mtu={self.mtu}, max_unencoded_size={self.max_unencoded_size}, " f"line_length={self._line_length}" ) - case BufferSize() | BufferParams() | _LegacyParams(): + case BufferSize() | BufferParams(): pass case _ as unreachable: assert_never(unreachable) @@ -662,8 +491,7 @@ def max_unencoded_size(self) -> int: (Verified against native_sim/QEMU/mps2: a `buf_size - 4` message round-trips; `buf_size - 3` is dropped.) - `BufferParams`, the deprecated 7.1.0 params, and `Auto` before initialization - instead bound the message by an *encoded* line-buffer budget: how many + `BufferParams`, and `Auto` before initialization, instead bound the message by an *encoded* line-buffer budget: how many unencoded bytes survive base64 expansion and per-line framing within `mtu`. SMP serial framing (the 2-byte length + 2-byte CRC16): @@ -678,8 +506,6 @@ def max_unencoded_size(self) -> int: return buf_size - _FRAME_OVERHEAD case BufferParams(): return self._encoded_budget_max_unencoded_size() - case _LegacyParams(): - return self._encoded_budget_max_unencoded_size() case _ as unreachable: assert_never(unreachable) diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 5ecc883..ff00fec 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -4,9 +4,7 @@ import asyncio import inspect -import logging -import warnings -from collections.abc import Callable, Generator +from collections.abc import Generator from typing import Any, Final, get_args from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch @@ -480,147 +478,6 @@ def test_fragmentation_strategy_alias() -> None: assert set(get_args(SerialFragmentationStrategy)) == {Auto, BufferSize, BufferParams} -@pytest.mark.parametrize( - "make, mtu, line_length, line_buffers", - [ - pytest.param( - lambda: SMPSerialTransport( - PORT, max_smp_encoded_frame_size=512, line_length=128, line_buffers=4 - ), - 512, - 128, - 4, - id="kw-frame-ll-lb", - ), - pytest.param( - lambda: SMPSerialTransport(PORT, line_length=64, line_buffers=4), - 256, # max_smp_encoded_frame_size defaults to the 7.1.0 256 - 64, - 4, - id="kw-ll-lb-defaults-frame-256", - ), - # 7.1.0 positional layout was (max_smp_encoded_frame_size, line_length, line_buffers), - # with the line_length=128, line_buffers=2 defaults. - pytest.param(lambda: SMPSerialTransport(PORT, 256), 256, 128, 2, id="pos-frame"), - pytest.param(lambda: SMPSerialTransport(PORT, 256, 128, 2), 256, 128, 2, id="pos-triple"), - # A frame size larger than line_length * line_buffers still drives mtu (as in 7.1.0), - # rather than being silently downgraded to the 128 * 2 == 256 budget. - pytest.param(lambda: SMPSerialTransport(PORT, 512), 512, 128, 2, id="pos-frame-gt-budget"), - pytest.param( - lambda: SMPSerialTransport(PORT, max_smp_encoded_frame_size=1024), - 1024, - 128, - 2, - id="kw-frame-only-gt-budget", - ), - ], -) -def test_deprecated_params_reproduce_7_1_0( - make: Callable[[], SMPSerialTransport], mtu: int, line_length: int, line_buffers: int -) -> None: - """The deprecated 7.1.0 params still construct, warn, and keep 7.1.0 sizing. - - `mtu` is the explicit `max_smp_encoded_frame_size` (defaulting to the 7.1.0 256), - independent of `line_length * line_buffers` -- not silently downgraded to the budget. - """ - with pytest.warns(DeprecationWarning, match="fragmentation_strategy"): - t = make() - assert t.mtu == mtu - assert t._line_length == line_length - assert t._line_buffers == line_buffers - - -@pytest.mark.parametrize( - "frame_size, expected_mtu, expected_max_unencoded", - # Values from smpclient 7.1.0 (default line_length=128, line_buffers=2): - # mtu == max_smp_encoded_frame_size, max_unencoded == _base64_max(mtu) - framing(2). - [(256, 256, 169), (512, 512, 361), (1024, 1024, 745)], -) -def test_deprecated_frame_size_matches_7_1_0_throughput( - frame_size: int, expected_mtu: int, expected_max_unencoded: int -) -> None: - """A legacy `max_smp_encoded_frame_size` yields the exact 7.1.0 mtu/max_unencoded_size. - - Guards against the regression where the frame size was downgraded to 128 * 2 == 256 - (which halved, or worse, the per-request payload for upgraders). - """ - with pytest.warns(DeprecationWarning): - t = SMPSerialTransport(PORT, max_smp_encoded_frame_size=frame_size) - assert t.mtu == expected_mtu - assert t.max_unencoded_size == expected_max_unencoded - - -def test_deprecated_params_match_equivalent_buffer_params() -> None: - """A *consistent* deprecated call (frame == line_length*line_buffers) equals its BufferParams.""" - with pytest.warns(DeprecationWarning): - legacy = SMPSerialTransport( - PORT, max_smp_encoded_frame_size=512, line_length=128, line_buffers=4 - ) - modern = SMPSerialTransport( - PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=4) - ) - - assert legacy.mtu == modern.mtu # 512 == 128 * 4 - assert legacy.max_unencoded_size == modern.max_unencoded_size - assert legacy._line_length == modern._line_length - assert legacy._line_buffers == modern._line_buffers - - -def test_deprecated_frame_size_mismatch_is_logged(caplog: pytest.LogCaptureFixture) -> None: - """A frame size disagreeing with line_length*line_buffers is logged but still drives mtu. - - 7.1.0 logged the mismatch (WARNING when greater, ERROR when smaller) and kept using the - explicit max_smp_encoded_frame_size; this reproduces that, rather than downgrading mtu. - """ - with caplog.at_level(logging.WARNING), pytest.warns(DeprecationWarning): - t = SMPSerialTransport( - PORT, max_smp_encoded_frame_size=512, line_length=128, line_buffers=2 - ) - assert any("is not equal to" in record.message for record in caplog.records) - assert t.mtu == 512 # the explicit frame size wins, as in 7.1.0 (not 128 * 2 == 256) - - caplog.clear() - with caplog.at_level(logging.ERROR), pytest.warns(DeprecationWarning): - t = SMPSerialTransport(PORT, max_smp_encoded_frame_size=64, line_length=128, line_buffers=2) - assert any( - record.levelno == logging.ERROR and "is less than" in record.message - for record in caplog.records - ) - assert t.mtu == 64 # still honored, as in 7.1.0 - - -@pytest.mark.parametrize( - "make", - [ - pytest.param(lambda: SMPSerialTransport(PORT), id="auto-default"), - pytest.param( - lambda: SMPSerialTransport(PORT, fragmentation_strategy=Auto()), id="auto-explicit" - ), - pytest.param(lambda: SMPSerialTransport(PORT, BufferSize(buf_size=1024)), id="buffersize"), - pytest.param( - lambda: SMPSerialTransport(PORT, BufferParams(line_length=128, line_buffers=4)), - id="bufferparams", - ), - ], -) -def test_modern_constructors_do_not_warn(make: Callable[[], SMPSerialTransport]) -> None: - """The modern fragmentation_strategy API must never emit a DeprecationWarning.""" - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - make() - - -def test_explicit_strategy_wins_over_stray_legacy_args(caplog: pytest.LogCaptureFixture) -> None: - """An explicit strategy is returned as-is (never the legacy path), but stray args are logged.""" - resolve = SMPSerialTransport._resolve_fragmentation_strategy - with warnings.catch_warnings(), caplog.at_level(logging.WARNING): - warnings.simplefilter("error", DeprecationWarning) # the explicit strategy must not warn - assert resolve(BufferSize(buf_size=1024), None, 64, None) == BufferSize(buf_size=1024) - assert resolve(Auto(), 999, 64, 8) == Auto() - # the silently-dropped legacy args are surfaced rather than ignored without a trace - assert any("ignoring deprecated" in record.message for record in caplog.records) - - @pytest.mark.parametrize( "strategy", [ From e4e8228d685585ce8f395957ad70b852d271add5 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 15:59:14 -0700 Subject: [PATCH 15/19] refactor(transport): Final config, a structural sequence default, and no Optional sizing state Per review on #144 ("Are these final?", "you say it defaults to wrapping_sequence, yet you default it to None in the sig", "less mutation"): - `_ConnectableTransport` is now a concrete base, generic over the transport's fragmentation strategy union. Its `__init__` sets `_fragmentation_strategy`, `_connect_timeout_s`, and `_sequence` as `Final`. As Protocol members they could not be `Final`: a `Final` assignment in an implementer conflicts with a writable Protocol attribute. `connect`/`disconnect`/`negotiate` are `@abstractmethod`, and their docstrings point at the `connected()` bracket. - `sequence: Iterator[u8] | None = None` becomes `sequence: Callable[[], Iterator[u8]] = wrapping_sequence` on every transport and on `SMPClient`, so the signature states the default. A factory rather than an iterator, because a default iterator is evaluated once at definition time and would be shared across instances. The "defaults to `wrapping_sequence()`" prose is gone. - `_negotiated_buf_size: int | None` is gone. The configured strategy stays `Final`, and one slot, `_sizing`, holds the strategy as `negotiate()` resolved it. `Auto` resolves to `BufferSize(n)` when the server advertises `n`; GATT `Unfragmented` resolves to `BufferSize(min(mtu, n))`. An unadvertised read resets to the unresolved strategy, so a re-negotiation never keeps a stale size from an earlier server. Every sizing property now matches `_sizing` with no `is None` branches; `Auto` there only means "the server advertised nothing". - Attributes that are never reassigned are `Final`: UDP's `_mtu`, bleak's `_buffer`/`_notify_condition`/`_disconnected_event`/`_winrt`, and `SMPClient._timeout_s`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/__init__.py | 10 +-- src/smpclient/transport/__init__.py | 82 ++++++++++++--------- src/smpclient/transport/ble.py | 19 ++--- src/smpclient/transport/bumble/__init__.py | 11 +-- src/smpclient/transport/serial/common.py | 31 +++----- src/smpclient/transport/serial/encoded.py | 37 +++------- src/smpclient/transport/serial/unencoded.py | 32 ++++---- src/smpclient/transport/udp.py | 29 ++++---- tests/test_smp_client.py | 2 +- 9 files changed, 117 insertions(+), 136 deletions(-) diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index 8996be9..ce72f71 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -38,7 +38,7 @@ from __future__ import annotations import logging -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Callable, Iterator from hashlib import sha256 from typing import TYPE_CHECKING, Final, TypeVar @@ -90,7 +90,7 @@ class SMPClient: Args: transport: the connected `SMPTransport`; the client never opens or closes it timeout_s: the default timeout in seconds for SMP requests - sequence: this client's SMP sequence space; defaults to `wrapping_sequence()` + sequence: this client's SMP sequence space Example: ```python @@ -119,11 +119,11 @@ def __init__( # noqa: DOC301 transport: SMPTransport, *, timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, + sequence: Callable[[], Iterator[u8]] = wrapping_sequence, ): self._transport: Final = transport - self._timeout_s = timeout_s - self._sequence: Final = wrapping_sequence() if sequence is None else sequence + self._timeout_s: Final = timeout_s + self._sequence: Final = sequence() async def request( self, request: SMPRequest[TRep, TEr1, TEr2], timeout_s: float | None = None diff --git a/src/smpclient/transport/__init__.py b/src/smpclient/transport/__init__.py index 816d0c6..a6dff05 100644 --- a/src/smpclient/transport/__init__.py +++ b/src/smpclient/transport/__init__.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging -from collections.abc import AsyncIterator, Iterator +from abc import abstractmethod +from collections.abc import AsyncIterator, Callable, Iterator from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypeAlias +from typing import TYPE_CHECKING, Final, Generic, NamedTuple, Protocol, TypeAlias, TypeVar from uuid import UUID from typing_extensions import Self, assert_never, override @@ -104,33 +105,46 @@ def max_unencoded_size(self) -> int: # pragma: no cover ... -class _ConnectableTransport(SMPTransport, Protocol): - """An `SMPTransport` that opens and closes its own link. - - `SMPClient` sees only the `SMPTransport` part. Prefer the `connected()` bracket; - `connect()` and `disconnect()` are for a lifetime that a lexical scope can't express. - """ +_TStrategy = TypeVar("_TStrategy") +"""A transport's fragmentation strategy union.""" - _sequence: Iterator[u8] - """The SMP sequence space that the MCUmgr parameters read draws from.""" - _connect_timeout_s: float - """Bounds establishing the link, including reading the MCUmgr parameters.""" +class _ConnectableTransport(SMPTransport, Generic[_TStrategy]): + """An `SMPTransport` that opens and closes its own link. - _negotiated_buf_size: int | None = None - """The server's advertised `buf_size`, once a fragmentation strategy that asks has read it.""" + `SMPClient` sees only the `SMPTransport` part. + """ + def __init__( + self, + fragmentation_strategy: _TStrategy, + connect_timeout_s: float, + sequence: Callable[[], Iterator[u8]], + ) -> None: + self._fragmentation_strategy: Final = fragmentation_strategy + self._sizing: _TStrategy = fragmentation_strategy + """The fragmentation strategy as `negotiate()` resolved it.""" + self._connect_timeout_s: Final = connect_timeout_s + self._sequence: Final = sequence() + + @abstractmethod async def connect(self) -> None: # pragma: no cover - """Open the link, then `negotiate()`.""" - ... + """Open the link, then `negotiate()`. + + Prefer `connected()`: a bare `connect()` gives up the bracket's guarantee that the + link is closed, on error and on cancellation. + """ + @abstractmethod async def disconnect(self) -> None: # pragma: no cover - """Close the link.""" - ... + """Close the link. + + Prefer `connected()`, which calls this for you on every exit. + """ + @abstractmethod async def negotiate(self) -> None: # pragma: no cover """Adopt the server's MCUmgr parameters, if the fragmentation strategy asks for them.""" - ... async def _read_buf_size(self) -> int | None: """The server's advertised `buf_size`, or `None` if it doesn't provide one.""" @@ -152,16 +166,24 @@ async def connected(self) -> AsyncIterator[Self]: logger.warning(f"Error during disconnect: {e}") -class _GATTTransport(_ConnectableTransport): +class _GATTTransport(_ConnectableTransport[GATTFragmentationStrategy]): """A `_ConnectableTransport` that writes SMP messages to a GATT characteristic.""" - _fragmentation_strategy: GATTFragmentationStrategy - @override async def negotiate(self) -> None: match self._fragmentation_strategy: - case Auto() | Unfragmented(): - self._negotiated_buf_size = await self._read_buf_size() + case Auto(): + match await self._read_buf_size(): + case None: + self._sizing = Auto() + case buf_size: + self._sizing = BufferSize(buf_size) + case Unfragmented(): + match await self._read_buf_size(): + case None: + self._sizing = Unfragmented() + case buf_size: + self._sizing = BufferSize(min(self.mtu, buf_size)) case BufferSize(): pass case _ as unreachable: @@ -170,15 +192,9 @@ async def negotiate(self) -> None: @property @override def max_unencoded_size(self) -> int: - match self._fragmentation_strategy: - case Auto(): - return self.mtu if self._negotiated_buf_size is None else self._negotiated_buf_size - case Unfragmented(): - return ( - self.mtu - if self._negotiated_buf_size is None - else min(self.mtu, self._negotiated_buf_size) - ) + match self._sizing: + case Auto() | Unfragmented(): + return self.mtu case BufferSize(buf_size=buf_size): return buf_size case _ as unreachable: diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index 652d204..380f14c 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -6,7 +6,7 @@ import logging import re import sys -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Callable, Coroutine, Iterator from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeAlias, TypeGuard, TypeVar from uuid import UUID @@ -125,7 +125,7 @@ def __init__( bluez: BlueZClientArgs = {}, fragmentation_strategy: GATTFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, + sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, ) -> None: """Initialize the BLE transport; `connect()` scans for and connects to `address`. @@ -137,18 +137,15 @@ def __init__( `BufferSize`. connect_timeout_s: Bounds scanning and connecting, and reading the server's MCUmgr parameters. - sequence: The SMP sequence space the MCUmgr parameters read draws from; - defaults to `wrapping_sequence()`. + sequence: The SMP sequence space the MCUmgr parameters read draws from. """ self._address: Final = address - self._fragmentation_strategy = fragmentation_strategy - self._connect_timeout_s = connect_timeout_s - self._sequence = _request.wrapping_sequence() if sequence is None else sequence - self._buffer = bytearray() - self._notify_condition = asyncio.Condition() - self._disconnected_event = asyncio.Event() + super().__init__(fragmentation_strategy, connect_timeout_s, sequence) + self._buffer: Final = bytearray() + self._notify_condition: Final = asyncio.Condition() + self._disconnected_event: Final = asyncio.Event() self._disconnected_event.set() - self._winrt = winrt + self._winrt: Final = winrt self._bluez: Final = bluez self._link: _Link = _Closed() diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index eb6bc00..771a223 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -4,7 +4,7 @@ import asyncio import logging -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import asynccontextmanager from dataclasses import dataclass from typing import TYPE_CHECKING, AsyncIterator, Final, NamedTuple, Protocol, TypeAlias @@ -147,7 +147,7 @@ def __init__( settle_s: float = DEFAULT_POST_PAIR_SETTLE_S, fragmentation_strategy: GATTFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, + sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, ) -> None: """Initialize the bumble transport. @@ -177,13 +177,10 @@ def __init__( `BufferSize`. connect_timeout_s: Bounds scanning for a name, and reading the server's MCUmgr parameters. - sequence: The SMP sequence space the MCUmgr parameters read draws from; - defaults to `wrapping_sequence()`. + sequence: The SMP sequence space the MCUmgr parameters read draws from. """ + super().__init__(fragmentation_strategy, connect_timeout_s, sequence) self._address: Final = address - self._fragmentation_strategy = fragmentation_strategy - self._connect_timeout_s = connect_timeout_s - self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._hci: Final = hci self._host_address: Final = host_address self._host_name: Final = host_name diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index 62b39cf..f933f04 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -4,10 +4,10 @@ import asyncio import logging -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Callable, Iterator from contextlib import asynccontextmanager, contextmanager from time import monotonic -from typing import TYPE_CHECKING, Final, Generator, NamedTuple, Protocol, TypeAlias, final +from typing import TYPE_CHECKING, Final, Generator, NamedTuple, Protocol, TypeAlias, TypeVar, final try: from serial import Serial, SerialException @@ -19,7 +19,6 @@ raise from typing_extensions import Self, assert_never, override -from smpclient import _request from smpclient.transport import SMPTransportDisconnected, _ConnectableTransport if TYPE_CHECKING: @@ -95,7 +94,10 @@ class SerialOptions(NamedTuple): mode if it is already open in exclusive access mode.""" -class _SerialTransportBase(_ConnectableTransport): +_TStrategy = TypeVar("_TStrategy") + + +class _SerialTransportBase(_ConnectableTransport[_TStrategy]): """Connection-management base class for serial-port-backed SMP transports. Holds the `pyserial` `Serial` instance, the open/retry connect loop, borrowing a @@ -112,23 +114,14 @@ class _SerialTransportBase(_ConnectableTransport): def __init__( self, port: str, - connect_timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, - options: SerialOptions = SerialOptions(), + fragmentation_strategy: _TStrategy, + connect_timeout_s: float, + sequence: Callable[[], Iterator[u8]], + options: SerialOptions, ) -> None: - """Initialize the underlying `pyserial` `Serial` instance. - - Args: - port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. - connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr - parameters. - sequence: The SMP sequence space the MCUmgr parameters read draws from; - defaults to `wrapping_sequence()`. - options: The `pyserial` port settings. - """ + """Hold a closed `Serial` with the `options` until `connect()` opens it.""" + super().__init__(fragmentation_strategy, connect_timeout_s, sequence) self._port: Final = port - self._connect_timeout_s = connect_timeout_s - self._sequence = _request.wrapping_sequence() if sequence is None else sequence self._serial: Final = Serial(**options._asdict()) self._link: _Link = _Owned() diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index a4ef6bb..6e8c82a 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -24,13 +24,14 @@ import asyncio import logging import math -from collections.abc import Iterator +from collections.abc import Callable, Iterator from enum import IntEnum, unique from typing import TYPE_CHECKING, Final, NamedTuple, TypeAlias from smp import packet as smppacket from typing_extensions import assert_never, override +from smpclient import _request from smpclient.transport import Auto from smpclient.transport.serial.common import SerialOptions, _SerialTransportBase @@ -134,7 +135,7 @@ class BufferParams(NamedTuple): """ -class SMPSerialTransport(_SerialTransportBase): +class SMPSerialTransport(_SerialTransportBase[SerialFragmentationStrategy]): @unique class BufferState(IntEnum): SMP = 0 @@ -153,7 +154,7 @@ def __init__( fragmentation_strategy: SerialFragmentationStrategy = Auto(), *, connect_timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, + sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, options: SerialOptions = SerialOptions(), ) -> None: """Initialize the serial transport. @@ -163,20 +164,12 @@ def __init__( fragmentation_strategy: how to size SMP messages. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr parameters. - sequence: The SMP sequence space the MCUmgr parameters read draws from; - defaults to `wrapping_sequence()`. + sequence: The SMP sequence space the MCUmgr parameters read draws from. options: The `pyserial` port settings. """ - super().__init__( - port, - connect_timeout_s, - sequence, - options, - ) - self._validate_strategy(fragmentation_strategy) - self._fragmentation_strategy: Final = fragmentation_strategy + super().__init__(port, fragmentation_strategy, connect_timeout_s, sequence, options) self._smp_packet_queue: asyncio.Queue[bytes] = asyncio.Queue() """Contains full SMP packets.""" @@ -241,7 +234,7 @@ def _reset_state(self) -> None: @property def _line_length(self) -> int: """The base64 line length used to fragment outgoing frames.""" - match self._fragmentation_strategy: + match self._sizing: case Auto(): return _DEFAULT_LINE_LENGTH case BufferSize(line_length=line_length): @@ -261,10 +254,8 @@ def _line_buffers(self) -> int: buffer); `Auto` falls back to a conservative default until the server's params are read. """ - match self._fragmentation_strategy: + match self._sizing: case Auto(): - if self._negotiated_buf_size is not None: - return max(1, self._negotiated_buf_size // self._line_length) return _AUTO_LINE_BUFFERS case BufferSize(buf_size=buf_size): return max(1, buf_size // self._line_length) @@ -276,10 +267,8 @@ def _line_buffers(self) -> int: @property def _max_smp_encoded_frame_size(self) -> int: """The configured buffer size that the MTU reports.""" - match self._fragmentation_strategy: + match self._sizing: case Auto(): - if self._negotiated_buf_size is not None: - return self._negotiated_buf_size return self._line_length * self._line_buffers case BufferSize(buf_size=buf_size): return buf_size @@ -295,14 +284,14 @@ async def negotiate(self) -> None: case Auto(): match await self._read_buf_size(): case None: - pass + self._sizing = Auto() case buf_size if buf_size <= _FRAME_OVERHEAD: raise ValueError( f"server buffer size ({buf_size}) must exceed the " f"{_FRAME_OVERHEAD}-byte frame overhead to carry a message" ) case buf_size: - self._negotiated_buf_size = buf_size + self._sizing = BufferSize(buf_size=buf_size) logger.info( f"Auto-configured from server buf_size={buf_size}: " f"mtu={self.mtu}, max_unencoded_size={self.max_unencoded_size}, " @@ -497,10 +486,8 @@ def max_unencoded_size(self) -> int: SMP serial framing (the 2-byte length + 2-byte CRC16): https://docs.zephyrproject.org/latest/services/device_mgmt/smp_transport.html """ - match self._fragmentation_strategy: + match self._sizing: case Auto(): - if self._negotiated_buf_size is not None: - return self._negotiated_buf_size - _FRAME_OVERHEAD return self._encoded_budget_max_unencoded_size() case BufferSize(buf_size=buf_size): return buf_size - _FRAME_OVERHEAD diff --git a/src/smpclient/transport/serial/unencoded.py b/src/smpclient/transport/serial/unencoded.py index 701d2d9..08f303c 100644 --- a/src/smpclient/transport/serial/unencoded.py +++ b/src/smpclient/transport/serial/unencoded.py @@ -15,12 +15,13 @@ import asyncio import logging -from collections.abc import Iterator +from collections.abc import Callable, Iterator from typing import TYPE_CHECKING, Final, TypeAlias from smp import header as smphdr from typing_extensions import assert_never, override +from smpclient import _request from smpclient.exceptions import SMPClientException from smpclient.transport import Auto, BufferSize from smpclient.transport.serial.common import SerialOptions, _SerialTransportBase @@ -40,7 +41,7 @@ """How `SMPSerialRawTransport` sizes SMP messages: `Auto` or `BufferSize`.""" -class SMPSerialRawTransport(_SerialTransportBase): +class SMPSerialRawTransport(_SerialTransportBase[RawSerialFragmentationStrategy]): def __init__( self, port: str, @@ -48,7 +49,7 @@ def __init__( *, framing: SerialFraming | None = None, connect_timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, + sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, options: SerialOptions = SerialOptions(), ) -> None: """Initialize the raw serial transport. @@ -62,17 +63,10 @@ def __init__( `None` sends the bare `[header][payload]`. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr parameters. - sequence: The SMP sequence space the MCUmgr parameters read draws from; - defaults to `wrapping_sequence()`. + sequence: The SMP sequence space the MCUmgr parameters read draws from. options: The `pyserial` port settings. """ - super().__init__( - port, - connect_timeout_s, - sequence, - options, - ) - self._fragmentation_strategy: Final = fragmentation_strategy + super().__init__(port, fragmentation_strategy, connect_timeout_s, sequence, options) self._framing: Final = framing logger.debug(f"Initialized {self.__class__.__name__}") @@ -169,7 +163,11 @@ async def _poll_read_into(self, buf: bytearray) -> None: async def negotiate(self) -> None: match self._fragmentation_strategy: case Auto(): - self._negotiated_buf_size = await self._read_buf_size() + match await self._read_buf_size(): + case None: + self._sizing = Auto() + case buf_size: + self._sizing = BufferSize(buf_size) case BufferSize(): pass case _ as unreachable: @@ -183,13 +181,9 @@ def mtu(self) -> int: @property @override def max_unencoded_size(self) -> int: - match self._fragmentation_strategy: + match self._sizing: case Auto(): - return ( - _DEFAULT_BUF_SIZE - if self._negotiated_buf_size is None - else self._negotiated_buf_size - ) + return _DEFAULT_BUF_SIZE case BufferSize(buf_size=buf_size): return buf_size case _ as unreachable: diff --git a/src/smpclient/transport/udp.py b/src/smpclient/transport/udp.py index 21455ce..e1e0c17 100644 --- a/src/smpclient/transport/udp.py +++ b/src/smpclient/transport/udp.py @@ -4,7 +4,7 @@ import asyncio import logging -from collections.abc import Iterator +from collections.abc import Callable, Iterator from socket import AF_INET6 from typing import TYPE_CHECKING, Final, TypeAlias @@ -51,7 +51,7 @@ """ -class SMPUDPTransport(_ConnectableTransport): +class SMPUDPTransport(_ConnectableTransport[UDPFragmentationStrategy]): def __init__( self, address: str, @@ -60,7 +60,7 @@ def __init__( mtu: int = 1500, fragmentation_strategy: UDPFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, - sequence: Iterator[u8] | None = None, + sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, ) -> None: """Initialize the SMP UDP transport. @@ -73,15 +73,12 @@ def __init__( fragmentation_strategy: How to size SMP messages: `Auto` or `BufferSize`. connect_timeout_s: Bounds connecting, and reading the server's MCUmgr parameters. - sequence: The SMP sequence space the MCUmgr parameters read draws from; - defaults to `wrapping_sequence()`. + sequence: The SMP sequence space the MCUmgr parameters read draws from. """ + super().__init__(fragmentation_strategy, connect_timeout_s, sequence) self._address: Final = address self._port: Final = port - self._connect_timeout_s = connect_timeout_s - self._sequence = _request.wrapping_sequence() if sequence is None else sequence - self._mtu = mtu - self._fragmentation_strategy: Final = fragmentation_strategy + self._mtu: Final = mtu self._is_ipv6 = False self._client: Final = UDPClient() @@ -179,7 +176,11 @@ def mtu(self) -> int: async def negotiate(self) -> None: match self._fragmentation_strategy: case Auto(): - self._negotiated_buf_size = await self._read_buf_size() + match await self._read_buf_size(): + case None: + self._sizing = Auto() + case buf_size: + self._sizing = BufferSize(buf_size) case BufferSize(): pass case _ as unreachable: @@ -194,13 +195,9 @@ def max_unencoded_size(self) -> int: The IP version is auto-detected after connection. """ mss: Final = self._mtu - (IPV6_UDP_OVERHEAD if self._is_ipv6 else IPV4_UDP_OVERHEAD) - match self._fragmentation_strategy: + match self._sizing: case Auto(): - return ( - mss - if self._negotiated_buf_size is None - else min(mss, self._negotiated_buf_size) - ) + return mss case BufferSize(buf_size=buf_size): return min(mss, buf_size) case _ as unreachable: diff --git a/tests/test_smp_client.py b/tests/test_smp_client.py index 5a4ef98..8916286 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -226,7 +226,7 @@ def test_wrapping_sequence() -> None: async def test_injected_sequence() -> None: """The sequence space is injectable, so a test can pin what goes on the wire.""" m = SMPMockTransport() - s = SMPClient(m, sequence=iter((7, 9))) + s = SMPClient(m, sequence=lambda: iter((7, 9))) m.receive.return_value = bytes(ResetWriteResponse().to_frame(sequence=0)) for expected in (7, 9): From f6678a1d5cc6a92474b0d547e31ee711b8651bb5 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 16:05:49 -0700 Subject: [PATCH 16/19] breaking(transport): the link target is an argument to connect(), not the constructor Per review on #144 ("Port wouldn't be required if we achieve via SMPSerialTransport.borrowed()... right?"): a transport that only borrows never needed a port or address. The constructor now holds only config: the fragmentation strategy, options, timeouts, and sequence. The target belongs to the primitive that opens a link. async with SMPSerialTransport(BufferSize(1024)).connected("/dev/ttyACM0") as t: ... async with SMPSerialTransport().borrowed(open_port) as t: ... async with SMPUDPTransport().connected("192.168.1.1", 1337) as t: ... async with SMPBLETransport().connected("AA:BB:CC:DD:EE:FF") as t: ... async with SMPBumbleTransport(hci="usb:0").connected("AA:BB:CC:DD:EE:FF") as t: ... - `connect(target)` and `connected(target)` are defined per transport: serial `port`, BLE/bumble `address`, UDP `address, port=1337`. The signatures differ, so `_ConnectableTransport` no longer declares `connect`. What each bracket shares is `_released_on_exit()`, which yields the link and releases it best-effort on every exit. Both `connected()` and `borrowed()` use it. - The primitives' docstrings point at their bracket, and the base class docstring says what the bare primitives give up. - The positional constructor arguments are now the ones `main` had: serial and raw serial take `fragmentation_strategy`; UDP takes `mtu`. - Tests, the integration harness (`_link`, `socket_link`, the recovery and line-length tests), the examples, the `SMPClient` docstring example, and the bumble CLI all pass the target to `connect`/`connected`. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/ble/helloworld.py | 2 +- examples/ble/imagestate.py | 2 +- examples/ble/mcumgrparameters.py | 2 +- examples/ble/upgrade.py | 4 +- examples/ble/upload.py | 4 +- examples/udp/helloworld.py | 2 +- examples/usb/download_file.py | 2 +- examples/usb/helloworld.py | 2 +- examples/usb/upgrade.py | 16 +--- examples/usb/upload_file.py | 2 +- src/smpclient/__init__.py | 2 +- src/smpclient/transport/__init__.py | 22 ++--- src/smpclient/transport/ble.py | 28 ++++--- src/smpclient/transport/bumble/__init__.py | 33 +++++--- src/smpclient/transport/bumble/__main__.py | 7 +- src/smpclient/transport/serial/common.py | 29 ++++--- src/smpclient/transport/serial/encoded.py | 4 +- src/smpclient/transport/serial/unencoded.py | 4 +- src/smpclient/transport/udp.py | 31 +++---- tests/extensions/test_intercreate.py | 2 +- tests/integration/conftest.py | 10 +-- tests/integration/test_fragmentation.py | 7 +- tests/integration/test_serial_recovery.py | 10 +-- tests/test_smp_ble_transport.py | 62 +++++++------- tests/test_smp_bumble_transport.py | 74 ++++++++--------- tests/test_smp_client.py | 11 +-- tests/test_smp_serial_raw_transport.py | 92 ++++++++++----------- tests/test_smp_serial_transport.py | 76 ++++++++--------- tests/test_smp_udp_transport.py | 42 +++++----- 29 files changed, 282 insertions(+), 302 deletions(-) diff --git a/examples/ble/helloworld.py b/examples/ble/helloworld.py index 24be1d1..2cb0ca3 100644 --- a/examples/ble/helloworld.py +++ b/examples/ble/helloworld.py @@ -16,7 +16,7 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPBLETransport(smp_servers[0].address).connected() as transport: + async with SMPBLETransport().connected(smp_servers[0].address) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/ble/imagestate.py b/examples/ble/imagestate.py index a919a82..99216b3 100644 --- a/examples/ble/imagestate.py +++ b/examples/ble/imagestate.py @@ -16,7 +16,7 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPBLETransport(smp_servers[0].address).connected() as transport: + async with SMPBLETransport().connected(smp_servers[0].address) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/ble/mcumgrparameters.py b/examples/ble/mcumgrparameters.py index 250356d..a7c4700 100644 --- a/examples/ble/mcumgrparameters.py +++ b/examples/ble/mcumgrparameters.py @@ -16,7 +16,7 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPBLETransport(smp_servers[0].address).connected() as transport: + async with SMPBLETransport().connected(smp_servers[0].address) as transport: client = SMPClient(transport) print("OK") print(f"Client MTU is {client._transport.mtu}B") diff --git a/examples/ble/upgrade.py b/examples/ble/upgrade.py index 8a48f22..7a5bf5a 100644 --- a/examples/ble/upgrade.py +++ b/examples/ble/upgrade.py @@ -64,7 +64,7 @@ async def main() -> None: print("OK") print("Connecting to A SMP DUT...", end="", flush=True) - async with SMPBLETransport(a_smp_dut.name or a_smp_dut.address).connected() as transport: + async with SMPBLETransport().connected(a_smp_dut.name or a_smp_dut.address) as transport: client = SMPClient(transport) print("OK") @@ -120,7 +120,7 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: b_smp_dut = cast(BLEDevice, b_smp_dut) print("Connecting to B SMP DUT...", end="", flush=True) - async with SMPBLETransport(b_smp_dut.name or b_smp_dut.address).connected() as transport: + async with SMPBLETransport().connected(b_smp_dut.name or b_smp_dut.address) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/ble/upload.py b/examples/ble/upload.py index a810139..e71a6d9 100644 --- a/examples/ble/upload.py +++ b/examples/ble/upload.py @@ -30,9 +30,9 @@ async def main() -> None: print(f"Found {len(smp_servers)} SMP servers: {smp_servers}") print("Connecting to the first SMP server...", end="", flush=True) - async with SMPBLETransport( + async with SMPBLETransport().connected( smp_servers[0].name or smp_servers[0].address - ).connected() as transport: + ) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/udp/helloworld.py b/examples/udp/helloworld.py index 77894ce..4bdbc71 100644 --- a/examples/udp/helloworld.py +++ b/examples/udp/helloworld.py @@ -18,7 +18,7 @@ async def main() -> None: parser.add_argument("address", help="The IP address to connect to") address = parser.parse_args().address - async with SMPUDPTransport(address).connected() as transport: + async with SMPUDPTransport().connected(address) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/usb/download_file.py b/examples/usb/download_file.py index aa751aa..9faeece 100644 --- a/examples/usb/download_file.py +++ b/examples/usb/download_file.py @@ -16,7 +16,7 @@ async def main() -> None: port = args.port file_location = args.file_location - async with SMPSerialTransport(port).connected() as transport: + async with SMPSerialTransport().connected(port) as transport: client = SMPClient(transport) start_s = time.time() file_data = await client.download_file(file_location) diff --git a/examples/usb/helloworld.py b/examples/usb/helloworld.py index eb16b71..a9fc50c 100644 --- a/examples/usb/helloworld.py +++ b/examples/usb/helloworld.py @@ -15,7 +15,7 @@ async def main() -> None: parser.add_argument("port", help="The serial port to connect to") port = parser.parse_args().port - async with SMPSerialTransport(port).connected() as transport: + async with SMPSerialTransport().connected(port) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/usb/upgrade.py b/examples/usb/upgrade.py index f238a00..f3c3506 100644 --- a/examples/usb/upgrade.py +++ b/examples/usb/upgrade.py @@ -106,12 +106,8 @@ async def main() -> None: print("Connecting to SMP DUT...", end="", flush=True) async with SMPSerialTransport( - port_a.device, - fragmentation_strategy=BufferParams( - line_length=line_length, - line_buffers=line_buffers, - ), - ).connected() as transport: + BufferParams(line_length=line_length, line_buffers=line_buffers) + ).connected(port_a.device) as transport: client = SMPClient(transport) print("OK") @@ -185,12 +181,8 @@ async def ensure_request(request: SMPRequest[TRep, TEr1, TEr2]) -> TRep: print("Connecting to B SMP DUT...", end="", flush=True) async with SMPSerialTransport( - port_b.device, - fragmentation_strategy=BufferParams( - line_length=line_length, - line_buffers=line_buffers, - ), - ).connected() as transport: + BufferParams(line_length=line_length, line_buffers=line_buffers) + ).connected(port_b.device) as transport: client = SMPClient(transport) print("OK") diff --git a/examples/usb/upload_file.py b/examples/usb/upload_file.py index 01df75b..b19561c 100644 --- a/examples/usb/upload_file.py +++ b/examples/usb/upload_file.py @@ -64,7 +64,7 @@ async def main() -> None: Etiam elit velit, posuere ut pulvinar ac, condimentum eget justo. Fusce a erat velit. Vivamus imperdiet ultrices orci in hendrerit. """ - async with SMPSerialTransport(port).connected() as transport: + async with SMPSerialTransport().connected(port) as transport: client = SMPClient(transport) start_s = time.time() async for offset in client.upload_file(file_data=file_data, file_path=file_path): diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index ce72f71..1268db8 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -100,7 +100,7 @@ class SMPClient: from smpclient.transport.ble import SMPBLETransport async def main(): - async with SMPBLETransport("00:11:22:33:44:55").connected() as transport: + async with SMPBLETransport().connected("00:11:22:33:44:55") as transport: client = SMPClient(transport) response = await client.request(EchoWriteRequest(d="Hello, World!")) diff --git a/src/smpclient/transport/__init__.py b/src/smpclient/transport/__init__.py index a6dff05..4ec74ca 100644 --- a/src/smpclient/transport/__init__.py +++ b/src/smpclient/transport/__init__.py @@ -112,7 +112,9 @@ def max_unencoded_size(self) -> int: # pragma: no cover class _ConnectableTransport(SMPTransport, Generic[_TStrategy]): """An `SMPTransport` that opens and closes its own link. - `SMPClient` sees only the `SMPTransport` part. + `SMPClient` sees only the `SMPTransport` part. Open a link with the `connected()` or + `borrowed()` bracket: the bare `connect()`, `borrow()`, and `disconnect()` primitives give + up the bracket's guarantee that the link is released on error and on cancellation. """ def __init__( @@ -127,20 +129,9 @@ def __init__( self._connect_timeout_s: Final = connect_timeout_s self._sequence: Final = sequence() - @abstractmethod - async def connect(self) -> None: # pragma: no cover - """Open the link, then `negotiate()`. - - Prefer `connected()`: a bare `connect()` gives up the bracket's guarantee that the - link is closed, on error and on cancellation. - """ - @abstractmethod async def disconnect(self) -> None: # pragma: no cover - """Close the link. - - Prefer `connected()`, which calls this for you on every exit. - """ + """Release the link; prefer the bracket that opened it, which releases it on every exit.""" @abstractmethod async def negotiate(self) -> None: # pragma: no cover @@ -154,9 +145,8 @@ async def _read_buf_size(self) -> int | None: return None if params is None else params.buf_size @asynccontextmanager - async def connected(self) -> AsyncIterator[Self]: - """Open the link for the duration of the `async with`, then close it.""" - await self.connect() + async def _released_on_exit(self) -> AsyncIterator[Self]: + """Yield the open link, then release it best-effort on every exit.""" try: yield self finally: diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index 380f14c..e82d8e5 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -119,7 +119,6 @@ class SMPBLETransport(_GATTTransport): def __init__( self, - address: str, *, winrt: WinRTClientArgs = {}, bluez: BlueZClientArgs = {}, @@ -127,10 +126,9 @@ def __init__( connect_timeout_s: float = 2.5, sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, ) -> None: - """Initialize the BLE transport; `connect()` scans for and connects to `address`. + """Initialize the BLE transport. Args: - address: The device's MAC address, macOS UUID, or advertised name. winrt: WinRT backend arguments, e.g. `use_cached_services`. bluez: BlueZ backend arguments, e.g. the `adapter` to scan and connect with. fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or @@ -139,7 +137,6 @@ def __init__( MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from. """ - self._address: Final = address super().__init__(fragmentation_strategy, connect_timeout_s, sequence) self._buffer: Final = bytearray() self._notify_condition: Final = asyncio.Condition() @@ -154,11 +151,15 @@ def __init__( logger.debug(f"Initialized {self.__class__.__name__}") - @override - async def connect(self) -> None: + async def connect(self, address: str) -> None: + """Scan for and connect to `address`, then `negotiate()`; prefer `connected()`. + + Args: + address: The device's MAC address, macOS UUID, or advertised name. + """ # noqa: DOC501, DOC503 try: await asyncio.wait_for( - self._connect(self._address, self._connect_timeout_s), + self._connect(address, self._connect_timeout_s), timeout=self._connect_timeout_s, ) await self.negotiate() @@ -166,6 +167,13 @@ async def connect(self) -> None: await self._best_effort_disconnect() raise + @asynccontextmanager + async def connected(self, address: str) -> AsyncIterator[Self]: + """Connect to `address` for the duration of the `async with`, then disconnect.""" + await self.connect(address) + async with self._released_on_exit(): + yield self + async def _connect(self, address: str, timeout_s: float) -> None: logger.debug(f"Scanning for {address=}") device: BLEDevice | None = ( @@ -199,7 +207,7 @@ async def _connect(self, address: str, timeout_s: float) -> None: await self._start_smp() async def borrow(self, client: BleakClient) -> None: - """Adopt the caller's connected `client`, then `negotiate()`; `disconnect()` leaves it up.""" + """Adopt the caller's connected `client`, then `negotiate()`; prefer `borrowed()`.""" self._link = _Borrowed(client) try: await self._start_smp() @@ -212,10 +220,8 @@ async def borrow(self, client: BleakClient) -> None: async def borrowed(self, client: BleakClient) -> AsyncIterator[Self]: """Borrow the caller's connected `client` for the duration of the `async with`.""" await self.borrow(client) - try: + async with self._released_on_exit(): yield self - finally: - await self.disconnect() @property def _active_client(self) -> BleakClient: diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index 771a223..44cc2e2 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -135,7 +135,6 @@ class SMPBumbleTransport(_GATTTransport): def __init__( self, - address: str, *, hci: str = DEFAULT_HCI_TRANSPORT, host_address: Address = DEFAULT_HOST_ADDRESS, @@ -152,7 +151,6 @@ def __init__( """Initialize the bumble transport. Args: - address: The peer's BD_ADDR, or an advertised name to scan for. hci: The bumble HCI transport spec, e.g. `"usb:0"` or `"tcp-client:host:port"`. See bumble's `open_transport()` for the full list of supported schemes. @@ -180,7 +178,6 @@ def __init__( sequence: The SMP sequence space the MCUmgr parameters read draws from. """ super().__init__(fragmentation_strategy, connect_timeout_s, sequence) - self._address: Final = address self._hci: Final = hci self._host_address: Final = host_address self._host_name: Final = host_name @@ -205,8 +202,15 @@ def __init__( logger.debug(f"Initialized {self.__class__.__name__}(hci={hci!r})") - @override - async def connect(self) -> None: + async def connect(self, address: str) -> None: + """Connect to `address`, then `negotiate()`; prefer `connected()`. + + Args: + address: The peer's BD_ADDR, or an advertised name to scan for. + + Raises: + SMPBumbleTransportException: if the transport already has a link. + """ # noqa: DOC503 if not isinstance(self._state, Disconnected): raise SMPBumbleTransportException( f"connect() called while in state {type(self._state).__name__}" @@ -241,9 +245,7 @@ async def connect(self) -> None: ) await self._state.device.power_on() - target = await _resolve_target( - self._state.device, self._address, self._connect_timeout_s - ) + target = await _resolve_target(self._state.device, address, self._connect_timeout_s) logger.info(f"Connecting to {target}") self._state.connection = await self._state.device.connect(Address(target)) self._state.connection.on(Connection.EVENT_DISCONNECTION, self._on_disconnection) @@ -364,7 +366,7 @@ async def borrow( *, peer: Peer | None = None, ) -> None: - """Adopt a caller-owned `Connection`, then `negotiate()`; `disconnect()` only unsubscribes.""" + """Adopt a caller-owned `Connection`, then `negotiate()`; prefer `borrowed()`.""" if not isinstance(self._state, Disconnected): raise SMPBumbleTransportException( f"borrow() called while in state {type(self._state).__name__}" @@ -403,11 +405,16 @@ async def borrowed( peer: Peer | None = None, ) -> AsyncIterator[Self]: """Borrow the caller's `connection` for the duration of the `async with`.""" - try: - await self.borrow(connection, peer=peer) + await self.borrow(connection, peer=peer) + async with self._released_on_exit(): + yield self + + @asynccontextmanager + async def connected(self, address: str) -> AsyncIterator[Self]: + """Connect to `address` for the duration of the `async with`, then disconnect.""" + await self.connect(address) + async with self._released_on_exit(): yield self - finally: - await self.disconnect() async def pair( self, diff --git a/src/smpclient/transport/bumble/__main__.py b/src/smpclient/transport/bumble/__main__.py index af177bc..19914be 100644 --- a/src/smpclient/transport/bumble/__main__.py +++ b/src/smpclient/transport/bumble/__main__.py @@ -93,10 +93,9 @@ async def _pair(args: _PairArgs) -> int: async def _echo(args: _EchoArgs) -> int: - transport: Final = SMPBumbleTransport( - args.address, hci=args.hci, connect_timeout_s=args.timeout - ) - async with transport.connected(): + async with SMPBumbleTransport(hci=args.hci, connect_timeout_s=args.timeout).connected( + args.address + ) as transport: response = await SMPClient(transport, timeout_s=args.timeout).request( EchoWriteRequest(d=args.message) ) diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index f933f04..6f17b9d 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -113,7 +113,6 @@ class _SerialTransportBase(_ConnectableTransport[_TStrategy]): def __init__( self, - port: str, fragmentation_strategy: _TStrategy, connect_timeout_s: float, sequence: Callable[[], Iterator[u8]], @@ -121,7 +120,6 @@ def __init__( ) -> None: """Hold a closed `Serial` with the `options` until `connect()` opens it.""" super().__init__(fragmentation_strategy, connect_timeout_s, sequence) - self._port: Final = port self._serial: Final = Serial(**options._asdict()) self._link: _Link = _Owned() @@ -138,17 +136,17 @@ def _conn(self) -> SerialPort: def _reset_state(self) -> None: """Reset any per-connection state. Subclasses override as needed.""" - @override - async def connect(self) -> None: + async def connect(self, port: str) -> None: + """Open `port`, then `negotiate()`; prefer `connected()`.""" try: - await self._open() + await self._open(port) await self.negotiate() except (Exception, asyncio.CancelledError): self._serial.close() raise async def borrow(self, port: SerialPort) -> None: - """Adopt the caller's open `port`, then `negotiate()`; `disconnect()` leaves it open.""" + """Adopt the caller's open `port`, then `negotiate()`; prefer `borrowed()`.""" self._reset_state() self._link = _Borrowed(port) try: @@ -157,19 +155,24 @@ async def borrow(self, port: SerialPort) -> None: self._link = _Owned() raise + @asynccontextmanager + async def connected(self, port: str) -> AsyncIterator[Self]: + """Open `port` for the duration of the `async with`, then close it.""" + await self.connect(port) + async with self._released_on_exit(): + yield self + @asynccontextmanager async def borrowed(self, port: SerialPort) -> AsyncIterator[Self]: """Borrow the caller's open `port` for the duration of the `async with`.""" await self.borrow(port) - try: + async with self._released_on_exit(): yield self - finally: - await self.disconnect() - async def _open(self) -> None: - """Open the port off the event loop, retrying until `connect_timeout_s`.""" + async def _open(self, port: str) -> None: + """Open `port` off the event loop, retrying until `connect_timeout_s`.""" self._reset_state() - self._serial.port = self._port + self._serial.port = port logger.debug(f"Connecting to {self._serial.port=}") start_time: Final = monotonic() while monotonic() - start_time <= self._connect_timeout_s: @@ -186,7 +189,7 @@ async def _open(self) -> None: logger.debug(f"Connected to {self._serial.port=}") return - raise TimeoutError(f"Failed to connect to {self._port=}") + raise TimeoutError(f"Failed to connect to {port=}") @final @override diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index 6e8c82a..9753402 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -150,7 +150,6 @@ class BufferState(IntEnum): def __init__( self, - port: str, fragmentation_strategy: SerialFragmentationStrategy = Auto(), *, connect_timeout_s: float = 2.5, @@ -160,7 +159,6 @@ def __init__( """Initialize the serial transport. Args: - port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. fragmentation_strategy: how to size SMP messages. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr parameters. @@ -169,7 +167,7 @@ def __init__( """ self._validate_strategy(fragmentation_strategy) - super().__init__(port, fragmentation_strategy, connect_timeout_s, sequence, options) + super().__init__(fragmentation_strategy, connect_timeout_s, sequence, options) self._smp_packet_queue: asyncio.Queue[bytes] = asyncio.Queue() """Contains full SMP packets.""" diff --git a/src/smpclient/transport/serial/unencoded.py b/src/smpclient/transport/serial/unencoded.py index 08f303c..0ffba54 100644 --- a/src/smpclient/transport/serial/unencoded.py +++ b/src/smpclient/transport/serial/unencoded.py @@ -44,7 +44,6 @@ class SMPSerialRawTransport(_SerialTransportBase[RawSerialFragmentationStrategy]): def __init__( self, - port: str, fragmentation_strategy: RawSerialFragmentationStrategy = Auto(), *, framing: SerialFraming | None = None, @@ -55,7 +54,6 @@ def __init__( """Initialize the raw serial transport. Args: - port: The serial port, e.g. `/dev/ttyACM0` or `COM3`. fragmentation_strategy: How to size one SMP message (header + payload): `Auto` or `BufferSize`. A serial link has no MTU of its own, but the SMP server's receive buffer (`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`) does. @@ -66,7 +64,7 @@ def __init__( sequence: The SMP sequence space the MCUmgr parameters read draws from. options: The `pyserial` port settings. """ - super().__init__(port, fragmentation_strategy, connect_timeout_s, sequence, options) + super().__init__(fragmentation_strategy, connect_timeout_s, sequence, options) self._framing: Final = framing logger.debug(f"Initialized {self.__class__.__name__}") diff --git a/src/smpclient/transport/udp.py b/src/smpclient/transport/udp.py index e1e0c17..7efd57e 100644 --- a/src/smpclient/transport/udp.py +++ b/src/smpclient/transport/udp.py @@ -4,12 +4,13 @@ import asyncio import logging -from collections.abc import Callable, Iterator +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import asynccontextmanager from socket import AF_INET6 from typing import TYPE_CHECKING, Final, TypeAlias from smp import header as smphdr -from typing_extensions import assert_never, override +from typing_extensions import Self, assert_never, override from smpclient import _request from smpclient.exceptions import SMPClientException @@ -54,10 +55,8 @@ class SMPUDPTransport(_ConnectableTransport[UDPFragmentationStrategy]): def __init__( self, - address: str, - port: int = 1337, - *, mtu: int = 1500, + *, fragmentation_strategy: UDPFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, @@ -65,8 +64,6 @@ def __init__( """Initialize the SMP UDP transport. Args: - address: The server's IPv4 or IPv6 address, or a host name. - port: The server's SMP UDP port. mtu: The Maximum Transmission Unit (MTU) of the link layer in bytes. IP and UDP header overhead will be subtracted to calculate the maximum UDP payload size (MSS) to avoid fragmentation per RFC 8085 section 3.2. @@ -76,32 +73,36 @@ def __init__( sequence: The SMP sequence space the MCUmgr parameters read draws from. """ super().__init__(fragmentation_strategy, connect_timeout_s, sequence) - self._address: Final = address - self._port: Final = port self._mtu: Final = mtu self._is_ipv6 = False self._client: Final = UDPClient() - @override - async def connect(self) -> None: - logger.debug(f"Connecting to {self._address=} {self._port=}") + async def connect(self, address: str, port: int = 1337) -> None: + """Connect to `address`:`port`, then `negotiate()`; prefer `connected()`.""" + logger.debug(f"Connecting to {address=} {port=}") await asyncio.wait_for( - self._client.connect(Addr(host=self._address, port=self._port)), - self._connect_timeout_s, + self._client.connect(Addr(host=address, port=port)), self._connect_timeout_s ) if sock := self._client._transport.get_extra_info('socket'): self._is_ipv6 = sock.family == AF_INET6 logger.debug(f"Detected {'IPv6' if self._is_ipv6 else 'IPv4'} connection") - logger.info(f"Connected to {self._address=} {self._port=}") + logger.info(f"Connected to {address=} {port=}") try: await self.negotiate() except (Exception, asyncio.CancelledError): await self.disconnect() raise + @asynccontextmanager + async def connected(self, address: str, port: int = 1337) -> AsyncIterator[Self]: + """Connect to `address`:`port` for the duration of the `async with`, then disconnect.""" + await self.connect(address, port) + async with self._released_on_exit(): + yield self + @override async def disconnect(self) -> None: logger.debug("Disconnecting from transport") diff --git a/tests/extensions/test_intercreate.py b/tests/extensions/test_intercreate.py index ac41116..cc45bb0 100644 --- a/tests/extensions/test_intercreate.py +++ b/tests/extensions/test_intercreate.py @@ -23,7 +23,7 @@ async def test_upload_hello_world_bin_encoded(mock_mtu: PropertyMock) -> None: ) as f: image = f.read() - m = SMPSerialTransport("/dev/ttyACM0") + m = SMPSerialTransport() s = ICUploadClient(m) assert s._transport.mtu == 127 assert s._transport.max_unencoded_size < 127 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 32cf7c1..23f3440 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -81,9 +81,9 @@ def _link( case PtyEndpoint(pty): match fixture.transport: case "serial" | "shell": - return SMPSerialTransport(pty).connected() + return SMPSerialTransport().connected(pty) case "serial_raw": - return SMPSerialRawTransport(pty).connected() + return SMPSerialRawTransport().connected(pty) case "udp": pytest.fail("UDP fixtures do not present as a PTY serial endpoint") case _ as unreachable: @@ -91,15 +91,15 @@ def _link( case SocketSerialEndpoint(url): match fixture.transport: case "serial" | "shell": - return socket_link(SMPSerialTransport(url), url) + return socket_link(SMPSerialTransport(), url) case "serial_raw": - return socket_link(SMPSerialRawTransport(url), url) + return socket_link(SMPSerialRawTransport(), url) case "udp": pytest.fail("UDP fixtures do not present as a socket serial endpoint") case _ as unreachable: assert_never(unreachable) case UdpEndpoint(host, port): - return SMPUDPTransport(host, port).connected() + return SMPUDPTransport().connected(host, port) case _: assert_never(endpoint) diff --git a/tests/integration/test_fragmentation.py b/tests/integration/test_fragmentation.py index 9a3221a..4578c19 100644 --- a/tests/integration/test_fragmentation.py +++ b/tests/integration/test_fragmentation.py @@ -124,10 +124,9 @@ async def test_non_default_line_length(fixture: ServerFixture) -> None: """ async with serve(fixture) as endpoint: assert isinstance(endpoint, PtyEndpoint) - transport = SMPSerialTransport( - endpoint.pty, fragmentation_strategy=BufferParams(line_length=512, line_buffers=1) - ) - async with transport.connected(): + async with SMPSerialTransport(BufferParams(line_length=512, line_buffers=1)).connected( + endpoint.pty + ) as transport: client = SMPClient(transport) await _wait_until_answering(client) assert transport._line_length == 512 diff --git a/tests/integration/test_serial_recovery.py b/tests/integration/test_serial_recovery.py index 728fd01..0adb81b 100644 --- a/tests/integration/test_serial_recovery.py +++ b/tests/integration/test_serial_recovery.py @@ -99,14 +99,14 @@ def _fixture(variant: _Recovery) -> tuple[str, str]: assert_never(unreachable) -def _build_transport(variant: _Recovery, url: str) -> _SocketTransport: +def _build_transport(variant: _Recovery) -> _SocketTransport: match variant: case Console(strategy=strategy): - return SMPSerialTransport(url, fragmentation_strategy=strategy) + return SMPSerialTransport(strategy) case Raw(): - return SMPSerialRawTransport(url) + return SMPSerialRawTransport() case RawCobs(): - return SMPSerialRawTransport(url, framing=Cobs()) + return SMPSerialRawTransport(framing=Cobs()) case _ as unreachable: assert_never(unreachable) @@ -168,7 +168,7 @@ async def test_upload_to_mcuboot_recovery(variant: _Recovery, fixture: ServerFix async with connected(fixture) as cs: assert isinstance(cs.endpoint, SocketSerialEndpoint) - transport = _build_transport(variant, cs.endpoint.url) + transport = _build_transport(variant) async with reboot_into_recovery(cs, socket_link(transport, cs.endpoint.url)) as bootloader: # Re-negotiate in case the first read raced the bootloader coming up. diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index e9a1341..931661b 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -41,7 +41,7 @@ def __new__(cls, *args, **kwargs) -> "MockBleakClient": # type: ignore def test_constructor() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() assert t._buffer == bytearray() assert isinstance(t._notify_condition, asyncio.Condition) @@ -94,19 +94,19 @@ async def test_connect( mock_find_device_by_address: MagicMock, ) -> None: # assert that it searches by name if MAC or UUID is not provided - await SMPBLETransport("device name", connect_timeout_s=1.0).connect() + await SMPBLETransport(connect_timeout_s=1.0).connect("device name") mock_find_device_by_name.assert_called_once_with("device name", timeout=1.0, bluez={}) mock_find_device_by_name.reset_mock() # assert that it searches by MAC if MAC is provided - await SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=1.0).connect() + await SMPBLETransport(connect_timeout_s=1.0).connect("00:00:00:00:00:00") mock_find_device_by_address.assert_called_once_with("00:00:00:00:00:00", timeout=1.0, bluez={}) mock_find_device_by_address.reset_mock() # assert that it searches by UUID if UUID is provided - await SMPBLETransport( - UUID("00000000-0000-4000-8000-000000000000").hex, connect_timeout_s=1.0 - ).connect() + await SMPBLETransport(connect_timeout_s=1.0).connect( + UUID("00000000-0000-4000-8000-000000000000").hex + ) mock_find_device_by_address.assert_called_once_with( "00000000000040008000000000000000", timeout=1.0, bluez={} ) @@ -115,12 +115,12 @@ async def test_connect( # assert that it raises an exception if the device is not found mock_find_device_by_address.return_value = None with pytest.raises(SMPBLETransportDeviceNotFound): - await SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=1.0).connect() + await SMPBLETransport(connect_timeout_s=1.0).connect("00:00:00:00:00:00") mock_find_device_by_address.reset_mock() # assert that connect is awaited - t = SMPBLETransport("name", connect_timeout_s=1.0) - await t.connect() + t = SMPBLETransport(connect_timeout_s=1.0) + await t.connect("name") _owned_client(t).connect.assert_awaited_once_with() # these are hard to mock now because the _client is created in the connect method @@ -157,7 +157,7 @@ async def test_connect( async def test_connect_scans_and_connects_with_the_bluez_adapter( mock_bleak_client: MagicMock, mock_find_device_by_address: MagicMock ) -> None: - await SMPBLETransport(ADDRESS, bluez={"adapter": "hci1"}, connect_timeout_s=1.0).connect() + await SMPBLETransport(bluez={"adapter": "hci1"}, connect_timeout_s=1.0).connect(ADDRESS) mock_find_device_by_address.assert_called_once_with( ADDRESS, timeout=1.0, bluez={"adapter": "hci1"} @@ -180,7 +180,7 @@ async def test_scan_uses_the_bluez_adapter(mock_bleak_scanner: MagicMock) -> Non @pytest.mark.asyncio async def test_disconnect() -> None: client: Final = MagicMock(spec=BleakClient) - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() t._link = _Owned(client) await t.disconnect() @@ -214,7 +214,7 @@ def _borrowable_client(max_write: int = 244) -> MagicMock: @pytest.mark.asyncio async def test_borrowed_subscribes_and_leaves_the_client_connected() -> None: client: Final = _borrowable_client(max_write=244) - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() async with t.borrowed(client) as borrowed: assert borrowed is t @@ -231,7 +231,7 @@ async def test_borrowed_subscribes_and_leaves_the_client_connected() -> None: @pytest.mark.asyncio async def test_a_returned_borrow_raises_disconnected() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() async with t.borrowed(_borrowable_client()): pass @@ -246,7 +246,7 @@ async def test_a_returned_borrow_raises_disconnected() -> None: async def test_borrowed_receive_raises_once_the_client_disconnects() -> None: """The owner holds the client's disconnect callback, so a borrowed wait polls instead.""" client: Final = _borrowable_client() - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() async with t.borrowed(client): client.is_connected = False @@ -256,7 +256,7 @@ async def test_borrowed_receive_raises_once_the_client_disconnects() -> None: @pytest.mark.asyncio async def test_borrowed_receive_leaves_no_task_polling_when_cancelled() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() async with t.borrowed(_borrowable_client()): tasks_before: Final = asyncio.all_tasks() @@ -271,7 +271,7 @@ async def test_borrowed_receive_leaves_no_task_polling_when_cancelled() -> None: @pytest.mark.asyncio async def test_borrow_negotiates_the_fragmentation_strategy() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() with advertise(2048) as read_mcumgr_parameters: await t.borrow(_borrowable_client()) @@ -283,7 +283,7 @@ async def test_borrow_negotiates_the_fragmentation_strategy() -> None: @pytest.mark.asyncio async def test_borrow_returns_the_client_when_negotiation_fails() -> None: client: Final = _borrowable_client() - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() with ( patch( @@ -309,7 +309,7 @@ async def _never_returns(*_args: object) -> None: async def test_returning_a_borrowed_client_survives_its_unsubscribe(stop_notify: object) -> None: client: Final = _borrowable_client() client.stop_notify.side_effect = stop_notify - t = SMPBLETransport(ADDRESS, connect_timeout_s=0.1) + t = SMPBLETransport(connect_timeout_s=0.1) async with t.borrowed(client): pass @@ -320,7 +320,7 @@ async def test_returning_a_borrowed_client_survives_its_unsubscribe(stop_notify: @pytest.mark.asyncio async def test_send() -> None: client: Final = MagicMock(spec=BleakClient) - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() t._link = _Owned(client) t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) t._smp_characteristic.max_write_without_response_size = 20 @@ -332,7 +332,7 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_receive() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() t._link = _Owned(MagicMock(spec=BleakClient)) t._smp_characteristic = MagicMock(spec=BleakGATTCharacteristic) t._smp_characteristic.uuid = str(SMP_CHARACTERISTIC_UUID) @@ -363,7 +363,7 @@ async def fragmented_notifies() -> None: @pytest.mark.asyncio async def test_send_and_receive() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() t.send = AsyncMock() # type: ignore t.receive = AsyncMock() # type: ignore await t.send_and_receive(b"Hello pytest!") @@ -372,14 +372,14 @@ async def test_send_and_receive() -> None: def test_max_unencoded_size() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() t._max_write_without_response_size = 42 assert t.max_unencoded_size == 42 @pytest.mark.asyncio async def test_max_unencoded_size_mcumgr_param() -> None: - t = SMPBLETransport(ADDRESS) + t = SMPBLETransport() t._max_write_without_response_size = 42 assert (await negotiated(t, 9001)).max_unencoded_size == 9001 @@ -388,14 +388,14 @@ async def test_max_unencoded_size_mcumgr_param() -> None: @pytest.mark.parametrize("buf_size, expected", [(9001, 42), (30, 30)]) async def test_unfragmented_caps_at_the_write_size(buf_size: int, expected: int) -> None: """One message per write: never more than one write, nor more than the server holds.""" - t = SMPBLETransport(ADDRESS, fragmentation_strategy=Unfragmented()) + t = SMPBLETransport(fragmentation_strategy=Unfragmented()) t._max_write_without_response_size = 42 assert (await negotiated(t, buf_size)).max_unencoded_size == expected @pytest.mark.asyncio async def test_buffer_size_never_reads() -> None: - t = SMPBLETransport(ADDRESS, fragmentation_strategy=BufferSize(512)) + t = SMPBLETransport(fragmentation_strategy=BufferSize(512)) with advertise(9001) as read: await t.negotiate() read.assert_not_awaited() @@ -440,7 +440,7 @@ async def test_connect_raises_on_peer_disconnect_during_start_notify( When the peer disconnects mid-`start_notify` (e.g. failed pairing), `connect()` must surface `SMPTransportDisconnected` rather than hang. """ - t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=5.0) + t = SMPBLETransport(connect_timeout_s=5.0) async def _trip_disconnect_callback() -> MagicMock: # Wait until the transport reaches start_notify and clears the event, @@ -452,7 +452,7 @@ async def _trip_disconnect_callback() -> MagicMock: t._set_disconnected_event(client) return client - connect_task = asyncio.create_task(t.connect()) + connect_task = asyncio.create_task(t.connect(ADDRESS)) trip_task = asyncio.create_task(_trip_disconnect_callback()) with pytest.raises(SMPTransportDisconnected): @@ -473,9 +473,9 @@ async def test_connect_raises_on_timeout_during_start_notify( _mock_find_device_by_address: MagicMock, ) -> None: """`connect()` must honor `connect_timeout_s` even when `start_notify` hangs.""" - t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=0.05) + t = SMPBLETransport(connect_timeout_s=0.05) with pytest.raises(asyncio.TimeoutError): - await t.connect() + await t.connect(ADDRESS) mock_bleak_client.return_value.disconnect.assert_awaited() @@ -489,10 +489,10 @@ async def test_connect_does_not_leak_tasks_on_external_cancel( _mock_find_device_by_address: MagicMock, ) -> None: """Caller-driven cancellation must not leave `_await_or_disconnect` sub-tasks running.""" - t = SMPBLETransport("00:00:00:00:00:00", connect_timeout_s=60.0) + t = SMPBLETransport(connect_timeout_s=60.0) tasks_before = {id(task) for task in asyncio.all_tasks()} - connect_task = asyncio.create_task(t.connect()) + connect_task = asyncio.create_task(t.connect(ADDRESS)) while t._disconnected_event.is_set(): await asyncio.sleep(0) # wait until BleakClient.connect() returned await asyncio.sleep(0) # let start_notify await begin diff --git a/tests/test_smp_bumble_transport.py b/tests/test_smp_bumble_transport.py index 97b891b..a7b1ffd 100644 --- a/tests/test_smp_bumble_transport.py +++ b/tests/test_smp_bumble_transport.py @@ -89,7 +89,7 @@ def test_smp_uuids_match_ble_transport() -> None: def test_constructor_defaults() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() assert isinstance(t._state, Disconnected) assert t._hci == DEFAULT_HCI_TRANSPORT assert t._host_name == DEFAULT_HOST_NAME @@ -98,27 +98,27 @@ def test_constructor_defaults() -> None: @pytest.mark.asyncio async def test_send_in_disconnected_state_raises() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() with pytest.raises(SMPBumbleTransportException): await t.send(b"x") def test_mtu_in_disconnected_state_raises() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() with pytest.raises(SMPBumbleTransportException): _ = t.mtu @pytest.mark.asyncio async def test_pair_in_disconnected_state_raises() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() with pytest.raises(SMPBumbleTransportException): await t.pair(NoInputNoOutput()) @pytest.mark.asyncio async def test_pair_in_borrowed_state_raises() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() t._state = ConnectedBorrowed( connection=MagicMock(), peer=MagicMock(), smp_characteristic=MagicMock(), max_write=20 ) @@ -128,23 +128,23 @@ async def test_pair_in_borrowed_state_raises() -> None: @pytest.mark.asyncio async def test_disconnect_in_disconnected_state_is_noop() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() await t.disconnect() assert isinstance(t._state, Disconnected) @pytest.mark.asyncio async def test_connect_while_connected_raises() -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() t._state = Connecting() with pytest.raises(SMPBumbleTransportException, match="Connecting"): - await t.connect() + await t.connect(ADDRESS) def _make_connected( max_write: int = 244, fragmentation_strategy: GATTFragmentationStrategy = Auto() ) -> tuple[SMPBumbleTransport, MagicMock]: - t = SMPBumbleTransport(ADDRESS, fragmentation_strategy=fragmentation_strategy) + t = SMPBumbleTransport(fragmentation_strategy=fragmentation_strategy) smp_char = MagicMock() smp_char.write_value = AsyncMock() t._state = Connected( @@ -549,8 +549,8 @@ def _device_with_hci(*_args: object, **_kwargs: object) -> MagicMock: async def test_connect_transitions_to_connected_state( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport(ADDRESS) - await t.connect() + t = SMPBumbleTransport() + await t.connect(ADDRESS) assert isinstance(t._state, Connected) assert t._state.max_write == 247 - ATT_WRITE_OVERHEAD bumble_env.device.power_on.assert_awaited_once() @@ -580,8 +580,8 @@ async def test_connect_proactively_encrypts_when_bonded( "smpclient.transport.bumble.resolve_keystore", lambda _s, namespace: env.keystore, ) - t = SMPBumbleTransport(ADDRESS) - await t.connect() + t = SMPBumbleTransport() + await t.connect(ADDRESS) env.connection.encrypt.assert_awaited_once() @@ -608,8 +608,8 @@ async def _connect_snapshotting(*args: object, **kwargs: object) -> MagicMock: bumble_env.device.connect = _connect_snapshotting - t = SMPBumbleTransport(ADDRESS, pair_on_connect=delegate) - await t.connect() + t = SMPBumbleTransport(pair_on_connect=delegate) + await t.connect(ADDRESS) assert factory_set_at["value"], ( "pairing_config_factory must be set before device.connect() returns" ) @@ -628,7 +628,7 @@ async def _pair_and_encrypt() -> None: bumble_env.connection.pair.side_effect = _pair_and_encrypt delegate = NoInputNoOutput() - t = SMPBumbleTransport(ADDRESS, pair_on_connect=delegate, settle_s=0.0) + t = SMPBumbleTransport(pair_on_connect=delegate, settle_s=0.0) captured: dict[str, object] = {} original_on = bumble_env.connection.on @@ -650,7 +650,7 @@ async def _emit_security_request_after_connect(*args: object, **kwargs: object) bumble_env.device.connect = _emit_security_request_after_connect - await t.connect() + await t.connect(ADDRESS) bumble_env.connection.pair.assert_awaited_once() assert isinstance(t._state, Connected) @@ -667,7 +667,7 @@ async def _pair_and_encrypt() -> None: bumble_env.connection.pair.side_effect = _pair_and_encrypt - t = SMPBumbleTransport(ADDRESS, pair_on_connect=NoInputNoOutput(), settle_s=0.0) + t = SMPBumbleTransport(pair_on_connect=NoInputNoOutput(), settle_s=0.0) t._state = Connecting(connection=bumble_env.connection, device=bumble_env.device) t._pair_lock = asyncio.Lock() t._pair_result = None @@ -685,9 +685,9 @@ async def test_connect_failure_tears_down_partial_state( bumble_env: _MockBumbleEnvironment, ) -> None: bumble_env.smp_char.subscribe.side_effect = RuntimeError("boom") - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() with pytest.raises(RuntimeError, match="boom"): - await t.connect() + await t.connect(ADDRESS) assert isinstance(t._state, Disconnected) bumble_env.connection.disconnect.assert_awaited() bumble_env.device.power_off.assert_awaited() @@ -698,8 +698,8 @@ async def test_connect_failure_tears_down_partial_state( async def test_disconnect_owned_tears_down_everything( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport(ADDRESS) - await t.connect() + t = SMPBumbleTransport() + await t.connect(ADDRESS) await t.disconnect() assert isinstance(t._state, Disconnected) bumble_env.smp_char.unsubscribe.assert_awaited() @@ -712,7 +712,7 @@ async def test_disconnect_owned_tears_down_everything( async def test_borrow_borrowed_only_unsubscribes_on_disconnect( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() await t.borrow(bumble_env.connection) assert isinstance(t._state, ConnectedBorrowed) await t.disconnect() @@ -726,7 +726,7 @@ async def test_borrow_borrowed_only_unsubscribes_on_disconnect( async def test_borrow_returns_the_connection_when_negotiation_fails( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() with ( patch( @@ -754,9 +754,9 @@ async def connect_until_cancelled(*_args: object, **_kwargs: object) -> MagicMoc return bumble_env.connection bumble_env.device.connect = AsyncMock(side_effect=connect_until_cancelled) - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() - connect: Final = asyncio.create_task(t.connect()) + connect: Final = asyncio.create_task(t.connect(ADDRESS)) await connecting.wait() connect.cancel() with pytest.raises(asyncio.CancelledError): @@ -773,7 +773,7 @@ async def test_borrow_skips_discover_when_services_present( bumble_env: _MockBumbleEnvironment, ) -> None: bumble_env.peer.services = [MagicMock()] - t = SMPBumbleTransport(ADDRESS) + t = SMPBumbleTransport() await t.borrow(bumble_env.connection, peer=bumble_env.peer) bumble_env.peer.discover_all.assert_not_called() @@ -782,8 +782,8 @@ async def test_borrow_skips_discover_when_services_present( async def test_borrow_while_connected_raises( bumble_env: _MockBumbleEnvironment, ) -> None: - t = SMPBumbleTransport(ADDRESS) - await t.connect() + t = SMPBumbleTransport() + await t.connect(ADDRESS) with pytest.raises(SMPBumbleTransportException): await t.borrow(bumble_env.connection) @@ -1168,7 +1168,7 @@ async def test_cli_echo_success(capsys: pytest.CaptureFixture[str]) -> None: client = MagicMock() client.request = AsyncMock(return_value=response) transport = MagicMock() - transport.connected.return_value = nullcontext() + transport.connected.return_value = nullcontext(transport) with ( patch( @@ -1182,8 +1182,8 @@ async def test_cli_echo_success(capsys: pytest.CaptureFixture[str]) -> None: _EchoArgs(hci="usb:0", address="AA:BB:CC:DD:EE:FF", message="ping", timeout=5.0) ) assert rc == 0 - transport_class.assert_called_once_with("AA:BB:CC:DD:EE:FF", hci="usb:0", connect_timeout_s=5.0) - transport.connected.assert_called_once_with() + transport_class.assert_called_once_with(hci="usb:0", connect_timeout_s=5.0) + transport.connected.assert_called_once_with("AA:BB:CC:DD:EE:FF") assert "pong" in capsys.readouterr().out @@ -1195,7 +1195,7 @@ async def test_cli_echo_returns_1_on_error(capsys: pytest.CaptureFixture[str]) - client = MagicMock() client.request = AsyncMock(return_value=response) transport = MagicMock() - transport.connected.return_value = nullcontext() + transport.connected.return_value = nullcontext(transport) with ( patch("smpclient.transport.bumble.__main__.SMPBumbleTransport", return_value=transport), @@ -1309,8 +1309,8 @@ def _post_pair_encrypted() -> None: bumble_env.connection.pair = AsyncMock(side_effect=_post_pair_encrypted) - t = SMPBumbleTransport(ADDRESS, pair_on_connect=NoInputNoOutput(), settle_s=0.0) - await t.connect() + t = SMPBumbleTransport(pair_on_connect=NoInputNoOutput(), settle_s=0.0) + await t.connect(ADDRESS) bumble_env.connection.pair.assert_awaited_once() assert isinstance(t._state, Connected) @@ -1325,9 +1325,9 @@ async def test_resolve_target_raises_when_no_device_with_name( "smpclient.transport.bumble.scan_for_devices", AsyncMock(return_value=()), ) - t = SMPBumbleTransport("UnknownName", connect_timeout_s=0.1) + t = SMPBumbleTransport(connect_timeout_s=0.1) with pytest.raises(SMPBumbleTransportDeviceNotFound): - await t.connect() + await t.connect("UnknownName") # Suppress unused-imports warnings for symbols re-exported for downstream code. diff --git a/tests/test_smp_client.py b/tests/test_smp_client.py index 8916286..b12cd2c 100644 --- a/tests/test_smp_client.py +++ b/tests/test_smp_client.py @@ -48,9 +48,6 @@ SMPSerialTransport, ) -PORT = "/dev/ttyACM0" -"""A port name for transports that are never opened.""" - FRAME_OVERHEAD = smppacket.FRAME_LENGTH_STRUCT.size + smppacket.CRC16_STRUCT.size """The SMP serial frame's 2-byte length + 2-byte CRC16 that share the decoded buffer.""" @@ -391,7 +388,6 @@ async def test_upload_hello_world_bin_encoded( pytest.skip("The line buffer size is too small") m = SMPSerialTransport( - PORT, fragmentation_strategy=BufferParams( line_length=line_length, line_buffers=line_buffers, @@ -457,7 +453,7 @@ async def test_upload_hello_world_bin_raw(mtu: int) -> None: ) as f: image = f.read() - m = SMPSerialRawTransport(PORT, fragmentation_strategy=smptransport.BufferSize(mtu)) + m = SMPSerialRawTransport(fragmentation_strategy=smptransport.BufferSize(mtu)) s = SMPClient(m) assert s._transport.mtu == mtu assert s._transport.max_unencoded_size == mtu, "The raw transport has no encoding overhead" @@ -637,7 +633,6 @@ async def test_file_upload_test_encoded(max_smp_encoded_frame_size: int, line_bu pytest.skip("The line buffer size is too small") m = SMPSerialTransport( - PORT, fragmentation_strategy=BufferParams( line_length=line_length, line_buffers=line_buffers, @@ -867,9 +862,7 @@ def test_maximize_upload_packet_fills_decoded_buffer( the wire -- larger than the buffer, which the server decodes incrementally as the lines arrive. The unified generic handles both `ImageUploadWriteRequest` and `FileUploadRequest`. """ - client = SMPClient( - SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) - ) + client = SMPClient(SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size))) max_unencoded_size = client._transport.max_unencoded_size assert max_unencoded_size == buf_size - FRAME_OVERHEAD diff --git a/tests/test_smp_serial_raw_transport.py b/tests/test_smp_serial_raw_transport.py index 26fc4c2..6603d18 100644 --- a/tests/test_smp_serial_raw_transport.py +++ b/tests/test_smp_serial_raw_transport.py @@ -32,26 +32,26 @@ def mock_serial() -> Generator[None, Any, None]: def test_constructor() -> None: - t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(512)) + t = SMPSerialRawTransport(fragmentation_strategy=BufferSize(512)) assert t.mtu == 512 assert t.max_unencoded_size == 512 def test_constructor_defaults() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() assert t.mtu == 384 @pytest.mark.asyncio async def test_negotiate_with_auto() -> None: """`Auto` adopts the server's buffer: the whole message rides in it, with no framing.""" - t = await negotiated(SMPSerialRawTransport(PORT), 1024) + t = await negotiated(SMPSerialRawTransport(), 1024) assert t.mtu == t.max_unencoded_size == 1024 @pytest.mark.asyncio async def test_negotiate_never_reads_for_buffer_size() -> None: - t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(512)) + t = SMPSerialRawTransport(fragmentation_strategy=BufferSize(512)) with advertise(1024) as read: await t.negotiate() read.assert_not_awaited() @@ -63,10 +63,10 @@ async def test_connect_disconnect() -> None: ports: list[str] = ["COM2", "/dev/ttyACM0", "/dev/ttyUSB0"] for p in ports: - t = SMPSerialRawTransport(p, connect_timeout_s=1.0) + t = SMPSerialRawTransport(connect_timeout_s=1.0) t._conn.read_all = MagicMock(return_value=b"") # type: ignore - await asyncio.wait_for(t.connect(), timeout=1.0) + await asyncio.wait_for(t.connect(p), timeout=1.0) t._conn.open.assert_called_once() # type: ignore assert t._conn.port == p @@ -79,21 +79,21 @@ async def test_connect_disconnect() -> None: @pytest.mark.asyncio async def test_connect_retries_until_timeout() -> None: - t = SMPSerialRawTransport(PORT, connect_timeout_s=0.1) + t = SMPSerialRawTransport(connect_timeout_s=0.1) t._conn.open = MagicMock(side_effect=SerialException("nope")) # type: ignore with pytest.raises(TimeoutError): - await asyncio.wait_for(t.connect(), timeout=2.0) + await asyncio.wait_for(t.connect(PORT), timeout=2.0) @pytest.mark.asyncio async def test_connect_closes_the_port_when_the_flush_fails() -> None: """The flush is outside the retry, which would reopen the open port until the timeout.""" - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() t._conn.reset_input_buffer = MagicMock(side_effect=SerialException("flush")) # type: ignore with pytest.raises(SerialException): - await t.connect() + await t.connect(PORT) t._conn.open.assert_called_once() # type: ignore t._conn.close.assert_called_once() # type: ignore @@ -101,7 +101,7 @@ async def test_connect_closes_the_port_when_the_flush_fails() -> None: @pytest.mark.asyncio async def test_connect_closes_the_port_when_cancelled_while_negotiating() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() with ( patch( @@ -110,7 +110,7 @@ async def test_connect_closes_the_port_when_cancelled_while_negotiating() -> Non ), pytest.raises(asyncio.CancelledError), ): - await t.connect() + await t.connect(PORT) t._conn.close.assert_called_once() # type: ignore @@ -118,7 +118,7 @@ async def test_connect_closes_the_port_when_cancelled_while_negotiating() -> Non @pytest.mark.asyncio async def test_borrowed_uses_the_port_and_leaves_it_open() -> None: port: Final = MagicMock(out_waiting=0) - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() r = EchoWriteRequest(d="Hello pytest!").to_frame(sequence=0) async with t.borrowed(port) as borrowed: @@ -134,7 +134,7 @@ async def test_borrowed_uses_the_port_and_leaves_it_open() -> None: @pytest.mark.asyncio async def test_borrow_negotiates_the_fragmentation_strategy() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() with advertise(2048) as read_mcumgr_parameters: await t.borrow(MagicMock()) @@ -145,7 +145,7 @@ async def test_borrow_negotiates_the_fragmentation_strategy() -> None: @pytest.mark.asyncio async def test_borrow_reverts_to_the_owned_port_when_negotiation_fails() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() with ( patch( @@ -161,7 +161,7 @@ async def test_borrow_reverts_to_the_owned_port_when_negotiation_fails() -> None @pytest.mark.asyncio async def test_send() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() t._conn.write = MagicMock() # type: ignore p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore @@ -176,7 +176,7 @@ async def test_send() -> None: @pytest.mark.asyncio async def test_send_waits_for_tx_drain() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() t._conn.write = MagicMock() # type: ignore p = PropertyMock(side_effect=(1, 0)) type(t._conn).out_waiting = p # type: ignore @@ -187,14 +187,14 @@ async def test_send_waits_for_tx_drain() -> None: @pytest.mark.asyncio async def test_send_too_large_raises() -> None: - t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(16)) + t = SMPSerialRawTransport(fragmentation_strategy=BufferSize(16)) with pytest.raises(ValueError): await t.send(b"\x00" * 32) @pytest.mark.asyncio async def test_send_disconnected_raises() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() t._conn.write = MagicMock(side_effect=SerialException("disconnected")) # type: ignore with pytest.raises(SMPTransportDisconnected): @@ -203,8 +203,8 @@ async def test_send_disconnected_raises() -> None: @pytest.mark.asyncio async def test_receive_single_packet() -> None: - t = SMPSerialRawTransport(PORT) - await t.connect() + t = SMPSerialRawTransport() + await t.connect(PORT) m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) t._conn.read_all = MagicMock(side_effect=[bytes(m)]) # type: ignore @@ -217,8 +217,8 @@ async def test_receive_single_packet() -> None: @pytest.mark.asyncio async def test_receive_fragmented() -> None: - t = SMPSerialRawTransport(PORT) - await t.connect() + t = SMPSerialRawTransport() + await t.connect(PORT) m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) fragments = [ @@ -237,8 +237,8 @@ async def test_receive_fragmented() -> None: @pytest.mark.asyncio async def test_receive_byte_at_a_time() -> None: - t = SMPSerialRawTransport(PORT) - await t.connect() + t = SMPSerialRawTransport() + await t.connect(PORT) m = EchoWriteResponse(r="Hi").to_frame(sequence=0) t._conn.read_all = MagicMock( # type: ignore @@ -253,8 +253,8 @@ async def test_receive_byte_at_a_time() -> None: @pytest.mark.asyncio async def test_receive_consecutive_messages() -> None: - t = SMPSerialRawTransport(PORT) - await t.connect() + t = SMPSerialRawTransport() + await t.connect(PORT) m1 = EchoWriteResponse(r="SMP Message 1").to_frame(sequence=0) m2 = EchoWriteResponse(r="SMP Message 2").to_frame(sequence=1) @@ -276,8 +276,8 @@ async def test_receive_overrun_raises() -> None: SMP is strictly request/response; the server should never send unsolicited bytes. """ - t = SMPSerialRawTransport(PORT) - await t.connect() + t = SMPSerialRawTransport() + await t.connect(PORT) m = EchoWriteResponse(r="Hello!").to_frame(sequence=0) t._conn.read_all = MagicMock(side_effect=[bytes(m) + b"\x00\x01\x02"]) # type: ignore @@ -290,8 +290,8 @@ async def test_receive_overrun_raises() -> None: @pytest.mark.asyncio async def test_receive_polls_when_nothing_available() -> None: - t = SMPSerialRawTransport(PORT) - await t.connect() + t = SMPSerialRawTransport() + await t.connect(PORT) m = EchoWriteResponse(r="ok").to_frame(sequence=0) t._conn.read_all = MagicMock(side_effect=[b"", b"", bytes(m)]) # type: ignore @@ -310,8 +310,8 @@ async def test_receive_oversized_header_raises() -> None: Defensive bound against noisy or corrupted UART traffic that would otherwise cause an unbounded wait. """ - t = SMPSerialRawTransport(PORT, fragmentation_strategy=BufferSize(64)) - await t.connect() + t = SMPSerialRawTransport(fragmentation_strategy=BufferSize(64)) + await t.connect(PORT) bogus_header = smphdr.Header( op=smphdr.OP.WRITE_RSP, @@ -332,7 +332,7 @@ async def test_receive_oversized_header_raises() -> None: @pytest.mark.asyncio async def test_receive_disconnected_raises() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() t._conn.read_all = MagicMock(side_effect=SerialException("disconnected")) # type: ignore with pytest.raises(SMPTransportDisconnected): @@ -341,7 +341,7 @@ async def test_receive_disconnected_raises() -> None: @pytest.mark.asyncio async def test_send_and_receive() -> None: - t = SMPSerialRawTransport(PORT) + t = SMPSerialRawTransport() t.send = AsyncMock() # type: ignore t.receive = AsyncMock() # type: ignore @@ -353,7 +353,7 @@ async def test_send_and_receive() -> None: @pytest.mark.asyncio async def test_send_with_cobs_framing_encodes() -> None: - t = SMPSerialRawTransport(PORT, framing=Cobs()) + t = SMPSerialRawTransport(framing=Cobs()) t._conn.write = MagicMock() # type: ignore p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore @@ -367,8 +367,8 @@ async def test_send_with_cobs_framing_encodes() -> None: @pytest.mark.asyncio async def test_receive_with_cobs_framing_decodes() -> None: - t = SMPSerialRawTransport(PORT, framing=Cobs()) - await t.connect() + t = SMPSerialRawTransport(framing=Cobs()) + await t.connect(PORT) m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) (wire,) = Cobs().encode(bytes(m)) @@ -381,8 +381,8 @@ async def test_receive_with_cobs_framing_decodes() -> None: @pytest.mark.asyncio async def test_receive_with_cobs_framing_fragmented() -> None: - t = SMPSerialRawTransport(PORT, framing=Cobs()) - await t.connect() + t = SMPSerialRawTransport(framing=Cobs()) + await t.connect(PORT) m = EchoWriteResponse(r="fragment me across reads").to_frame(sequence=0) (wire,) = Cobs().encode(bytes(m)) @@ -399,8 +399,8 @@ async def test_receive_two_cobs_frames_in_one_read() -> None: The next receive returns it without consulting read_all again. """ - t = SMPSerialRawTransport(PORT, framing=Cobs()) - await t.connect() + t = SMPSerialRawTransport(framing=Cobs()) + await t.connect(PORT) m1 = EchoWriteResponse(r="first").to_frame(sequence=0) m2 = EchoWriteResponse(r="second").to_frame(sequence=1) @@ -422,8 +422,8 @@ async def test_receive_cobs_framing_resyncs_past_corrupt_frame() -> None: The two frames carry *different* payloads, so a decoder that wrongly accepted the corrupt frame would surface `dropped`, not `recovered`. """ - t = SMPSerialRawTransport(PORT, framing=Cobs()) - await t.connect() + t = SMPSerialRawTransport(framing=Cobs()) + await t.connect(PORT) dropped = EchoWriteResponse(r="dropped").to_frame(sequence=0) recovered = EchoWriteResponse(r="recovered").to_frame(sequence=1) @@ -446,8 +446,8 @@ async def test_receive_framed_yields_so_an_outer_timeout_can_fire() -> None: `_read_all` is synchronous, so the loop must yield each iteration; otherwise an outer `asyncio.timeout` could never fire on a wrong-baud / wrong-protocol / noisy peer. """ - t = SMPSerialRawTransport(PORT, framing=Cobs()) - await t.connect() + t = SMPSerialRawTransport(framing=Cobs()) + await t.connect(PORT) m = EchoWriteResponse(r="never valid").to_frame(sequence=0) corrupt = cobs_encode(bytes(m) + CRC16_STRUCT.pack(crc16_func(bytes(m)) ^ 0xFFFF)) + b"\x00" diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index ff00fec..5e6d3af 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -42,16 +42,14 @@ def mock_serial() -> Generator[None, Any, None]: def test_constructor() -> None: # Test with Auto() (default): conservative 7.1.0-equivalent 128 * 2 budget pre-init - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() assert t.mtu == 256 # 128 * 2, the conservative default before server params are read assert t._line_length == 128 assert t._line_buffers == 2 assert t._max_smp_encoded_frame_size == 256 # Test with BufferParams - t = SMPSerialTransport( - PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=4) - ) + t = SMPSerialTransport(fragmentation_strategy=BufferParams(line_length=128, line_buffers=4)) assert t.mtu == 512 # 128 * 4 assert t._line_length == 128 assert t._line_buffers == 4 @@ -59,7 +57,7 @@ def test_constructor() -> None: assert t.max_unencoded_size < 512 # Test with BufferSize: fills the decoded buffer (buf_size - 4), like Auto - t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) + t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=1024)) assert t.mtu == 1024 assert t._line_length == 128 assert t._max_smp_encoded_frame_size == 1024 @@ -84,7 +82,7 @@ def test_serial_options_lock_pyserial() -> None: def test_options_configure_the_port() -> None: options: Final = SerialOptions(baudrate=9600, rtscts=True, exclusive=True) with patch("smpclient.transport.serial.common.Serial") as serial_class: - SMPSerialTransport(PORT, options=options) + SMPSerialTransport(options=options) serial_class.assert_called_once_with(**options._asdict()) @@ -93,10 +91,10 @@ async def test_connect_disconnect() -> None: ports: list[str] = ["COM2", "/dev/ttyACM0", "/dev/ttyUSB0"] for p in ports: - t = SMPSerialTransport(p, connect_timeout_s=1.0) + t = SMPSerialTransport(connect_timeout_s=1.0) t._conn.read_all = MagicMock(return_value=b"") # type: ignore - await asyncio.wait_for(t.connect(), timeout=1.0) + await asyncio.wait_for(t.connect(p), timeout=1.0) t._conn.open.assert_called_once() # type: ignore assert t._conn.port == p @@ -109,7 +107,7 @@ async def test_connect_disconnect() -> None: @pytest.mark.asyncio async def test_send() -> None: - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() t._conn.write = MagicMock() # type: ignore p = PropertyMock(return_value=0) type(t._conn).out_waiting = p # type: ignore @@ -132,7 +130,7 @@ async def test_send() -> None: async def test_borrowed_receives_from_the_port_and_leaves_it_open() -> None: m: Final = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) port: Final = MagicMock(read_all=MagicMock(side_effect=smppacket.encode(bytes(m), 8))) - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() async with t.borrowed(port): assert await t.receive() == bytes(m) @@ -144,7 +142,7 @@ async def test_borrowed_receives_from_the_port_and_leaves_it_open() -> None: @pytest.mark.asyncio async def test_receive() -> None: - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() m = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) p = [p for p in smppacket.encode(bytes(m), t.max_unencoded_size)] t._read_one_smp_packet = AsyncMock(side_effect=p) # type: ignore @@ -164,8 +162,8 @@ async def test_receive() -> None: @pytest.mark.asyncio async def test_read_one_smp_packet() -> None: - t = SMPSerialTransport(PORT) - await t.connect() + t = SMPSerialTransport() + await t.connect(PORT) m1 = EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0) m2 = EchoWriteResponse(r="Hello computer!").to_frame(sequence=1) @@ -202,7 +200,7 @@ async def test_read_one_smp_packet() -> None: @pytest.mark.asyncio async def test_send_and_receive() -> None: - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() t.send = AsyncMock() # type: ignore t.receive = AsyncMock() # type: ignore @@ -214,7 +212,7 @@ async def test_send_and_receive() -> None: @pytest.mark.asyncio async def test_receive_timeout() -> None: - t = SMPSerialTransport(PORT, options=SerialOptions(timeout=0.1)) + t = SMPSerialTransport(options=SerialOptions(timeout=0.1)) t._read_one_smp_packet = AsyncMock(side_effect=TimeoutError) # type: ignore with pytest.raises(TimeoutError): @@ -223,8 +221,8 @@ async def test_receive_timeout() -> None: @pytest.mark.asyncio async def test_only_serial_data_no_smp() -> None: - t = SMPSerialTransport(PORT) - await t.connect() + t = SMPSerialTransport() + await t.connect(PORT) t._conn.read_all = MagicMock( # type: ignore side_effect=[ @@ -260,8 +258,8 @@ async def test_only_serial_data_no_smp() -> None: @pytest.mark.asyncio async def test_only_smp_data_no_serial() -> None: - t = SMPSerialTransport(PORT) - await t.connect() + t = SMPSerialTransport() + await t.connect(PORT) m1 = EchoWriteResponse(r="SMP Message 1").to_frame(sequence=0) m2 = EchoWriteResponse(r="SMP Message 2").to_frame(sequence=1) @@ -284,8 +282,8 @@ async def test_only_smp_data_no_serial() -> None: @pytest.mark.asyncio async def test_serial_and_smp_data() -> None: - t = SMPSerialTransport(PORT) - await t.connect() + t = SMPSerialTransport() + await t.connect(PORT) m1 = EchoWriteResponse(r="SMP1").to_frame(sequence=0) m2 = EchoWriteResponse(r="SMP2").to_frame(sequence=1) @@ -328,7 +326,7 @@ async def test_serial_and_smp_data() -> None: @pytest.mark.asyncio async def test_not_connected_exception_handling() -> None: - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() t._serial.is_open = False t._conn.read_all = MagicMock(side_effect=SerialException("Not connected")) # type: ignore @@ -339,7 +337,7 @@ async def test_not_connected_exception_handling() -> None: @pytest.mark.asyncio async def test_negotiate_with_auto() -> None: """Test that Auto mode updates parameters based on server's buffer size.""" - t = SMPSerialTransport(PORT) # Uses Auto() by default + t = SMPSerialTransport() # Uses Auto() by default # Before negotiating, uses the conservative 7.1.0-equivalent 128 * 2 defaults assert t._line_length == 128 @@ -360,9 +358,7 @@ async def test_negotiate_with_auto() -> None: @pytest.mark.asyncio async def test_negotiate_with_buffer_params() -> None: """Test that BufferParams mode doesn't change user-specified parameters.""" - t = SMPSerialTransport( - PORT, fragmentation_strategy=BufferParams(line_length=128, line_buffers=2) - ) + t = SMPSerialTransport(fragmentation_strategy=BufferParams(line_length=128, line_buffers=2)) # Before negotiating assert t._line_length == 128 @@ -381,7 +377,7 @@ async def test_negotiate_with_buffer_params() -> None: async def test_negotiate_never_reads_for_pinned_strategies() -> None: """A pinned strategy knows its size, so negotiating never reads the server's params.""" for strategy in (BufferParams(line_length=128, line_buffers=4), BufferSize(buf_size=1024)): - t = SMPSerialTransport(PORT, fragmentation_strategy=strategy) + t = SMPSerialTransport(fragmentation_strategy=strategy) max_unencoded_size = t.max_unencoded_size with advertise(256) as read: await t.negotiate() @@ -392,7 +388,7 @@ async def test_negotiate_never_reads_for_pinned_strategies() -> None: def test_buffer_size() -> None: """BufferSize fills the decoded reassembly buffer: max message == buf_size - 4.""" for buf_size in (96, 256, 384, 512, 1024, 2048): - t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) + t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size)) assert t.mtu == buf_size assert t._line_length == 128 assert t.max_unencoded_size == buf_size - FRAME_OVERHEAD @@ -401,9 +397,9 @@ def test_buffer_size() -> None: @pytest.mark.asyncio async def test_buffer_size_matches_negotiated_auto() -> None: """BufferSize(n) is equivalent to Auto negotiated against buf_size n.""" - auto = SMPSerialTransport(PORT) + auto = SMPSerialTransport() await negotiated(auto, 1024) - told = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=1024)) + told = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=1024)) assert told.max_unencoded_size == auto.max_unencoded_size == 1024 - FRAME_OVERHEAD assert told.mtu == auto.mtu == 1024 @@ -412,7 +408,7 @@ async def test_buffer_size_matches_negotiated_auto() -> None: def test_buffer_size_small_line_length() -> None: """A server with a sub-128 per-line buffer keeps the full decoded-buffer payload.""" - t = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=384, line_length=64)) + t = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=384, line_length=64)) assert t._line_length == 64 assert t.max_unencoded_size == 384 - FRAME_OVERHEAD @@ -421,17 +417,15 @@ def test_buffer_size_small_line_length() -> None: async def test_line_buffers_never_misleading_zero() -> None: """Sub-line-length decoded buffers report >= 1 line buffer, never a misleading 0.""" # BufferSize with a buffer smaller than one line still reports at least one line buffer. - assert ( - SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=96))._line_buffers == 1 - ) + assert SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=96))._line_buffers == 1 # Auto negotiated against a sub-line-length server buffer, likewise. - auto_small = SMPSerialTransport(PORT) + auto_small = SMPSerialTransport() await negotiated(auto_small, 96) assert auto_small._line_buffers == 1 # A non-multiple server buffer floors to a sane count and still fills buf_size - overhead. - auto_400 = SMPSerialTransport(PORT) + auto_400 = SMPSerialTransport() await negotiated(auto_400, 400) assert auto_400._line_buffers == 400 // 128 # 3 assert auto_400.max_unencoded_size == 400 - FRAME_OVERHEAD @@ -463,8 +457,8 @@ async def test_decoded_buffer_strategies_put_full_encoded_frame_on_the_wire() -> """ expected_encoded = {384: 527, 512: 702, 1024: 1404, 2048: 2801} for buf_size, encoded_size in expected_encoded.items(): - told = SMPSerialTransport(PORT, fragmentation_strategy=BufferSize(buf_size=buf_size)) - auto = SMPSerialTransport(PORT) + told = SMPSerialTransport(fragmentation_strategy=BufferSize(buf_size=buf_size)) + auto = SMPSerialTransport() await negotiated(auto, buf_size) for t in (told, auto): @@ -492,7 +486,7 @@ def test_fragmentation_strategy_alias() -> None: def test_invalid_strategy_raises_value_error(strategy: SerialFragmentationStrategy) -> None: """The modern API rejects sizes that would hang the encoder or yield a non-positive payload.""" with pytest.raises(ValueError): - SMPSerialTransport(PORT, fragmentation_strategy=strategy) + SMPSerialTransport(fragmentation_strategy=strategy) @pytest.mark.parametrize( @@ -507,13 +501,13 @@ def test_invalid_strategy_raises_value_error(strategy: SerialFragmentationStrate ) def test_valid_strategy_does_not_raise(strategy: SerialFragmentationStrategy) -> None: """Valid strategies construct and report a positive max_unencoded_size.""" - t = SMPSerialTransport(PORT, fragmentation_strategy=strategy) + t = SMPSerialTransport(fragmentation_strategy=strategy) assert t.max_unencoded_size > 0 @pytest.mark.asyncio async def test_auto_rejects_tiny_server_buffer() -> None: """Auto raises if the server advertises a buffer too small to hold a framed message.""" - t = SMPSerialTransport(PORT) + t = SMPSerialTransport() with pytest.raises(ValueError, match="frame overhead"): await negotiated(t, FRAME_OVERHEAD) # buf_size == overhead -> zero-byte payload diff --git a/tests/test_smp_udp_transport.py b/tests/test_smp_udp_transport.py index 6005dc2..b3bd7ba 100644 --- a/tests/test_smp_udp_transport.py +++ b/tests/test_smp_udp_transport.py @@ -16,48 +16,48 @@ pytestmark = pytest.mark.usefixtures("skip_negotiation") ADDRESS = "192.168.0.1" -"""An address; the UDP client is mocked or never connected.""" +"""An address; the UDP client is mocked.""" def test_init() -> None: - t = SMPUDPTransport(ADDRESS) + t = SMPUDPTransport() assert t.mtu == 1500 assert isinstance(t._client, UDPClient) - t = SMPUDPTransport(ADDRESS, mtu=512) + t = SMPUDPTransport(mtu=512) assert t.mtu == 512 @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_connect(_: MagicMock) -> None: - t = SMPUDPTransport("192.168.0.1", connect_timeout_s=0.001) + t = SMPUDPTransport(connect_timeout_s=0.001) t._client = cast(MagicMock, t._client) # type: ignore # Mock _transport for IPv4/IPv6 detection t._client._transport = MagicMock() t._client._transport.get_extra_info.return_value = None - await t.connect() - t._client.connect.assert_awaited_once_with(Addr(host="192.168.0.1", port=1337)) + await t.connect(ADDRESS) + t._client.connect.assert_awaited_once_with(Addr(host=ADDRESS, port=1337)) @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_connect_port(_: MagicMock) -> None: - t = SMPUDPTransport("192.168.0.1", 1338) + t = SMPUDPTransport() t._client = cast(MagicMock, t._client) # type: ignore t._client._transport = MagicMock() t._client._transport.get_extra_info.return_value = None - await t.connect() - t._client.connect.assert_awaited_once_with(Addr(host="192.168.0.1", port=1338)) + await t.connect(ADDRESS, 1338) + t._client.connect.assert_awaited_once_with(Addr(host=ADDRESS, port=1338)) @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_disconnect(_: MagicMock) -> None: - t = SMPUDPTransport(ADDRESS) + t = SMPUDPTransport() t._client = cast(MagicMock, t._client) # type: ignore t._client._protocol = MagicMock() @@ -78,7 +78,7 @@ async def test_disconnect(_: MagicMock) -> None: @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_send(_: MagicMock) -> None: - t = SMPUDPTransport(ADDRESS) + t = SMPUDPTransport() t._client.send = cast(MagicMock, t._client.send) # type: ignore await t.send(b"hello") @@ -98,7 +98,7 @@ async def test_send(_: MagicMock) -> None: @patch("smpclient.transport.udp.UDPClient", autospec=True) @pytest.mark.asyncio async def test_receive(_: MagicMock) -> None: - t = SMPUDPTransport(ADDRESS) + t = SMPUDPTransport() t._client.receive = AsyncMock() # type: ignore message = bytes(EchoWriteResponse(r="Hello pytest!").to_frame(sequence=0)) # type: ignore # noqa @@ -129,7 +129,7 @@ async def test_send_and_receive() -> None: patch("smpclient.transport.udp.SMPUDPTransport.send") as send_mock, patch("smpclient.transport.udp.SMPUDPTransport.receive") as receive_mock, ): - t = SMPUDPTransport(ADDRESS) + t = SMPUDPTransport() message: Final = b"hello" await t.send_and_receive(message) send_mock.assert_awaited_once_with(message) @@ -138,7 +138,7 @@ async def test_send_and_receive() -> None: def test_max_unencoded_size_ipv4() -> None: """Test MSS calculation for IPv4 (default).""" - t = SMPUDPTransport(ADDRESS, mtu=1500) + t = SMPUDPTransport(mtu=1500) # Before connection, defaults to IPv4 assert t.max_unencoded_size == 1500 - IPV4_UDP_OVERHEAD assert t.max_unencoded_size == 1472 @@ -146,7 +146,7 @@ def test_max_unencoded_size_ipv4() -> None: def test_max_unencoded_size_custom_mtu() -> None: """Test MSS calculation with custom MTU.""" - t = SMPUDPTransport(ADDRESS, mtu=512) + t = SMPUDPTransport(mtu=512) assert t.max_unencoded_size == 512 - IPV4_UDP_OVERHEAD assert t.max_unencoded_size == 484 @@ -158,7 +158,7 @@ def test_max_unencoded_size_custom_mtu() -> None: @pytest.mark.asyncio async def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expected: int) -> None: """Zephyr copies each datagram into one `buf_size` buffer, so neither bound may be exceeded.""" - t = await negotiated(SMPUDPTransport(ADDRESS, mtu=1500), buf_size) + t = await negotiated(SMPUDPTransport(mtu=1500), buf_size) assert t.max_unencoded_size == expected @@ -167,7 +167,7 @@ async def test_max_unencoded_size_capped_by_server_buffer(buf_size: int, expecte async def test_buffer_size_is_capped_by_the_mss_and_never_reads( buf_size: int, expected: int ) -> None: - t = SMPUDPTransport(ADDRESS, mtu=1500, fragmentation_strategy=BufferSize(buf_size)) + t = SMPUDPTransport(mtu=1500, fragmentation_strategy=BufferSize(buf_size)) with advertise(4096) as read: await t.negotiate() read.assert_not_awaited() @@ -177,10 +177,10 @@ async def test_buffer_size_is_capped_by_the_mss_and_never_reads( @pytest.mark.asyncio async def test_ipv4_detection_real_socket() -> None: """Test IPv4 auto-detection with real socket connection.""" - t = SMPUDPTransport("127.0.0.1", mtu=1500, connect_timeout_s=1.0) + t = SMPUDPTransport(mtu=1500, connect_timeout_s=1.0) # Create a real UDP connection to localhost IPv4 - await t.connect() + await t.connect("127.0.0.1") assert t._is_ipv6 is False assert t.max_unencoded_size == 1500 - IPV4_UDP_OVERHEAD @@ -192,10 +192,10 @@ async def test_ipv4_detection_real_socket() -> None: @pytest.mark.asyncio async def test_ipv6_detection_real_socket() -> None: """Test IPv6 auto-detection with real socket connection.""" - t = SMPUDPTransport("::1", mtu=1500, connect_timeout_s=1.0) + t = SMPUDPTransport(mtu=1500, connect_timeout_s=1.0) # Create a real UDP connection to localhost IPv6 - await t.connect() + await t.connect("::1") assert t._is_ipv6 is True assert t.max_unencoded_size == 1500 - IPV6_UDP_OVERHEAD From eab036305673b890a1bf97df97565bd688b3f23e Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 16:10:11 -0700 Subject: [PATCH 17/19] breaking(ble): bleak backend options are one sum type, not two kwargs at once Per review on #144 ("You're telling me that we accept both? On macos too? At the same time... This is a sum type."): `winrt=` and `bluez=` could both be passed, and neither means anything on macOS. They become one `backend` argument: BleakBackend = PlatformDefault | BlueZ | WinRT SMPBLETransport(backend=BlueZ(BlueZClientArgs(adapter="hci1"))) SMPBLETransport(backend=WinRT(WinRTClientArgs(use_cached_services=True))) SMPBLETransport() # PlatformDefault() The variants wrap bleak's own `BlueZClientArgs`/`WinRTClientArgs`, so bleak's option schema stays the single source of truth. `_bluez_args` and `_winrt_args` each match the backend exhaustively into the `bluez=`/`winrt=` that `BleakClient` and `BleakScanner` take; the backend that wasn't chosen gets `{}`. `SMPBLETransport.scan()` takes the same `backend` instead of `bluez: BlueZScannerArgs = {}`, which also drops a mutable default argument. `test_connect_passes_bleak_only_the_chosen_backend` checks, for each variant, what the scanner and the client receive. Closes #103 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/transport/ble.py | 63 ++++++++++++++++++++++++++------- tests/test_smp_ble_transport.py | 38 +++++++++++++++----- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index e82d8e5..f19d1a5 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -75,6 +75,46 @@ def _session(self) -> GattSession: ... ) +class PlatformDefault(NamedTuple): + """bleak's defaults for the platform's backend.""" + + +class BlueZ(NamedTuple): + """Options for bleak's BlueZ backend (Linux), e.g. the `adapter` to scan and connect with.""" + + args: BlueZClientArgs + + +class WinRT(NamedTuple): + """Options for bleak's WinRT backend (Windows), e.g. `use_cached_services`.""" + + args: WinRTClientArgs + + +BleakBackend: TypeAlias = PlatformDefault | BlueZ | WinRT +"""The bleak backend options that `SMPBLETransport` scans and connects with.""" + + +def _bluez_args(backend: BleakBackend) -> BlueZClientArgs: + match backend: + case BlueZ(args=args): + return args + case PlatformDefault() | WinRT(): + return {} + case _ as unreachable: + assert_never(unreachable) + + +def _winrt_args(backend: BleakBackend) -> WinRTClientArgs: + match backend: + case WinRT(args=args): + return args + case PlatformDefault() | BlueZ(): + return {} + case _ as unreachable: + assert_never(unreachable) + + class SMPBLETransportException(SMPClientException): """Base class for SMP BLE transport exceptions.""" @@ -120,8 +160,7 @@ class SMPBLETransport(_GATTTransport): def __init__( self, *, - winrt: WinRTClientArgs = {}, - bluez: BlueZClientArgs = {}, + backend: BleakBackend = PlatformDefault(), fragmentation_strategy: GATTFragmentationStrategy = Auto(), connect_timeout_s: float = 2.5, sequence: Callable[[], Iterator[u8]] = _request.wrapping_sequence, @@ -129,8 +168,7 @@ def __init__( """Initialize the BLE transport. Args: - winrt: WinRT backend arguments, e.g. `use_cached_services`. - bluez: BlueZ backend arguments, e.g. the `adapter` to scan and connect with. + backend: The bleak backend options to scan and connect with. fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or `BufferSize`. connect_timeout_s: Bounds scanning and connecting, and reading the server's @@ -142,8 +180,7 @@ def __init__( self._notify_condition: Final = asyncio.Condition() self._disconnected_event: Final = asyncio.Event() self._disconnected_event.set() - self._winrt: Final = winrt - self._bluez: Final = bluez + self._backend: Final = backend self._link: _Link = _Closed() self._max_write_without_response_size = 20 @@ -178,11 +215,11 @@ async def _connect(self, address: str, timeout_s: float) -> None: logger.debug(f"Scanning for {address=}") device: BLEDevice | None = ( await BleakScanner.find_device_by_address( - address, timeout=timeout_s, bluez=BlueZScannerArgs(**self._bluez) + address, timeout=timeout_s, bluez=BlueZScannerArgs(**_bluez_args(self._backend)) ) if MAC_ADDRESS_PATTERN.match(address) or UUID_PATTERN.match(address) else await BleakScanner.find_device_by_name( - address, timeout=timeout_s, bluez=BlueZScannerArgs(**self._bluez) + address, timeout=timeout_s, bluez=BlueZScannerArgs(**_bluez_args(self._backend)) ) ) @@ -191,8 +228,8 @@ async def _connect(self, address: str, timeout_s: float) -> None: BleakClient( device, services=(str(SMP_SERVICE_UUID),), - winrt=self._winrt, - bluez=self._bluez, + winrt=_winrt_args(self._backend), + bluez=_bluez_args(self._backend), timeout=timeout_s, disconnected_callback=self._set_disconnected_event, ) @@ -352,11 +389,11 @@ def mtu(self) -> int: return self._max_write_without_response_size @staticmethod - async def scan(timeout: int = 5, bluez: BlueZScannerArgs = {}) -> list[BLEDevice]: - """Scan for BLE devices, on the BlueZ `adapter` if `bluez` names one.""" + async def scan(timeout: int = 5, backend: BleakBackend = PlatformDefault()) -> list[BLEDevice]: + """Scan for BLE devices with the bleak `backend` options.""" logger.debug(f"Scanning for BLE devices for {timeout} seconds") devices: Final = await BleakScanner( - service_uuids=[str(SMP_SERVICE_UUID)], bluez=bluez + service_uuids=[str(SMP_SERVICE_UUID)], bluez=BlueZScannerArgs(**_bluez_args(backend)) ).discover(timeout=timeout, return_adv=True) smp_servers: Final = [ d for d, a in devices.values() if SMP_SERVICE_UUID in {UUID(u) for u in a.service_uuids} diff --git a/tests/test_smp_ble_transport.py b/tests/test_smp_ble_transport.py index 931661b..942f6ab 100644 --- a/tests/test_smp_ble_transport.py +++ b/tests/test_smp_ble_transport.py @@ -7,6 +7,8 @@ import pytest from bleak import BleakClient +from bleak.args.bluez import BlueZClientArgs +from bleak.args.winrt import WinRTClientArgs from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.device import BLEDevice from bleak.exc import BleakError @@ -18,8 +20,12 @@ SMP_CHARACTERISTIC_UUID, SMP_SERVICE_UUID, UUID_PATTERN, + BleakBackend, + BlueZ, + PlatformDefault, SMPBLETransport, SMPBLETransportDeviceNotFound, + WinRT, _Owned, ) from tests.support import advertise, negotiated @@ -148,21 +154,37 @@ async def test_connect( ) +@pytest.mark.parametrize( + "backend, bluez, winrt", + [ + pytest.param(PlatformDefault(), {}, {}, id="default"), + pytest.param(BlueZ(BlueZClientArgs(adapter="hci1")), {"adapter": "hci1"}, {}, id="bluez"), + pytest.param( + WinRT(WinRTClientArgs(use_cached_services=True)), + {}, + {"use_cached_services": True}, + id="winrt", + ), + ], +) @patch( "smpclient.transport.ble.BleakScanner.find_device_by_address", return_value=BLEDevice(ADDRESS, "name", None), ) @patch("smpclient.transport.ble.BleakClient", side_effect=MockBleakClient) @pytest.mark.asyncio -async def test_connect_scans_and_connects_with_the_bluez_adapter( - mock_bleak_client: MagicMock, mock_find_device_by_address: MagicMock +async def test_connect_passes_bleak_only_the_chosen_backend( + mock_bleak_client: MagicMock, + mock_find_device_by_address: MagicMock, + backend: BleakBackend, + bluez: BlueZClientArgs, + winrt: WinRTClientArgs, ) -> None: - await SMPBLETransport(bluez={"adapter": "hci1"}, connect_timeout_s=1.0).connect(ADDRESS) + await SMPBLETransport(backend=backend, connect_timeout_s=1.0).connect(ADDRESS) - mock_find_device_by_address.assert_called_once_with( - ADDRESS, timeout=1.0, bluez={"adapter": "hci1"} - ) - assert mock_bleak_client.call_args.kwargs["bluez"] == {"adapter": "hci1"} + mock_find_device_by_address.assert_called_once_with(ADDRESS, timeout=1.0, bluez=bluez) + assert mock_bleak_client.call_args.kwargs["bluez"] == bluez + assert mock_bleak_client.call_args.kwargs["winrt"] == winrt @patch("smpclient.transport.ble.BleakScanner") @@ -170,7 +192,7 @@ async def test_connect_scans_and_connects_with_the_bluez_adapter( async def test_scan_uses_the_bluez_adapter(mock_bleak_scanner: MagicMock) -> None: mock_bleak_scanner.return_value.discover = AsyncMock(return_value={}) - assert await SMPBLETransport.scan(bluez={"adapter": "hci1"}) == [] + assert await SMPBLETransport.scan(backend=BlueZ(BlueZClientArgs(adapter="hci1"))) == [] mock_bleak_scanner.assert_called_once_with( service_uuids=[str(SMP_SERVICE_UUID)], bluez={"adapter": "hci1"} From 7bad118a3782c2e914bb0ea5508b99856f820bf9 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 16:11:32 -0700 Subject: [PATCH 18/19] feat(client): SMPClient is generic over its transport Per review on #144 ("Should be generic over the transport, so that users can access the transport in a type safe way."): TTransport = TypeVar("TTransport", bound=SMPTransport) class SMPClient(Generic[TTransport]): ... @property def transport(self) -> TTransport: ... `SMPClient(SMPSerialTransport())` is an `SMPClient[SMPSerialTransport]`, so `client.transport.read_serial()` type-checks with no cast. `ICUploadClient` is generic over the same `TTransport`. `SMPClient.__init__` gains its missing `-> None`. `tests/test_generics_typing.py` asserts the type of `client.transport` for both classes under mypy and pyright. Typing the property as plain `SMPTransport` fails that check. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/__init__.py | 16 ++++++++++++---- src/smpclient/extensions/intercreate.py | 4 ++-- tests/test_generics_typing.py | 8 ++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/smpclient/__init__.py b/src/smpclient/__init__.py index 1268db8..56550a0 100644 --- a/src/smpclient/__init__.py +++ b/src/smpclient/__init__.py @@ -40,7 +40,7 @@ import logging from collections.abc import AsyncIterator, Callable, Iterator from hashlib import sha256 -from typing import TYPE_CHECKING, Final, TypeVar +from typing import TYPE_CHECKING, Final, Generic, TypeVar import msgspec from smp import SMPRequest @@ -76,8 +76,11 @@ ) """A single-shot upload request whose `data` field is filled to maximize throughput.""" +TTransport = TypeVar("TTransport", bound=SMPTransport) +"""The type of the client's transport.""" -class SMPClient: + +class SMPClient(Generic[TTransport]): """Create a client to the SMP server at the other end of the live `transport`. This class provides a high-level interface to an SMP server. Other than @@ -116,15 +119,20 @@ async def main(): def __init__( # noqa: DOC301 self, - transport: SMPTransport, + transport: TTransport, *, timeout_s: float = 2.5, sequence: Callable[[], Iterator[u8]] = wrapping_sequence, - ): + ) -> None: self._transport: Final = transport self._timeout_s: Final = timeout_s self._sequence: Final = sequence() + @property + def transport(self) -> TTransport: + """The live transport this client exchanges requests over.""" + return self._transport + async def request( self, request: SMPRequest[TRep, TEr1, TEr2], timeout_s: float | None = None ) -> TRep | TEr1 | TEr2: diff --git a/src/smpclient/extensions/intercreate.py b/src/smpclient/extensions/intercreate.py index c8a7343..0516303 100644 --- a/src/smpclient/extensions/intercreate.py +++ b/src/smpclient/extensions/intercreate.py @@ -4,11 +4,11 @@ from smp.user import intercreate as ic -from smpclient import SMPClient, error, success +from smpclient import SMPClient, TTransport, error, success from smpclient.exceptions import SMPUploadError -class ICUploadClient(SMPClient): +class ICUploadClient(SMPClient[TTransport]): """Support for Intercreate Group Upload.""" async def ic_upload(self, data: bytes, image: int = 0) -> AsyncIterator[int]: diff --git a/tests/test_generics_typing.py b/tests/test_generics_typing.py index c61f0c8..e6b09a9 100644 --- a/tests/test_generics_typing.py +++ b/tests/test_generics_typing.py @@ -21,6 +21,14 @@ from typing_extensions import assert_never, assert_type from smpclient import SMPClient, error, error_v1, error_v2, success +from smpclient.extensions.intercreate import ICUploadClient +from smpclient.transport.serial import SMPSerialTransport + + +def _check_client_keeps_its_transport_type(transport: SMPSerialTransport) -> None: + """A client's `transport` is the transport it was given, e.g. for `read_serial()`.""" + assert_type(SMPClient(transport).transport, SMPSerialTransport) + assert_type(ICUploadClient(transport).transport, SMPSerialTransport) async def _check_exhaustive_narrowing(client: SMPClient) -> None: From 7c91b0b922e0dc2906c328940ee0a2a07ad2faa9 Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Wed, 23 Sep 2026 16:14:06 -0700 Subject: [PATCH 19/19] =?UTF-8?q?style:=20CLAUDE.md=20pass=20=E2=80=94=20c?= =?UTF-8?q?ut=20restating=20docstrings,=20exhaustive=20matches,=20position?= =?UTF-8?q?al=20bond=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #144 ("llm doc slop - restates the code and other doc strings", "why kwargs only?"): - The strategy aliases no longer list their own members or restate the sizing rules the properties implement. `SerialFragmentationStrategy`, `RawSerialFragmentationStrategy`, and `UDPFragmentationStrategy` are each one line. The constructors' `fragmentation_strategy` Args no longer repeat the union. - `_request.exchange` and `_request.read_mcumgr_parameters` are private helpers, so their docstrings are one line. `exchange` was a copy of `SMPClient.request`'s docstring, which remains the documented contract. - The nested `match` on the params read (`int | None`) ended in a bare capture, which rules out an `assert_never` arm. Each now matches `case int() as buf_size:` and closes with `case _ as unreachable: assert_never(unreachable)`. That covers all five transports' `negotiate()`, including encoded serial's guarded arm. - `bonded_devices`, `clear_bond`, and `clear_bonds` drop the `*`. There is no ambiguity for keyword-only to guard against. - Test comments no longer mention the removed 7.1.0 params. - `serial.common` reuses `smpclient.transport._TStrategy` instead of redeclaring an identical TypeVar. Refs #58 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/smpclient/_request.py | 29 ++------------------- src/smpclient/transport/__init__.py | 8 ++++-- src/smpclient/transport/ble.py | 3 +-- src/smpclient/transport/bumble/__init__.py | 8 +++--- src/smpclient/transport/serial/common.py | 7 ++--- src/smpclient/transport/serial/encoded.py | 15 +++++------ src/smpclient/transport/serial/unencoded.py | 12 +++++---- src/smpclient/transport/udp.py | 12 ++++----- tests/test_smp_serial_transport.py | 4 +-- 9 files changed, 34 insertions(+), 64 deletions(-) diff --git a/src/smpclient/_request.py b/src/smpclient/_request.py index f2324cb..979ae80 100644 --- a/src/smpclient/_request.py +++ b/src/smpclient/_request.py @@ -139,22 +139,7 @@ async def exchange( sequence: u8, timeout_s: float, ) -> TRep | TEr1 | TEr2: - """Send `request` as SMP sequence `sequence` and return the typed Response or Error. - - Args: - transport: the live transport to exchange the request over - request: the `SMPRequest` to send - sequence: the SMP sequence number to send `request` as - timeout_s: the timeout for the exchange in seconds - - Returns: - The typed and validated Response or Error - - Raises: - TimeoutError: if the request times out - SMPBadSequence: if the response sequence does not match the request sequence - SMPValidationException: if the response cannot be parsed as a Response or Error - """ + """The core of `SMPClient.request`, as SMP sequence number `sequence`.""" request_frame: Final = request.to_frame(sequence) try: @@ -196,17 +181,7 @@ async def exchange( async def read_mcumgr_parameters( transport: SMPTransport, sequence: u8, timeout_s: float ) -> MCUMgrParametersReadResponse | None: - """Read the server's MCUmgr parameters over `transport`. - - Args: - transport: the live transport to read the parameters over - sequence: the SMP sequence number to send the request as - timeout_s: the timeout for the exchange in seconds - - Returns: - The parameters, or `None` (with a warning) if the server answers with an error or - not at all - """ + """The server's MCUmgr parameters, or `None` (warned) if it answers an error or not at all.""" try: response: Final = await exchange( transport, MCUMgrParametersReadRequest(), sequence, timeout_s diff --git a/src/smpclient/transport/__init__.py b/src/smpclient/transport/__init__.py index 4ec74ca..61398d9 100644 --- a/src/smpclient/transport/__init__.py +++ b/src/smpclient/transport/__init__.py @@ -166,14 +166,18 @@ async def negotiate(self) -> None: match await self._read_buf_size(): case None: self._sizing = Auto() - case buf_size: + case int() as buf_size: self._sizing = BufferSize(buf_size) + case _ as unreachable: + assert_never(unreachable) case Unfragmented(): match await self._read_buf_size(): case None: self._sizing = Unfragmented() - case buf_size: + case int() as buf_size: self._sizing = BufferSize(min(self.mtu, buf_size)) + case _ as unreachable: + assert_never(unreachable) case BufferSize(): pass case _ as unreachable: diff --git a/src/smpclient/transport/ble.py b/src/smpclient/transport/ble.py index f19d1a5..045bcd5 100644 --- a/src/smpclient/transport/ble.py +++ b/src/smpclient/transport/ble.py @@ -169,8 +169,7 @@ def __init__( Args: backend: The bleak backend options to scan and connect with. - fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or - `BufferSize`. + fragmentation_strategy: How to size SMP messages. connect_timeout_s: Bounds scanning and connecting, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from. diff --git a/src/smpclient/transport/bumble/__init__.py b/src/smpclient/transport/bumble/__init__.py index 44cc2e2..47de27f 100644 --- a/src/smpclient/transport/bumble/__init__.py +++ b/src/smpclient/transport/bumble/__init__.py @@ -171,8 +171,7 @@ def __init__( `pair_on_connect` and `pair()`. settle_s: Wait between successful pair and proceeding (or disconnecting) so the peer can finalize bonding. - fragmentation_strategy: How to size SMP messages: `Auto`, `Unfragmented`, or - `BufferSize`. + fragmentation_strategy: How to size SMP messages. connect_timeout_s: Bounds scanning for a name, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from. @@ -592,7 +591,7 @@ async def _teardown_borrowed(self) -> None: async def bonded_devices( - *, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS + keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS ) -> tuple[str, ...]: """Return the BD_ADDRs of peers in the keystore that `host_address` bonds with.""" return tuple( @@ -603,7 +602,6 @@ async def bonded_devices( async def clear_bond( address: str, - *, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS, ) -> None: @@ -613,7 +611,7 @@ async def clear_bond( async def clear_bonds( - *, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS + keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS ) -> None: """Delete every bond from the keystore that `host_address` bonds with.""" await resolve_keystore(keystore, namespace=str(host_address)).delete_all() diff --git a/src/smpclient/transport/serial/common.py b/src/smpclient/transport/serial/common.py index 6f17b9d..fdc93ad 100644 --- a/src/smpclient/transport/serial/common.py +++ b/src/smpclient/transport/serial/common.py @@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Callable, Iterator from contextlib import asynccontextmanager, contextmanager from time import monotonic -from typing import TYPE_CHECKING, Final, Generator, NamedTuple, Protocol, TypeAlias, TypeVar, final +from typing import TYPE_CHECKING, Final, Generator, NamedTuple, Protocol, TypeAlias, final try: from serial import Serial, SerialException @@ -19,7 +19,7 @@ raise from typing_extensions import Self, assert_never, override -from smpclient.transport import SMPTransportDisconnected, _ConnectableTransport +from smpclient.transport import SMPTransportDisconnected, _ConnectableTransport, _TStrategy if TYPE_CHECKING: from _typeshed import ReadableBuffer @@ -94,9 +94,6 @@ class SerialOptions(NamedTuple): mode if it is already open in exclusive access mode.""" -_TStrategy = TypeVar("_TStrategy") - - class _SerialTransportBase(_ConnectableTransport[_TStrategy]): """Connection-management base class for serial-port-backed SMP transports. diff --git a/src/smpclient/transport/serial/encoded.py b/src/smpclient/transport/serial/encoded.py index 9753402..47d5785 100644 --- a/src/smpclient/transport/serial/encoded.py +++ b/src/smpclient/transport/serial/encoded.py @@ -127,12 +127,7 @@ class BufferParams(NamedTuple): SerialFragmentationStrategy: TypeAlias = Auto | BufferSize | BufferParams -"""How `SMPSerialTransport` sizes SMP messages: `Auto`, `BufferSize`, or `BufferParams`. - -With `Auto`, connecting reads the server's `buf_size` (the decoded reassembly buffer) and -the transport sends messages up to `buf_size - 4`, filling that buffer; until the parameters -are read, or if the server doesn't provide them, it assumes a conservative line budget. -""" +"""How `SMPSerialTransport` sizes SMP messages.""" class SMPSerialTransport(_SerialTransportBase[SerialFragmentationStrategy]): @@ -159,7 +154,7 @@ def __init__( """Initialize the serial transport. Args: - fragmentation_strategy: how to size SMP messages. + fragmentation_strategy: How to size SMP messages. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from. @@ -283,18 +278,20 @@ async def negotiate(self) -> None: match await self._read_buf_size(): case None: self._sizing = Auto() - case buf_size if buf_size <= _FRAME_OVERHEAD: + case int() as buf_size if buf_size <= _FRAME_OVERHEAD: raise ValueError( f"server buffer size ({buf_size}) must exceed the " f"{_FRAME_OVERHEAD}-byte frame overhead to carry a message" ) - case buf_size: + case int() as buf_size: self._sizing = BufferSize(buf_size=buf_size) logger.info( f"Auto-configured from server buf_size={buf_size}: " f"mtu={self.mtu}, max_unencoded_size={self.max_unencoded_size}, " f"line_length={self._line_length}" ) + case _ as unreachable: + assert_never(unreachable) case BufferSize() | BufferParams(): pass case _ as unreachable: diff --git a/src/smpclient/transport/serial/unencoded.py b/src/smpclient/transport/serial/unencoded.py index 0ffba54..39b5174 100644 --- a/src/smpclient/transport/serial/unencoded.py +++ b/src/smpclient/transport/serial/unencoded.py @@ -38,7 +38,7 @@ `CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`.""" RawSerialFragmentationStrategy: TypeAlias = Auto | BufferSize -"""How `SMPSerialRawTransport` sizes SMP messages: `Auto` or `BufferSize`.""" +"""How `SMPSerialRawTransport` sizes SMP messages.""" class SMPSerialRawTransport(_SerialTransportBase[RawSerialFragmentationStrategy]): @@ -54,9 +54,9 @@ def __init__( """Initialize the raw serial transport. Args: - fragmentation_strategy: How to size one SMP message (header + payload): `Auto` - or `BufferSize`. A serial link has no MTU of its own, but the SMP server's - receive buffer (`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`) does. + fragmentation_strategy: How to size one SMP message (header + payload). A + serial link has no MTU of its own, but the SMP server's receive buffer + (`CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE`) does. framing: optional wire framing for each SMP message (e.g. `Cobs()`); `None` sends the bare `[header][payload]`. connect_timeout_s: Bounds opening the port, and reading the server's MCUmgr @@ -164,8 +164,10 @@ async def negotiate(self) -> None: match await self._read_buf_size(): case None: self._sizing = Auto() - case buf_size: + case int() as buf_size: self._sizing = BufferSize(buf_size) + case _ as unreachable: + assert_never(unreachable) case BufferSize(): pass case _ as unreachable: diff --git a/src/smpclient/transport/udp.py b/src/smpclient/transport/udp.py index 7efd57e..6e63ba0 100644 --- a/src/smpclient/transport/udp.py +++ b/src/smpclient/transport/udp.py @@ -45,11 +45,7 @@ UDPFragmentationStrategy: TypeAlias = Auto | BufferSize -"""How `SMPUDPTransport` sizes SMP messages: `Auto` or `BufferSize`. - -Either way a message never exceeds one datagram's payload (the MSS): the server receives -each request as a single datagram into a single buffer. -""" +"""How `SMPUDPTransport` sizes SMP messages.""" class SMPUDPTransport(_ConnectableTransport[UDPFragmentationStrategy]): @@ -67,7 +63,7 @@ def __init__( mtu: The Maximum Transmission Unit (MTU) of the link layer in bytes. IP and UDP header overhead will be subtracted to calculate the maximum UDP payload size (MSS) to avoid fragmentation per RFC 8085 section 3.2. - fragmentation_strategy: How to size SMP messages: `Auto` or `BufferSize`. + fragmentation_strategy: How to size SMP messages. connect_timeout_s: Bounds connecting, and reading the server's MCUmgr parameters. sequence: The SMP sequence space the MCUmgr parameters read draws from. @@ -180,8 +176,10 @@ async def negotiate(self) -> None: match await self._read_buf_size(): case None: self._sizing = Auto() - case buf_size: + case int() as buf_size: self._sizing = BufferSize(buf_size) + case _ as unreachable: + assert_never(unreachable) case BufferSize(): pass case _ as unreachable: diff --git a/tests/test_smp_serial_transport.py b/tests/test_smp_serial_transport.py index 5e6d3af..845087c 100644 --- a/tests/test_smp_serial_transport.py +++ b/tests/test_smp_serial_transport.py @@ -41,7 +41,7 @@ def mock_serial() -> Generator[None, Any, None]: def test_constructor() -> None: - # Test with Auto() (default): conservative 7.1.0-equivalent 128 * 2 budget pre-init + # Test with Auto() (default): conservative 128 * 2 budget pre-init t = SMPSerialTransport() assert t.mtu == 256 # 128 * 2, the conservative default before server params are read assert t._line_length == 128 @@ -339,7 +339,7 @@ async def test_negotiate_with_auto() -> None: """Test that Auto mode updates parameters based on server's buffer size.""" t = SMPSerialTransport() # Uses Auto() by default - # Before negotiating, uses the conservative 7.1.0-equivalent 128 * 2 defaults + # Before negotiating, uses the conservative 128 * 2 defaults assert t._line_length == 128 assert t._line_buffers == 2 assert t._max_smp_encoded_frame_size == 256