feat: add MS3008 HTTP motor control service
This commit is contained in:
commit
0dea53cabb
25
.env.example
Normal file
25
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.env
|
||||
tmp/
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.12
|
||||
251
README.md
Normal file
251
README.md
Normal 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
39
pyproject.toml
Normal 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
61
script/launch.sh
Executable 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
|
||||
1
src/lingkong_motor_service/__init__.py
Normal file
1
src/lingkong_motor_service/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Lingkong CAN motor HTTP service."""
|
||||
3
src/lingkong_motor_service/__main__.py
Normal file
3
src/lingkong_motor_service/__main__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .main import run
|
||||
|
||||
run()
|
||||
156
src/lingkong_motor_service/api.py
Normal file
156
src/lingkong_motor_service/api.py
Normal 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
|
||||
143
src/lingkong_motor_service/config.py
Normal file
143
src/lingkong_motor_service/config.py
Normal 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")
|
||||
406
src/lingkong_motor_service/controller.py
Normal file
406
src/lingkong_motor_service/controller.py
Normal 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")
|
||||
30
src/lingkong_motor_service/main.py
Normal file
30
src/lingkong_motor_service/main.py
Normal 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()
|
||||
91
src/lingkong_motor_service/protocol.py
Normal file
91
src/lingkong_motor_service/protocol.py
Normal 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),
|
||||
)
|
||||
108
src/lingkong_motor_service/transport.py
Normal file
108
src/lingkong_motor_service/transport.py
Normal 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
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
31
tests/fakes.py
Normal file
31
tests/fakes.py
Normal 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
225
tests/test_api.py
Normal 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
184
tests/test_can.py
Normal 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
76
tests/test_config.py
Normal 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
392
tests/test_controller.py
Normal 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
56
tests/test_protocol.py
Normal 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
94
tests/test_transport.py
Normal 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
497
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user