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