返回教程正文

配套源码

motor.py

app/bricks/zdt_motor/motor.py
zdt_motor Brick:用 Python 控制一台 ZDT 电机app/bricks/zdt_motor/motor.py
Python717 行
"""提供普通 App 可以直接使用的单电机控制接口。

一个 ``ZDTMotor`` 对象只表示一台电机。用户传入电机地址后,
就可以用 RPM、角度等容易理解的单位读取或控制电机,无需手写 CAN 报文。
"""

from dataclasses import replace

from .capabilities import capabilities_for
from .commands import common, emm, x
from .compat import brick
from .config import ChecksumType, Firmware, HomeMode, MotorConfig
from .errors import (
    ZDTCommandError,
    ZDTConfigurationError,
    ZDTFormatError,
    ZDTParameterError,
    ZDTProtocolError,
)
from .raw import RawMotorAPI


ACK_RECEIVED = 0x02
ACK_HOME_ALREADY_ACTIVE = 0x12
ACK_LIMIT_ALREADY_ACTIVE = 0x22
ACK_PARAMETER_ERROR = 0xE2
ACK_FORMAT_ERROR = 0xEE
ACK_ACTION_COMPLETE = 0x9F


def _u16(data):
    """
    @description         : 从高字节在前的两字节解析无符号整数
    @param data          : 两字节数据
    @return              : 0至65535整数
    """
    payload = bytes(data)
    if len(payload) != 2:
        raise ZDTProtocolError("uint16 field must contain 2 bytes")
    return int.from_bytes(payload, "big")


def _u32(data):
    """
    @description         : 从高字节在前的四字节解析无符号整数
    @param data          : 四字节数据
    @return              : 0至4294967295整数
    """
    payload = bytes(data)
    if len(payload) != 4:
        raise ZDTProtocolError("uint32 field must contain 4 bytes")
    return int.from_bytes(payload, "big")


def _signed_magnitude(data, magnitude_size):
    """
    @description         : 解析手册中的方向字节加无符号绝对值格式
    @param data          : 方向字节和绝对值
    @param magnitude_size: 绝对值字节数
    @return              : 带符号整数
    """
    payload = bytes(data)
    if len(payload) != magnitude_size + 1:
        raise ZDTProtocolError("signed-magnitude response length is invalid")
    if payload[0] not in (0x00, 0x01):
        raise ZDTProtocolError("signed-magnitude sign must be 0x00 or 0x01")
    magnitude = int.from_bytes(payload[1:], "big")
    return -magnitude if payload[0] == 0x01 else magnitude


def _decode_ack(response, *, allow_home_no_motion=False):
    """
    @description         : 解析02、12、22、E2、EE和9F命令状态
    @param response      : ZDTResponse
    @param allow_home_no_motion: 回零时是否接受已在零点或限位状态
    @return              : 包含状态码和状态名称的字典
    """
    if len(response.data) != 1:
        raise ZDTProtocolError("command acknowledgement must contain one status byte")
    status = response.data[0]
    if status == ACK_RECEIVED:
        return {"status": status, "status_name": "received", "accepted": True}
    if status == ACK_ACTION_COMPLETE:
        return {"status": status, "status_name": "completed", "accepted": True}
    if status in (ACK_HOME_ALREADY_ACTIVE, ACK_LIMIT_ALREADY_ACTIVE):
        status_name = (
            "already_at_home" if status == ACK_HOME_ALREADY_ACTIVE else "limit_active"
        )
        if allow_home_no_motion:
            return {"status": status, "status_name": status_name, "accepted": True}
        raise ZDTCommandError(
            f"motor did not move because {status_name}",
            status=status,
            function_code=response.function_code,
        )
    if status == ACK_PARAMETER_ERROR:
        raise ZDTParameterError(
            "motor rejected parameters or current state does not allow the command",
            status=status,
            function_code=response.function_code,
        )
    if status == ACK_FORMAT_ERROR:
        raise ZDTFormatError(
            "motor reported an invalid command format",
            status=status,
            function_code=response.function_code,
        )
    raise ZDTCommandError(
        f"unknown motor command status 0x{status:02X}",
        status=status,
        function_code=response.function_code,
    )


@brick
class ZDTMotor:
    """一台 ZDT 第二代闭环电机。

    如果总线上有多台电机,请为每个地址创建一个对象,并让它们共用同一个
    ``ZDTBus``。例如,地址 1、2、3、4 对应四个 ``ZDTMotor`` 对象。
    """

    def __init__(
        self,
        *,
        bus,
        motor_id=1,
        model="X57S",
        firmware="emm",
        checksum=None,
        microstep=16,
        step_angle_degrees=1.8,
        timeout_s=None,
    ):
        """
        @description         : 创建一台电机对象,并把它绑定到指定地址和共享CAN总线
        @param bus           : 已创建的ZDTBus,多台电机应共用同一个对象
        @param motor_id      : 这台电机的CAN地址,范围1至255
        @param model         : 电机型号,当前确认使用X57S
        @param firmware      : 电机菜单中的FWType,填写emm或x
        @param checksum      : 校验方式,None表示使用ZDTBus的设置
        @param microstep     : 电机当前细分,范围1至256
        @param step_angle_degrees: 电机步距角,只能是0.9或1.8度
        @param timeout_s     : 等待电机应答的秒数,None表示使用ZDTBus设置
        @return              : 无返回值
        """
        required_bus_api = ("request", "checksum", "default_timeout_s")
        missing_bus_api = [
            name for name in required_bus_api if not hasattr(bus, name)
        ]
        if missing_bus_api:
            raise TypeError(
                "bus must implement the ZDT request interface; missing "
                + ", ".join(missing_bus_api)
            )
        resolved_checksum = bus.checksum if checksum is None else checksum
        resolved_timeout = bus.default_timeout_s if timeout_s is None else timeout_s
        self.bus = bus
        self._config = MotorConfig(
            model=model,
            firmware=firmware,
            motor_id=motor_id,
            checksum=resolved_checksum,
            microstep=microstep,
            step_angle_degrees=step_angle_degrees,
            timeout_s=resolved_timeout,
        )
        if self._config.checksum is not bus.checksum:
            raise ZDTConfigurationError(
                "all motors sharing one ZDTBus must use the bus checksum"
            )
        self.capabilities = capabilities_for(
            self._config.model,
            self._config.firmware,
        )
        self.raw = RawMotorAPI(self)

    @property
    def motor_id(self):
        """
        @description         : 获取当前对象绑定的电机地址
        @param               : 无参数
        @return              : 地址1至255
        """
        return self._config.motor_id

    @property
    def model(self):
        """
        @description         : 获取能力门控使用的电机型号
        @param               : 无参数
        @return              : 型号字符串
        """
        return self._config.model

    @property
    def firmware(self):
        """
        @description         : 获取当前对象使用的固件协议布局
        @param               : 无参数
        @return              : Firmware枚举
        """
        return self._config.firmware

    def supports(self, feature):
        """
        @description         : 查询当前型号和固件是否支持某能力
        @param feature       : 功能名称
        @return              : 支持返回True
        """
        return self.capabilities.supports(feature)

    def enable(self, *, synchronized=False):
        """
        @description         : 使能当前电机,使其进入可以接收运动命令的状态
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        self.capabilities.require("enable")
        return self._execute(common.build_enable(True, synchronized=synchronized))

    def disable(self, *, synchronized=False):
        """
        @description         : 失能当前电机,电机轴通常可以自由转动
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        self.capabilities.require("enable")
        return self._execute(common.build_enable(False, synchronized=synchronized))

    def stop(self, *, synchronized=False):
        """
        @description         : 请求当前电机停止运动
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        self.capabilities.require("stop")
        return self._execute(common.build_stop(synchronized=synchronized))

    def safe_stop_and_disable(self):
        """
        @description         : 尝试停止再失能,确保两条安全命令都发送
        @param               : 无参数
        @return              : stop和disable结果字典
        """
        results = {}
        first_error = None
        for name, action in (("stop", self.stop), ("disable", self.disable)):
            try:
                results[name] = action()
            except Exception as error:
                results[name] = {"error": str(error)}
                if first_error is None:
                    first_error = error
        if first_error is not None:
            raise first_error
        return results

    def set_speed(
        self,
        rpm,
        *,
        direction=None,
        acceleration=None,
        synchronized=False,
    ):
        """
        @description         : 使用RPM设置当前电机的目标速度
        @param rpm           : 目标转速,可使用正负号表示方向
        @param direction     : cw、ccw或None;None时从rpm正负号判断
        @param acceleration  : Emm为0至255加速度档位,X为RPM/S
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        self.capabilities.require("speed")
        if self.firmware is Firmware.EMM:
            command = emm.build_speed(
                rpm,
                direction=direction,
                acceleration=10 if acceleration is None else acceleration,
                synchronized=synchronized,
            )
        else:
            command = x.build_speed(
                rpm,
                direction=direction,
                acceleration=1000 if acceleration is None else acceleration,
                synchronized=synchronized,
            )
        return self._execute(command)

    def move_relative(
        self,
        degrees,
        *,
        rpm=60,
        direction=None,
        acceleration=None,
        deceleration=None,
        synchronized=False,
    ):
        """
        @description         : 相对当前位置运动指定工程角度
        @param degrees       : 相对角度,可用符号表达方向
        @param rpm           : 最大速度RPM
        @param direction     : cw、ccw或None
        @param acceleration  : Emm为档位,X为RPM/S
        @param deceleration  : X固件减速度,Emm忽略且必须为None
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        return self._move(
            degrees,
            rpm=rpm,
            direction=direction,
            acceleration=acceleration,
            deceleration=deceleration,
            mode="relative_current",
            synchronized=synchronized,
        )

    def move_absolute(
        self,
        degrees,
        *,
        rpm=60,
        direction=None,
        acceleration=None,
        deceleration=None,
        synchronized=False,
    ):
        """
        @description         : 相对坐标零点运动到指定工程角度
        @param degrees       : 绝对目标角度,可用符号表达方向
        @param rpm           : 最大速度RPM
        @param direction     : cw、ccw或None
        @param acceleration  : Emm为档位,X为RPM/S
        @param deceleration  : X固件减速度,Emm忽略且必须为None
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        return self._move(
            degrees,
            rpm=rpm,
            direction=direction,
            acceleration=acceleration,
            deceleration=deceleration,
            mode="absolute",
            synchronized=synchronized,
        )

    def get_speed(self):
        """
        @description         : 读取驱动器报告的实时转速,并转换为带方向的RPM
        @param               : 无参数
        @return              : 浮点RPM
        """
        response = self._request(common.build_read_speed())
        raw_speed = _signed_magnitude(response.data, 2)
        return float(raw_speed) if self.firmware is Firmware.EMM else raw_speed / 10.0

    def get_position(self):
        """
        @description         : 读取驱动器报告的实时位置,并转换为带方向的角度
        @param               : 无参数
        @return              : 浮点角度
        """
        response = self._request(common.build_read_position())
        return self._decode_position(_signed_magnitude(response.data, 4))

    def get_target_position(self):
        """
        @description         : 读取电机目标位置并转换为带符号角度
        @param               : 无参数
        @return              : 浮点角度
        """
        response = self._request(common.build_read_target_position())
        return self._decode_position(_signed_magnitude(response.data, 4))

    def get_position_error(self):
        """
        @description         : 读取位置误差并转换为带符号角度
        @param               : 无参数
        @return              : 浮点角度误差
        """
        self.capabilities.require("position_error")
        response = self._request(common.build_read_position_error())
        raw_error = _signed_magnitude(response.data, 4)
        if self.firmware is Firmware.EMM:
            return raw_error * 360.0 / 65536.0
        return raw_error / 100.0

    def get_status(self):
        """
        @description         : 读取电机状态,并转换为使能、到位和故障等布尔值
        @param               : 无参数
        @return              : 状态字节和布尔标志字典
        """
        self.capabilities.require("motor_status")
        response = self._request(common.build_read_motor_status())
        if len(response.data) != 1:
            raise ZDTProtocolError("motor status response length is invalid")
        value = response.data[0]
        return {
            "raw": value,
            "enabled": bool(value & 0x01),
            "position_reached": bool(value & 0x02),
            "stall": bool(value & 0x04),
            "stall_protection": bool(value & 0x08),
            "left_limit": bool(value & 0x10),
            "right_limit": bool(value & 0x20),
            "power_loss": bool(value & 0x80),
        }

    def get_home_status(self):
        """
        @description         : 读取并解析回零、编码器和保护状态
        @param               : 无参数
        @return              : 回零状态字典
        """
        self.capabilities.require("home_status")
        response = self._request(common.build_read_home_status())
        if len(response.data) != 1:
            raise ZDTProtocolError("home status response length is invalid")
        value = response.data[0]
        home_bits = value & 0x0C
        home_state = {
            0x04: "running",
            0x08: "failed",
            0x00: "complete_or_idle",
        }.get(home_bits, "invalid")
        return {
            "raw": value,
            "encoder_ready": bool(value & 0x01),
            "calibration_ready": bool(value & 0x02),
            "home_running": bool(value & 0x04),
            "home_failed": bool(value & 0x08),
            "over_temperature": bool(value & 0x10),
            "over_current": bool(value & 0x20),
            "home_state": home_state,
        }

    def get_version(self):
        """
        @description         : 读取固件版本和硬件系列型号
        @param               : 无参数
        @return              : 版本信息字典
        """
        self.capabilities.require("read_version")
        response = self._request(common.build_read_version())
        if len(response.data) != 4:
            raise ZDTProtocolError("version response length is invalid")
        firmware_number = _u16(response.data[:2])
        hardware_descriptor = response.data[2]
        hardware_version = response.data[3]
        hardware_type = hardware_descriptor & 0x0F
        type_names = {0: "20", 1: "28", 2: "35", 3: "42", 4: "57", 5: "86"}
        return {
            "firmware_raw": firmware_number,
            "firmware_version": (
                f"{firmware_number // 100}."
                f"{(firmware_number // 10) % 10}.{firmware_number % 10}"
            ),
            "hardware_series": (hardware_descriptor >> 4) & 0x0F,
            "hardware_type": hardware_type,
            "hardware_type_name": type_names.get(hardware_type, "unknown"),
            "hardware_version_raw": hardware_version,
        }

    def get_phase_parameters(self):
        """
        @description         : 读取电机相电阻和相电感
        @param               : 无参数
        @return              : 毫欧和微亨字典
        """
        self.capabilities.require("read_phase_parameters")
        response = self._request(common.build_read_phase_parameters())
        if len(response.data) != 4:
            raise ZDTProtocolError("phase parameter response length is invalid")
        return {
            "resistance_milliohm": _u16(response.data[:2]),
            "inductance_microhenry": _u16(response.data[2:]),
        }

    def get_bus_voltage(self):
        """
        @description         : 读取总线电压并返回伏特
        @param               : 无参数
        @return              : 浮点伏特
        """
        self.capabilities.require("bus_voltage")
        response = self._request(common.build_read_bus_voltage())
        return _u16(response.data) / 1000.0

    def get_phase_current(self):
        """
        @description         : 读取电机实际相电流
        @param               : 无参数
        @return              : 整数毫安
        """
        self.capabilities.require("phase_current")
        response = self._request(common.build_read_phase_current())
        return _u16(response.data)

    def get_encoder_degrees(self):
        """
        @description         : 读取单圈线性编码器并转换为0至360度
        @param               : 无参数
        @return              : 浮点角度
        """
        self.capabilities.require("encoder")
        response = self._request(common.build_read_encoder())
        return _u16(response.data) * 360.0 / 65536.0

    def get_input_pulses(self):
        """
        @description         : 读取带符号输入脉冲累计值
        @param               : 无参数
        @return              : 带符号整数脉冲数
        """
        self.capabilities.require("input_pulses")
        response = self._request(common.build_read_input_pulses())
        return _signed_magnitude(response.data, 4)

    def get_temperature(self):
        """
        @description         : 对明确标记X42S/Y42的温度功能执行能力门控
        @param               : 无参数
        @return              : X57S不支持并抛出异常
        """
        self.capabilities.require("temperature")

    def get_bus_current(self):
        """
        @description         : 对明确标记X42S/Y42的总线电流功能执行能力门控
        @param               : 无参数
        @return              : X57S不支持并抛出异常
        """
        self.capabilities.require("bus_current")

    def home(self, mode=HomeMode.NEAREST, *, synchronized=False):
        """
        @description         : 触发指定模式回零
        @param mode          : HomeMode或0至5
        @param synchronized  : True缓存到同步触发,False立即执行
        @return              : 结构化命令状态
        """
        self.capabilities.require("home")
        response = self._request(common.build_home(mode, synchronized=synchronized))
        return _decode_ack(response, allow_home_no_motion=True)

    def abort_home(self):
        """
        @description         : 强制中断当前回零操作
        @param               : 无参数
        @return              : 结构化命令状态
        """
        self.capabilities.require("home")
        return self._execute(common.build_abort_home())

    def set_motor_id(self, new_motor_id, *, store=True):
        """
        @description         : 修改地址并在成功后更新当前对象绑定地址
        @param new_motor_id  : 新地址1至255
        @param store         : True永久写入电机Flash
        @return              : 结构化命令状态
        """
        self.capabilities.require("set_motor_id")
        old_motor_id = self.motor_id
        command = common.build_set_motor_id(new_motor_id, store=store)
        response = self.bus.request(
            old_motor_id,
            command,
            timeout_s=self._config.timeout_s,
            response_address=(old_motor_id, new_motor_id),
        )
        result = _decode_ack(response)
        self._config = replace(self._config, motor_id=new_motor_id)
        result["old_motor_id"] = old_motor_id
        result["motor_id"] = new_motor_id
        result["stored"] = bool(store)
        return result

    def set_microstep(self, microstep, *, store=True):
        """
        @description         : 修改细分并同步更新角度换算配置
        @param microstep     : 细分1至256
        @param store         : True永久写入电机Flash
        @return              : 结构化命令状态
        """
        self.capabilities.require("set_microstep")
        result = self._execute(common.build_set_microstep(microstep, store=store))
        self._config = replace(self._config, microstep=microstep)
        result.update({"microstep": microstep, "stored": bool(store)})
        return result

    def set_current_limit(self, current_ma, *, store=True):
        """
        @description         : 修改闭环模式最大电流
        @param current_ma    : 0至5000mA
        @param store         : True永久写入电机Flash
        @return              : 结构化命令状态
        """
        self.capabilities.require("set_current_limit")
        result = self._execute(
            common.build_set_current_limit(current_ma, store=store)
        )
        result.update({"current_ma": current_ma, "stored": bool(store)})
        return result

    def set_direction(self, direction, *, store=True):
        """
        @description         : 修改电机定义的运动正方向
        @param direction     : cw或ccw
        @param store         : True永久写入电机Flash
        @return              : 结构化命令状态
        """
        self.capabilities.require("set_direction")
        result = self._execute(common.build_set_direction(direction, store=store))
        result.update({"direction": str(direction), "stored": bool(store)})
        return result

    def read_basic_info(self):
        """
        @description         : 执行默认安全只读的版本、位置、速度和状态查询
        @param               : 无参数
        @return              : 基础信息字典
        """
        return {
            "motor_id": self.motor_id,
            "model": self.model,
            "firmware": self.firmware.value,
            "version": self.get_version(),
            "position_degrees": self.get_position(),
            "speed_rpm": self.get_speed(),
            "status": self.get_status(),
        }

    def _move(
        self,
        degrees,
        *,
        rpm,
        direction,
        acceleration,
        deceleration,
        mode,
        synchronized,
    ):
        """
        @description         : 按固件选择Emm脉冲位置或X角度位置编码
        @param degrees       : 工程角度
        @param rpm           : 最大RPM
        @param direction     : cw、ccw或None
        @param acceleration  : 固件对应加速度
        @param deceleration  : X固件减速度或None
        @param mode          : 位置参考模式
        @param synchronized  : 是否缓存同步执行
        @return              : 结构化命令状态
        """
        self.capabilities.require("position")
        if self.firmware is Firmware.EMM:
            if deceleration is not None:
                raise ZDTConfigurationError(
                    "Emm position mode does not have a separate deceleration field"
                )
            command = emm.build_position(
                degrees,
                rpm=rpm,
                direction=direction,
                acceleration=10 if acceleration is None else acceleration,
                mode=mode,
                synchronized=synchronized,
                microstep=self._config.microstep,
                step_angle_degrees=self._config.step_angle_degrees,
            )
        else:
            command = x.build_position(
                degrees,
                rpm=rpm,
                direction=direction,
                acceleration=1000 if acceleration is None else acceleration,
                deceleration=deceleration,
                mode=mode,
                synchronized=synchronized,
            )
        return self._execute(command)

    def _decode_position(self, raw_position):
        """
        @description         : 按Emm或X固件规则把原始位置换算成角度
        @param raw_position  : 带符号原始位置
        @return              : 浮点角度
        """
        if self.firmware is Firmware.EMM:
            return raw_position * 360.0 / 65536.0
        return raw_position / 10.0

    def _execute(self, command):
        """
        @description         : 发送控制命令并严格解析命令状态
        @param command       : LogicalCommand
        @return              : 结构化命令状态
        """
        return _decode_ack(self._request(command))

    def _request(self, command):
        """
        @description         : 使用对象地址和超时调用共享Bus
        @param command       : LogicalCommand
        @return              : ZDTResponse
        """
        return self.bus.request(
            self.motor_id,
            command,
            timeout_s=self._config.timeout_s,
        )