返回教程正文

配套源码

bus.py

app/bricks/zdt_motor/bus.py
ROS 2 与 App 通讯 02:控制四台 ZDT 电机app/bricks/zdt_motor/bus.py
Python655 行
"""一个 Backend 共享给多个 ZDTMotor 的请求、响应和事件分发器。"""

from dataclasses import dataclass, replace
import queue
import threading
import time

from .backends import CanBackend, SocketCANBackend
from .bus_base import BusKind, ZDTMotorBus
from .commands import common
from .config import (
    ChecksumType,
    parse_checksum_type,
    validate_motor_id,
    validate_nonnegative_number,
    validate_positive_number,
)
from .endpoints import SocketCanEndpoint
from .errors import (
    ZDTBackendError,
    ZDTBusBusyError,
    ZDTConfigurationError,
    ZDTProtocolError,
    ZDTTimeoutError,
    ZDTUnsupportedFeatureError,
)
from .messages import LogicalCommand
from .protocols import (
    ZDTCanProtocol,
    parse_arbitration_id,
    reassemble_can_frames,
)


ASYNC_COMPLETION_STATUS = 0x9F
ASYNC_COMPLETION_FUNCTIONS = frozenset({0xF5, 0x9A, 0xFD})
DEFAULT_EVENT_QUEUE_SIZE = 256


@dataclass
class _PendingRequest:
    """内部等待项。"""

    expected_length: int
    result_queue: queue.Queue


@dataclass
class _Assembly:
    """内部多包重组状态。"""

    frames: list
    expected_length: int | None
    next_packet: int
    started_at: float


@dataclass(frozen=True)
class BusTrace:
    """供监视脚本显示的原始帧记录。"""

    direction: str
    frame: object


class ZDTCanBus(ZDTMotorBus):
    """支持一个 SocketCAN 总线共享多个电机对象。"""

    def __init__(
        self,
        *,
        name="zdt_can",
        endpoint=None,
        interface="can",
        device=None,
        checksum=ChecksumType.FIXED_6B,
        backend=None,
        default_timeout_s=0.5,
        event_queue_size=DEFAULT_EVENT_QUEUE_SIZE,
        trace_callback=None,
    ):
        """
        @description         : 配置共享CAN总线但不自动修改或拉起系统接口
        @param name          : 便于日志和诊断识别的总线名称
        @param endpoint      : SocketCanEndpoint接口配置对象
        @param interface     : 兼容旧代码,仅支持can或socketcan
        @param device        : 兼容旧代码的SocketCAN接口名,推荐改用endpoint
        @param checksum      : ZDT校验方式
        @param backend       : 可选CanBackend,单元测试可传FakeBackend
        @param default_timeout_s: 默认应答超时秒数
        @param event_queue_size: 异步事件队列最大容量
        @param trace_callback: 可选原始帧回调
        @return              : 无返回值
        """
        normalized_default_timeout = validate_positive_number(
            "default_timeout_s",
            default_timeout_s,
        )
        if (
            isinstance(event_queue_size, bool)
            or not isinstance(event_queue_size, int)
            or event_queue_size <= 0
        ):
            raise ZDTConfigurationError(
                "event_queue_size must be a positive integer"
            )
        if not isinstance(name, str) or not name.strip():
            raise ZDTConfigurationError("CAN bus name must not be empty")
        normalized_interface = str(interface).lower()
        if normalized_interface not in ("can", "socketcan"):
            raise ZDTUnsupportedFeatureError(
                f"backend '{interface}' is reserved for a future bus class"
            )
        if endpoint is not None and device is not None:
            raise ZDTConfigurationError(
                "use endpoint or legacy device, not both"
            )
        if endpoint is not None and not isinstance(endpoint, SocketCanEndpoint):
            raise TypeError("endpoint must be a SocketCanEndpoint")
        if endpoint is None:
            resolved_device = device
            if resolved_device is None and isinstance(backend, SocketCANBackend):
                resolved_device = backend.device
            endpoint = SocketCanEndpoint(interface=resolved_device or "can0")
        if backend is None:
            backend = SocketCANBackend(device=endpoint.interface)
        if not isinstance(backend, CanBackend):
            raise TypeError("backend must implement CanBackend")
        if (
            isinstance(backend, SocketCANBackend)
            and backend.device != endpoint.interface
        ):
            raise ZDTConfigurationError(
                "SocketCAN backend device does not match endpoint interface"
            )
        self.name = name.strip()
        self._endpoint = endpoint
        self.backend = backend
        self.device = endpoint.interface
        self._checksum = parse_checksum_type(checksum)
        self.protocol = ZDTCanProtocol(self.checksum)
        self._default_timeout_s = normalized_default_timeout
        self.trace_callback = trace_callback
        self._pending = {}
        self._assemblies = {}
        self._events = queue.Queue(maxsize=event_queue_size)
        self._dropped_event_count = 0
        self._lock = threading.RLock()
        self._send_lock = threading.Lock()
        self._lifecycle_lock = threading.RLock()
        self._stop_event = threading.Event()
        self._receiver_thread = None
        self._receiver_error = None
        self._last_protocol_error = None

    @property
    def checksum(self):
        """
        @description         : 获取当前总线使用的ZDT校验方式
        @param               : 无参数
        @return              : ChecksumType枚举值
        """
        return self._checksum

    @property
    def default_timeout_s(self):
        """
        @description         : 获取当前总线默认请求超时时间
        @param               : 无参数
        @return              : 超时秒数
        """
        return self._default_timeout_s

    @property
    def dropped_event_count(self):
        """
        @description         : 获取当前总线对象累计丢弃的异步事件数量
        @param               : 无参数
        @return              : 累计丢弃事件数量
        """
        return self._dropped_event_count

    @property
    def kind(self):
        """
        @description         : 标明当前对象是一条CAN总线
        @param               : 无参数
        @return              : BusKind.CAN
        """
        return BusKind.CAN

    @property
    def endpoint(self):
        """
        @description         : 获取当前CAN总线使用的SocketCAN端点
        @param               : 无参数
        @return              : SocketCanEndpoint对象
        """
        return self._endpoint

    def describe(self):
        """
        @description         : 返回当前CAN总线的可读配置和运行状态
        @param               : 无参数
        @return              : 只包含基础数据类型的字典
        """
        return {
            "name": self.name,
            "kind": self.kind.value,
            "endpoint": self.endpoint.describe(),
            "checksum": self.checksum.value,
            "backend": type(self.backend).__name__,
            "state": "open" if self.is_open else "closed",
        }

    @property
    def is_open(self):
        """
        @description         : 判断接收线程是否正在运行
        @param               : 无参数
        @return              : 已打开返回True
        """
        return self._receiver_thread is not None and self._receiver_thread.is_alive()

    @property
    def last_protocol_error(self):
        """
        @description         : 获取最近一条非致命协议异常用于诊断
        @param               : 无参数
        @return              : 最近异常对象或None
        """
        return self._last_protocol_error

    def open(self):
        """
        @description         : 打开Backend并启动响应分发线程
        @param               : 无参数
        @return              : 当前ZDTCanBus
        """
        with self._lifecycle_lock:
            if self.is_open:
                return self
            self.backend.open()
            self._stop_event.clear()
            self._receiver_error = None
            self._last_protocol_error = None
            receiver = threading.Thread(
                target=self._receive_loop,
                name="zdt-can-receiver",
                daemon=True,
            )
            self._receiver_thread = receiver
            receiver.start()
            return self

    def close(self):
        """
        @description         : 停止分发线程并关闭共享Backend
        @param               : 无参数
        @return              : 无返回值
        """
        with self._lifecycle_lock:
            self._stop_event.set()
            receiver = self._receiver_thread
            if receiver is not None and receiver is not threading.current_thread():
                receiver.join(timeout=1.0)
            self._receiver_thread = None
            self.backend.close()
            self._fail_all(ZDTBackendError("ZDT bus closed"))

    def encode_frames(self, address, command):
        """
        @description         : 把ZDT逻辑命令编码成经典CAN扩展帧但不发送
        @param address       : 电机地址
        @param command       : LogicalCommand
        @return              : CanFrame元组
        """
        return self.protocol.encode_command(address, command)

    def start_synchronized(self):
        """
        @description         : 广播触发所有已缓存的同步电机命令且不等待单电机应答
        @param               : 无参数
        @return              : 无返回值
        """
        self._send_command(0, common.build_sync_start())

    def request(
        self,
        address,
        command,
        *,
        timeout_s=None,
        response_address=None,
    ):
        """
        @description         : 发送逻辑命令并等待匹配地址和功能码的完整应答
        @param address       : 发送目标地址,可为广播地址0
        @param command       : LogicalCommand
        @param timeout_s     : 可选本次超时秒数
        @param response_address: None使用发送地址;整数或地址集合限制允许应答的地址
        @return              : ZDTResponse
        """
        if not isinstance(command, LogicalCommand):
            raise TypeError("command must be a LogicalCommand")
        send_address = validate_motor_id(address, allow_broadcast=True)
        if response_address is None:
            reply_addresses = (validate_motor_id(send_address),)
        elif isinstance(response_address, (tuple, list, set, frozenset)):
            reply_addresses = tuple(
                validate_motor_id(item) for item in response_address
            )
            if not reply_addresses:
                raise ZDTConfigurationError(
                    "response_address collection must not be empty"
                )
        else:
            reply_addresses = (validate_motor_id(response_address),)
        effective_timeout = validate_positive_number(
            "timeout_s",
            self.default_timeout_s if timeout_s is None else timeout_s,
        )
        self.open()
        if self._receiver_error is not None:
            raise ZDTBackendError(f"receiver stopped: {self._receiver_error}")

        keys = tuple(
            (reply_address, command.function_code)
            for reply_address in dict.fromkeys(reply_addresses)
        )
        pending = _PendingRequest(
            expected_length=command.expected_response_length,
            result_queue=queue.Queue(maxsize=1),
        )
        with self._lock:
            busy_keys = [key for key in keys if key in self._pending]
            if busy_keys:
                raise ZDTBusBusyError(
                    f"request already pending for {busy_keys}"
                )
            for key in keys:
                self._pending[key] = pending

        try:
            self._send_command(send_address, command)
            try:
                result = pending.result_queue.get(timeout=effective_timeout)
            except queue.Empty as error:
                raise ZDTTimeoutError(
                    f"motor address candidates {reply_addresses} function "
                    f"0x{command.function_code:02X} timed out after "
                    f"{effective_timeout:.3f}s"
                ) from error
            if isinstance(result, BaseException):
                raise result
            return result
        finally:
            with self._lock:
                for key in keys:
                    self._pending.pop(key, None)
                    self._assemblies.pop(key, None)

    def next_event(self, timeout_s=0.0):
        """
        @description         : 获取未匹配请求的主动返回或周期返回事件
        @param timeout_s     : 最大等待秒数,0表示立即返回
        @return              : ZDTResponse或None
        """
        normalized_timeout = validate_nonnegative_number("timeout_s", timeout_s)
        try:
            return self._events.get(timeout=normalized_timeout)
        except queue.Empty:
            return None

    def _send_command(self, address, command):
        """
        @description         : 编码并发送命令帧但不创建或等待请求响应
        @param address       : 目标地址,允许广播地址0
        @param command       : LogicalCommand
        @return              : 已发送CanFrame元组
        """
        if not isinstance(command, LogicalCommand):
            raise TypeError("command must be a LogicalCommand")
        send_address = validate_motor_id(address, allow_broadcast=True)
        self.open()
        if self._receiver_error is not None:
            raise ZDTBackendError(f"receiver stopped: {self._receiver_error}")
        frames = self.protocol.encode_command(send_address, command)
        with self._send_lock:
            for frame in frames:
                self._trace("tx", frame)
                self.backend.send(frame)
        return frames

    def _receive_loop(self):
        """
        @description         : 后台接收CAN帧并分发给请求或事件队列
        @param               : 无参数
        @return              : 无返回值
        """
        while not self._stop_event.is_set():
            try:
                frame = self.backend.receive(0.05)
            except Exception as error:
                self._receiver_error = error
                self._fail_all(ZDTBackendError(f"receiver failed: {error}"))
                return
            if frame is None:
                try:
                    self._expire_assemblies()
                except Exception as error:
                    self._last_protocol_error = error
                continue
            try:
                self._trace("rx", frame)
                self._consume_frame(frame)
            except Exception as error:
                # 单条坏帧、重复返回或观察回调异常不能终止整个CAN接收线程。
                self._last_protocol_error = error
                continue

    def _consume_frame(self, frame):
        """
        @description         : 校验帧类型并推进对应电机功能码的分包重组
        @param frame         : 收到的CanFrame
        @return              : 无返回值
        """
        if not frame.is_extended or not frame.data:
            return
        try:
            address, packet = parse_arbitration_id(frame.arbitration_id)
            validate_motor_id(address)
        except Exception:
            return
        function_code = frame.data[0]
        key = (address, function_code)
        with self._lock:
            pending = self._pending.get(key)
            expected_length = pending.expected_length if pending else None
            if packet == 0:
                assembly = _Assembly(
                    frames=[frame],
                    expected_length=expected_length,
                    next_packet=1,
                    started_at=time.monotonic(),
                )
                self._assemblies[key] = assembly
            else:
                assembly = self._assemblies.get(key)
                if assembly is None:
                    candidates = [
                        candidate_key
                        for candidate_key, candidate in self._assemblies.items()
                        if candidate_key[0] == address
                        and candidate.next_packet == packet
                    ]
                    if len(candidates) == 1:
                        self._complete_with_error(
                            candidates[0],
                            ZDTProtocolError(
                                "ZDT continuation function code mismatch"
                            ),
                        )
                    return
                if packet != assembly.next_packet:
                    self._complete_with_error(
                        key,
                        ZDTProtocolError("ZDT packet sequence mismatch"),
                    )
                    return
                assembly.frames.append(frame)
                assembly.next_packet += 1

            logical_length = len(assembly.frames[0].data) + sum(
                len(item.data) - 1 for item in assembly.frames[1:]
            )
            if (
                assembly.expected_length is not None
                and logical_length < assembly.expected_length
                and len(frame.data) < 8
            ):
                self._complete_with_error(
                    key,
                    ZDTProtocolError(
                        f"ZDT response length {logical_length} != "
                        f"{assembly.expected_length}"
                    ),
                )
                return
            complete = (
                assembly.expected_length is not None
                and logical_length >= assembly.expected_length
            ) or (
                assembly.expected_length is None and len(frame.data) < 8
            )
            if complete:
                self._finish_assembly(key, logical_length)

    def _finish_assembly(self, key, logical_length):
        """
        @description         : 重组、校验并投递一条完整ZDT响应
        @param key           : 地址和功能码元组
        @param logical_length: 当前累计逻辑长度
        @return              : 无返回值
        """
        assembly = self._assemblies.pop(key, None)
        if assembly is None:
            return
        expected_length = assembly.expected_length or logical_length
        pending = self._pending.get(key)
        try:
            logical_data = reassemble_can_frames(
                assembly.frames,
                expected_length,
            )
            response = self.protocol.validate_response(
                key[0],
                logical_data,
                expected_function=key[1],
            )
            response = replace(
                response,
                timestamp=assembly.frames[-1].timestamp,
            )
        except Exception as error:
            self._last_protocol_error = error
            if pending is not None:
                self._put_pending_result(pending, error)
            return
        if (
            response.function_code in ASYNC_COMPLETION_FUNCTIONS
            and response.data == bytes((ASYNC_COMPLETION_STATUS,))
        ):
            self._put_event(response)
            return
        if pending is None:
            self._put_event(response)
            return
        self._put_pending_result(pending, response)

    def _complete_with_error(self, key, error):
        """
        @description         : 结束一个损坏的分包并通知等待请求
        @param key           : 地址和功能码元组
        @param error         : 协议异常
        @return              : 无返回值
        """
        self._assemblies.pop(key, None)
        self._last_protocol_error = error
        pending = self._pending.get(key)
        if pending is not None:
            self._put_pending_result(pending, error)

    @staticmethod
    def _put_pending_result(pending, result):
        """
        @description         : 非阻塞投递请求结果并安全忽略重复返回造成的队列已满
        @param pending       : 当前等待请求
        @param result        : ZDTResponse或异常
        @return              : 成功投递返回True,队列已满返回False
        """
        try:
            pending.result_queue.put_nowait(result)
            return True
        except queue.Full:
            return False

    def _put_event(self, response):
        """
        @description         : 非阻塞投递异步事件,队列满时丢弃最旧事件
        @param response      : 已校验的ZDTResponse
        @return              : 成功投递返回True,队列已满返回False
        """
        try:
            self._events.put_nowait(response)
            return True
        except queue.Full:
            try:
                self._events.get_nowait()
            except queue.Empty:
                pass
            else:
                self._dropped_event_count += 1
            try:
                self._events.put_nowait(response)
                return True
            except queue.Full:
                return False

    def _expire_assemblies(self):
        """
        @description         : 清理超过默认超时的未完成主动分包
        @param               : 无参数
        @return              : 无返回值
        """
        deadline = time.monotonic() - self.default_timeout_s
        with self._lock:
            stale = [
                key
                for key, assembly in self._assemblies.items()
                if assembly.started_at < deadline and key not in self._pending
            ]
            for key in stale:
                self._assemblies.pop(key, None)

    def _fail_all(self, error):
        """
        @description         : 将Backend致命错误投递给全部等待请求
        @param error         : 要投递的异常
        @return              : 无返回值
        """
        with self._lock:
            delivered = set()
            for pending in self._pending.values():
                pending_id = id(pending)
                if pending_id in delivered:
                    continue
                delivered.add(pending_id)
                self._put_pending_result(pending, error)

    def _trace(self, direction, frame):
        """
        @description         : 调用可选的原始帧观察回调
        @param direction     : tx或rx
        @param frame         : CanFrame
        @return              : 无返回值
        """
        if self.trace_callback is not None:
            try:
                self.trace_callback(BusTrace(direction=direction, frame=frame))
            except Exception:
                pass

    def __enter__(self):
        """
        @description         : 进入上下文并打开共享Bus
        @param               : 无参数
        @return              : 当前ZDTBus
        """
        return self.open()

    def __exit__(self, exception_type, exception, traceback):
        """
        @description         : 退出上下文并关闭共享Bus
        @param exception_type: 上下文异常类型或None
        @param exception     : 上下文异常对象或None
        @param traceback     : 上下文异常堆栈或None
        @return              : False,不屏蔽异常
        """
        self.close()
        return False


# 兼容旧版导入;新代码应使用含义更明确的 ZDTCanBus。
ZDTBus = ZDTCanBus