Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@
"camas": {
"type": "stdio",
"command": "uv",
"args": [
"run",
"camas",
"mcp",
"--rich"
]
"args": ["run", "camas", "mcp"]
}
}
}
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ dev = [
"pytest-cov>=7.0.0",
"pytest-asyncio>=0.23.2",
"ruff>=0.12.0",
"camas[mcp,check]==0.1.14",
"camas[mcp]==0.1.29",
"pyright>=1.1,<2",
]
doc = [
"mkdocstrings[python]>=0.26.1",
Expand Down Expand Up @@ -123,3 +124,7 @@ exclude_lines = [
':\s*\.\.\.\s*$',
'^\s*\.\.\.\s*$',
]

[tool.pyright]
include = ["src", "tests", "examples"]
typeCheckingMode = "standard"
6 changes: 6 additions & 0 deletions src/smpclient/transport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,27 @@ async def connect(self, address: str, timeout_s: float) -> None: # pragma: no c
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`.

Args:
data: The encoded `SMPRequest`.
"""
...

async def receive(self) -> bytes: # pragma: no cover
"""Receive the decoded `SMPResponse` data.

Returns:
The `SMPResponse` bytes.
"""
...

async def send_and_receive(self, data: bytes) -> bytes: # pragma: no cover
"""Send the encoded `SMPRequest` `data` and receive the decoded `SMPResponse`.
Expand All @@ -56,6 +60,7 @@ async def send_and_receive(self, data: bytes) -> bytes: # pragma: no cover
Returns:
The `SMPResponse` bytes.
"""
...

def initialize(self, smp_server_transport_buffer_size: int) -> None: # pragma: no cover
"""Initialize the `SMPTransport` with the server transport buffer size.
Expand All @@ -68,6 +73,7 @@ def initialize(self, smp_server_transport_buffer_size: int) -> None: # pragma:
@property
def mtu(self) -> int: # pragma: no cover
"""The Maximum Transmission Unit (MTU) in 8-bit bytes."""
...

@property
def max_unencoded_size(self) -> int: # pragma: no cover
Expand Down
26 changes: 18 additions & 8 deletions src/smpclient/transport/ble.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import re
import sys
from collections.abc import Coroutine
from typing import Any, Final, Protocol, TypeGuard, TypeVar
from typing import Any, Final, Protocol, TypeAlias, TypeGuard, TypeVar
from uuid import UUID

try:
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
from bleak import BleakClient, BleakScanner
from bleak.args.winrt import WinRTClientArgs
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.client import BaseBleakClient
from bleak.backends.device import BLEDevice
except ModuleNotFoundError as e:
Expand Down Expand Up @@ -48,6 +49,15 @@ class BleakClientWinRT(Protocol):
def _session(self) -> GattSession: ...


_ClientBackend: TypeAlias = BaseBleakClient | BleakClientBlueZDBus | BleakClientWinRT
"""Any `BleakClient._backend`: the platform's real backend, plus the off-platform stubs.

On each platform one of `BleakClientBlueZDBus`/`BleakClientWinRT` is bleak's real
`BaseBleakClient` subclass and the other is the local `Protocol` stub, so the backend
predicates below must accept the union to narrow either one.
"""


MAC_ADDRESS_PATTERN: Final = re.compile(r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE)
UUID_PATTERN: Final = re.compile(
r"^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}\Z",
Expand Down Expand Up @@ -135,7 +145,7 @@ async def _connect(self, address: str, timeout_s: float) -> None:
"The SMP characteristic MTU is 20 bytes, possibly a Windows bug, checking again"
)
await asyncio.sleep(2)
smp_characteristic._max_write_without_response_size = (
smp_characteristic._max_write_without_response_size = ( # pyright: ignore[reportAttributeAccessIssue]
self._client._backend._session.max_pdu_size - 3 # type: ignore
)
self._max_write_without_response_size = (
Expand Down Expand Up @@ -182,7 +192,7 @@ async def receive(self) -> bytes:
logger.debug(f"Waiting for notify on {SMP_CHARACTERISTIC_UUID=}")
await self._notify_or_disconnect()

header: Final = smphdr.Header.loads(self._buffer[: smphdr.Header.SIZE])
header: Final = smphdr.Header.loads(bytes(self._buffer[: smphdr.Header.SIZE]))
logger.debug(f"Received {header=}")

message_length: Final = header.length + header.SIZE
Expand All @@ -199,7 +209,7 @@ async def receive(self) -> bytes:
raise SMPBLETransportException("Length of buffer passed expected message size.")
await self._notify_or_disconnect()

async def _notify_callback(self, sender: BleakGATTCharacteristic, data: bytes) -> None:
async def _notify_callback(self, sender: BleakGATTCharacteristic, data: bytearray) -> None:
if sender.uuid != str(SMP_CHARACTERISTIC_UUID): # pragma: no cover
raise SMPBLETransportException(f"Unexpected notify from {sender}; {data=}")
async with self._notify_condition:
Expand All @@ -211,8 +221,8 @@ async def send_and_receive(self, data: bytes) -> bytes:
await self.send(data)
return await self.receive()

@override
@property
@override
def mtu(self) -> int:
return self._max_write_without_response_size

Expand All @@ -230,11 +240,11 @@ async def scan(timeout: int = 5) -> list[BLEDevice]:
return smp_servers

@staticmethod
def _bluez_backend(client_backend: BaseBleakClient) -> TypeGuard[BleakClientBlueZDBus]:
def _bluez_backend(client_backend: _ClientBackend) -> TypeGuard[BleakClientBlueZDBus]:
return client_backend.__class__.__name__ == "BleakClientBlueZDBus"

@staticmethod
def _winrt_backend(client_backend: BaseBleakClient) -> TypeGuard[BleakClientWinRT]:
def _winrt_backend(client_backend: _ClientBackend) -> TypeGuard[BleakClientWinRT]:
return client_backend.__class__.__name__ == "BleakClientWinRT"

def _set_disconnected_event(self, client: BleakClient) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/smpclient/transport/bumble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ async def receive(self) -> bytes:

while len(buffer) < smphdr.Header.SIZE:
buffer.extend(await self._next_chunk())
header: Final = smphdr.Header.loads(buffer[: smphdr.Header.SIZE])
header: Final = smphdr.Header.loads(bytes(buffer[: smphdr.Header.SIZE]))
logger.debug(f"Received {header=}")

message_length: Final = header.length + smphdr.Header.SIZE
Expand Down Expand Up @@ -428,8 +428,8 @@ async def pair(
case _:
assert_never(self._state)

@override
@property
@override
def mtu(self) -> int:
return self._require_connected("mtu").max_write

Expand Down
4 changes: 2 additions & 2 deletions src/smpclient/transport/serial/encoded.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,13 +709,13 @@ def _could_be_smp_packet_start(self, byte: int) -> bool:
"""Return True if the given byte value matches the start of any SMP packet delimiter."""
return byte == smppacket.START_DELIMITER[0] or byte == smppacket.CONTINUE_DELIMITER[0]

@override
@property
@override
def mtu(self) -> int:
return self._max_smp_encoded_frame_size

@override
@property
@override
def max_unencoded_size(self) -> int:
"""The maximum unencoded SMP message size, in bytes.

Expand Down
4 changes: 4 additions & 0 deletions src/smpclient/transport/serial/framing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ class SerialFraming(Protocol):

def encode(self, data: bytes) -> Iterator[bytes]: # pragma: no cover
"""Yield the wire bytes framing the SMP message `data`."""
...

def feed(self, data: bytes) -> None: # pragma: no cover
"""Buffer received bytes for decoding."""
...

def take(self) -> bytes | None: # pragma: no cover
"""Return the next decoded SMP message, or `None` if no complete frame is buffered.

Unconsumed bytes persist for the next call (a read may span frame boundaries), and a
framing that can detect corruption drops the damaged frame and resynchronises.
"""
...

def reset(self) -> None: # pragma: no cover
"""Discard buffered bytes so a new connection starts clean."""
...
2 changes: 1 addition & 1 deletion src/smpclient/transport/serial/unencoded.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ async def _poll_read_into(self, buf: bytearray) -> None:
else:
await asyncio.sleep(self._POLLING_INTERVAL_S)

@override
@property
@override
def mtu(self) -> int:
return self._mtu
6 changes: 3 additions & 3 deletions src/smpclient/transport/udp.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,20 @@ async def receive(self) -> bytes:
raise SMPClientException(error)

logger.debug(f"Finished receiving message of length {message_length} B")
return message
return bytes(message)

@override
async def send_and_receive(self, data: bytes) -> bytes:
await self.send(data)
return await self.receive()

@override
@property
@override
def mtu(self) -> int:
return self._mtu

@override
@property
@override
def max_unencoded_size(self) -> int:
"""Maximum UDP payload size (MSS) to avoid fragmentation.

Expand Down
22 changes: 14 additions & 8 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@

from pathlib import Path

from camas import Config, Parallel, Sequential, Task
from camas import Claude, Config, Parallel, Sequential, Task

format = Task("ruff format .", mutates=True)
format = Task("ruff format {paths}", mutates=True, paths=".")

lint = Parallel(
Task("ruff check ."),
Task("pydoclint src/smpclient"),
Task("ruff check {paths}", paths="."),
Task("pydoclint {paths}", paths="src/smpclient"),
)

fix = Sequential(
Task("ruff check --fix .", mutates=True),
Task("ruff format .", mutates=True),
Task("ruff check --fix {paths}", mutates=True, paths="."),
Task("ruff format {paths}", mutates=True, paths="."),
)

typecheck = Task("mypy .")
mypy = Task("mypy .")
pyright = Task("pyright")
typecheck = Parallel(mypy, pyright)

test = Task("pytest -v --ignore=tests/integration")

Expand All @@ -40,4 +42,8 @@
matrix={"PY": tuple(_PYTHONS)},
)

_ = Config(default_task=all, github_task=check)
_ = Config(
default_task=all,
github_task=check,
agent=Claude(fix=fix, check=Parallel(lint, typecheck)),
)
3 changes: 2 additions & 1 deletion tests/test_smp_ble_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from uuid import UUID

import pytest
from bleak import BleakClient, BleakGATTCharacteristic
from bleak import BleakClient
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.backends.device import BLEDevice

from smpclient.requests.os_management import EchoWrite
Expand Down
Loading
Loading