feat: add MS3008 HTTP motor control service

This commit is contained in:
xtkuang 2026-08-07 09:16:59 +08:00
commit 0dea53cabb
23 changed files with 2878 additions and 0 deletions

25
.env.example Normal file
View File

@ -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=

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.env
tmp/

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.12

251
README.md Normal file
View File

@ -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 界面默认为 <http://127.0.0.1:8000/docs>。真实硬件上只能运行一个服务
进程,不要使用 `--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 错误计数。

39
pyproject.toml Normal file
View File

@ -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"]

61
script/launch.sh Executable file
View File

@ -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

View File

@ -0,0 +1 @@
"""Lingkong CAN motor HTTP service."""

View File

@ -0,0 +1,3 @@
from .main import run
run()

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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()

View File

@ -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("<hhhh", *slots),
)
def build_mixed_command_frame(commands_by_motor: Mapping[int, MotorCommand]) -> 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),
)

View File

@ -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)

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@

31
tests/fakes.py Normal file
View File

@ -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

225
tests/test_api.py Normal file
View File

@ -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())

184
tests/test_can.py Normal file
View File

@ -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("<b", reply.data, 1)[0]
power_raw, speed_dps, encoder_raw = struct.unpack_from("<hhH", reply.data, 2)
reports[motor_id] = {
"temperature_c": temperature,
"power_raw": power_raw,
"speed_dps": speed_dps,
"encoder_raw": encoder_raw,
}
print(
f"RX ID=0x{reply.arbitration_id:03X} "
f"DATA={bytes(reply.data).hex(' ').upper()} "
f"motor={motor_id} temp={temperature}C power={power_raw} "
f"speed={speed_dps}dps encoder={encoder_raw}"
)
missing = sorted({1, 2} - reports.keys())
if missing:
print(f"未收到电机 {missing} 的状态回包")
return reports
def torque_control(
bus: can.BusABC,
motor1: int,
motor2: int,
frequency_hz: float = 10.0,
) -> 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("<hhhh", motor1, motor2, 0, 0)
control_message = can.Message(
arbitration_id=CONTROL_FRAME_ID,
is_extended_id=False,
data=control_data,
)
period = 1.0 / frequency_hz
next_send = time.monotonic()
count = 0
try:
while True:
bus.send(control_message)
count += 1
if count % 100 == 0:
print(
f"TX ID=0x{CONTROL_FRAME_ID:03X} "
f"DATA={control_data.hex(' ').upper()} count={count}"
)
next_send += period
now = time.monotonic()
if next_send > 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()

76
tests/test_config.py Normal file
View File

@ -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()

392
tests/test_controller.py Normal file
View File

@ -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

56
tests/test_protocol.py Normal file
View File

@ -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")

94
tests/test_transport.py Normal file
View File

@ -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}))

497
uv.lock generated Normal file
View File

@ -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" },
]