Abstractions

j5 utilises a number of abstractions to enable similar APIs across platforms and hardware. This page explains design decisions behind the major abstractions and how to use them correctly.

Component

A component is the smallest logical part of some hardware.

A component will have the same basic functionality no matter what hardware it is on. For example, an LED is still an LED, no matter whether it is on an Arduino, or the control panel of a jumbo jet; it still can be turned on and off.

The component should expose a user-friendly API, attempting to be consistent with other components where possible.

Validation of user input should be done in the component.

Implementation

A component is implemented by sub-classing the j5.components.Component.

It is uniquely identified on a particular j5.boards.Board by an integer, which is usually passed into the constructor.

Every instance of a component should have a reference to a j5.backends.Backend, that implements the relevant j5.components.Interface.

The relevant j5.components.Interface should also be defined.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class LED(Component):
    """A standard Light Emitting Diode."""

    def __init__(self, identifier: int, backend: LEDInterface) -> None:
        self._backend = backend
        self._identifier = identifier

    @staticmethod
    def interface_class() -> Type[LEDInterface]:
        """Get the interface class that is required to use this component."""
        return LEDInterface

    @property
    def identifier(self) -> int:
        """An integer to identify the component on a board."""
        return self._identifier

    @property
    def state(self) -> bool:
        """Get the current state of the LED."""
        return self._backend.get_led_state(self._identifier)

    @state.setter
    def state(self, new_state: bool) -> None:
        """Set the state of the LED."""
        self._backend.set_led_state(self._identifier, new_state)

Interface

An interface defines the low-level methods that are required to control a given component.

Implementation

An interface should sub-class j5.components.Interface.

The interface class should contain abstract methods required to control the component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class LEDInterface(Interface):
    """An interface containing the methods required to control an LED."""

    @abstractmethod
    def get_led_state(self, identifier: int) -> bool:
        """Get the state of an LED."""
        raise NotImplementedError  # pragma: no cover

    @abstractmethod
    def set_led_state(self, identifier: int, state: bool) -> None:
        """Set the state of an LED."""
        raise NotImplementedError  # pragma: no cover

Board

A Board is a class that exposes a group of components, used to represent a physical board in a robotics kit.

The Board class should not directly interact with any hardware, instead making calls to the Backend class where necessary, and preferably diverting interaction through the component classes where possible.

Implementation

An interface should sub-class j5.boards.Board.

It will need to implement a number of abstract functions on that class.

Components should be created in the constructor, and should be made available to the user through properties. Care should be taken to ensure that users cannot accidentally override components.

A backend should also be passed to the board in the constructor, usually done in j5.backends.Backend.discover()

A notable method that should be implemented is j5.boards.Board.make_safe(), which should call the appropriate methods on the components to ensure that the board is safe in the event of something going wrong.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
if TYPE_CHECKING:  # pragma: no cover
    from j5.components import Component  # noqa: F401


class MotorBoard(Board):
    """Student Robotics v4 Motor Board."""

    name: str = "Student Robotics v4 Motor Board"

    def __init__(
            self,
            serial: str,
            backend: Backend,
            *,
            safe_state: MotorState = MotorSpecialState.BRAKE,
    ):
        self._serial = serial
        self._backend = backend
        self._safe_state = safe_state

        self._outputs = ImmutableList[Motor](
            Motor(output, cast(MotorInterface, self._backend))
            for output in range(0, 2)
        )

    @property
    def serial_number(self) -> str:
        """Get the serial number."""
        return self._serial

    @property
    def firmware_version(self) -> Optional[str]:
        """Get the firmware version of the board."""
        return self._backend.firmware_version

    @property
    def motors(self) -> ImmutableList[Motor]:
        """Get the motors on this board."""
        return self._outputs

Backend

A backend implements all of the interfaces required to control a board.

A backend also contains a method that can discover boards.

Multiple backends can be implemented for one board, but a backend can only support one board. This could be used for implementing a simulated version of a board, in addition to the hardware implementation.

Backends can also validate is data is suitable for them, and throw an error if not; for example j5.backends.hardware.env.NotSupportedByHardwareError.

Implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class SRV4MotorBoardConsoleBackend(
    MotorInterface,
    Backend,
):
    """The console implementation of the SR v4 motor board."""

    board = MotorBoard

    @classmethod
    def discover(cls) -> Set[Board]:
        """Discover boards that this backend can control."""
        return {cast(Board, MotorBoard("SERIAL", cls("SERIAL")))}

    def __init__(self, serial: str, console_class: Type[Console] = Console) -> None:
        self._serial = serial

        # Initialise our stored values for the state.
        self._state: List[MotorState] = [
            MotorSpecialState.BRAKE
            for _ in range(0, 2)
        ]

        # Setup console helper
        self._console = console_class(f"{self.board.__name__}({self._serial})")

    @property
    def serial(self) -> str:
        """The serial number reported by the board."""
        return self._serial

    @property
    def firmware_version(self) -> Optional[str]:
        """The firmware version reported by the board."""
        return None  # Console, so no firmware

    def get_motor_state(self, identifier: int) -> MotorState:
        """Get the current motor state."""
        # We are unable to read the state from the motor board, in hardware
        # so instead of asking, we'll get the last set value.
        return self._state[identifier]

    def set_motor_state(self, identifier: int, power: MotorState) -> None:
        """Set the state of a motor."""
        if identifier not in range(0, 2):
            raise ValueError(
                f"Invalid motor identifier: {identifier}, valid values are: 0, 1",
            )
        self._state[identifier] = power
        if isinstance(power, MotorSpecialState):
            power_human_name = power.name
        else:
            power_human_name = str(power)
        self._console.info(f"Setting motor {identifier} to {power_human_name}.")

Class Diagram

The below diagram shows a class having instances of another class as an attribute with a dotted line. Solid lines indicate that there is a sub-class relationship

digraph {
     Interface -> LEDInterface
     {LEDInterface, Backend} -> HardwarePowerBoardBackend
     Component -> LED
     Board -> PowerBoard

     HardwarePowerBoardBackend -> {PowerBoard} [style=dotted]
     PowerBoard -> LED [style=dotted]
     Board -> PowerBoard [style=dotted]
     BoardGroup -> PowerBoard [style=dotted]
     LED -> LEDInterface [style=dotted]
     Robot -> {BoardGroup, PowerBoard}  [style=dotted]
}