feat: add motor power derating and thermal cutoff
This commit is contained in:
parent
c9005baf9a
commit
83312c277c
@ -19,6 +19,14 @@ MOTOR_CLOSE_CONTROL_RAW=400
|
|||||||
# HTTP open/close 后持续广播直到 stop;服务正常退出会自动 Stop+Disable。
|
# HTTP open/close 后持续广播直到 stop;服务正常退出会自动 Stop+Disable。
|
||||||
MOTOR_BROADCAST_FREQUENCY_HZ=10
|
MOTOR_BROADCAST_FREQUENCY_HZ=10
|
||||||
|
|
||||||
|
# open/close 开始 5 秒后保持原方向,将两台电机的开环 raw 幅值降为 100。
|
||||||
|
MOTOR_DERATE_AFTER_S=5
|
||||||
|
MOTOR_DERATED_CONTROL_RAW=100
|
||||||
|
|
||||||
|
# 每 0.5 秒请求一次状态2;任一电机达到 80C 后两路归零并停止。
|
||||||
|
MOTOR_TEMPERATURE_LIMIT_C=80
|
||||||
|
MOTOR_TEMPERATURE_POLL_INTERVAL_S=0.5
|
||||||
|
|
||||||
# 监听全部 IPv4 网络接口;客户端应使用本机真实局域网 IP,不能使用 0.0.0.0。
|
# 监听全部 IPv4 网络接口;客户端应使用本机真实局域网 IP,不能使用 0.0.0.0。
|
||||||
MOTOR_HTTP_HOST=0.0.0.0
|
MOTOR_HTTP_HOST=0.0.0.0
|
||||||
MOTOR_HTTP_PORT=8000
|
MOTOR_HTTP_PORT=8000
|
||||||
|
|||||||
19
README.md
19
README.md
@ -12,6 +12,8 @@
|
|||||||
- 持续广播频率:`10 Hz`
|
- 持续广播频率:`10 Hz`
|
||||||
- HTTP `open`:ID 1 = `+400` raw,ID 2 = `-400` raw
|
- HTTP `open`:ID 1 = `+400` raw,ID 2 = `-400` raw
|
||||||
- HTTP `close`:ID 1 = `-400` raw,ID 2 = `+400` raw
|
- HTTP `close`:ID 1 = `-400` raw,ID 2 = `+400` raw
|
||||||
|
- `open`/`close` 持续 `5 s` 后:保持方向,两台电机幅值降为 `100` raw
|
||||||
|
- 温度达到 `80 C`:两台电机控制量归零并发送 Stop
|
||||||
- 服务启动:自动 Enable;正常退出:自动 Stop 后 Disable
|
- 服务启动:自动 Enable;正常退出:自动 Stop 后 Disable
|
||||||
- MS3008 开环 raw 范围:`-850..+850`
|
- MS3008 开环 raw 范围:`-850..+850`
|
||||||
|
|
||||||
@ -123,6 +125,10 @@ MOTOR_IDS=1,2
|
|||||||
MOTOR_OPEN_CONTROL_RAW=400
|
MOTOR_OPEN_CONTROL_RAW=400
|
||||||
MOTOR_CLOSE_CONTROL_RAW=400
|
MOTOR_CLOSE_CONTROL_RAW=400
|
||||||
MOTOR_BROADCAST_FREQUENCY_HZ=10
|
MOTOR_BROADCAST_FREQUENCY_HZ=10
|
||||||
|
MOTOR_DERATE_AFTER_S=5
|
||||||
|
MOTOR_DERATED_CONTROL_RAW=100
|
||||||
|
MOTOR_TEMPERATURE_LIMIT_C=80
|
||||||
|
MOTOR_TEMPERATURE_POLL_INTERVAL_S=0.5
|
||||||
MOTOR_HTTP_HOST=0.0.0.0
|
MOTOR_HTTP_HOST=0.0.0.0
|
||||||
MOTOR_HTTP_PORT=8000
|
MOTOR_HTTP_PORT=8000
|
||||||
MOTOR_API_TOKEN=
|
MOTOR_API_TOKEN=
|
||||||
@ -132,6 +138,15 @@ MOTOR_API_TOKEN=
|
|||||||
固定方向:`open` 为 `{1: +400, 2: -400}`,`close` 为
|
固定方向:`open` 为 `{1: +400, 2: -400}`,`close` 为
|
||||||
`{1: -400, 2: +400}`。
|
`{1: -400, 2: +400}`。
|
||||||
|
|
||||||
|
每次调用 `open` 或 `close` 都会重新开始计时。持续 5 秒后,程序保持当前方向并把
|
||||||
|
两台电机的开环 raw 幅值降至 100,即 `open={1:+100,2:-100}`、
|
||||||
|
`close={1:-100,2:+100}`。方向切换也会重新开始 5 秒计时。
|
||||||
|
|
||||||
|
运动期间服务每 0.5 秒发送一次 `0x9C` 状态2请求并读取温度。如果任一配置电机回报
|
||||||
|
温度达到或超过 `80 C`,程序会把两台电机控制量都设为 0、发送 Stop,并锁存温度保护;
|
||||||
|
电机冷却后需要正常重启服务才能再次运动。保护依赖有效的状态2回包:如果
|
||||||
|
`temperature_c_by_motor` 为空,表示尚未收到温度,不能认为温度保护已经得到现场验证。
|
||||||
|
|
||||||
完成接线和物理安全准备后,将 `MOTOR_HARDWARE_ENABLED` 改为 `true`。
|
完成接线和物理安全准备后,将 `MOTOR_HARDWARE_ENABLED` 改为 `true`。
|
||||||
此开关是启动安全总闸:服务启动后会立即自动 Enable,无需 HTTP 调用。
|
此开关是启动安全总闸:服务启动后会立即自动 Enable,无需 HTTP 调用。
|
||||||
程序不会自动读取 `.env`,启动前导入:
|
程序不会自动读取 `.env`,启动前导入:
|
||||||
@ -247,6 +262,10 @@ curl "http://${SERVER_IP}:8000/v1/motor/status"
|
|||||||
实际响应还包含电机 ID、控制量、广播状态等字段。硬件未启用或状态冲突仍返回
|
实际响应还包含电机 ID、控制量、广播状态等字段。硬件未启用或状态冲突仍返回
|
||||||
HTTP `409`,CAN/USB 通讯失败仍返回 HTTP `503`。
|
HTTP `409`,CAN/USB 通讯失败仍返回 HTTP `503`。
|
||||||
|
|
||||||
|
状态响应中的 `derated` 表示是否已进入 5 秒后的低功耗阶段,
|
||||||
|
`temperature_c_by_motor` 是各电机最近一次状态2温度,`temperature_limited` 表示
|
||||||
|
80 C 归零保护是否已触发。
|
||||||
|
|
||||||
`MOTOR_HARDWARE_ENABLED=true` 时,启动过程会自动 Enable。HTTP 只保留
|
`MOTOR_HARDWARE_ENABLED=true` 时,启动过程会自动 Enable。HTTP 只保留
|
||||||
`open`、`close`、`stop` 和 `status`,不提供 `enable`/`disable`。
|
`open`、`close`、`stop` 和 `status`,不提供 `enable`/`disable`。
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,7 @@ def create_app(
|
|||||||
motor_status.hardware_enabled
|
motor_status.hardware_enabled
|
||||||
and motor_status.can_connected
|
and motor_status.can_connected
|
||||||
and motor_status.last_error is None
|
and motor_status.last_error is None
|
||||||
|
and not motor_status.temperature_limited
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_200_OK if ready else status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_200_OK if ready else status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
|||||||
@ -69,6 +69,10 @@ class Settings:
|
|||||||
open_control_raw: int = 400
|
open_control_raw: int = 400
|
||||||
close_control_raw: int = 400
|
close_control_raw: int = 400
|
||||||
broadcast_frequency_hz: float = 10.0
|
broadcast_frequency_hz: float = 10.0
|
||||||
|
derate_after_s: float = 5.0
|
||||||
|
derated_control_raw: int = 100
|
||||||
|
temperature_limit_c: int = 80
|
||||||
|
temperature_poll_interval_s: float = 0.5
|
||||||
http_host: str = "0.0.0.0"
|
http_host: str = "0.0.0.0"
|
||||||
http_port: int = 8000
|
http_port: int = 8000
|
||||||
api_token: str | None = None
|
api_token: str | None = None
|
||||||
@ -85,6 +89,10 @@ class Settings:
|
|||||||
open_control_raw=_env_int("MOTOR_OPEN_CONTROL_RAW", 400),
|
open_control_raw=_env_int("MOTOR_OPEN_CONTROL_RAW", 400),
|
||||||
close_control_raw=_env_int("MOTOR_CLOSE_CONTROL_RAW", 400),
|
close_control_raw=_env_int("MOTOR_CLOSE_CONTROL_RAW", 400),
|
||||||
broadcast_frequency_hz=_env_float("MOTOR_BROADCAST_FREQUENCY_HZ", 10.0),
|
broadcast_frequency_hz=_env_float("MOTOR_BROADCAST_FREQUENCY_HZ", 10.0),
|
||||||
|
derate_after_s=_env_float("MOTOR_DERATE_AFTER_S", 5.0),
|
||||||
|
derated_control_raw=_env_int("MOTOR_DERATED_CONTROL_RAW", 100),
|
||||||
|
temperature_limit_c=_env_int("MOTOR_TEMPERATURE_LIMIT_C", 80),
|
||||||
|
temperature_poll_interval_s=_env_float("MOTOR_TEMPERATURE_POLL_INTERVAL_S", 0.5),
|
||||||
http_host=os.getenv("MOTOR_HTTP_HOST", "0.0.0.0").strip(),
|
http_host=os.getenv("MOTOR_HTTP_HOST", "0.0.0.0").strip(),
|
||||||
http_port=_env_int("MOTOR_HTTP_PORT", 8000),
|
http_port=_env_int("MOTOR_HTTP_PORT", 8000),
|
||||||
api_token=token,
|
api_token=token,
|
||||||
@ -135,6 +143,36 @@ class Settings:
|
|||||||
or not 1.0 <= self.broadcast_frequency_hz <= 1_000.0
|
or not 1.0 <= self.broadcast_frequency_hz <= 1_000.0
|
||||||
):
|
):
|
||||||
raise ConfigError("MOTOR_BROADCAST_FREQUENCY_HZ must be in 1..1000")
|
raise ConfigError("MOTOR_BROADCAST_FREQUENCY_HZ must be in 1..1000")
|
||||||
|
if (
|
||||||
|
isinstance(self.derate_after_s, bool)
|
||||||
|
or not isinstance(self.derate_after_s, int | float)
|
||||||
|
or not isfinite(self.derate_after_s)
|
||||||
|
or not 0.01 <= self.derate_after_s <= 3_600.0
|
||||||
|
):
|
||||||
|
raise ConfigError("MOTOR_DERATE_AFTER_S must be in 0.01..3600")
|
||||||
|
if (
|
||||||
|
isinstance(self.derated_control_raw, bool)
|
||||||
|
or not isinstance(self.derated_control_raw, int)
|
||||||
|
or not 0 <= self.derated_control_raw <= MS_OPEN_LOOP_MAX
|
||||||
|
):
|
||||||
|
raise ConfigError(f"MOTOR_DERATED_CONTROL_RAW must be in 0..{MS_OPEN_LOOP_MAX}")
|
||||||
|
if self.derated_control_raw > min(self.open_control_raw, self.close_control_raw):
|
||||||
|
raise ConfigError(
|
||||||
|
"MOTOR_DERATED_CONTROL_RAW must not exceed the open/close control values"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
isinstance(self.temperature_limit_c, bool)
|
||||||
|
or not isinstance(self.temperature_limit_c, int)
|
||||||
|
or not 1 <= self.temperature_limit_c <= 127
|
||||||
|
):
|
||||||
|
raise ConfigError("MOTOR_TEMPERATURE_LIMIT_C must be in 1..127")
|
||||||
|
if (
|
||||||
|
isinstance(self.temperature_poll_interval_s, bool)
|
||||||
|
or not isinstance(self.temperature_poll_interval_s, int | float)
|
||||||
|
or not isfinite(self.temperature_poll_interval_s)
|
||||||
|
or not 0.05 <= self.temperature_poll_interval_s <= 60.0
|
||||||
|
):
|
||||||
|
raise ConfigError("MOTOR_TEMPERATURE_POLL_INTERVAL_S must be in 0.05..60")
|
||||||
if not self.http_host:
|
if not self.http_host:
|
||||||
raise ConfigError("MOTOR_HTTP_HOST must not be empty")
|
raise ConfigError("MOTOR_HTTP_HOST must not be empty")
|
||||||
if not 1 <= self.http_port <= 65_535:
|
if not 1 <= self.http_port <= 65_535:
|
||||||
|
|||||||
@ -14,6 +14,8 @@ from .protocol import (
|
|||||||
MotorCommand,
|
MotorCommand,
|
||||||
build_mixed_command_frame,
|
build_mixed_command_frame,
|
||||||
build_open_loop_frame,
|
build_open_loop_frame,
|
||||||
|
build_status_2_request,
|
||||||
|
parse_status_2_reply,
|
||||||
)
|
)
|
||||||
from .transport import CanTransport, CanTransportError
|
from .transport import CanTransport, CanTransportError
|
||||||
|
|
||||||
@ -54,6 +56,12 @@ class MotorStatus:
|
|||||||
can_connected: bool
|
can_connected: bool
|
||||||
broadcast_frequency_hz: float
|
broadcast_frequency_hz: float
|
||||||
streaming: bool
|
streaming: bool
|
||||||
|
derated: bool
|
||||||
|
derate_after_s: float
|
||||||
|
derated_control_raw: int
|
||||||
|
temperature_c_by_motor: dict[int, int]
|
||||||
|
temperature_limit_c: int
|
||||||
|
temperature_limited: bool
|
||||||
last_error: str | None
|
last_error: str | None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
@ -62,6 +70,9 @@ class MotorStatus:
|
|||||||
result["control_raw_by_motor"] = {
|
result["control_raw_by_motor"] = {
|
||||||
str(motor_id): value for motor_id, value in self.control_raw_by_motor.items()
|
str(motor_id): value for motor_id, value in self.control_raw_by_motor.items()
|
||||||
}
|
}
|
||||||
|
result["temperature_c_by_motor"] = {
|
||||||
|
str(motor_id): value for motor_id, value in self.temperature_c_by_motor.items()
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@ -83,6 +94,10 @@ class MotorController:
|
|||||||
self._state = MotorState.DISABLED
|
self._state = MotorState.DISABLED
|
||||||
self._control_raw = 0
|
self._control_raw = 0
|
||||||
self._control_raw_by_motor = {motor_id: 0 for motor_id in settings.motor_ids}
|
self._control_raw_by_motor = {motor_id: 0 for motor_id in settings.motor_ids}
|
||||||
|
self._movement_started_ns: int | None = None
|
||||||
|
self._derated = False
|
||||||
|
self._temperature_c_by_motor: dict[int, int] = {}
|
||||||
|
self._temperature_limited = False
|
||||||
self._last_error: str | None = None
|
self._last_error: str | None = None
|
||||||
self._broadcast_stop: threading.Event | None = None
|
self._broadcast_stop: threading.Event | None = None
|
||||||
self._broadcast_thread: threading.Thread | None = None
|
self._broadcast_thread: threading.Thread | None = None
|
||||||
@ -105,6 +120,12 @@ class MotorController:
|
|||||||
and self._broadcast_thread.is_alive()
|
and self._broadcast_thread.is_alive()
|
||||||
and self._state in {MotorState.OPENING, MotorState.CLOSING}
|
and self._state in {MotorState.OPENING, MotorState.CLOSING}
|
||||||
),
|
),
|
||||||
|
derated=self._derated,
|
||||||
|
derate_after_s=self._settings.derate_after_s,
|
||||||
|
derated_control_raw=self._settings.derated_control_raw,
|
||||||
|
temperature_c_by_motor=dict(self._temperature_c_by_motor),
|
||||||
|
temperature_limit_c=self._settings.temperature_limit_c,
|
||||||
|
temperature_limited=self._temperature_limited,
|
||||||
last_error=self._last_error,
|
last_error=self._last_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -125,6 +146,10 @@ class MotorController:
|
|||||||
self._state = MotorState.READY
|
self._state = MotorState.READY
|
||||||
self._control_raw = 0
|
self._control_raw = 0
|
||||||
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
||||||
|
self._movement_started_ns = None
|
||||||
|
self._derated = False
|
||||||
|
self._temperature_c_by_motor = {}
|
||||||
|
self._temperature_limited = False
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
time.sleep(0.02)
|
time.sleep(0.02)
|
||||||
return self.status()
|
return self.status()
|
||||||
@ -164,6 +189,8 @@ class MotorController:
|
|||||||
self._state = state
|
self._state = state
|
||||||
self._control_raw_by_motor = dict(values)
|
self._control_raw_by_motor = dict(values)
|
||||||
self._control_raw = values.get(1, next(iter(values.values()), 0))
|
self._control_raw = values.get(1, next(iter(values.values()), 0))
|
||||||
|
self._movement_started_ns = time.monotonic_ns()
|
||||||
|
self._derated = False
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
# Establish the background safety path before the first non-zero
|
# Establish the background safety path before the first non-zero
|
||||||
# frame. The controller lock keeps the new thread from sending
|
# frame. The controller lock keeps the new thread from sending
|
||||||
@ -176,6 +203,10 @@ class MotorController:
|
|||||||
self._require_hardware()
|
self._require_hardware()
|
||||||
if self._state is MotorState.FAULT:
|
if self._state is MotorState.FAULT:
|
||||||
raise MotorCommunicationError(self._last_error or "motor controller is faulted")
|
raise MotorCommunicationError(self._last_error or "motor controller is faulted")
|
||||||
|
if self._temperature_limited:
|
||||||
|
raise MotorNotEnabledError(
|
||||||
|
"temperature protection is latched; restart the service after the motors cool"
|
||||||
|
)
|
||||||
if not self._armed_by_service or self._state is MotorState.DISABLED:
|
if not self._armed_by_service or self._state is MotorState.DISABLED:
|
||||||
raise MotorNotEnabledError("motor auto-enable did not complete during service startup")
|
raise MotorNotEnabledError("motor auto-enable did not complete during service startup")
|
||||||
|
|
||||||
@ -205,6 +236,8 @@ class MotorController:
|
|||||||
self._state = MotorState.DISABLED
|
self._state = MotorState.DISABLED
|
||||||
self._control_raw = 0
|
self._control_raw = 0
|
||||||
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
||||||
|
self._movement_started_ns = None
|
||||||
|
self._derated = False
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
return self.status()
|
return self.status()
|
||||||
|
|
||||||
@ -250,7 +283,12 @@ class MotorController:
|
|||||||
|
|
||||||
def _broadcast_loop(self, stop_event: threading.Event) -> None:
|
def _broadcast_loop(self, stop_event: threading.Event) -> None:
|
||||||
period_ns = max(1, round(1_000_000_000 / self._settings.broadcast_frequency_hz))
|
period_ns = max(1, round(1_000_000_000 / self._settings.broadcast_frequency_hz))
|
||||||
|
derate_after_ns = round(self._settings.derate_after_s * 1_000_000_000)
|
||||||
|
temperature_poll_period_ns = round(
|
||||||
|
self._settings.temperature_poll_interval_s * 1_000_000_000
|
||||||
|
)
|
||||||
next_deadline_ns = time.monotonic_ns() + period_ns
|
next_deadline_ns = time.monotonic_ns() + period_ns
|
||||||
|
next_temperature_poll_ns = time.monotonic_ns()
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
wait_s = max(0.0, (next_deadline_ns - time.monotonic_ns()) / 1_000_000_000)
|
wait_s = max(0.0, (next_deadline_ns - time.monotonic_ns()) / 1_000_000_000)
|
||||||
@ -260,9 +298,36 @@ class MotorController:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if self._state not in {MotorState.OPENING, MotorState.CLOSING}:
|
if self._state not in {MotorState.OPENING, MotorState.CLOSING}:
|
||||||
return
|
return
|
||||||
|
now_ns = time.monotonic_ns()
|
||||||
|
if (
|
||||||
|
not self._derated
|
||||||
|
and self._movement_started_ns is not None
|
||||||
|
and now_ns - self._movement_started_ns >= derate_after_ns
|
||||||
|
):
|
||||||
|
self._control_raw_by_motor = {
|
||||||
|
motor_id: (
|
||||||
|
self._settings.derated_control_raw
|
||||||
|
if value > 0
|
||||||
|
else -self._settings.derated_control_raw
|
||||||
|
if value < 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
for motor_id, value in self._control_raw_by_motor.items()
|
||||||
|
}
|
||||||
|
self._control_raw = self._control_raw_by_motor.get(
|
||||||
|
1, next(iter(self._control_raw_by_motor.values()), 0)
|
||||||
|
)
|
||||||
|
self._derated = True
|
||||||
frame = build_open_loop_frame(self._control_raw_by_motor)
|
frame = build_open_loop_frame(self._control_raw_by_motor)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if now_ns >= next_temperature_poll_ns:
|
||||||
|
self._transport.send(build_status_2_request(self._settings.motor_ids))
|
||||||
|
next_temperature_poll_ns = now_ns + temperature_poll_period_ns
|
||||||
|
self._read_temperature_replies()
|
||||||
|
with self._lock:
|
||||||
|
if self._temperature_limited:
|
||||||
|
return
|
||||||
self._transport.send(frame)
|
self._transport.send(frame)
|
||||||
except CanTransportError as exc:
|
except CanTransportError as exc:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@ -286,6 +351,30 @@ class MotorController:
|
|||||||
self._broadcast_thread = None
|
self._broadcast_thread = None
|
||||||
self._broadcast_stop = None
|
self._broadcast_stop = None
|
||||||
|
|
||||||
|
def _read_temperature_replies(self) -> None:
|
||||||
|
while True:
|
||||||
|
frame = self._transport.receive(timeout_s=0.0)
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
report = parse_status_2_reply(frame)
|
||||||
|
if report is None or report.motor_id not in self._settings.motor_ids:
|
||||||
|
continue
|
||||||
|
with self._lock:
|
||||||
|
self._temperature_c_by_motor[report.motor_id] = report.temperature_c
|
||||||
|
if report.temperature_c >= self._settings.temperature_limit_c:
|
||||||
|
self._temperature_limited = True
|
||||||
|
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._movement_started_ns = None
|
||||||
|
self._last_error = (
|
||||||
|
f"motor {report.motor_id} temperature {report.temperature_c}C reached "
|
||||||
|
f"the {self._settings.temperature_limit_c}C limit; output set to zero"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
def _best_effort_sender_stop(self) -> None:
|
def _best_effort_sender_stop(self) -> None:
|
||||||
frames = (
|
frames = (
|
||||||
build_open_loop_frame({}),
|
build_open_loop_frame({}),
|
||||||
@ -338,6 +427,9 @@ class MotorController:
|
|||||||
self._state = MotorState.STOPPED
|
self._state = MotorState.STOPPED
|
||||||
self._control_raw = 0
|
self._control_raw = 0
|
||||||
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
||||||
|
self._movement_started_ns = None
|
||||||
|
self._derated = False
|
||||||
|
if not self._temperature_limited:
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
|
|
||||||
def _best_effort_disable_locked(self) -> None:
|
def _best_effort_disable_locked(self) -> None:
|
||||||
@ -374,6 +466,8 @@ class MotorController:
|
|||||||
self._state = MotorState.FAULT if cleanup_error else MotorState.DISABLED
|
self._state = MotorState.FAULT if cleanup_error else MotorState.DISABLED
|
||||||
self._control_raw = 0
|
self._control_raw = 0
|
||||||
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
||||||
|
self._movement_started_ns = None
|
||||||
|
self._derated = False
|
||||||
|
|
||||||
def _send_sequence_locked(self, *frames: CanFrame) -> None:
|
def _send_sequence_locked(self, *frames: CanFrame) -> None:
|
||||||
for frame in frames:
|
for frame in frames:
|
||||||
@ -395,6 +489,8 @@ class MotorController:
|
|||||||
self._state = MotorState.FAULT
|
self._state = MotorState.FAULT
|
||||||
self._control_raw = 0
|
self._control_raw = 0
|
||||||
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
self._control_raw_by_motor = {motor_id: 0 for motor_id in self._settings.motor_ids}
|
||||||
|
self._movement_started_ns = None
|
||||||
|
self._derated = False
|
||||||
self._last_error = message
|
self._last_error = message
|
||||||
|
|
||||||
def _require_hardware(self) -> None:
|
def _require_hardware(self) -> None:
|
||||||
|
|||||||
@ -13,6 +13,7 @@ MOTOR_ID_MIN = 1
|
|||||||
MOTOR_ID_MAX = 4
|
MOTOR_ID_MAX = 4
|
||||||
MS_OPEN_LOOP_MIN = -850
|
MS_OPEN_LOOP_MIN = -850
|
||||||
MS_OPEN_LOOP_MAX = 850
|
MS_OPEN_LOOP_MAX = 850
|
||||||
|
MOTOR_REPLY_BASE_ID = 0x140
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(ValueError):
|
class ProtocolError(ValueError):
|
||||||
@ -25,6 +26,7 @@ class MotorCommand(IntEnum):
|
|||||||
DISABLE = 0x80
|
DISABLE = 0x80
|
||||||
STOP = 0x81
|
STOP = 0x81
|
||||||
ENABLE = 0x88
|
ENABLE = 0x88
|
||||||
|
READ_STATUS_2 = 0x9C
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@ -40,6 +42,17 @@ class CanFrame:
|
|||||||
raise ProtocolError("V2.35 broadcast frames must have DLC=8")
|
raise ProtocolError("V2.35 broadcast frames must have DLC=8")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MotorStatus2:
|
||||||
|
"""Decoded V2.35 status2 reply for one motor."""
|
||||||
|
|
||||||
|
motor_id: int
|
||||||
|
temperature_c: int
|
||||||
|
power_raw: int
|
||||||
|
speed_dps: int
|
||||||
|
encoder_raw: int
|
||||||
|
|
||||||
|
|
||||||
def _validate_motor_id(motor_id: int) -> None:
|
def _validate_motor_id(motor_id: int) -> None:
|
||||||
if (
|
if (
|
||||||
isinstance(motor_id, bool)
|
isinstance(motor_id, bool)
|
||||||
@ -89,3 +102,33 @@ def build_mixed_command_frame(commands_by_motor: Mapping[int, MotorCommand]) ->
|
|||||||
arbitration_id=MIXED_COMMAND_FRAME_ID,
|
arbitration_id=MIXED_COMMAND_FRAME_ID,
|
||||||
data=bytes(payload),
|
data=bytes(payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_2_request(motor_ids: tuple[int, ...]) -> CanFrame:
|
||||||
|
"""Build a mixed broadcast request for each configured motor's status2."""
|
||||||
|
|
||||||
|
return build_mixed_command_frame(
|
||||||
|
{motor_id: MotorCommand.READ_STATUS_2 for motor_id in motor_ids}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_status_2_reply(frame: CanFrame) -> MotorStatus2 | None:
|
||||||
|
"""Decode a status2 reply, returning ``None`` for unrelated frames."""
|
||||||
|
|
||||||
|
motor_id = frame.arbitration_id - MOTOR_REPLY_BASE_ID
|
||||||
|
if (
|
||||||
|
frame.is_extended_id
|
||||||
|
or not MOTOR_ID_MIN <= motor_id <= MOTOR_ID_MAX
|
||||||
|
or frame.data[0] != MotorCommand.READ_STATUS_2
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
temperature_c = struct.unpack_from("<b", frame.data, 1)[0]
|
||||||
|
power_raw, speed_dps, encoder_raw = struct.unpack_from("<hhH", frame.data, 2)
|
||||||
|
return MotorStatus2(
|
||||||
|
motor_id=motor_id,
|
||||||
|
temperature_c=temperature_c,
|
||||||
|
power_raw=power_raw,
|
||||||
|
speed_dps=speed_dps,
|
||||||
|
encoder_raw=encoder_raw,
|
||||||
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
@ -19,6 +20,8 @@ class CanTransport(Protocol):
|
|||||||
|
|
||||||
def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: ...
|
def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None: ...
|
||||||
|
|
||||||
|
def receive(self, *, timeout_s: float = 0.0) -> CanFrame | None: ...
|
||||||
|
|
||||||
def shutdown(self) -> None: ...
|
def shutdown(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@ -34,6 +37,10 @@ class DisabledCanTransport:
|
|||||||
del tx_queue_timeout_s
|
del tx_queue_timeout_s
|
||||||
raise CanTransportError("hardware access is disabled by MOTOR_HARDWARE_ENABLED")
|
raise CanTransportError("hardware access is disabled by MOTOR_HARDWARE_ENABLED")
|
||||||
|
|
||||||
|
def receive(self, *, timeout_s: float = 0.0) -> CanFrame | None:
|
||||||
|
del timeout_s
|
||||||
|
raise CanTransportError("hardware access is disabled by MOTOR_HARDWARE_ENABLED")
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -91,6 +98,30 @@ class PythonCanTransport:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise CanTransportError(f"CAN send failed: {exc}") from exc
|
raise CanTransportError(f"CAN send failed: {exc}") from exc
|
||||||
|
|
||||||
|
def receive(self, *, timeout_s: float = 0.0) -> CanFrame | None:
|
||||||
|
deadline = time.monotonic() + max(0.0, timeout_s)
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
if not self._is_open:
|
||||||
|
raise CanTransportError("CAN transport is closed")
|
||||||
|
while True:
|
||||||
|
remaining_s = max(0.0, deadline - time.monotonic())
|
||||||
|
message = self._bus.recv(timeout=remaining_s)
|
||||||
|
if message is None:
|
||||||
|
return None
|
||||||
|
if len(message.data) == 8:
|
||||||
|
return CanFrame(
|
||||||
|
arbitration_id=message.arbitration_id,
|
||||||
|
data=bytes(message.data),
|
||||||
|
is_extended_id=message.is_extended_id,
|
||||||
|
)
|
||||||
|
if remaining_s == 0.0:
|
||||||
|
return None
|
||||||
|
except CanTransportError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise CanTransportError(f"CAN receive failed: {exc}") from exc
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._is_open:
|
if not self._is_open:
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class FakeTransport:
|
|||||||
self.tx_queue_timeouts_s: list[float | None] = []
|
self.tx_queue_timeouts_s: list[float | None] = []
|
||||||
self.is_open = True
|
self.is_open = True
|
||||||
self.fail_next = False
|
self.fail_next = False
|
||||||
|
self.received_frames: list[CanFrame] = []
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None:
|
def send(self, frame: CanFrame, *, tx_queue_timeout_s: float | None = None) -> None:
|
||||||
@ -26,6 +27,17 @@ class FakeTransport:
|
|||||||
self.frame_times_ns.append(time.monotonic_ns())
|
self.frame_times_ns.append(time.monotonic_ns())
|
||||||
self.tx_queue_timeouts_s.append(tx_queue_timeout_s)
|
self.tx_queue_timeouts_s.append(tx_queue_timeout_s)
|
||||||
|
|
||||||
|
def receive(self, *, timeout_s: float = 0.0) -> CanFrame | None:
|
||||||
|
del timeout_s
|
||||||
|
with self.lock:
|
||||||
|
if not self.received_frames:
|
||||||
|
return None
|
||||||
|
return self.received_frames.pop(0)
|
||||||
|
|
||||||
|
def queue_received(self, frame: CanFrame) -> None:
|
||||||
|
with self.lock:
|
||||||
|
self.received_frames.append(frame)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.is_open = False
|
self.is_open = False
|
||||||
|
|||||||
@ -13,6 +13,10 @@ def test_defaults_match_connected_ms3008_installation() -> None:
|
|||||||
assert settings.open_control_raw == 400
|
assert settings.open_control_raw == 400
|
||||||
assert settings.close_control_raw == 400
|
assert settings.close_control_raw == 400
|
||||||
assert settings.broadcast_frequency_hz == 10
|
assert settings.broadcast_frequency_hz == 10
|
||||||
|
assert settings.derate_after_s == 5
|
||||||
|
assert settings.derated_control_raw == 100
|
||||||
|
assert settings.temperature_limit_c == 80
|
||||||
|
assert settings.temperature_poll_interval_s == 0.5
|
||||||
assert settings.http_host == "0.0.0.0"
|
assert settings.http_host == "0.0.0.0"
|
||||||
assert settings.api_token is None
|
assert settings.api_token is None
|
||||||
|
|
||||||
@ -38,6 +42,10 @@ def test_environment_values_are_parsed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setenv("MOTOR_IDS", "2, 1")
|
monkeypatch.setenv("MOTOR_IDS", "2, 1")
|
||||||
monkeypatch.setenv("MOTOR_OPEN_CONTROL_RAW", "123")
|
monkeypatch.setenv("MOTOR_OPEN_CONTROL_RAW", "123")
|
||||||
monkeypatch.setenv("MOTOR_CLOSE_CONTROL_RAW", "234")
|
monkeypatch.setenv("MOTOR_CLOSE_CONTROL_RAW", "234")
|
||||||
|
monkeypatch.setenv("MOTOR_DERATE_AFTER_S", "6")
|
||||||
|
monkeypatch.setenv("MOTOR_DERATED_CONTROL_RAW", "90")
|
||||||
|
monkeypatch.setenv("MOTOR_TEMPERATURE_LIMIT_C", "75")
|
||||||
|
monkeypatch.setenv("MOTOR_TEMPERATURE_POLL_INTERVAL_S", "0.25")
|
||||||
|
|
||||||
settings = Settings.from_env()
|
settings = Settings.from_env()
|
||||||
|
|
||||||
@ -46,6 +54,10 @@ def test_environment_values_are_parsed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert settings.motor_ids == (2, 1)
|
assert settings.motor_ids == (2, 1)
|
||||||
assert settings.open_control_raw == 123
|
assert settings.open_control_raw == 123
|
||||||
assert settings.close_control_raw == 234
|
assert settings.close_control_raw == 234
|
||||||
|
assert settings.derate_after_s == 6
|
||||||
|
assert settings.derated_control_raw == 90
|
||||||
|
assert settings.temperature_limit_c == 75
|
||||||
|
assert settings.temperature_poll_interval_s == 0.25
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("channel", [-1, 2, "CAN1", True])
|
@pytest.mark.parametrize("channel", [-1, 2, "CAN1", True])
|
||||||
@ -75,3 +87,24 @@ def test_broadcast_frequency_must_be_finite_and_bounded(frequency: float) -> Non
|
|||||||
|
|
||||||
def test_external_http_binding_does_not_require_api_token() -> None:
|
def test_external_http_binding_does_not_require_api_token() -> None:
|
||||||
Settings(http_host="0.0.0.0", api_token=None).validate()
|
Settings(http_host="0.0.0.0", api_token=None).validate()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value", "message"),
|
||||||
|
[
|
||||||
|
("derate_after_s", 0, "MOTOR_DERATE_AFTER_S"),
|
||||||
|
("derated_control_raw", 851, "MOTOR_DERATED_CONTROL_RAW"),
|
||||||
|
("temperature_limit_c", 128, "MOTOR_TEMPERATURE_LIMIT_C"),
|
||||||
|
("temperature_poll_interval_s", 0, "MOTOR_TEMPERATURE_POLL_INTERVAL_S"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_power_and_temperature_settings_are_bounded(
|
||||||
|
field: str, value: object, message: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ConfigError, match=message):
|
||||||
|
Settings(**{field: value}).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_derated_value_cannot_increase_the_configured_output() -> None:
|
||||||
|
with pytest.raises(ConfigError, match="must not exceed"):
|
||||||
|
Settings(open_control_raw=50, derated_control_raw=100).validate()
|
||||||
|
|||||||
@ -78,6 +78,13 @@ def wait_for_payload_count(transport: FakeTransport, payload: bytes, count: int)
|
|||||||
raise AssertionError(f"did not observe {count} copies of {payload.hex()}")
|
raise AssertionError(f"did not observe {count} copies of {payload.hex()}")
|
||||||
|
|
||||||
|
|
||||||
|
def status_2_reply(motor_id: int, temperature_c: int) -> CanFrame:
|
||||||
|
return CanFrame(
|
||||||
|
arbitration_id=0x140 + motor_id,
|
||||||
|
data=bytes([0x9C, temperature_c & 0xFF, 0, 0, 0, 0, 0, 0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_requires_enable_before_motion() -> None:
|
def test_requires_enable_before_motion() -> None:
|
||||||
controller = MotorController(enabled_settings(), FakeTransport())
|
controller = MotorController(enabled_settings(), FakeTransport())
|
||||||
|
|
||||||
@ -218,6 +225,87 @@ def test_open_keeps_broadcasting_until_explicit_stop() -> None:
|
|||||||
controller.shutdown()
|
controller.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("action", "initial_payload", "derated_payload", "expected_values"),
|
||||||
|
[
|
||||||
|
("open", "900170fe00000000", "64009cff00000000", {1: 100, 2: -100}),
|
||||||
|
("close", "70fe900100000000", "9cff640000000000", {1: -100, 2: 100}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_motion_derates_after_configured_delay_without_changing_direction(
|
||||||
|
action: str,
|
||||||
|
initial_payload: str,
|
||||||
|
derated_payload: str,
|
||||||
|
expected_values: dict[int, int],
|
||||||
|
) -> None:
|
||||||
|
transport = FakeTransport()
|
||||||
|
settings = enabled_settings(
|
||||||
|
broadcast_frequency_hz=200,
|
||||||
|
derate_after_s=0.03,
|
||||||
|
temperature_poll_interval_s=1,
|
||||||
|
)
|
||||||
|
controller = MotorController(settings, transport)
|
||||||
|
controller.enable()
|
||||||
|
|
||||||
|
getattr(controller, action)()
|
||||||
|
wait_for_payload_count(transport, bytes.fromhex(initial_payload), 2)
|
||||||
|
wait_for_payload_count(transport, bytes.fromhex(derated_payload), 2)
|
||||||
|
|
||||||
|
status = controller.status()
|
||||||
|
assert status.derated is True
|
||||||
|
assert status.control_raw_by_motor == expected_values
|
||||||
|
assert status.temperature_limited is False
|
||||||
|
controller.stop()
|
||||||
|
controller.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_below_limit_is_reported_without_stopping() -> None:
|
||||||
|
transport = FakeTransport()
|
||||||
|
transport.queue_received(status_2_reply(1, 79))
|
||||||
|
controller = MotorController(enabled_settings(temperature_poll_interval_s=0.05), transport)
|
||||||
|
controller.enable()
|
||||||
|
|
||||||
|
controller.open()
|
||||||
|
deadline = time.monotonic() + 0.2
|
||||||
|
while 1 not in controller.status().temperature_c_by_motor and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
status = controller.status()
|
||||||
|
assert status.temperature_c_by_motor == {1: 79}
|
||||||
|
assert status.temperature_limited is False
|
||||||
|
assert status.state is MotorState.OPENING
|
||||||
|
assert status.streaming is True
|
||||||
|
controller.stop()
|
||||||
|
controller.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_at_limit_sets_both_outputs_to_zero_and_latches() -> None:
|
||||||
|
transport = FakeTransport()
|
||||||
|
transport.queue_received(status_2_reply(2, 80))
|
||||||
|
controller = MotorController(enabled_settings(temperature_poll_interval_s=0.05), transport)
|
||||||
|
controller.enable()
|
||||||
|
|
||||||
|
controller.open()
|
||||||
|
deadline = time.monotonic() + 0.2
|
||||||
|
while not controller.status().temperature_limited and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
status = controller.status()
|
||||||
|
assert status.temperature_c_by_motor == {2: 80}
|
||||||
|
assert status.temperature_limited is True
|
||||||
|
assert status.control_raw_by_motor == {1: 0, 2: 0}
|
||||||
|
assert status.state is MotorState.STOPPED
|
||||||
|
assert status.streaming is False
|
||||||
|
assert "motor 2 temperature 80C" in (status.last_error or "")
|
||||||
|
assert frame_dump(transport)[-2:] == [
|
||||||
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||||||
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||||||
|
]
|
||||||
|
with pytest.raises(MotorNotEnabledError, match="temperature protection is latched"):
|
||||||
|
controller.close()
|
||||||
|
controller.shutdown()
|
||||||
|
|
||||||
|
|
||||||
def test_enable_sends_zero_and_enable() -> None:
|
def test_enable_sends_zero_and_enable() -> None:
|
||||||
transport = FakeTransport()
|
transport = FakeTransport()
|
||||||
controller = MotorController(enabled_settings(), transport)
|
controller = MotorController(enabled_settings(), transport)
|
||||||
|
|||||||
@ -3,11 +3,16 @@ import pytest
|
|||||||
from lingkong_motor_service.protocol import (
|
from lingkong_motor_service.protocol import (
|
||||||
CONTROL_FRAME_ID,
|
CONTROL_FRAME_ID,
|
||||||
MIXED_COMMAND_FRAME_ID,
|
MIXED_COMMAND_FRAME_ID,
|
||||||
|
MOTOR_REPLY_BASE_ID,
|
||||||
MS_OPEN_LOOP_MAX,
|
MS_OPEN_LOOP_MAX,
|
||||||
|
CanFrame,
|
||||||
MotorCommand,
|
MotorCommand,
|
||||||
|
MotorStatus2,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
build_mixed_command_frame,
|
build_mixed_command_frame,
|
||||||
build_open_loop_frame,
|
build_open_loop_frame,
|
||||||
|
build_status_2_request,
|
||||||
|
parse_status_2_reply,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -54,3 +59,27 @@ def test_mixed_enable_stop_and_disable_vectors() -> None:
|
|||||||
assert enable.data == bytes.fromhex("88 00 00 00 00 00 00 00")
|
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 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")
|
assert disable.data == bytes.fromhex("00 00 80 00 00 00 00 00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_2_request_and_reply_temperature() -> None:
|
||||||
|
request = build_status_2_request((1, 2))
|
||||||
|
reply = CanFrame(
|
||||||
|
arbitration_id=MOTOR_REPLY_BASE_ID + 2,
|
||||||
|
data=bytes.fromhex("9C 50 64 00 0A 00 34 12"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request.arbitration_id == MIXED_COMMAND_FRAME_ID
|
||||||
|
assert request.data == bytes.fromhex("9C 00 9C 00 00 00 00 00")
|
||||||
|
assert parse_status_2_reply(reply) == MotorStatus2(
|
||||||
|
motor_id=2,
|
||||||
|
temperature_c=80,
|
||||||
|
power_raw=100,
|
||||||
|
speed_dps=10,
|
||||||
|
encoder_raw=0x1234,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_2_parser_ignores_unrelated_frame() -> None:
|
||||||
|
frame = CanFrame(arbitration_id=CONTROL_FRAME_ID, data=bytes(8))
|
||||||
|
|
||||||
|
assert parse_status_2_reply(frame) is None
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class StubBus:
|
|||||||
self.messages: list[can.Message] = []
|
self.messages: list[can.Message] = []
|
||||||
self.timeouts: list[float | None] = []
|
self.timeouts: list[float | None] = []
|
||||||
self.shutdown_count = 0
|
self.shutdown_count = 0
|
||||||
|
self.received_messages: list[can.Message] = []
|
||||||
|
|
||||||
def send(self, message: can.Message, timeout: float | None = None) -> None:
|
def send(self, message: can.Message, timeout: float | None = None) -> None:
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
@ -23,6 +24,12 @@ class StubBus:
|
|||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
self.shutdown_count += 1
|
self.shutdown_count += 1
|
||||||
|
|
||||||
|
def recv(self, timeout: float | None = None) -> can.Message | None:
|
||||||
|
del timeout
|
||||||
|
if not self.received_messages:
|
||||||
|
return None
|
||||||
|
return self.received_messages.pop(0)
|
||||||
|
|
||||||
|
|
||||||
def test_canalystii_configuration_and_standard_frame(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_canalystii_configuration_and_standard_frame(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
@ -92,3 +99,25 @@ def test_shutdown_is_idempotent_and_send_after_shutdown_fails(
|
|||||||
assert bus.shutdown_count == 1
|
assert bus.shutdown_count == 1
|
||||||
with pytest.raises(CanTransportError, match="CAN transport is closed"):
|
with pytest.raises(CanTransportError, match="CAN transport is closed"):
|
||||||
transport.send(build_open_loop_frame({1: 0}))
|
transport.send(build_open_loop_frame({1: 0}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_canalystii_receives_standard_status_frame(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
bus = StubBus()
|
||||||
|
bus.received_messages.append(
|
||||||
|
can.Message(
|
||||||
|
arbitration_id=0x141,
|
||||||
|
is_extended_id=False,
|
||||||
|
data=bytes.fromhex("9C 4F 64 00 0A 00 34 12"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(can, "Bus", lambda **kwargs: bus)
|
||||||
|
transport = PythonCanTransport(Settings(hardware_enabled=True))
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = transport.receive(timeout_s=0.01)
|
||||||
|
|
||||||
|
assert frame is not None
|
||||||
|
assert frame.arbitration_id == 0x141
|
||||||
|
assert frame.data == bytes.fromhex("9C 4F 64 00 0A 00 34 12")
|
||||||
|
finally:
|
||||||
|
transport.shutdown()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user