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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A simplified, typed, and testable Python library for controlling the `XKC-KL200-
- Typed API for sensor control and distance reads.
- Straightforward request/response serial flow.
- No hardware required for tests because the serial link is mocked.
- `uv`, `black`, `ruff`, `mypy`, and `pytest` ready from the start.
- `uv`, `ruff`, `mypy`, `pytest`, and `vulture` ready from the start.

---

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "xkc-kl200-python"
version = "6.0.0"
version = "7.0.0"
description = "A typed Python library for controlling the XKC-KL200 UART laser distance sensor."
readme = "README.md"
authors = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
"""Protocol frame helpers shared by commands, parsing, and tests."""
"""Wire constants and frame helpers for the XKC-KL200 UART protocol."""

from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final

from .constants import (
COMMAND_HEADER,
FRAME_LENGTH,
READ_DISTANCE_COMMAND,
SYSTEM_HEADER,
)
FRAME_LENGTH = 9
COMMAND_HEADER = 0x62
SYSTEM_HEADER = 0x61
DEFAULT_SENSOR_ADDRESS = 0xFFFF

READ_DISTANCE_COMMAND = 0x33
CHANGE_BAUD_RATE_COMMAND = 0x30
CHANGE_ADDRESS_COMMAND = 0x32
SET_LED_MODE_COMMAND = 0x37
SET_RELAY_MODE_COMMAND = 0x38
RESET_COMMAND = 0x39
SET_COMMUNICATION_MODE_COMMAND = 0x30

MIN_ADDRESS = 0x0000
MAX_ADDRESS = 0xFFFE

BAUD_RATE_TO_CODE = {
2400: 0,
4800: 1,
9600: 2,
14400: 3,
19200: 4,
38400: 5,
56000: 6,
57600: 7,
115200: 8,
128000: 9,
}

CODE_TO_BAUD_RATE = {code: baudrate for baudrate, code in BAUD_RATE_TO_CODE.items()}


@dataclass(frozen=True, kw_only=True, slots=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@

import serial

from ._protocol import (
COMMAND_HEADER,
FRAME_LENGTH,
SYSTEM_HEADER,
calculate_checksum,
)
from .config import SensorConfig
from .constants import COMMAND_HEADER, FRAME_LENGTH, SYSTEM_HEADER, XkcKl200Status
from .utils import calculate_checksum
from .constants import XkcKl200Status


class SerialPort(Protocol):
Expand Down
4 changes: 3 additions & 1 deletion src/xkc_kl200_python/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

from dataclasses import dataclass

from .constants import (
from ._protocol import (
BAUD_RATE_TO_CODE,
DEFAULT_SENSOR_ADDRESS,
MAX_ADDRESS,
MIN_ADDRESS,
)

__all__ = ["SensorConfig"]


@dataclass(frozen=True, kw_only=True, slots=True)
class SensorConfig:
Expand Down
41 changes: 7 additions & 34 deletions src/xkc_kl200_python/constants.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,13 @@
"""Protocol constants and small enums for the XKC-KL200 UART interface."""
"""Public enums and status values for the XKC-KL200 UART interface."""

from enum import IntEnum

# Fixed frame layout values shared by command and measurement packets.
FRAME_LENGTH = 9
COMMAND_HEADER = 0x62
SYSTEM_HEADER = 0x61
DEFAULT_SENSOR_ADDRESS = 0xFFFF

# Command identifiers used by the device protocol.
READ_DISTANCE_COMMAND = 0x33
CHANGE_BAUD_RATE_COMMAND = 0x30
CHANGE_ADDRESS_COMMAND = 0x32
SET_LED_MODE_COMMAND = 0x37
SET_RELAY_MODE_COMMAND = 0x38
RESET_COMMAND = 0x39
SET_COMMUNICATION_MODE_COMMAND = 0x30

MIN_ADDRESS = 0x0000
MAX_ADDRESS = 0xFFFE

# Supported baud-rate mappings between user values and protocol codes.
BAUD_RATE_TO_CODE = {
2400: 0,
4800: 1,
9600: 2,
14400: 3,
19200: 4,
38400: 5,
56000: 6,
57600: 7,
115200: 8,
128000: 9,
}

CODE_TO_BAUD_RATE = {code: baudrate for baudrate, code in BAUD_RATE_TO_CODE.items()}
__all__ = [
"CommunicationMode",
"LedMode",
"RelayMode",
"XkcKl200Status",
]


class XkcKl200Status(IntEnum):
Expand Down
6 changes: 6 additions & 0 deletions src/xkc_kl200_python/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Public exception types for fresh-read failures in the XKC-KL200 library."""

__all__ = [
"XkcKl200ReadError",
"XkcKl200ResponseError",
"XkcKl200TimeoutError",
]


class XkcKl200ReadError(Exception):
"""Base exception for failures while reading a fresh sensor measurement."""
Expand Down
26 changes: 14 additions & 12 deletions src/xkc_kl200_python/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
from dataclasses import replace
from typing import Self, TypeVar

from .config import SensorConfig
from .constants import (
from ._protocol import (
BAUD_RATE_TO_CODE,
CHANGE_ADDRESS_COMMAND,
CHANGE_BAUD_RATE_COMMAND,
CODE_TO_BAUD_RATE,
COMMAND_HEADER,
EMPTY_FRAME_PAYLOAD,
MAX_ADDRESS,
MIN_ADDRESS,
READ_DISTANCE_COMMAND,
Expand All @@ -19,22 +19,24 @@
SET_LED_MODE_COMMAND,
SET_RELAY_MODE_COMMAND,
SYSTEM_HEADER,
FramePayload,
build_command_frame,
parse_frame,
parse_measurement_frame,
)
from ._serial_manager import SerialFactory, SerialManager
from .config import SensorConfig
from .constants import (
CommunicationMode,
LedMode,
RelayMode,
XkcKl200Status,
)
from .errors import XkcKl200ResponseError, XkcKl200TimeoutError
from .serial_manager import SerialFactory, SerialManager
from .utils import (
EMPTY_FRAME_PAYLOAD,
FramePayload,
build_command_frame,
parse_frame,
parse_measurement_frame,
)

EnumValue = TypeVar("EnumValue", bound=int)
__all__ = ["XkcKl200"]

_EnumValue = TypeVar("_EnumValue", bound=int)


class XkcKl200:
Expand Down Expand Up @@ -263,7 +265,7 @@ def _resolve_baud_rate_code(baud_rate: int) -> int | None:

@staticmethod
def _coerce_enum_value(
value: int | EnumValue, enum_type: type[EnumValue]
value: int | _EnumValue, enum_type: type[_EnumValue]
) -> int | None:
"""Validate an integer-like value against an enum type."""
try:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_utils.py → tests/test_protocol.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Protocol helper tests shared by runtime code and test fixtures."""
"""Tests for private protocol helpers."""

import pytest

from xkc_kl200_python.constants import READ_DISTANCE_COMMAND
from xkc_kl200_python.utils import (
from xkc_kl200_python._protocol import (
READ_DISTANCE_COMMAND,
FramePayload,
build_command_frame,
calculate_checksum,
Expand Down
46 changes: 46 additions & 0 deletions tests/test_public_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Tests for the supported typed package interface."""

import importlib.util

import xkc_kl200_python
from xkc_kl200_python import config, constants, errors, sensor


def test_package_exports_are_exact_and_sorted() -> None:
"""Expose exactly the documented high-level API."""
assert xkc_kl200_python.__all__ == [
"CommunicationMode",
"LedMode",
"RelayMode",
"SensorConfig",
"XkcKl200",
"XkcKl200ReadError",
"XkcKl200ResponseError",
"XkcKl200Status",
"XkcKl200TimeoutError",
]


def test_public_modules_declare_exact_interfaces() -> None:
"""Keep each importable public module limited to supported names."""
assert config.__all__ == ["SensorConfig"]
assert constants.__all__ == [
"CommunicationMode",
"LedMode",
"RelayMode",
"XkcKl200Status",
]
assert errors.__all__ == [
"XkcKl200ReadError",
"XkcKl200ResponseError",
"XkcKl200TimeoutError",
]
assert sensor.__all__ == ["XkcKl200"]


def test_internal_module_paths_are_explicitly_private() -> None:
"""Ship underscored internals without retaining the old module paths."""
assert importlib.util.find_spec("xkc_kl200_python._protocol") is not None
assert importlib.util.find_spec("xkc_kl200_python._serial_manager") is not None
assert importlib.util.find_spec("xkc_kl200_python.serial_manager") is None
assert importlib.util.find_spec("xkc_kl200_python.utils") is None
2 changes: 1 addition & 1 deletion tests/test_sensor_init_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from conftest import FakeSerialFactory

from xkc_kl200_python import CommunicationMode, LedMode, RelayMode, XkcKl200
from xkc_kl200_python._protocol import build_command_frame
from xkc_kl200_python.constants import XkcKl200Status
from xkc_kl200_python.utils import build_command_frame


def test_invalid_led_mode_returns_invalid_parameter(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_sensor_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from conftest import FakeSerialFactory

from xkc_kl200_python import CommunicationMode, SensorConfig, XkcKl200
from xkc_kl200_python._protocol import FramePayload, build_command_frame
from xkc_kl200_python.constants import XkcKl200Status
from xkc_kl200_python.utils import FramePayload, build_command_frame


def make_sensor(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_sensor_reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
XkcKl200ResponseError,
XkcKl200TimeoutError,
)
from xkc_kl200_python._protocol import FramePayload, build_command_frame
from xkc_kl200_python.constants import XkcKl200Status
from xkc_kl200_python.utils import FramePayload, build_command_frame


def test_read_distance_updates_state(serial_factory: FakeSerialFactory) -> None:
Expand Down
19 changes: 11 additions & 8 deletions tests/test_serial_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import pytest
from conftest import FakeSerial, FakeSerialFactory

from xkc_kl200_python._protocol import FramePayload, build_command_frame
from xkc_kl200_python._serial_manager import (
SerialManager,
default_serial_factory,
)
from xkc_kl200_python.config import SensorConfig
from xkc_kl200_python.constants import XkcKl200Status
from xkc_kl200_python.serial_manager import SerialManager, default_serial_factory
from xkc_kl200_python.utils import FramePayload, build_command_frame


def test_write_frame(serial_factory: FakeSerialFactory) -> None:
Expand Down Expand Up @@ -42,7 +45,7 @@ def fake_constructor(
return fake_serial

monkeypatch.setattr(
"xkc_kl200_python.serial_manager.serial.Serial", fake_constructor
"xkc_kl200_python._serial_manager.serial.Serial", fake_constructor
)

result = default_serial_factory("/dev/ttyUSB0", 9600, 1.0)
Expand Down Expand Up @@ -95,11 +98,11 @@ def test_read_frame_sleeps_before_timeout(
monotonic_values = iter([0.0, 0.0, 0.002])

monkeypatch.setattr(
"xkc_kl200_python.serial_manager.time.monotonic",
"xkc_kl200_python._serial_manager.time.monotonic",
lambda: next(monotonic_values),
)
monkeypatch.setattr(
"xkc_kl200_python.serial_manager.time.sleep",
"xkc_kl200_python._serial_manager.time.sleep",
sleep_calls.append,
)

Expand Down Expand Up @@ -328,7 +331,7 @@ def fake_sleep(duration: float) -> None:
if len(sleep_calls) == 1:
serial_port.queue_read(valid_frame)

monkeypatch.setattr("xkc_kl200_python.serial_manager.time.sleep", fake_sleep)
monkeypatch.setattr("xkc_kl200_python._serial_manager.time.sleep", fake_sleep)

result, status = manager.read_frame(expected_command=0x33, timeout=0.01)

Expand Down Expand Up @@ -378,11 +381,11 @@ def test_read_frame_returns_deferred_checksum_error_at_timeout(
bytes([0x62, 0x33, 0x09, 0xFF, 0xFF, 0x00, 0x64, 0x00, 0x00])
)
monkeypatch.setattr(
"xkc_kl200_python.serial_manager.time.monotonic",
"xkc_kl200_python._serial_manager.time.monotonic",
lambda: next(monotonic_values),
)
monkeypatch.setattr(
"xkc_kl200_python.serial_manager.time.sleep",
"xkc_kl200_python._serial_manager.time.sleep",
sleep_calls.append,
)

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.