commit 0dea53cabb0aa6e8a576ccd36dc7da3901483542 Author: xtkuang <87661715@qq.com> Date: Fri Aug 7 09:16:59 2026 +0800 feat: add MS3008 HTTP motor control service diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5d191a3 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# 默认禁用真实硬件;改为 true 后,服务启动会自动 Enable。 +MOTOR_HARDWARE_ENABLED=false + +# CANalyst-II 使用 python-can 的 canalystii/PyUSB 直连后端。 +# 面板 CAN1=channel 0,CAN2=channel 1;下面配置使用面板 CAN1。 +MOTOR_CAN_CHANNEL=0 +MOTOR_CAN_DEVICE=0 +MOTOR_CAN_BITRATE=1000000 + +# MS3008 广播模式,现场电机 ID 为 1 和 2。 +MOTOR_IDS=1,2 + +# MS 的 0x280 是开环 raw,不是力矩或 N·m;协议范围为 -850..+850。 +# HTTP open:ID1=+400、ID2=-400;HTTP close:ID1=-400、ID2=+400。 +# 这两个配置值是绝对值,每台电机的符号由程序固定映射。 +MOTOR_OPEN_CONTROL_RAW=400 +MOTOR_CLOSE_CONTROL_RAW=400 + +# HTTP open/close 后持续广播直到 stop;服务正常退出会自动 Stop+Disable。 +MOTOR_BROADCAST_FREQUENCY_HZ=10 + +MOTOR_HTTP_HOST=127.0.0.1 +MOTOR_HTTP_PORT=8000 +# 绑定非本机地址时必须设置,请使用足够长的随机值。 +MOTOR_API_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d373391 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.env +tmp/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..263c806 --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# 瓴控 MS3008 双电机 HTTP 控制服务 + +该项目使用 `uv` 管理 Python 环境,通过 `python-can` 的 +`canalystii`/PyUSB 后端直连 CANalyst-II,并提供 HTTP 接口控制 +广播模式下的两台瓴控 MS3008 电机。 + +当前现场配置为: + +- CAN 波特率:`1 Mbps` +- 电机 ID:`1,2` +- 广播控制帧:标准帧 `0x280`,DLC 8 +- 持续广播频率:`10 Hz` +- HTTP `open`:ID 1 = `+400` raw,ID 2 = `-400` raw +- HTTP `close`:ID 1 = `-400` raw,ID 2 = `+400` raw +- 服务启动:自动 Enable;正常退出:自动 Stop 后 Disable +- MS3008 开环 raw 范围:`-850..+850` + +同时控制 ID 1、2 时,固定控制帧分别为: + +- `open`:`0x280 90 01 70 FE 00 00 00 00` +- `close`:`0x280 70 FE 90 01 00 00 00 00` + +> `400` 是 MS3008 的协议原始开环控制量,不是力矩,也不是 N·m。 +> 正负号只表示协议方向;机械正反方向必须逐台、低值、短脉冲实测确认。 +> 上述映射是方向确认后的目标值,禁止将其用于首次上电试转。 + +## 1. 硬件和协议边界 + +- 两台电机必须已用厂商上位机设为广播模式、`1 Mbps`,ID 分别为 1 和 2。 +- `0x280` 的四个 16 位小端槽位依次对应 ID 1..4。本现场使用前两个槽位, + ID 3、4 槽位写 0。总线上不要同时运行另一个广播控制程序。 +- MS3008 的 `0x280` 是开环控制命令,不是 MF/MG 的力矩电流命令;不得用 + “力矩 400”“400 N·m”等方式描述或标定。 +- 每次 HTTP Stop 会显式执行两轮“零控制量 + 协议 Stop”,间隔 `20 ms`; + 每轮的 Stop 帧最多等待 `100 ms` 让 CANalyst-II TX 队列处理完。 + 这仍不等于电机 ACK、物理急停、STO 或断电。 +- `MOTOR_HARDWARE_ENABLED=true` 时,服务启动自动发送零输出和 `0x88` Enable; + HTTP 不再提供 `enable`/`disable` 接口。 +- 服务正常退出会先执行两轮 Stop,再发送 `0x80` Disable。`0x80` + 会清除电机多圈计数;`SIGKILL`、进程崩溃或断电时无法保证执行退出清理。 + 普通暂停应调用 `stop`;`open`/`close` 换向不会 Disable。 +- USB 设备打开或 `send()` 返回不代表电机已实际转动;可在 HTTP + 服务停止时使用独立手动脚本读取状态回包作为可选诊断。 + +## 2. 用 uv 创建环境 + +需要 Python 3.11 或更高版本,并先安装 [uv](https://docs.astral.sh/uv/)。在本目录执行: + +```bash +uv sync --locked +uv run --locked pytest +``` + +`uv sync` 会创建 `.venv` 并按照 `uv.lock` 安装依赖。如果项目位于不支持虚拟环境 +软链接的 Samba/NAS 挂载盘,可以把环境放到本地磁盘;后续 `uv run` 也要保留 +同一个变量: + +```bash +export UV_PROJECT_ENVIRONMENT=/tmp/lingkong-motor-service-venv +uv sync --locked +uv run --locked pytest +``` + +## 3. CANalyst-II 直连后端 + +当前设备为 `VID:PID 04d8:0053`。它使用厂商自定义的 USB 协议, +不会自动生成 SocketCAN `canX` 接口。服务固定使用 +`python-can[canalystii]`: + +```dotenv +MOTOR_CAN_CHANNEL=0 +MOTOR_CAN_DEVICE=0 +MOTOR_CAN_BITRATE=1000000 +``` + +通道号与面板标识的对应关系为: + +- 面板 `CAN1` → `MOTOR_CAN_CHANNEL=0` +- 面板 `CAN2` → `MOTOR_CAN_CHANNEL=1` +- `MOTOR_CAN_DEVICE=0` 表示 PyUSB 枚举到的第一块 CANalyst-II,与通道号无关。 + +可只读检查 USB 设备: + +```bash +lsusb -d 04d8:0053 +lsusb -t +``` + +该 USB interface 不应绑定 `usbserial_generic`,也不应把强制生成的 +`ttyUSB0..5` 当成可靠 CAN 通道。如果 PyUSB 报权限错误,应为 +`04d8:0053` 设置范围明确的 udev 权限规则,不要以 root 长期运行 HTTP 服务。 + +### CAN 接线检查 + +断电后检查: + +- 总线两端各有约 `120 Ω` 终端,CAN_H 与 CAN_L 之间实测应约为 `60 Ω`。 +- USB-CAN 与电机控制侧信号地可靠共地。 +- CAN_H 对 CAN_H、CAN_L 对 CAN_L,不能接反。 +- 采用总线型连接并尽量缩短支线;`1 Mbps` 对布线和终端更敏感。 + +该后端不能可靠报告 CAN ACK、bus-off 或真实发送失败;停止时的 +TX 队列等待只证明设备队列已处理,不证明电机已收到或机械已停。因此服务的 +`can_connected` 只表示 USB 对象已打开。独立手动状态脚本可读取 ID 1、2 的 +`0x9C` 回包作为可选诊断。 + +## 4. 服务配置与启动 + +复制配置: + +```bash +cp .env.example .env +``` + +`.env.example` 已对应当前 MS3008 双电机现场,关键项为: + +```dotenv +MOTOR_HARDWARE_ENABLED=false +MOTOR_CAN_CHANNEL=0 +MOTOR_CAN_DEVICE=0 +MOTOR_CAN_BITRATE=1000000 +MOTOR_IDS=1,2 +MOTOR_OPEN_CONTROL_RAW=400 +MOTOR_CLOSE_CONTROL_RAW=400 +MOTOR_BROADCAST_FREQUENCY_HZ=10 +``` + +`MOTOR_OPEN_CONTROL_RAW` 和 `MOTOR_CLOSE_CONTROL_RAW` 是幅值;程序按电机 ID +固定方向:`open` 为 `{1: +400, 2: -400}`,`close` 为 +`{1: -400, 2: +400}`。 + +完成接线和物理安全准备后,将 `MOTOR_HARDWARE_ENABLED` 改为 `true`。 +此开关是启动安全总闸:服务启动后会立即自动 Enable,无需 HTTP 调用。 +程序不会自动读取 `.env`,启动前导入: + +```bash +set -a +. ./.env +set +a +uv run --locked lingkong-motor-service +``` + +也可直接一键启动(自动加载 `.env`、同步锁定依赖并检查端口冲突): + +```bash +./script/launch.sh +``` + +如果 `.env` 不存在,脚本会根据 `.env.example` 创建一份,完成硬件检查并设置 +`MOTOR_HARDWARE_ENABLED=true` 后再次运行。 + +Swagger 界面默认为 。真实硬件上只能运行一个服务 +进程,不要使用 `--reload` 或多个 worker。 + +## 5. 首次实机测试顺序 + +必须先逐台验证,不能直接以 ID `1,2` 和目标 raw 首次试转: + +1. 断开负载并架空输出轴,设置电源限流,确认物理急停和断电回路有效。 +2. 在 HTTP 服务未运行时,可选执行 + `uv run --locked python tests/test_can.py status`,观察 ID 1、2 的 `0x9C` + 状态回包作为独立诊断。运行该脚本时不得同时启动 HTTP 服务。 +3. 临时设置 `MOTOR_IDS=1`,将两个方向的控制量都改为 `20`。服务启动并自动使能后只调用一次 + `open`(服务内部会以 10 Hz 持续广播 ID 1 = `+20`),约 `100 ms` 时必须显式调用 `stop`; + 确认完全停稳后,才以同样方式测试 `close` 的 ID 1 = `-20/100 ms`,再显式 `stop`、停稳并 + 最后用 `Ctrl-C` 正常退出,由服务自动 Stop 并 Disable。 +4. 保持 raw `20`,把 `MOTOR_IDS` 改为 `2`,按 ID 2 `open=-20`、 + `close=+20` 的预期验证第二台电机。 +5. 两台的状态、方向和停机都确认后,才逐级提高控制量。每一级、每个方向之间 + 都必须 Stop 并确认机械完全停稳,最终映射为 `open={1:+400,2:-400}`、 + `close={1:-400,2:+400}`。 +6. 首次方向验证仍必须执行“当前方向 → Stop → 确认停稳 → 另一方向”。正常运行时 + `open → close` 和 `close → open` 都可直接调用;服务会自动停止当前广播,发送零输出 + 与 Stop,等待 `20 ms` 后开始另一方向广播。该等待不代表机械轴已经停稳。 + +`100 ms` 是上限目标,不是普通 shell/HTTP 调度的硬实时保证。旁站人员必须一直 +可以操作物理急停;必须由操作者显式调用 HTTP `stop`,且 HTTP `stop` 不能作为唯一保护。 + +### 手动 CAN 测试脚本 + +[`tests/test_can.py`](tests/test_can.py) 是一个不依赖项目其他脚本的简单手动测试程序。 +它直接使用 `python-can` 的 `canalystii` 后端,默认面板 CAN1、1 Mbps、电机 ID 1和2。 +运行前要停止 HTTP 服务。 + +读取状态2(0x9C): + +```bash +uv run --locked python tests/test_can.py status +``` + +发送 `open` 映射(ID 1 = `+400`、ID 2 = `-400`,持续 10 Hz, +`Ctrl-C` 后自动零输出并 Stop): + +```bash +uv run --locked python tests/test_can.py torque 400 -400 +``` + +发送 `close` 映射(ID 1 = `-400`、ID 2 = `+400`,持续 10 Hz, +`Ctrl-C` 后自动零输出并 Stop): + +```bash +uv run --locked python tests/test_can.py torque -400 400 +``` + +停止 ID 1、2: + +```bash +uv run --locked python tests/test_can.py stop +``` + +仅测试一台时,另一台的值填 `0`,例如 `torque 20 0`。MS 的该控制值是开环 +`powerControl raw`,不是 N·m 力矩。 + +## 6. HTTP 接口 + +完成上述低值方向验证后,正常操作顺序如下: + +```bash +curl -X POST http://127.0.0.1:8000/v1/motor/open +# 方向切换会自动执行零输出、Stop 和 20 ms 等待 +curl -X POST http://127.0.0.1:8000/v1/motor/close +curl -X POST http://127.0.0.1:8000/v1/motor/open +curl -X POST http://127.0.0.1:8000/v1/motor/stop +curl http://127.0.0.1:8000/v1/motor/status +``` + +`MOTOR_HARDWARE_ENABLED=true` 时,启动过程会自动 Enable。HTTP 只保留 +`open`、`close`、`stop` 和 `status`,不提供 `enable`/`disable`。 + +`open` 固定使用 `{1:+400,2:-400}`,`close` 固定使用 +`{1:-400,2:+400}`;HTTP 请求不能临时提高 raw。状态响应中 +`control_raw_by_motor` 显示每台电机的实际有符号控制量;保留的 `control_raw` +字段表示 ID 1(单独配置 ID 2 时表示 ID 2)。调用任一运动接口后,服务内部线程都会以 +`MOTOR_BROADCAST_FREQUENCY_HZ` +(默认 10 Hz)持续发送完整 `0x280` 四槽帧,不会自动超时,HTTP 客户端断开也不会 +停止。`open` 与 `close` 相互切换时会自动执行零输出与 Stop;最终仍必须显式调用 +`stop`。单次 `stop` 会自动重复两轮停止序列,不需要客户端再调用两次。 +服务正常退出会自动执行两轮 Stop,再 Disable 并关闭 CAN 设备。 + +如果设置了 `MOTOR_API_TOKEN`,请求需携带: + +```bash +curl -X POST -H "X-API-Key: your-token" \ + http://127.0.0.1:8000/v1/motor/open +``` + +## 7. 验证边界 + +自动化测试只验证配置、协议编码、HTTP 状态机和持续广播逻辑,不证明电机已经运动、 +方向正确、状态回包可信或急停有效。真实 MS3008 验证结果必须单独记录,并包含 +电机 ID、回包、raw、脉冲时长、停止结果和 USB/CAN 错误计数。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa74f87 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "lingkong-motor-service" +version = "0.1.0" +description = "HTTP control service for Lingkong broadcast-mode CAN motors" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.116,<0.117", + "python-can[canalystii]>=4.6,<5", + "uvicorn>=0.35,<0.36", +] + +[project.scripts] +lingkong-motor-service = "lingkong_motor_service.main:run" + +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "httpx>=0.28,<0.29", + "pytest>=8.4,<9", + "ruff>=0.12,<0.13", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/lingkong_motor_service"] + +[tool.pytest.ini_options] +addopts = "-ra" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/script/launch.sh b/script/launch.sh new file mode 100755 index 0000000..6a14837 --- /dev/null +++ b/script/launch.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +ENV_FILE="${MOTOR_ENV_FILE:-${PROJECT_ROOT}/.env}" + +cd "${PROJECT_ROOT}" + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv is not installed or is not in PATH" >&2 + exit 1 +fi + +if [[ ! -f "${ENV_FILE}" ]]; then + cp -- "${PROJECT_ROOT}/.env.example" "${ENV_FILE}" + echo "Created ${ENV_FILE} from .env.example." >&2 + echo "Set MOTOR_HARDWARE_ENABLED=true after checking the hardware, then run again." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "${ENV_FILE}" +set +a + +SERVICE_PORT="${MOTOR_HTTP_PORT:-8000}" +if command -v curl >/dev/null 2>&1; then + HEALTH_CODE="$( + curl --noproxy '*' --silent --output /dev/null --write-out '%{http_code}' \ + --max-time 1 "http://127.0.0.1:${SERVICE_PORT}/healthz" || true + )" + if [[ "${HEALTH_CODE}" == "200" ]]; then + OPEN_ROUTE_CODE="$( + curl --noproxy '*' --silent --output /dev/null --write-out '%{http_code}' \ + --max-time 1 "http://127.0.0.1:${SERVICE_PORT}/v1/motor/open" || true + )" + REMOVED_ENABLE_ROUTE_CODE="$( + curl --noproxy '*' --silent --output /dev/null --write-out '%{http_code}' \ + --max-time 1 "http://127.0.0.1:${SERVICE_PORT}/v1/motor/enable" || true + )" + if [[ "${OPEN_ROUTE_CODE}" == "405" && "${REMOVED_ENABLE_ROUTE_CODE}" == "404" ]]; then + echo "Lingkong motor service is already running on port ${SERVICE_PORT}." + exit 0 + fi + echo "error: an older or different service is already using port ${SERVICE_PORT}" >&2 + echo "Stop that service before launching the new open/close API." >&2 + exit 1 + fi +fi + +# Samba/NAS mounts may not support uv's virtual-environment symlinks reliably. +export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-/tmp/lingkong-motor-service-venv}" + +echo "Synchronizing the locked environment in ${UV_PROJECT_ENVIRONMENT}..." +uv sync --locked + +echo "Starting Lingkong motor service on ${MOTOR_HTTP_HOST:-127.0.0.1}:${SERVICE_PORT}..." +echo "Motors auto-enable at startup; Ctrl-C performs Stop followed by Disable." +exec uv run --locked lingkong-motor-service diff --git a/src/lingkong_motor_service/__init__.py b/src/lingkong_motor_service/__init__.py new file mode 100644 index 0000000..aab4f6f --- /dev/null +++ b/src/lingkong_motor_service/__init__.py @@ -0,0 +1 @@ +"""Lingkong CAN motor HTTP service.""" diff --git a/src/lingkong_motor_service/__main__.py b/src/lingkong_motor_service/__main__.py new file mode 100644 index 0000000..ac1fb34 --- /dev/null +++ b/src/lingkong_motor_service/__main__.py @@ -0,0 +1,3 @@ +from .main import run + +run() diff --git a/src/lingkong_motor_service/api.py b/src/lingkong_motor_service/api.py new file mode 100644 index 0000000..f3a8375 --- /dev/null +++ b/src/lingkong_motor_service/api.py @@ -0,0 +1,156 @@ +"""FastAPI application and HTTP routes.""" + +from __future__ import annotations + +import asyncio +import hmac +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, Header, HTTPException, Request, status +from fastapi.responses import JSONResponse + +from .config import Settings +from .controller import ( + HardwareDisabledError, + MotorCommunicationError, + MotorController, + MotorNotEnabledError, + MotorStatus, +) +from .transport import CanTransport, create_transport + +TransportFactory = Callable[[Settings], CanTransport] + + +def create_app( + settings: Settings | None = None, + transport_factory: TransportFactory = create_transport, +) -> FastAPI: + service_settings = settings or Settings.from_env() + service_settings.validate() + + @asynccontextmanager + async def lifespan(app: FastAPI): + transport = transport_factory(service_settings) + controller = MotorController(service_settings, transport) + executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="lingkong-motor-api") + app.state.motor_controller = controller + app.state.motor_executor = executor + try: + if service_settings.hardware_enabled: + await asyncio.wrap_future(executor.submit(controller.enable)) + yield + finally: + try: + await asyncio.wrap_future(executor.submit(controller.shutdown)) + finally: + executor.shutdown(wait=True, cancel_futures=True) + + app = FastAPI( + title="Lingkong Motor Service", + version="0.1.0", + description=( + "HTTP wrapper for Lingkong MS broadcast open-loop control. " + "The service enables motors at startup; open/close broadcast until Stop, " + "and shutdown sends Stop followed by Disable." + ), + lifespan=lifespan, + ) + + async def authorize( + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, + ) -> None: + expected = service_settings.api_token + if expected is None: + return + if x_api_key is None or not hmac.compare_digest(x_api_key, expected): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid API key") + + def controller_from(request: Request) -> MotorController: + return request.app.state.motor_controller + + def response_for(result: MotorStatus, action: str) -> dict[str, Any]: + return { + "accepted": True, + "action": action, + "transmission": "CAN frame transmitted; physical motor motion is not verified", + **result.to_dict(), + } + + async def run_action( + request: Request, + action: str, + operation: Callable[[], MotorStatus], + ) -> dict[str, Any]: + try: + future = request.app.state.motor_executor.submit(operation) + # Polling keeps the ASGI event loop responsive without executing + # blocking USB/CAN calls on it. + while not future.done(): + await asyncio.sleep(0.001) + result = future.result() + return response_for(result, action) + except (HardwareDisabledError, MotorNotEnabledError) as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except MotorCommunicationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) + ) from exc + + @app.get("/healthz") + async def healthz(request: Request) -> dict[str, Any]: + motor_status = controller_from(request).status() + return { + "status": "ok", + "hardware_enabled": motor_status.hardware_enabled, + "can_connected": motor_status.can_connected, + } + + @app.get("/readyz") + async def readyz(request: Request) -> JSONResponse: + motor_status = controller_from(request).status() + ready = ( + motor_status.hardware_enabled + and motor_status.can_connected + and motor_status.last_error is None + ) + return JSONResponse( + status_code=status.HTTP_200_OK if ready else status.HTTP_503_SERVICE_UNAVAILABLE, + content={"ready": ready}, + ) + + @app.get("/v1/motor/status", dependencies=[Depends(authorize)]) + async def motor_status(request: Request) -> dict[str, Any]: + return controller_from(request).status().to_dict() + + @app.post( + "/v1/motor/open", + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(authorize)], + ) + async def open_motor(request: Request) -> dict[str, Any]: + controller = controller_from(request) + return await run_action(request, "open", controller.open) + + @app.post( + "/v1/motor/close", + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(authorize)], + ) + async def close_motor(request: Request) -> dict[str, Any]: + controller = controller_from(request) + return await run_action(request, "close", controller.close) + + @app.post( + "/v1/motor/stop", + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(authorize)], + ) + async def stop(request: Request) -> dict[str, Any]: + controller = controller_from(request) + return await run_action(request, "stop", controller.stop) + + return app diff --git a/src/lingkong_motor_service/config.py b/src/lingkong_motor_service/config.py new file mode 100644 index 0000000..a1681ba --- /dev/null +++ b/src/lingkong_motor_service/config.py @@ -0,0 +1,143 @@ +"""Environment-based service configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from math import isfinite + +from .protocol import MS_OPEN_LOOP_MAX + + +class ConfigError(ValueError): + """Raised when service configuration is invalid or unsafe.""" + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigError(f"{name} must be true or false") + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw, 10) + except ValueError as exc: + raise ConfigError(f"{name} must be an integer") from exc + + +def _parse_motor_ids(name: str, raw: str) -> tuple[int, ...]: + parts = raw.split(",") + if not parts or any(not part.strip() for part in parts): + raise ConfigError(f"{name} must be a comma-separated list of motor IDs") + try: + return tuple(int(part.strip(), 10) for part in parts) + except ValueError as exc: + raise ConfigError(f"{name} must contain integer motor IDs") from exc + + +def _env_motor_ids() -> tuple[int, ...]: + return _parse_motor_ids("MOTOR_IDS", os.getenv("MOTOR_IDS", "1,2")) + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return float(raw) + except ValueError as exc: + raise ConfigError(f"{name} must be a number") from exc + + +@dataclass(frozen=True, slots=True) +class Settings: + hardware_enabled: bool = False + can_channel: int = 0 + can_device: int = 0 + can_bitrate: int = 1_000_000 + motor_ids: tuple[int, ...] = (1, 2) + open_control_raw: int = 400 + close_control_raw: int = 400 + broadcast_frequency_hz: float = 10.0 + http_host: str = "127.0.0.1" + http_port: int = 8000 + api_token: str | None = None + + @classmethod + def from_env(cls) -> Settings: + token = os.getenv("MOTOR_API_TOKEN", "").strip() or None + settings = cls( + hardware_enabled=_env_bool("MOTOR_HARDWARE_ENABLED", False), + can_channel=_env_int("MOTOR_CAN_CHANNEL", 0), + can_device=_env_int("MOTOR_CAN_DEVICE", 0), + can_bitrate=_env_int("MOTOR_CAN_BITRATE", 1_000_000), + motor_ids=_env_motor_ids(), + open_control_raw=_env_int("MOTOR_OPEN_CONTROL_RAW", 400), + close_control_raw=_env_int("MOTOR_CLOSE_CONTROL_RAW", 400), + broadcast_frequency_hz=_env_float("MOTOR_BROADCAST_FREQUENCY_HZ", 10.0), + http_host=os.getenv("MOTOR_HTTP_HOST", "127.0.0.1").strip(), + http_port=_env_int("MOTOR_HTTP_PORT", 8000), + api_token=token, + ) + settings.validate() + return settings + + def validate(self) -> None: + if ( + isinstance(self.can_channel, bool) + or not isinstance(self.can_channel, int) + or self.can_channel not in {0, 1} + ): + raise ConfigError("MOTOR_CAN_CHANNEL must be 0 (panel CAN1) or 1 (panel CAN2)") + if ( + isinstance(self.can_device, bool) + or not isinstance(self.can_device, int) + or self.can_device < 0 + ): + raise ConfigError("MOTOR_CAN_DEVICE must be a non-negative integer") + if self.can_bitrate not in {500_000, 1_000_000}: + raise ConfigError("MOTOR_CAN_BITRATE must be 500000 or 1000000 for V2.35") + if not isinstance(self.motor_ids, tuple) or not self.motor_ids: + raise ConfigError("MOTOR_IDS must contain at least one motor ID") + if any( + isinstance(motor_id, bool) or not isinstance(motor_id, int) or motor_id not in {1, 2} + for motor_id in self.motor_ids + ): + raise ConfigError("MOTOR_IDS values must be 1 or 2") + if len(set(self.motor_ids)) != len(self.motor_ids): + raise ConfigError("MOTOR_IDS must not contain duplicate IDs") + if ( + isinstance(self.open_control_raw, bool) + or not isinstance(self.open_control_raw, int) + or not 0 < self.open_control_raw <= MS_OPEN_LOOP_MAX + ): + raise ConfigError(f"MOTOR_OPEN_CONTROL_RAW must be in 1..{MS_OPEN_LOOP_MAX}") + if ( + isinstance(self.close_control_raw, bool) + or not isinstance(self.close_control_raw, int) + or not 0 < self.close_control_raw <= MS_OPEN_LOOP_MAX + ): + raise ConfigError(f"MOTOR_CLOSE_CONTROL_RAW must be in 1..{MS_OPEN_LOOP_MAX}") + if ( + isinstance(self.broadcast_frequency_hz, bool) + or not isinstance(self.broadcast_frequency_hz, int | float) + or not isfinite(self.broadcast_frequency_hz) + or not 1.0 <= self.broadcast_frequency_hz <= 1_000.0 + ): + raise ConfigError("MOTOR_BROADCAST_FREQUENCY_HZ must be in 1..1000") + if not self.http_host: + raise ConfigError("MOTOR_HTTP_HOST must not be empty") + if not 1 <= self.http_port <= 65_535: + raise ConfigError("MOTOR_HTTP_PORT must be in 1..65535") + if self.http_host not in {"127.0.0.1", "localhost", "::1"} and not self.api_token: + raise ConfigError("MOTOR_API_TOKEN is required when HTTP is exposed beyond localhost") diff --git a/src/lingkong_motor_service/controller.py b/src/lingkong_motor_service/controller.py new file mode 100644 index 0000000..51271fd --- /dev/null +++ b/src/lingkong_motor_service/controller.py @@ -0,0 +1,406 @@ +"""Motor state machine with a continuous broadcast sender.""" + +from __future__ import annotations + +import threading +import time +from dataclasses import asdict, dataclass +from enum import StrEnum +from typing import Any + +from .config import Settings +from .protocol import ( + CanFrame, + MotorCommand, + build_mixed_command_frame, + build_open_loop_frame, +) +from .transport import CanTransport, CanTransportError + + +class MotorControlError(RuntimeError): + """Base class for controller failures.""" + + +class HardwareDisabledError(MotorControlError): + """Raised when real CAN access has not been explicitly enabled.""" + + +class MotorNotEnabledError(MotorControlError): + """Raised when motion is requested before this service has enabled the drives.""" + + +class MotorCommunicationError(MotorControlError): + """Raised when a CAN frame cannot be transmitted.""" + + +class MotorState(StrEnum): + DISABLED = "disabled" + READY = "ready" + OPENING = "opening" + CLOSING = "closing" + STOPPED = "stopped" + FAULT = "fault" + + +@dataclass(frozen=True, slots=True) +class MotorStatus: + state: MotorState + motor_ids: tuple[int, ...] + control_mode: str + control_raw: int + control_raw_by_motor: dict[int, int] + hardware_enabled: bool + can_connected: bool + broadcast_frequency_hz: float + streaming: bool + last_error: str | None + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["motor_ids"] = list(self.motor_ids) + result["control_raw_by_motor"] = { + str(motor_id): value for motor_id, value in self.control_raw_by_motor.items() + } + return result + + +class MotorController: + """Own configured broadcast slots and continuously refresh active control.""" + + _BROADCAST_JOIN_TIMEOUT_S = 1.0 + _DIRECTION_CHANGE_DELAY_S = 0.02 + _STOP_REPEAT_COUNT = 2 + _STOP_REPEAT_INTERVAL_S = 0.02 + _STOP_TX_QUEUE_TIMEOUT_S = 0.1 + + def __init__(self, settings: Settings, transport: CanTransport) -> None: + settings.validate() + self._settings = settings + self._transport = transport + self._lock = threading.RLock() + self._command_lock = threading.Lock() + self._state = MotorState.DISABLED + self._control_raw = 0 + self._control_raw_by_motor = {motor_id: 0 for motor_id in settings.motor_ids} + self._last_error: str | None = None + self._broadcast_stop: threading.Event | None = None + self._broadcast_thread: threading.Thread | None = None + self._armed_by_service = False + self._closed = False + + def status(self) -> MotorStatus: + with self._lock: + return MotorStatus( + state=self._state, + motor_ids=self._settings.motor_ids, + control_mode="open_loop", + control_raw=self._control_raw, + control_raw_by_motor=dict(self._control_raw_by_motor), + hardware_enabled=self._settings.hardware_enabled, + can_connected=self._transport.is_open, + broadcast_frequency_hz=self._settings.broadcast_frequency_hz, + streaming=( + self._broadcast_thread is not None + and self._broadcast_thread.is_alive() + and self._state in {MotorState.OPENING, MotorState.CLOSING} + ), + last_error=self._last_error, + ) + + def enable(self) -> MotorStatus: + with self._command_lock: + with self._lock: + self._require_hardware() + if self._armed_by_service: + return self.status() + # Match the verified bench script: queue zero, then 0x88. + self._send_sequence_locked( + build_open_loop_frame({}), + build_mixed_command_frame( + {motor_id: MotorCommand.ENABLE for motor_id in self._settings.motor_ids} + ), + ) + self._armed_by_service = True + self._state = MotorState.READY + self._control_raw = 0 + self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids} + self._last_error = None + time.sleep(0.02) + return self.status() + + def open(self) -> MotorStatus: + values = { + motor_id: self._settings.open_control_raw * (1 if motor_id == 1 else -1) + for motor_id in self._settings.motor_ids + } + return self._move(values, MotorState.OPENING) + + def close(self) -> MotorStatus: + values = { + motor_id: self._settings.close_control_raw * (-1 if motor_id == 1 else 1) + for motor_id in self._settings.motor_ids + } + return self._move(values, MotorState.CLOSING) + + def _move(self, values: dict[int, int], state: MotorState) -> MotorStatus: + with self._command_lock: + with self._lock: + self._require_motion_allowed_locked() + direction_change = ( + self._state in {MotorState.OPENING, MotorState.CLOSING} + and self._state is not state + ) + + if direction_change: + self._stop_broadcast() + with self._lock: + self._require_motion_allowed_locked() + self._safe_stop_locked() + time.sleep(self._DIRECTION_CHANGE_DELAY_S) + + with self._lock: + self._require_motion_allowed_locked() + self._state = state + self._control_raw_by_motor = dict(values) + self._control_raw = values.get(1, next(iter(values.values()), 0)) + self._last_error = None + # Establish the background safety path before the first non-zero + # frame. The controller lock keeps the new thread from sending + # until this initial send has either succeeded or latched FAULT. + self._start_broadcast_locked() + self._send_sequence_locked(build_open_loop_frame(values)) + return self.status() + + def _require_motion_allowed_locked(self) -> None: + self._require_hardware() + if self._state is MotorState.FAULT: + raise MotorCommunicationError(self._last_error or "motor controller is faulted") + if not self._armed_by_service or self._state is MotorState.DISABLED: + raise MotorNotEnabledError("motor auto-enable did not complete during service startup") + + def stop(self) -> MotorStatus: + with self._command_lock: + self._stop_broadcast() + with self._lock: + self._require_hardware() + self._safe_stop_locked() + return self.status() + + def disable(self) -> MotorStatus: + with self._command_lock: + self._stop_broadcast() + with self._lock: + self._require_hardware() + self._send_sequence_locked( + build_open_loop_frame({}), + build_mixed_command_frame( + {motor_id: MotorCommand.STOP for motor_id in self._settings.motor_ids} + ), + build_mixed_command_frame( + {motor_id: MotorCommand.DISABLE for motor_id in self._settings.motor_ids} + ), + ) + self._armed_by_service = False + self._state = MotorState.DISABLED + self._control_raw = 0 + self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids} + self._last_error = None + return self.status() + + def shutdown(self) -> None: + with self._command_lock: + with self._lock: + if self._closed: + return + # Do not close the USB object while its sender still owns it. If a + # wedged send cannot exit, leave the transport open and report the + # failure so the caller can retry after the device recovers. + self._stop_broadcast() + with self._lock: + if self._settings.hardware_enabled and self._transport.is_open: + self._best_effort_disable_locked() + try: + self._transport.shutdown() + except CanTransportError as exc: + self._state = MotorState.FAULT + self._last_error = str(exc) + self._closed = True + + def _start_broadcast_locked(self) -> None: + if self._broadcast_thread is not None and self._broadcast_thread.is_alive(): + return + stop_event = threading.Event() + thread = threading.Thread( + target=self._broadcast_loop, + args=(stop_event,), + name="lingkong-can-broadcast", + daemon=True, + ) + self._broadcast_stop = stop_event + self._broadcast_thread = thread + try: + thread.start() + except RuntimeError as exc: + self._broadcast_stop = None + self._broadcast_thread = None + message = f"continuous CAN broadcast thread could not start: {exc}" + self._record_communication_fault_locked(message) + raise MotorCommunicationError(message) from exc + + def _broadcast_loop(self, stop_event: threading.Event) -> None: + period_ns = max(1, round(1_000_000_000 / self._settings.broadcast_frequency_hz)) + next_deadline_ns = time.monotonic_ns() + period_ns + try: + while True: + wait_s = max(0.0, (next_deadline_ns - time.monotonic_ns()) / 1_000_000_000) + if stop_event.wait(wait_s): + return + + with self._lock: + if self._state not in {MotorState.OPENING, MotorState.CLOSING}: + return + frame = build_open_loop_frame(self._control_raw_by_motor) + + try: + self._transport.send(frame) + except CanTransportError as exc: + with self._lock: + self._record_communication_fault_locked(str(exc)) + stop_event.set() + return + + next_deadline_ns += period_ns + now_ns = time.monotonic_ns() + if next_deadline_ns <= now_ns: + missed = (now_ns - next_deadline_ns) // period_ns + 1 + next_deadline_ns += missed * period_ns + finally: + # This is the last code path that may follow an in-flight non-zero + # send. Performing the stop sequence here means that even if a + # caller times out while joining, a send that later unblocks is + # followed by zero and Stop before the worker exits. + self._best_effort_sender_stop() + with self._lock: + if self._broadcast_thread is threading.current_thread(): + self._broadcast_thread = None + self._broadcast_stop = None + + def _best_effort_sender_stop(self) -> None: + frames = ( + build_open_loop_frame({}), + build_mixed_command_frame( + {motor_id: MotorCommand.STOP for motor_id in self._settings.motor_ids} + ), + ) + for frame in frames: + try: + self._transport.send(frame) + except CanTransportError: + continue + + def _stop_broadcast(self) -> None: + with self._lock: + stop_event = self._broadcast_stop + thread = self._broadcast_thread + if stop_event is not None: + stop_event.set() + + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=self._BROADCAST_JOIN_TIMEOUT_S) + if thread.is_alive(): + with self._lock: + message = ( + "continuous CAN broadcast thread did not stop within " + f"{self._BROADCAST_JOIN_TIMEOUT_S:g} seconds" + ) + self._record_communication_fault_locked(message) + raise MotorCommunicationError(message) + + with self._lock: + if self._broadcast_thread is thread: + self._broadcast_thread = None + self._broadcast_stop = None + + def _safe_stop_locked(self) -> None: + zero_frame = build_open_loop_frame({}) + stop_frame = build_mixed_command_frame( + {motor_id: MotorCommand.STOP for motor_id in self._settings.motor_ids} + ) + for attempt in range(self._STOP_REPEAT_COUNT): + self._send_sequence_locked(zero_frame) + self._send_frame_locked( + stop_frame, + tx_queue_timeout_s=self._STOP_TX_QUEUE_TIMEOUT_S, + ) + if attempt + 1 < self._STOP_REPEAT_COUNT: + time.sleep(self._STOP_REPEAT_INTERVAL_S) + self._state = MotorState.STOPPED + self._control_raw = 0 + self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids} + self._last_error = None + + def _best_effort_disable_locked(self) -> None: + zero_frame = build_open_loop_frame({}) + stop_frame = build_mixed_command_frame( + {motor_id: MotorCommand.STOP for motor_id in self._settings.motor_ids} + ) + disable_frame = build_mixed_command_frame( + {motor_id: MotorCommand.DISABLE for motor_id in self._settings.motor_ids} + ) + cleanup_error = self._last_error + for attempt in range(self._STOP_REPEAT_COUNT): + for frame, timeout_s in ( + (zero_frame, None), + (stop_frame, self._STOP_TX_QUEUE_TIMEOUT_S), + ): + try: + self._transport.send(frame, tx_queue_timeout_s=timeout_s) + except CanTransportError as exc: + cleanup_error = str(exc) + if attempt + 1 < self._STOP_REPEAT_COUNT: + time.sleep(self._STOP_REPEAT_INTERVAL_S) + + try: + self._transport.send( + disable_frame, + tx_queue_timeout_s=self._STOP_TX_QUEUE_TIMEOUT_S, + ) + except CanTransportError as exc: + cleanup_error = str(exc) + + self._last_error = cleanup_error + self._armed_by_service = False + self._state = MotorState.FAULT if cleanup_error else MotorState.DISABLED + self._control_raw = 0 + self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids} + + def _send_sequence_locked(self, *frames: CanFrame) -> None: + for frame in frames: + self._send_frame_locked(frame) + + def _send_frame_locked( + self, + frame: CanFrame, + *, + tx_queue_timeout_s: float | None = None, + ) -> None: + try: + self._transport.send(frame, tx_queue_timeout_s=tx_queue_timeout_s) + except CanTransportError as exc: + self._record_communication_fault_locked(str(exc)) + raise MotorCommunicationError(str(exc)) from exc + + def _record_communication_fault_locked(self, message: str) -> None: + self._state = MotorState.FAULT + self._control_raw = 0 + self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids} + self._last_error = message + + def _require_hardware(self) -> None: + if not self._settings.hardware_enabled: + raise HardwareDisabledError( + "hardware access is disabled; set MOTOR_HARDWARE_ENABLED=true after safety checks" + ) + if self._closed or not self._transport.is_open: + raise MotorCommunicationError("CAN transport is not available") diff --git a/src/lingkong_motor_service/main.py b/src/lingkong_motor_service/main.py new file mode 100644 index 0000000..4e107cd --- /dev/null +++ b/src/lingkong_motor_service/main.py @@ -0,0 +1,30 @@ +"""Console entry point.""" + +from __future__ import annotations + +import sys + +import uvicorn + +from .api import create_app +from .config import ConfigError, Settings +from .transport import CanTransportError + + +def run() -> None: + try: + settings = Settings.from_env() + app = create_app(settings) + uvicorn.run( + app, + host=settings.http_host, + port=settings.http_port, + workers=1, + ) + except (ConfigError, CanTransportError) as exc: + print(f"configuration/startup error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + + +if __name__ == "__main__": + run() diff --git a/src/lingkong_motor_service/protocol.py b/src/lingkong_motor_service/protocol.py new file mode 100644 index 0000000..889be0f --- /dev/null +++ b/src/lingkong_motor_service/protocol.py @@ -0,0 +1,91 @@ +"""Pure encoders for Lingkong broadcast CAN protocol V2.35.""" + +from __future__ import annotations + +import struct +from collections.abc import Mapping +from dataclasses import dataclass +from enum import IntEnum + +CONTROL_FRAME_ID = 0x280 +MIXED_COMMAND_FRAME_ID = 0x288 +MOTOR_ID_MIN = 1 +MOTOR_ID_MAX = 4 +MS_OPEN_LOOP_MIN = -850 +MS_OPEN_LOOP_MAX = 850 + + +class ProtocolError(ValueError): + """Raised when a value cannot be represented by protocol V2.35.""" + + +class MotorCommand(IntEnum): + """MS3008 commands used in the V2.35 mixed broadcast frame.""" + + DISABLE = 0x80 + STOP = 0x81 + ENABLE = 0x88 + + +@dataclass(frozen=True, slots=True) +class CanFrame: + """Library-independent representation of one classic CAN frame.""" + + arbitration_id: int + data: bytes + is_extended_id: bool = False + + def __post_init__(self) -> None: + if len(self.data) != 8: + raise ProtocolError("V2.35 broadcast frames must have DLC=8") + + +def _validate_motor_id(motor_id: int) -> None: + if ( + isinstance(motor_id, bool) + or not isinstance(motor_id, int) + or not MOTOR_ID_MIN <= motor_id <= MOTOR_ID_MAX + ): + raise ProtocolError("broadcast motor_id must be in the range 1..4") + + +def build_open_loop_frame(power_by_motor: Mapping[int, int]) -> CanFrame: + """Build an MS3008 open-loop frame on CAN ID 0x280. + + Values are raw ``powerControl`` quantities, not torque or calibrated SI + voltage. Missing slots are real zero commands, not ignore markers. + """ + + slots = [0, 0, 0, 0] + for motor_id, power_raw in power_by_motor.items(): + _validate_motor_id(motor_id) + if isinstance(power_raw, bool) or not isinstance(power_raw, int): + raise ProtocolError("MS open_loop_raw must be an integer") + if not MS_OPEN_LOOP_MIN <= power_raw <= MS_OPEN_LOOP_MAX: + raise ProtocolError( + f"MS open_loop_raw must be in {MS_OPEN_LOOP_MIN}..{MS_OPEN_LOOP_MAX}" + ) + slots[motor_id - 1] = power_raw + + return CanFrame( + arbitration_id=CONTROL_FRAME_ID, + data=struct.pack(" CanFrame: + """Build CAN ID 0x288 with one command in each motor's even byte.""" + + payload = bytearray(8) + for motor_id, command in commands_by_motor.items(): + _validate_motor_id(motor_id) + try: + parsed_command = MotorCommand(command) + except ValueError as exc: + raise ProtocolError(f"unsupported mixed command: {command!r}") from exc + payload[(motor_id - 1) * 2] = parsed_command + + return CanFrame( + arbitration_id=MIXED_COMMAND_FRAME_ID, + data=bytes(payload), + ) diff --git a/src/lingkong_motor_service/transport.py b/src/lingkong_motor_service/transport.py new file mode 100644 index 0000000..8974dc4 --- /dev/null +++ b/src/lingkong_motor_service/transport.py @@ -0,0 +1,108 @@ +"""CAN transport adapters.""" + +from __future__ import annotations + +import threading +from typing import Protocol + +from .config import Settings +from .protocol import CanFrame + + +class CanTransportError(RuntimeError): + """Raised when the CANalyst-II interface cannot be opened or written.""" + + +class CanTransport(Protocol): + @property + def is_open(self) -> bool: ... + + def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: ... + + def shutdown(self) -> None: ... + + +class DisabledCanTransport: + """Fail-closed transport used until hardware is explicitly enabled.""" + + @property + def is_open(self) -> bool: + return False + + def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: + del frame + del tx_queue_timeout_s + raise CanTransportError("hardware access is disabled by MOTOR_HARDWARE_ENABLED") + + def shutdown(self) -> None: + return None + + +class PythonCanTransport: + """python-can adapter for the CANalyst-II USB backend.""" + + def __init__(self, settings: Settings) -> None: + try: + import can + except ImportError as exc: # pragma: no cover - packaging failure + raise CanTransportError("python-can is not installed; run `uv sync`") from exc + + try: + self._bus = can.Bus( + interface="canalystii", + channel=settings.can_channel, + device=settings.can_device, + bitrate=settings.can_bitrate, + rx_queue_size=256, + receive_own_messages=False, + ) + except Exception as exc: + raise CanTransportError( + f"cannot open CANalyst-II device {settings.can_device}, " + f"channel {settings.can_channel}: {exc}" + ) from exc + + self._can = can + self._lock = threading.Lock() + self._is_open = True + + @property + def is_open(self) -> bool: + with self._lock: + return self._is_open + + def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: + message = self._can.Message( + arbitration_id=frame.arbitration_id, + data=frame.data, + is_extended_id=frame.is_extended_id, + check=True, + ) + try: + with self._lock: + if not self._is_open: + raise CanTransportError("CAN transport is closed") + # Continuous control uses None so each 2 ms cycle is not + # blocked. Safety-critical Stop frames use a finite timeout to + # wait until CANalyst-II has processed its hardware TX queue. + self._bus.send(message, timeout=tx_queue_timeout_s) + except CanTransportError: + raise + except Exception as exc: + raise CanTransportError(f"CAN send failed: {exc}") from exc + + def shutdown(self) -> None: + with self._lock: + if not self._is_open: + return + self._is_open = False + try: + self._bus.shutdown() + except Exception as exc: + raise CanTransportError(f"CAN shutdown failed: {exc}") from exc + + +def create_transport(settings: Settings) -> CanTransport: + if not settings.hardware_enabled: + return DisabledCanTransport() + return PythonCanTransport(settings) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..9584cb6 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import threading +import time + +from lingkong_motor_service.protocol import CanFrame + + +class FakeTransport: + def __init__(self) -> None: + self.frames: list[CanFrame] = [] + self.frame_times_ns: list[int] = [] + self.tx_queue_timeouts_s: list[float | None] = [] + self.is_open = True + self.fail_next = False + self.lock = threading.Lock() + + def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: + with self.lock: + if self.fail_next: + self.fail_next = False + from lingkong_motor_service.transport import CanTransportError + + raise CanTransportError("injected CAN failure") + self.frames.append(frame) + self.frame_times_ns.append(time.monotonic_ns()) + self.tx_queue_timeouts_s.append(tx_queue_timeout_s) + + def shutdown(self) -> None: + with self.lock: + self.is_open = False diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..21503ca --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,225 @@ +import asyncio +from contextlib import asynccontextmanager + +import httpx +import pytest + +from lingkong_motor_service.api import create_app +from lingkong_motor_service.config import Settings +from lingkong_motor_service.controller import MotorCommunicationError +from lingkong_motor_service.protocol import CONTROL_FRAME_ID, MIXED_COMMAND_FRAME_ID + +from .fakes import FakeTransport + + +@asynccontextmanager +async def client_for(app): + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +def test_hardware_gate_is_closed_by_default() -> None: + async def scenario() -> None: + app = create_app(Settings()) + + async with client_for(app) as client: + assert (await client.get("/healthz")).json() == { + "status": "ok", + "hardware_enabled": False, + "can_connected": False, + } + assert (await client.get("/readyz")).status_code == 503 + response = await client.post("/v1/motor/open") + assert (await client.post("/v1/motor/enable")).status_code == 404 + assert (await client.post("/v1/motor/disable")).status_code == 404 + openapi = (await client.get("/openapi.json")).json() + assert "/v1/motor/enable" not in openapi["paths"] + assert "/v1/motor/disable" not in openapi["paths"] + + assert response.status_code == 409 + assert "MOTOR_HARDWARE_ENABLED=true" in response.json()["detail"] + + asyncio.run(scenario()) + + +def test_hardware_service_auto_enables_and_shutdown_stops_then_disables() -> None: + async def scenario() -> None: + transport = FakeTransport() + app = create_app( + Settings(hardware_enabled=True), + transport_factory=lambda settings: transport, + ) + + async with client_for(app) as client: + ready = await client.get("/readyz") + motor_status = await client.get("/v1/motor/status") + + assert ready.status_code == 200 + assert ready.json() == {"ready": True} + assert motor_status.json()["state"] == "ready" + with transport.lock: + startup_frames = [ + (frame.arbitration_id, frame.data.hex()) for frame in transport.frames + ] + assert startup_frames == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8800880000000000"), + ] + + with transport.lock: + shutdown_frames = [ + (frame.arbitration_id, frame.data.hex()) for frame in transport.frames[-5:] + ] + assert shutdown_frames == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (MIXED_COMMAND_FRAME_ID, "8000800000000000"), + ] + assert transport.is_open is False + + asyncio.run(scenario()) + + +def test_http_open_and_close_can_switch_directions() -> None: + async def scenario() -> None: + transport = FakeTransport() + app = create_app( + Settings(hardware_enabled=True), + transport_factory=lambda settings: transport, + ) + + async with client_for(app) as client: + opening = await client.post("/v1/motor/open") + await asyncio.sleep(0.06) + still_opening = await client.get("/v1/motor/status") + closing = await client.post("/v1/motor/close") + reopened = await client.post("/v1/motor/open") + stopped = await client.post("/v1/motor/stop") + + assert opening.status_code == 202 + assert opening.json()["action"] == "open" + assert opening.json()["state"] == "opening" + assert opening.json()["control_raw"] == 400 + assert opening.json()["control_raw_by_motor"] == {"1": 400, "2": -400} + assert opening.json()["control_mode"] == "open_loop" + assert opening.json()["streaming"] is True + assert opening.json()["broadcast_frequency_hz"] == 10 + assert still_opening.json()["state"] == "opening" + assert still_opening.json()["streaming"] is True + assert closing.status_code == 202 + assert closing.json()["action"] == "close" + assert closing.json()["state"] == "closing" + assert closing.json()["control_raw"] == -400 + assert closing.json()["control_raw_by_motor"] == {"1": -400, "2": 400} + assert "not verified" in closing.json()["transmission"] + assert reopened.status_code == 202 + assert reopened.json()["action"] == "open" + assert reopened.json()["state"] == "opening" + assert reopened.json()["control_raw"] == 400 + assert reopened.json()["control_raw_by_motor"] == {"1": 400, "2": -400} + assert stopped.status_code == 202 + assert stopped.json()["state"] == "stopped" + + asyncio.run(scenario()) + + +def test_automatic_enable_and_motion_do_not_require_feedback_replies() -> None: + async def scenario() -> None: + transport = FakeTransport() + app = create_app( + Settings(hardware_enabled=True), + transport_factory=lambda settings: transport, + ) + + async with client_for(app) as client: + opening = await client.post("/v1/motor/open") + stopped = await client.post("/v1/motor/stop") + + assert opening.status_code == 202 + assert opening.json()["streaming"] is True + assert stopped.status_code == 202 + assert stopped.json()["streaming"] is False + + asyncio.run(scenario()) + + +def test_one_http_stop_sends_two_explicit_stop_rounds() -> None: + async def scenario() -> None: + transport = FakeTransport() + app = create_app( + Settings(hardware_enabled=True), + transport_factory=lambda settings: transport, + ) + + async with client_for(app) as client: + start = len(transport.frames) + + stopped = await client.post("/v1/motor/stop") + + with transport.lock: + frames = [ + (frame.arbitration_id, frame.data.hex()) for frame in transport.frames[start:] + ] + timeouts_s = transport.tx_queue_timeouts_s[start:] + assert stopped.status_code == 202 + assert stopped.json()["state"] == "stopped" + assert frames == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + ] + assert timeouts_s == [None, 0.1, None, 0.1] + + asyncio.run(scenario()) + + +def test_auto_enable_failure_aborts_startup_and_closes_transport() -> None: + async def scenario() -> None: + transport = FakeTransport() + transport.fail_next = True + app = create_app( + Settings(hardware_enabled=True), + transport_factory=lambda settings: transport, + ) + + with pytest.raises(MotorCommunicationError, match="injected CAN failure"): + async with client_for(app): + raise AssertionError("startup failure must prevent HTTP service readiness") + + with transport.lock: + cleanup_frames = [ + (frame.arbitration_id, frame.data.hex()) for frame in transport.frames + ] + cleanup_timeouts = list(transport.tx_queue_timeouts_s) + assert cleanup_frames == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (MIXED_COMMAND_FRAME_ID, "8000800000000000"), + ] + assert cleanup_timeouts == [None, 0.1, None, 0.1, 0.1] + assert transport.is_open is False + + asyncio.run(scenario()) + + +def test_api_key_is_required_when_configured() -> None: + async def scenario() -> None: + transport = FakeTransport() + app = create_app( + Settings(hardware_enabled=True, api_token="test-secret"), + transport_factory=lambda settings: transport, + ) + + async with client_for(app) as client: + assert (await client.get("/v1/motor/status")).status_code == 401 + response = await client.get("/v1/motor/status", headers={"X-API-Key": "test-secret"}) + assert response.status_code == 200 + + asyncio.run(scenario()) diff --git a/tests/test_can.py b/tests/test_can.py new file mode 100644 index 0000000..5949162 --- /dev/null +++ b/tests/test_can.py @@ -0,0 +1,184 @@ +"""CANalyst-II + MS3008 广播模式简单手动测试。""" + +import argparse +import struct +import time + +import can + +CAN_CHANNEL = 0 # CANalyst-II 面板 CAN1 +CAN_BITRATE = 1000000 +CAN_DEVICE = 0 + +CONTROL_FRAME_ID = 0x280 +COMMAND_FRAME_ID = 0x288 +READ_STATUS_2 = 0x9C +MOTOR_ON_COMMAND = 0x88 +STOP_COMMAND = 0x81 + + +def open_can() -> can.BusABC: + """打开 CANalyst-II:channel=0 对应面板 CAN1。""" + return can.Bus( + interface="canalystii", + channel=CAN_CHANNEL, + bitrate=CAN_BITRATE, + device=CAN_DEVICE, + ) + + +def read_status(bus: can.BusABC, timeout: float = 0.5) -> dict[int, dict[str, int]]: + """广播请求 ID 1、2 的状态2(0x9C)。""" + data = bytes([READ_STATUS_2, 0, READ_STATUS_2, 0, 0, 0, 0, 0]) + message = can.Message( + arbitration_id=COMMAND_FRAME_ID, + is_extended_id=False, + data=data, + ) + bus.send(message) + print(f"TX ID=0x{COMMAND_FRAME_ID:03X} DATA={data.hex(' ').upper()}") + + reports: dict[int, dict[str, int]] = {} + deadline = time.monotonic() + timeout + while len(reports) < 2: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + + reply = bus.recv(timeout=remaining) + print(reply) + if reply is None: + break + + motor_id = reply.arbitration_id - 0x140 + if motor_id not in (1, 2) or len(reply.data) != 8 or reply.data[0] != READ_STATUS_2: + continue + + temperature = struct.unpack_from(" None: + """ + 广播发送 ID 1、2 的控制量。 + + MS3008 中该值实际是 -850..850 的开环 powerControl raw,不是 N·m。 + """ + if not -850 <= motor1 <= 850 or not -850 <= motor2 <= 850: + raise ValueError("MS3008 控制量必须在 -850..850") + + enable_data = bytes([MOTOR_ON_COMMAND, 0, MOTOR_ON_COMMAND, 0, 0, 0, 0, 0]) + bus.send( + can.Message( + arbitration_id=COMMAND_FRAME_ID, + is_extended_id=False, + data=enable_data, + ) + ) + print(f"TX ID=0x{COMMAND_FRAME_ID:03X} DATA={enable_data.hex(' ').upper()}") + time.sleep(0.02) + + control_data = struct.pack(" now: + time.sleep(next_send - now) + else: + next_send = now + finally: + stop(bus) + + +def stop(bus: can.BusABC) -> None: + """连续两轮广播停止 ID 1、2(零输出 + 0x81)。""" + zero_data = bytes(8) + zero_message = can.Message( + arbitration_id=CONTROL_FRAME_ID, + is_extended_id=False, + data=zero_data, + ) + data = bytes([STOP_COMMAND, 0, STOP_COMMAND, 0, 0, 0, 0, 0]) + stop_message = can.Message( + arbitration_id=COMMAND_FRAME_ID, + is_extended_id=False, + data=data, + ) + + for attempt in range(2): + bus.send(zero_message) + print(f"TX ID=0x{CONTROL_FRAME_ID:03X} DATA={zero_data.hex(' ').upper()}") + bus.send(stop_message, timeout=0.1) + print(f"TX ID=0x{COMMAND_FRAME_ID:03X} DATA={data.hex(' ').upper()}") + if attempt == 0: + time.sleep(0.02) + + +def main() -> None: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status", help="读取 ID 1、2 状态") + + torque_parser = commands.add_parser("torque", help="发送 ID 1、2 控制量") + torque_parser.add_argument("motor1", type=int, help="ID 1 控制量") + torque_parser.add_argument("motor2", type=int, help="ID 2 控制量") + + commands.add_parser("stop", help="停止 ID 1、2") + args = parser.parse_args() + + bus = open_can() + print(bus) + try: + if args.command == "status": + read_status(bus) + elif args.command == "torque": + try: + torque_control(bus, args.motor1, args.motor2) + except KeyboardInterrupt: + print("已停止") + else: + stop(bus) + finally: + bus.shutdown() + + +if __name__ == "__main__": + main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3e3a62d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,76 @@ +import pytest + +from lingkong_motor_service.config import ConfigError, Settings + + +def test_defaults_match_connected_ms3008_installation() -> None: + settings = Settings() + + assert settings.can_channel == 0 + assert settings.can_device == 0 + assert settings.can_bitrate == 1_000_000 + assert settings.motor_ids == (1, 2) + assert settings.open_control_raw == 400 + assert settings.close_control_raw == 400 + assert settings.broadcast_frequency_hz == 10 + + +@pytest.mark.parametrize("field", ["open_control_raw", "close_control_raw"]) +@pytest.mark.parametrize("value", [851, 0, True, 1.5]) +def test_rejects_invalid_open_loop_magnitude(field: str, value: object) -> None: + with pytest.raises(ConfigError, match="1..850"): + Settings(**{field: value}).validate() + + +@pytest.mark.parametrize("motor_ids", [(), (1, 1), (0,), (3,), (5,), (True,)]) +def test_motor_ids_are_nonempty_unique_and_in_broadcast_range( + motor_ids: tuple[int, ...], +) -> None: + with pytest.raises(ConfigError, match="MOTOR_IDS"): + Settings(motor_ids=motor_ids).validate() + + +def test_environment_values_are_parsed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MOTOR_CAN_CHANNEL", "1") + monkeypatch.setenv("MOTOR_CAN_DEVICE", "2") + monkeypatch.setenv("MOTOR_IDS", "2, 1") + monkeypatch.setenv("MOTOR_OPEN_CONTROL_RAW", "123") + monkeypatch.setenv("MOTOR_CLOSE_CONTROL_RAW", "234") + + settings = Settings.from_env() + + assert settings.can_channel == 1 + assert settings.can_device == 2 + assert settings.motor_ids == (2, 1) + assert settings.open_control_raw == 123 + assert settings.close_control_raw == 234 + + +@pytest.mark.parametrize("channel", [-1, 2, "CAN1", True]) +def test_canalystii_channel_is_physical_zero_or_one(channel: object) -> None: + with pytest.raises(ConfigError, match="panel CAN1"): + Settings(can_channel=channel).validate() + + +@pytest.mark.parametrize("device", [-1, True, "0"]) +def test_canalystii_device_index_is_non_negative_integer(device: object) -> None: + with pytest.raises(ConfigError, match="MOTOR_CAN_DEVICE"): + Settings(can_device=device).validate() + + +def test_broadcast_frequency_is_loaded_from_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("MOTOR_BROADCAST_FREQUENCY_HZ", "250") + assert Settings.from_env().broadcast_frequency_hz == 250 + + +@pytest.mark.parametrize("frequency", [False, float("nan"), 0.0, 1001.0]) +def test_broadcast_frequency_must_be_finite_and_bounded(frequency: float) -> None: + with pytest.raises(ConfigError, match="MOTOR_BROADCAST_FREQUENCY_HZ"): + Settings(broadcast_frequency_hz=frequency).validate() + + +def test_non_loopback_http_requires_api_token() -> None: + with pytest.raises(ConfigError, match="MOTOR_API_TOKEN"): + Settings(http_host="0.0.0.0").validate() diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..893d0f1 --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,392 @@ +import threading +import time + +import pytest + +from lingkong_motor_service.config import Settings +from lingkong_motor_service.controller import ( + MotorCommunicationError, + MotorController, + MotorNotEnabledError, + MotorState, +) +from lingkong_motor_service.protocol import CONTROL_FRAME_ID, MIXED_COMMAND_FRAME_ID, CanFrame +from lingkong_motor_service.transport import CanTransportError + +from .fakes import FakeTransport + + +class BlockingSecondControlSendTransport(FakeTransport): + """Hold the sender's first refresh while allowing the synchronous send.""" + + def __init__(self) -> None: + super().__init__() + self.background_send_entered = threading.Event() + self.release_background_send = threading.Event() + self._nonzero_sends = 0 + self._count_lock = threading.Lock() + + def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: + if frame.arbitration_id == CONTROL_FRAME_ID and frame.data != bytes(8): + with self._count_lock: + self._nonzero_sends += 1 + should_block = self._nonzero_sends == 2 + if should_block: + self.background_send_entered.set() + self.release_background_send.wait(timeout=1.0) + super().send(frame, tx_queue_timeout_s=tx_queue_timeout_s) + + +class FailOnThirdZeroTransport(FakeTransport): + """Fail the strict zero frame during an open-to-close transition.""" + + def __init__(self) -> None: + super().__init__() + self._zero_count = 0 + + def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: + if frame.arbitration_id == CONTROL_FRAME_ID and frame.data == bytes(8): + self._zero_count += 1 + if self._zero_count == 3: + raise CanTransportError("injected transition stop failure") + super().send(frame, tx_queue_timeout_s=tx_queue_timeout_s) + + +def enabled_settings(**overrides: object) -> Settings: + values = { + "hardware_enabled": True, + "motor_ids": (1, 2), + "broadcast_frequency_hz": 500, + } + values.update(overrides) + return Settings(**values) + + +def frame_dump(transport: FakeTransport) -> list[tuple[int, str]]: + with transport.lock: + return [(frame.arbitration_id, frame.data.hex()) for frame in transport.frames] + + +def wait_for_payload_count(transport: FakeTransport, payload: bytes, count: int) -> None: + deadline = time.monotonic() + 0.2 + while time.monotonic() < deadline: + with transport.lock: + found = sum(frame.data == payload for frame in transport.frames) + if found >= count: + return + time.sleep(0.002) + raise AssertionError(f"did not observe {count} copies of {payload.hex()}") + + +def test_requires_enable_before_motion() -> None: + controller = MotorController(enabled_settings(), FakeTransport()) + + with pytest.raises(MotorNotEnabledError): + controller.open() + + +def test_open_and_close_can_switch_directions_without_manual_stop() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + + enabled = controller.enable() + assert enabled.state is MotorState.READY + assert enabled.streaming is False + assert frame_dump(transport) == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8800880000000000"), + ] + + opening = controller.open() + assert opening.control_raw == 400 + assert opening.control_raw_by_motor == {1: 400, 2: -400} + assert opening.state is MotorState.OPENING + assert opening.streaming is True + wait_for_payload_count(transport, bytes.fromhex("90 01 70 FE 00 00 00 00"), 3) + + transition_start = len(frame_dump(transport)) + closing = controller.close() + assert closing.control_raw == -400 + assert closing.control_raw_by_motor == {1: -400, 2: 400} + assert closing.state is MotorState.CLOSING + assert closing.streaming is True + wait_for_payload_count(transport, bytes.fromhex("70 FE 90 01 00 00 00 00"), 3) + + transition_frames = frame_dump(transport)[transition_start:] + first_close = transition_frames.index((CONTROL_FRAME_ID, "70fe900100000000")) + assert (CONTROL_FRAME_ID, "0000000000000000") in transition_frames[:first_close] + assert (MIXED_COMMAND_FRAME_ID, "8100810000000000") in transition_frames[:first_close] + assert not any( + frame == (CONTROL_FRAME_ID, "900170fe00000000") for frame in transition_frames[first_close:] + ) + + open_payload = bytes.fromhex("90 01 70 FE 00 00 00 00") + open_count_before = sum(frame.data == open_payload for frame in transport.frames) + transition_start = len(frame_dump(transport)) + reopened = controller.open() + assert reopened.control_raw == 400 + assert reopened.control_raw_by_motor == {1: 400, 2: -400} + assert reopened.state is MotorState.OPENING + assert reopened.streaming is True + wait_for_payload_count(transport, open_payload, open_count_before + 3) + + transition_frames = frame_dump(transport)[transition_start:] + first_open = transition_frames.index((CONTROL_FRAME_ID, "900170fe00000000")) + assert (CONTROL_FRAME_ID, "0000000000000000") in transition_frames[:first_open] + assert (MIXED_COMMAND_FRAME_ID, "8100810000000000") in transition_frames[:first_open] + assert not any( + frame == (CONTROL_FRAME_ID, "70fe900100000000") for frame in transition_frames[first_open:] + ) + + stopped = controller.stop() + assert stopped.state is MotorState.STOPPED + assert stopped.streaming is False + count_after_stop = len(frame_dump(transport)) + time.sleep(0.01) + assert len(frame_dump(transport)) == count_after_stop + controller.shutdown() + + +def test_open_to_close_stop_failure_never_sends_close_control() -> None: + transport = FailOnThirdZeroTransport() + controller = MotorController(enabled_settings(), transport) + controller.enable() + controller.open() + wait_for_payload_count(transport, bytes.fromhex("90 01 70 FE 00 00 00 00"), 3) + + with pytest.raises(MotorCommunicationError, match="transition stop failure"): + controller.close() + + status = controller.status() + assert status.state is MotorState.FAULT + assert status.last_error == "injected transition stop failure" + assert not any( + frame.data == bytes.fromhex("70 FE 90 01 00 00 00 00") for frame in transport.frames + ) + controller.shutdown() + + +@pytest.mark.parametrize( + ("motor_ids", "opening_values", "closing_values", "opening_payload", "closing_payload"), + [ + ((1,), {1: 400}, {1: -400}, "9001000000000000", "70fe000000000000"), + ((2,), {2: -400}, {2: 400}, "000070fe00000000", "0000900100000000"), + ], +) +def test_single_motor_keeps_its_configured_open_and_close_direction( + motor_ids: tuple[int, ...], + opening_values: dict[int, int], + closing_values: dict[int, int], + opening_payload: str, + closing_payload: str, +) -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(motor_ids=motor_ids), transport) + controller.enable() + + assert controller.open().control_raw_by_motor == opening_values + wait_for_payload_count(transport, bytes.fromhex(opening_payload), 2) + controller.stop() + assert controller.close().control_raw_by_motor == closing_values + wait_for_payload_count(transport, bytes.fromhex(closing_payload), 2) + controller.shutdown() + + +def test_open_keeps_broadcasting_until_explicit_stop() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + controller.enable() + controller.open() + payload = bytes.fromhex("90 01 70 FE 00 00 00 00") + wait_for_payload_count(transport, payload, 3) + count_before_wait = sum(frame.data == payload for frame in transport.frames) + + time.sleep(0.06) + + status = controller.status() + count_after_wait = sum(frame.data == payload for frame in transport.frames) + assert status.state is MotorState.OPENING + assert status.streaming is True + assert count_after_wait > count_before_wait + assert not any( + frame.arbitration_id == MIXED_COMMAND_FRAME_ID + and frame.data == bytes.fromhex("81 00 81 00 00 00 00 00") + for frame in transport.frames + ) + + assert controller.stop().state is MotorState.STOPPED + controller.shutdown() + + +def test_enable_sends_zero_and_enable() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + + status = controller.enable() + + assert status.state is MotorState.READY + assert frame_dump(transport) == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8800880000000000"), + ] + controller.shutdown() + + +def test_one_stop_request_sends_two_complete_rounds_with_tx_drain_wait() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + controller.enable() + start = len(transport.frames) + + stopped = controller.stop() + + with transport.lock: + frames = [(frame.arbitration_id, frame.data.hex()) for frame in transport.frames[start:]] + times_ns = transport.frame_times_ns[start:] + timeouts_s = transport.tx_queue_timeouts_s[start:] + assert stopped.state is MotorState.STOPPED + assert stopped.streaming is False + assert stopped.control_raw_by_motor == {1: 0, 2: 0} + assert frames == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + ] + assert timeouts_s == [None, 0.1, None, 0.1] + assert times_ns[2] - times_ns[1] >= 15_000_000 + controller.shutdown() + + +def test_background_send_failure_latches_fault_and_attempts_zero_stop() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + controller.enable() + controller.open() + transport.fail_next = True + + deadline = time.monotonic() + 0.2 + while controller.status().state is not MotorState.FAULT and time.monotonic() < deadline: + time.sleep(0.002) + + assert controller.status().state is MotorState.FAULT + assert controller.status().streaming is False + while ( + frame_dump(transport)[-1] != (MIXED_COMMAND_FRAME_ID, "8100810000000000") + and time.monotonic() < deadline + ): + time.sleep(0.002) + assert frame_dump(transport)[-2:] == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + ] + controller.shutdown() + + +def test_shutdown_joins_sender_before_zero_stop_disable_and_transport_shutdown() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + controller.enable() + controller.open() + wait_for_payload_count(transport, bytes.fromhex("90 01 70 FE 00 00 00 00"), 3) + + controller.shutdown() + + assert frame_dump(transport)[-5:] == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + (MIXED_COMMAND_FRAME_ID, "8000800000000000"), + ] + assert transport.tx_queue_timeouts_s[-5:] == [None, 0.1, None, 0.1, 0.1] + assert transport.is_open is False + count_after_close = len(frame_dump(transport)) + time.sleep(0.01) + assert len(frame_dump(transport)) == count_after_close + + +def test_send_failure_latches_local_fault() -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + transport.fail_next = True + + with pytest.raises(MotorCommunicationError): + controller.enable() + + assert controller.status().state is MotorState.FAULT + assert controller.status().last_error == "injected CAN failure" + controller.shutdown() + + +def test_thread_start_failure_never_sends_nonzero_control( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = FakeTransport() + controller = MotorController(enabled_settings(), transport) + controller.enable() + + def fail_start(thread: threading.Thread) -> None: + del thread + raise RuntimeError("injected thread start failure") + + monkeypatch.setattr(threading.Thread, "start", fail_start) + + with pytest.raises(MotorCommunicationError, match="could not start"): + controller.open() + + assert controller.status().state is MotorState.FAULT + assert not any( + frame.arbitration_id == CONTROL_FRAME_ID and frame.data != bytes(8) + for frame in transport.frames + ) + controller.shutdown() + + +def test_join_timeout_still_ends_with_sender_zero_and_stop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = BlockingSecondControlSendTransport() + controller = MotorController(enabled_settings(), transport) + monkeypatch.setattr(controller, "_BROADCAST_JOIN_TIMEOUT_S", 0.01) + controller.enable() + controller.open() + assert transport.background_send_entered.wait(timeout=0.2) + + with pytest.raises(MotorCommunicationError, match="did not stop"): + controller.stop() + + transport.release_background_send.set() + deadline = time.monotonic() + 0.2 + while frame_dump(transport)[-1] != (MIXED_COMMAND_FRAME_ID, "8100810000000000"): + if time.monotonic() >= deadline: + raise AssertionError("sender did not finish with zero and Stop") + time.sleep(0.002) + + assert frame_dump(transport)[-2:] == [ + (CONTROL_FRAME_ID, "0000000000000000"), + (MIXED_COMMAND_FRAME_ID, "8100810000000000"), + ] + controller.shutdown() + + +def test_shutdown_does_not_close_transport_while_sender_is_still_alive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = BlockingSecondControlSendTransport() + controller = MotorController(enabled_settings(), transport) + monkeypatch.setattr(controller, "_BROADCAST_JOIN_TIMEOUT_S", 0.01) + controller.enable() + controller.open() + assert transport.background_send_entered.wait(timeout=0.2) + + with pytest.raises(MotorCommunicationError, match="did not stop"): + controller.shutdown() + assert transport.is_open is True + + transport.release_background_send.set() + deadline = time.monotonic() + 0.2 + while controller._broadcast_thread is not None and time.monotonic() < deadline: + time.sleep(0.002) + controller.shutdown() + assert transport.is_open is False diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 0000000..e48b4ae --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,56 @@ +import pytest + +from lingkong_motor_service.protocol import ( + CONTROL_FRAME_ID, + MIXED_COMMAND_FRAME_ID, + MS_OPEN_LOOP_MAX, + MotorCommand, + ProtocolError, + build_mixed_command_frame, + build_open_loop_frame, +) + + +def test_pdf_golden_vector_positive_100_motor_1_negative_100_motor_3() -> None: + frame = build_open_loop_frame({1: 100, 3: -100}) + + assert frame.arbitration_id == CONTROL_FRAME_ID + assert frame.is_extended_id is False + assert frame.data == bytes.fromhex("64 00 00 00 9C FF 00 00") + + +def test_requested_open_and_close_vectors_for_motors_1_and_2() -> None: + opening = build_open_loop_frame({1: 400, 2: -400}) + closing = build_open_loop_frame({1: -400, 2: 400}) + + assert opening.data == bytes.fromhex("90 01 70 FE 00 00 00 00") + assert closing.data == bytes.fromhex("70 FE 90 01 00 00 00 00") + + +@pytest.mark.parametrize("value", [-851, 851]) +def test_rejects_values_outside_ms_open_loop_range(value: int) -> None: + with pytest.raises(ProtocolError, match="MS open_loop_raw"): + build_open_loop_frame({1: value}) + + +@pytest.mark.parametrize("value", [-MS_OPEN_LOOP_MAX, MS_OPEN_LOOP_MAX]) +def test_accepts_ms_open_loop_range_boundaries(value: int) -> None: + frame = build_open_loop_frame({2: value}) + assert len(frame.data) == 8 + + +@pytest.mark.parametrize("motor_id", [0, 5, True]) +def test_rejects_invalid_broadcast_motor_id(motor_id: int) -> None: + with pytest.raises(ProtocolError): + build_open_loop_frame({motor_id: 1}) + + +def test_mixed_enable_stop_and_disable_vectors() -> None: + enable = build_mixed_command_frame({1: MotorCommand.ENABLE}) + stop = build_mixed_command_frame({4: MotorCommand.STOP}) + disable = build_mixed_command_frame({2: MotorCommand.DISABLE}) + + assert enable.arbitration_id == MIXED_COMMAND_FRAME_ID + assert enable.data == bytes.fromhex("88 00 00 00 00 00 00 00") + assert stop.data == bytes.fromhex("00 00 00 00 00 00 81 00") + assert disable.data == bytes.fromhex("00 00 80 00 00 00 00 00") diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..ee3a80c --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,94 @@ +import can +import pytest + +from lingkong_motor_service.config import Settings +from lingkong_motor_service.protocol import build_open_loop_frame +from lingkong_motor_service.transport import ( + CanTransportError, + DisabledCanTransport, + PythonCanTransport, +) + + +class StubBus: + def __init__(self) -> None: + self.messages: list[can.Message] = [] + self.timeouts: list[float | None] = [] + self.shutdown_count = 0 + + def send(self, message: can.Message, timeout: float | None = None) -> None: + self.messages.append(message) + self.timeouts.append(timeout) + + def shutdown(self) -> None: + self.shutdown_count += 1 + + +def test_canalystii_configuration_and_standard_frame(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + bus = StubBus() + + def open_bus(**kwargs: object) -> StubBus: + captured.update(kwargs) + return bus + + monkeypatch.setattr(can, "Bus", open_bus) + transport = PythonCanTransport(Settings(hardware_enabled=True, can_channel=1, can_device=2)) + + try: + transport.send(build_open_loop_frame({1: 400, 2: -400})) + + assert captured == { + "interface": "canalystii", + "channel": 1, + "device": 2, + "bitrate": 1_000_000, + "rx_queue_size": 256, + "receive_own_messages": False, + } + assert bus.timeouts == [None] + assert len(bus.messages) == 1 + assert bus.messages[0].arbitration_id == 0x280 + assert bus.messages[0].is_extended_id is False + assert bytes(bus.messages[0].data) == bytes.fromhex("90 01 70 FE 00 00 00 00") + finally: + transport.shutdown() + + +def test_disabled_transport_fails_closed() -> None: + with pytest.raises(CanTransportError, match="hardware access is disabled"): + DisabledCanTransport().send(build_open_loop_frame({1: 0})) + + +def test_canalystii_can_wait_for_the_hardware_tx_queue( + monkeypatch: pytest.MonkeyPatch, +) -> None: + bus = StubBus() + monkeypatch.setattr(can, "Bus", lambda **kwargs: bus) + transport = PythonCanTransport(Settings(hardware_enabled=True)) + + try: + transport.send( + build_open_loop_frame({}), + tx_queue_timeout_s=0.1, + ) + + assert bus.timeouts == [0.1] + finally: + transport.shutdown() + + +def test_shutdown_is_idempotent_and_send_after_shutdown_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + bus = StubBus() + monkeypatch.setattr(can, "Bus", lambda **kwargs: bus) + transport = PythonCanTransport(Settings(hardware_enabled=True)) + + transport.shutdown() + transport.shutdown() + + assert transport.is_open is False + assert bus.shutdown_count == 1 + with pytest.raises(CanTransportError, match="CAN transport is closed"): + transport.send(build_open_loop_frame({1: 0})) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3af217f --- /dev/null +++ b/uv.lock @@ -0,0 +1,497 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "canalystii" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyusb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/54/8831c2b8fb593eaf6fbc450223f5ed0a208ce0d329ba94946eb06420ad86/canalystii-0.1.tar.gz", hash = "sha256:7471e09cdf85c407fd4a7ee0fd4e6de714eb9fca57434befbff02b801dcf0f84", size = 11847, upload-time = "2021-09-12T10:44:40.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/25/335886c7e2aa2f701cf73970880ec41d651f168be1200ea20d56de34ba03/canalystii-0.1-py3-none-any.whl", hash = "sha256:e3f1f80bf84912935e450cf71b3cf382625177f36ad2e502a5517e74b9cd7574", size = 10914, upload-time = "2021-09-12T10:44:39.24Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.116.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/64/1296f46d6b9e3b23fb22e5d01af3f104ef411425531376212f1eefa2794d/fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529", size = 298595, upload-time = "2025-09-16T18:29:23.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lingkong-motor-service" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "python-can", extra = ["canalystii"] }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.116,<0.117" }, + { name = "python-can", extras = ["canalystii"], specifier = ">=4.6,<5" }, + { name = "uvicorn", specifier = ">=0.35,<0.36" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28,<0.29" }, + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "ruff", specifier = ">=0.12,<0.13" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "python-can" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/f9/a9d99d36dd33be5badb747801c9255c3c526171a5542092eaacc73350fb8/python_can-4.6.1.tar.gz", hash = "sha256:290fea135d04b8504ebff33889cc6d301e2181a54099116609f940825ffe5005", size = 1206049, upload-time = "2025-08-12T07:44:58.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/34/e4ac153acdbcfba7f48bc73d6586a74c91cc919fcc2e29acbf81be329d1f/python_can-4.6.1-py3-none-any.whl", hash = "sha256:17f95255868a95108dcfcb90565a684dad32d5a3ebb35afd14f739e18c84ff6c", size = 276996, upload-time = "2025-08-12T07:44:56.55Z" }, +] + +[package.optional-dependencies] +canalystii = [ + { name = "canalystii" }, +] + +[[package]] +name = "pyusb" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, + { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, + { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +]