Source code for openbricks.interfaces

# SPDX-License-Identifier: MIT
"""
Abstract interfaces for openbricks components.

MicroPython doesn't ship full ``typing.Protocol`` support, so these are plain
base classes. Drivers should subclass the appropriate interface and fill in
every method. The higher-level modules (``robotics``, ``config``) only depend
on these interfaces, never on concrete drivers — that's what makes the system
plug-and-play.

If you add a new category of component (e.g. a distance sensor), add its
interface here.
"""

from openbricks import parameters
from openbricks.parameters import Stop


[docs] class Motor: """A bidirectional motor. Implementations range from an open-loop H-bridge driver (L298N) to a closed-loop geared motor with quadrature encoder (JGB37-520). The method names and semantics follow the Pybricks Prime ``Motor`` API (1.21.0): ``run(speed)`` is degrees per second, closed loop; the raw-duty command is ``dc(duty)``. Additional openbricks methods (``coast``, ``run_speed``) remain as aliases. Units ----- * ``duty`` is -100..100 (percent duty cycle, sign = direction). * ``speed`` is degrees per second at the output shaft (closed-loop only). * ``angle`` is degrees at the output shaft (closed-loop only). """
[docs] def run(self, speed): """Run at ``speed`` degrees per second, closed loop — Pybricks ``Motor.run()``. Non-blocking. Concrete: delegates to ``run_speed()`` (the openbricks alias), so open-loop drivers surface its ``NotImplementedError``. BREAKING (1.21.0): before Pybricks parity this method took percent power. That command is now ``dc(duty)`` — a script still calling ``run(30)`` for power gets 30 deg/s instead (slow, not dangerous) or ``NotImplementedError`` on open-loop drivers. """ self.run_speed(speed)
[docs] def dc(self, duty): """Run at a fixed raw duty cycle (-100..100), open loop — Pybricks ``Motor.dc()``. Non-blocking. This is the pre-1.21.0 ``run()``.""" raise NotImplementedError
[docs] def stop(self): """Stop and let the motor spin freely; it gradually stops from friction. Pybricks ``Motor.stop()`` semantics — the default of the three stop flavours (stop/brake/hold), and a concrete method: it delegates to ``coast()``, so every driver gets it for free. """ self.coast()
[docs] def brake(self): """Stop with active braking (both terminals shorted).""" raise NotImplementedError
[docs] def coast(self): """Stop by cutting drive power (motor free-wheels).""" raise NotImplementedError
[docs] def hold(self): """Stop and actively hold the current shaft angle via closed-loop control. Only motors with position-mode hardware (e.g. ST-3215) or a software position loop implement this; open-loop drivers raise ``NotImplementedError`` — pick ``brake`` or ``coast`` instead.""" raise NotImplementedError
# --- Optional closed-loop methods --- # Open-loop drivers may raise NotImplementedError or simply not override.
[docs] def angle(self): """Return the current shaft angle in degrees.""" raise NotImplementedError
[docs] def reset_angle(self, angle=0): """Set the current angle to ``angle`` degrees.""" raise NotImplementedError
[docs] def run_speed(self, deg_per_s): """Hold a target speed (closed loop).""" raise NotImplementedError
[docs] def run_angle(self, deg_per_s, target_angle, wait=True): """Rotate by ``target_angle`` degrees at ``deg_per_s``. Blocks if ``wait``; otherwise returns immediately and the caller polls ``done()`` to advance the move and detect completion.""" raise NotImplementedError
[docs] def done(self): """Return ``True`` if no non-blocking move is in flight or the active ``run_angle(wait=False)`` move has reached its target. Drivers that don't support non-blocking moves always return ``True`` (a wait=True call is finished before returning to the caller, by definition).""" return True
[docs] def speed(self): """Measured shaft speed in degrees per second — Pybricks ``Motor.speed()``. Closed-loop drivers implement it (encoder observer / servo present-speed register).""" raise NotImplementedError
[docs] def load(self): """Measured torque at the shaft in mNm — Pybricks ``Motor.load()``. Drivers with load feedback (serial servos) implement it; the value is derived from the servo's load register and its datasheet stall torque, so treat it as an estimate.""" raise NotImplementedError
[docs] def stalled(self): """``True`` when the motor is pushing as hard as it can but cannot reach its commanded speed — Pybricks ``Motor.stalled()``. Drivers with load feedback implement it.""" raise NotImplementedError
# --- Pybricks composite maneuvers --- # Concrete: built from the primitives above, so every closed-loop # driver gets them. ``then`` is a :class:`openbricks.parameters.Stop` # member (Pybricks Stop.HOLD is the default). def _apply_then(self, then): parameters.check(Stop, then, "then") if then == Stop.HOLD: self.hold() elif then == Stop.BRAKE: self.brake() elif then == Stop.COAST: self.coast() # Stop.NONE: leave the motor running.
[docs] def run_time(self, speed, time_ms, then=Stop.HOLD, wait=True): """Run at ``speed`` deg/s for ``time_ms`` ms, then stop with the ``then`` flavour — Pybricks ``Motor.run_time()``. ``wait=False`` is not supported (no background timer is allocated for it); pass ``wait=True`` or sequence it yourself.""" if not wait: raise NotImplementedError( "run_time(wait=False) is not supported") import time self.run_speed(speed) time.sleep_ms(int(time_ms)) self._apply_then(then)
[docs] def run_target(self, speed, target_angle, then=Stop.HOLD, wait=True): """Run to the ABSOLUTE ``target_angle`` (degrees, in the ``reset_angle`` frame) at up to ``speed`` deg/s — Pybricks ``Motor.run_target()``. Built on the relative ``run_angle``: the delta is measured from ``angle()`` at call time.""" here = self.angle() if here is None: raise OSError("cannot read angle for run_target") self.run_angle(speed, target_angle - here, wait=wait) if wait: self._apply_then(then)
[docs] def run_until_stalled(self, speed, then=Stop.COAST, duty_limit=None): """Run at ``speed`` deg/s until ``stalled()``, apply the ``then`` flavour, and return the angle where it stalled — Pybricks ``Motor.run_until_stalled()`` (its default ``then`` == Stop.COAST). ``duty_limit`` (percent, 0 < limit <= 100) caps the motor's torque for the duration of the run — the Pybricks gripper- homing pattern: drive gently into the end stop without crushing it. The cap is applied before the motion starts and restored afterwards, stall or not. Drivers opt in via ``_duty_limit_push`` / ``_duty_limit_pop`` (the ST3215/ ST3032 serial servos implement it as a temporary torque- limit register write; their stall detection scales to the cap).""" import time restore = None if duty_limit is not None: restore = self._duty_limit_push(duty_limit) try: self.run_speed(speed) while not self.stalled(): time.sleep_ms(20) self._apply_then(then) return self.angle() finally: if duty_limit is not None: self._duty_limit_pop(restore)
def _duty_limit_push(self, duty_limit): """Apply a temporary torque cap of ``duty_limit`` percent; return the token ``_duty_limit_pop`` needs to undo it. Drivers with a torque-limiting mechanism override both.""" raise NotImplementedError( "duty_limit is not supported on this motor type (the " "ST3215/ST3032 serial servos support it)") def _duty_limit_pop(self, restore): raise NotImplementedError( "duty_limit is not supported on this motor type (the " "ST3215/ST3032 serial servos support it)")
[docs] class Servo: """A position-controlled servo (angle-addressable)."""
[docs] def move_to(self, angle_deg, speed=None, wait=True): """Move to absolute angle in degrees.""" raise NotImplementedError
[docs] def angle(self): """Read back the current angle.""" raise NotImplementedError
[docs] class IMU: """A 3-axis inertial measurement unit. The expected unit convention is: * heading/yaw/pitch/roll in degrees * angular_velocity in degrees / second * acceleration in m / s^2 """
[docs] def heading(self): """Return heading (yaw) in degrees, wrapped to [-180, 180).""" raise NotImplementedError
[docs] def angular_velocity(self): """Return (wx, wy, wz) in deg/s.""" raise NotImplementedError
[docs] def acceleration(self): """Return (ax, ay, az) in m/s^2.""" raise NotImplementedError
[docs] class ColorSensor: """An RGB-ish color sensor."""
[docs] def rgb(self): """Return ``(r, g, b)`` each in 0..255.""" raise NotImplementedError
[docs] def ambient(self): """Return ambient / clear-channel intensity in 0..100.""" raise NotImplementedError
# NOTE: ``DistanceSensor`` lives in ``openbricks.distance`` rather # than here, even though it's an interface. ``openbricks/__init__.py`` # eagerly loads this module to re-export Motor/Servo/IMU/ColorSensor, # so every byte of bytecode here is paid by *every* import of # anything in the openbricks package — including the observer test # which runs against a tight MicroPython heap budget. Distance-sensor # users explicitly ``from openbricks.distance import DistanceSensor`` # (or just import their concrete driver, which does). # Hub-layer interfaces (StatusLED, Button, Display, Hub) live in # ``openbricks.hub`` alongside their concrete implementations so that # tests which don't touch the hub don't pay the class-loading cost on # MicroPython's tight unix heap.