Motors¶
User-facing motor classes.
Note
Every class below is documented once, at the module where it is defined.
from cubemarspycan import MitMotor and from cubemarspycan.motor import MitMotor
both reach cubemarspycan.motor.mit.MitMotor. Documenting the re-export sites
as well would register each method at two fully qualified names, which is what makes a
bare :meth:`hold` ambiguous.
The bus¶
Frame routing.
One MotorBus owns one transport and fans received frames out to registered
endpoints. Two properties matter:
Containment. An endpoint that raises is caught and counted; the other motors on the bus keep receiving. One motor’s bug must not silence the rest.
Lock-free reads. The endpoint list is an immutable tuple, replaced wholesale on register. The receive thread does a single attribute read to snapshot it, so registering a motor never blocks reception.
Unmatched frames are counted and sampled, which is what makes cubemars scan able to
tell “nothing on the bus” from “something is there but not answering to that id”.
- class Endpoint(*args, **kwargs)[source]¶
Bases:
ProtocolSomething that consumes frames addressed to it.
- class BusStats(
- rx_matched: int = 0,
- rx_unmatched: int = 0,
- endpoint_errors: int = 0,
- errors: ~collections.deque[str] = <factory>,
- unmatched_samples: ~collections.deque[str] = <factory>,
Bases:
objectRouting counters. Diagnostics, not shared state.
- record_endpoint_error(endpoint: object, exc: BaseException) None[source]¶
Count and sample an exception raised by an endpoint.
Called on the receive thread, from inside the
exceptthat keeps one motor’s bug from silencing the others, so it must not raise. The sample list is bounded.
- class MotorBus(transport: FrameTransport)[source]¶
Bases:
objectRoutes frames between a transport and a set of motors.
- start() None[source]¶
Begin receiving. Idempotent - a second call is a no-op.
Registering a motor afterwards is safe and needs no restart, since routing reads an immutable tuple that
register()replaces wholesale.
- close() None[source]¶
Close the transport and stop receiving.
Delegates to the transport, whose
closeis required to be idempotent and non-raising, so this is safe from an error path. Endpoints stay registered: closing the bus ends reception, it does not dismantle the object.
- property transport: FrameTransport¶
The injected transport. Exposed for diagnostics, not for sending.
Use
send()instead, so frames are counted.
- property endpoints: tuple[Endpoint, ...]¶
The registered endpoints, as an immutable snapshot.
Safe to read from any thread:
register()andunregister()publish a new tuple rather than mutating this one, which is what lets the receive thread take its snapshot with a single attribute read and no lock.
- register(endpoint: Endpoint) None[source]¶
Add an endpoint. Publishes a new tuple so the receive thread never locks.
- unregister(endpoint: Endpoint) None[source]¶
Remove an endpoint. Unknown endpoints are ignored.
Compares by identity, not equality. A frame already being dispatched may still reach the endpoint: the receive thread snapshots the tuple before iterating, so removal takes effect from the next frame, not the current one.
Shared endpoint behaviour¶
Shared motor plumbing: latch wiring, the update path, and the control lifecycle.
The one rule that shapes this module: nothing in the receive path raises. A fault
arrives as a frame, becomes a FaultEvent in a latch, and
only turns into control flow inside MotorEndpoint.update(), on the caller’s thread,
after a safe-stop frame has gone out. TMotorCANControl raises RuntimeError straight
from the python-can notifier thread, where the exception is swallowed and the motor keeps
being commanded while faulted.
- class MotorEndpoint(
- bus: MotorBus,
- motor_id: int,
- spec: MotorSpec,
- *,
- policy: SafetyPolicy = DEFAULT_POLICY,
- supply_voltage: float | None = None,
Bases:
Generic[StateT]One motor on one bus.
- accepts(frame: Frame) bool[source]¶
Whether this frame belongs to this motor. Runs on the receive thread.
Called for every frame on the bus, so it must be cheap and must never raise. Subclasses filter on several conditions, not just the payload’s first byte: a library that checks only that decodes any colliding frame as this motor’s state.
- on_frame(frame: Frame, rx_monotonic: float) None[source]¶
Decode one accepted frame and publish it. Runs on the receive thread.
Must never raise: a decode failure is counted, and a fault becomes latched data. Neither turns into control flow until
update()runs on the caller’s thread, after a safe-stop frame has gone out.rx_monotonicis the arrival time fromtime.monotonic(), which is what staleness is measured against - not the bus timestamp.
- update() StateT[source]¶
Send the staged command and return the state snapshot taken before it.
Subclasses widen this with their own optional arguments. The zero-argument form is the contract the base class relies on, in
settle().
- property in_control: bool¶
Whether a
control()block is currently open.Commands are refused outside one with
NotInControlMode, because the driver is not in the mode that would act on them.
- control(
- wait_s: float = 1.0,
Enter control mode, and guarantee leaving it.
The exit path runs even if the body raises, and it sends a safe stop before the exit frame so the motor is never left producing torque.
wait_sblocks until the first feedback frame arrives, which catches a wrong id or a dead bus at the top of thewithrather than a hundred silent iterations later. Pass0.0to skip it, which is what the stepped simulator needs because nothing advances until the test says so.
- settle( ) StateT | None[source]¶
Hold the current command for
seconds, keeping feedback flowing.Use after
zero_here(), or anywhere you need to wait without letting the link go quiet. Returns the last state seen.A bare
time.sleephere sends nothing, so the motor stops replying and the nextupdate()raises. Note that keeping the link alive is not by itself enough when the driver goes quiet - staleness is measured on received frames - which is whatexpect_silence()is for.
- expect_silence(seconds: float) None[source]¶
Tolerate missing feedback for
seconds, starting now.Some operations stop the driver replying for a while -
zero_here()is the one that bites. Staleness is measured against the last frame received, so transmitting through the gap does not help: without this, the firstupdate()after such an operation raisesStaleFeedbackErroreven though nothing is wrong.This suppresses only the fatal limit. The warning still fires, so a gap that turns out to be permanent is still visible, and a frame received after the window opened ends it early - see
_in_stale_gracefor why “after” rather than “fresh”.
- property fault: FaultEvent | None¶
The latched fault, if any. Reading it does not consume it.
- property faulted: bool¶
Whether a fault is latched, whether or not it has been raised yet.
Stays true until
clear_fault(), so it survives catching the exception. Reads the latch, so it is safe from either thread and sends nothing.
MIT mode¶
The user-facing MIT-mode motor.
- class MitReplyMode(*values)[source]¶
Bases:
EnumWhich arbitration id the driver answers on.
The manual says “0X00+Drive ID”, which is ambiguous, and firmware builds differ. So we learn it from the first plausible reply and then filter strictly on it, rather than matching on the payload’s first byte alone the way TMotorCANControl does - which lets any 8-byte frame whose first byte collides be decoded as motor state.
- class MitMotor(
- bus: MotorBus,
- motor_id: int,
- spec: MotorSpec,
- *,
- policy: SafetyPolicy = DEFAULT_POLICY,
- supply_voltage: float | None = None,
- reply_mode: MitReplyMode = MitReplyMode.AUTO,
- wrap_mode: WrapMode | None = None,
Bases:
MotorEndpoint[MitState]An AK actuator in MIT (impedance) mode.
Commands go out as a single frame carrying five coupled fields, so they are set together through
command()rather than one attribute at a time. Reading state and writing a setpoint are deliberately different operations:m.position = xwould write a setpoint whilem.positionread feedback, and the value you read back is never the value you wrote.- accepts(frame: Frame) bool[source]¶
Four conditions, not one. Learns the reply arbitration id on the first match.
- on_frame(frame: Frame, rx_monotonic: float) None[source]¶
Runs on the receive thread. Must never raise.
- control(
- wait_s: float = 1.0,
Enter MIT mode, and guarantee a safe stop and an exit on the way out.
Narrows the base context manager’s type so
with m.control() as m:yields aMitMotor. The behaviour - enter frames, optional wait for first feedback, then a zero-gain zero-torque frame followed by the exit frame in afinally- is the base class’s; seecontrol().wait_s=0skips the wait, which is what a stepped simulator needs.
- command(
- *,
- position: float | None = None,
- velocity: float | None = None,
- kp: float | None = None,
- kd: float | None = None,
- torque: float | None = None,
Stage the next command. Unspecified fields hold their previous value.
Returns whatever had to be clamped, so a saturating controller is visible rather than silently trimmed.
- update(
- *,
- position: float | None = None,
- velocity: float | None = None,
- kp: float | None = None,
- kd: float | None = None,
- torque: float | None = None,
Snapshot the latest state, send the staged command, return the snapshot.
The returned state is taken at the top of the call, so it predates the frame this call puts on the wire. One cycle of causality, stated rather than accidental. Calling with no arguments re-sends the staged command, which is what a fault-recovery path wants.
- zero_here(*, grace_s: float = 1.5) None[source]¶
Set the current position as zero.
The manual does not say whether this survives a power cycle, so do not rely on either behaviour.
The driver needs about a second afterwards before position is trustworthy, and it may stop replying during that time. Because staleness is measured against the last frame received, transmitting through that gap does not keep it at bay - so this calls
expect_silence()forgrace_sand the wait becomes routine:m.zero_here() m.settle(1.5)
grace_s=0restores the strict behaviour if you would rather see the gap.The gains are dropped for you. Zeroing moves the coordinate system, so a setpoint staged in the old frame would become an instruction to drive back to where the motor just came from. Staging zero gains is not enough on its own -
hold()only stages, it does not transmit - so the zeroed command is put on the wire before the origin moves.This transmits - a zeroed command frame, then the zero-position frame - so like
update()it requires control mode. Methods that only stage (command(),hold(),brake()) do not: that is the line. Outside thewithblock the driver is not in MIT mode, so both frames would go to a device that is not listening and the caller would never learn.(
set_origin()is a deliberate exception: servo mode has no documented entry handshake, so “control mode” there is a library-side assertion rather than a device state, and the origin packet stands alone.)
- property reply_arbitration_id: int | None¶
Which id the driver actually answers on, once learned. Worth writing down.
- property staged_command: tuple[float, float, float, float, float]¶
The five staged MIT fields, output-side:
(position_rad, velocity_radps, kp, kd, torque_nm).Staged, not sent - nothing reaches the wire until
update(). These are the values after clamping, so they are what will actually go out rather than what was asked for.
- property decode_errors: int¶
Frames accepted for this motor that then failed to decode.
Should stay at zero. A climbing count means something is answering on this id that is not this motor, or the link is corrupting payloads.
- describe() str[source]¶
A multi-line report of the fields, the effective limits, and the provenance.
Prints both the wire field range and the effective limit for each quantity, which differ in either direction across the AK line, plus where every constant came from. Worth printing once at startup: it is the fastest way to see that a spec is incomplete before a conversion refuses mid-run.
Servo mode¶
The user-facing servo-mode motor.
- class ServoMotor(
- bus: MotorBus,
- motor_id: int,
- spec: MotorSpec,
- *,
- policy: SafetyPolicy = DEFAULT_POLICY,
- supply_voltage: float | None = None,
- assume_mit: bool = False,
Bases:
MotorEndpoint[ServoStatus]An AK actuator in servo mode over CAN.
Servo mode is configured in CubeMarsTool, not commanded over CAN: the manual documents
0x2Cas an “entered servo mode” reply but no frame that causes entry. So this class detects rather than asserts, and says what it observed when detection fails. TMotorCANControl guesses, sending the MITFF..FCpayload as an extended frame, where it lands as a malformed duty-cycle command.- assume_mit¶
Send the MIT exit frame on entry, for a driver left in MIT mode.
- accepts(frame: Frame) bool[source]¶
Whether this frame is servo feedback for this motor. Runs on the receive thread.
Servo framing is unambiguous, so this is exact rather than learned: extended id, the motor id in the low byte, and a function id this library handles.
0x2Cand0x09are accepted as events and never decoded as position.
- on_frame(frame: Frame, rx_monotonic: float) None[source]¶
Runs on the receive thread. Must never raise.
- control(
- wait_s: float = 1.0,
Confirm the driver really is in servo mode, then hand control over.
- command(setpoint: Setpoint) None[source]¶
Stage a setpoint. Only one command mode is in flight at a time.
- update( ) ServoStatus[source]¶
Snapshot the latest status, send the staged setpoint, return the snapshot.
As in MIT mode, the returned status is taken at the top of the call and so predates the frame this call sends.
- set_origin(
- mode: OriginMode = OriginMode.TEMPORARY,
Set the current position as origin.
PERMANENTwrites flash and the manual restricts it to dual-encoder models. On a single-encoder motor such as the AK40-10 this raisesCapabilityErrorand no frame is sent. TMotorCANControl sends mode 1 unconditionally, and by default.
- erpm_for(output_radps: float) float[source]¶
Output rad/s to electrical RPM. Refuses if pole pairs or gear ratio are unknown.
- property status: ServoStatus | None¶
The most recent status, without sending anything.
Nonebefore the first.A persistent
Nonewhile the wiring is fine almost always means the driver’s CAN status rate is 0, so it never uploads - the single most common servo-mode confusion.
- property bootloader_seen: bool¶
True if the driver announced a jump to its bootloader (function id 0x09).
- property events: list[ServoEvent]¶
Non-status replies, in arrival order.
- property staged_setpoint: Setpoint¶
The setpoint that
update()will re-send if called with no argument.Exactly one setpoint is in flight at a time: the six command types are mutually exclusive, which is why they are distinct types rather than fields.
- property decode_errors: int¶
Frames accepted for this motor that then failed to decode. Should stay zero.
- describe() str[source]¶
A multi-line report of the scaling, the current limit, and the provenance.
States where the current limit comes from - datasheet peak, policy ceiling, or neither, in which case current commands are refused rather than sent with no bound. The servo current field accepts +/-60 A against this motor’s 7.3 A peak, so an unset limit is a real hazard and is reported as one.
Servo setpoints¶
Servo-mode setpoints, as value objects.
Servo mode has six mutually exclusive commands: a duty cycle, a current, a braking current, a speed, a position, and a position with a speed and acceleration profile. Only one can be in flight, and there is no meaningful way to combine them.
Making each a distinct type puts that exclusivity in the type system rather than in a
mode enum the caller has to keep in step. m.update(servo.Position(90.0)) says exactly
one thing; m.position = 90; m.current = 2.0 - which is how TMotorCANControl models
it - says two contradictory things and silently resolves them by whichever mode flag was
set last. For example:
m.update(servo.Duty(0.05))
m.update(servo.Current(1.5))
m.update(servo.Position(90.0))
m.update(servo.PositionSpeed(90.0, speed_erpm=5000, accel_erpm_s2=30000))
- class Current(amps: float)[source]¶
Bases:
Setpointq-axis current in amps. Output torque is roughly
amps * Kt * gear_ratio.
- class CurrentBrake(amps: float)[source]¶
Bases:
SetpointBraking current in amps, never negative. Holds position; watch the temperature.
- class Duty(value: float)[source]¶
Bases:
SetpointOpen-loop duty cycle, -1.0 to 1.0. Square-wave-like drive; no closed loop at all.
- class Position(degrees: float)[source]¶
Bases:
SetpointTarget angle in degrees. The driver moves there at its configured maximum speed.
- class PositionSpeed(degrees: float, speed_erpm: float, accel_erpm_s2: float)[source]¶
Bases:
SetpointTrapezoidal move: a target angle with speed and acceleration limits.
speed_erpmandaccel_erpm_s2are packed as int16 after dividing by 10, so their resolution is 10 ERPM and 10 ERPM/s^2 respectively.
- class Rpm(erpm: float)[source]¶
Bases:
SetpointSpeed in electrical RPM, not mechanical.
Use
erpm_for()to convert from output rad/s, which needs the pole pair count and gear ratio and refuses if either is unknown.
- class Setpoint[source]¶
Bases:
objectBase for the six servo commands.
- to_frame( ) Frame[source]¶
Encode this setpoint as one extended CAN frame for
motor_id.The shared contract, which every subclass keeps: pure, allocates exactly one
Frame, and performs no safety clamping - the wire fields are far wider than any motor (the current field is +/-60 A against the AK40-10’s 7.3 A peak), so limits belong toSafetyPolicy, not here. RaisesSpecIncompleteErrorif the scaling needed for this command is unknown forspec.Each subclass documents only what differs: its packet id and its scale factor.