⚠️ Breaking changes in v3.0.0 — motor state reporting and injected-resource ownership are now explicit. Read the CHANGELOG and the v3 Migration Guide before upgrading.
Robot Hat
This is a Python library for controlling hardware peripherals commonly used in robotics. This library provides APIs for controlling motors, servos, ultrasonic sensors, analog-to-digital converters (ADCs), and more, with a focus on extensibility, ease of use, and modern Python practices.
The motivation comes from dissatisfaction with the code quality, safety, and unnecessary sudo requirements found in many mainstream libraries provided by well-known robotics suppliers, such as Sunfounder's Robot-HAT or Freenove's Pidog.
Another reason is to provide a unified way to use different servo and motor controllers without writing custom code (or copying untyped, poorly written examples) for every hardware vendor.
Originally written as a replacement for Sunfounder's Robot-HAT, this library now also supports other peripherals and allows users to register custom drivers.
Unlike the aforementioned libraries:
- This library scales well for both small and large robotics projects. For example, advanced usage is demonstrated in the Picar-X Racer project.
- It offers type safety and portability.
- It avoids requiring sudo calls or introducing unnecessary system dependencies, focusing instead on clean, self-contained operations.
- Plugin-style extensibility.
Table of Contents
- Robot Hat
- Installation
- Usage examples
- 2D lidar scans for SLAM
- IMU samples for localization
- Wheel and steering angle encoders
- Motor control
- GPIO-driven DC motors
- Single GPIO-driven DC motor
- I2C-driven DC motors
- Controlling a servo motor with ServoCalibrationMode
- Shared I2C bus instance
- Combined example with vehicle robot (shared bus instance, servos and motors)
- I2C example
- GPIO Pin
- Ultrasonic sensor for distance measurement
- Reading battery voltage
- Adding custom drivers
- Comparison with Other Libraries
- Hardware-in-the-loop validation
- Development Environment Setup
Installation
Install this package via pip or your preferred package manager:
pip install robot-hat
Usage examples
2D lidar scans for SLAM
Lidar2DABC provides a vendor-neutral stream of typed measurements and complete
360-degree scans. The initial driver supports the RPLIDAR C1 in Standard mode over
either a native UART or a USB-to-UART adapter:
from robot_hat import RPLidarC1, RPLidarC1Config
lidar = RPLidarC1(RPLidarC1Config(port="/dev/serial/by-id/your-lidar"))
with lidar:
print(lidar.get_device_info())
print(lidar.get_health())
lidar.start_scan()
try:
for scan in lidar.iter_scans(min_measurements=100):
# Cartesian, valid-only (x, y) points in metres for a SLAM frontend.
points = scan.xy_points_m
process_scan(points)
finally:
lidar.stop_scan()
The C1 default is 460800 baud. See 2D lidar and UART support for Raspberry Pi 5 wiring, USB-UART discovery, lifecycle details, test doubles, and instructions for implementing another lidar model.
IMU samples for localization
The hardware-neutral IMUABC returns immutable, monotonic samples in SI units.
Each six-axis driver supports configurable full-scale ranges and exposes raw
counts only through a deliberately named diagnostic method. Magnetometers use
the separate MagnetometerABC contract and return teslas.
| Driver | Contract | Example hardware | Default I²C address | Default configuration |
|---|---|---|---|---|
SH3001 |
IMUABC |
SunFounder Robot HAT | 0x36 |
±2 g, ±2000°/s |
LSM6DS33 (gyro and accelerometer) |
IMUABC |
Pololu MiniIMU-9 v5 / AltIMU-10 v5 | 0x6b |
±2 g, ±245°/s, 104 Hz |
LSM9DS1 |
IMUABC |
Raspberry Pi Sense HAT v1/v2 | 0x6a |
±2 g, ±245°/s, 119 Hz |
LIS3MDL (magnetometer) |
MagnetometerABC |
Pololu MiniIMU-9 v5 / AltIMU-10 v5 | 0x1e |
±4 gauss, 10 Hz, ultra-high performance |
LSM9DS1Magnetometer |
MagnetometerABC |
Raspberry Pi Sense HAT v1/v2 | 0x1c |
±4 gauss, 20 Hz, ultra-high performance |
For example, the SH3001 can be sampled as follows:
from robot_hat import SH3001, SH3001Config
imu = SH3001(
config=SH3001Config(
accelerometer_range_g=2,
gyroscope_range_dps=2000,
)
)
try:
imu.initialize()
sample = imu.read_sample()
print(sample.acceleration_mps2)
print(sample.angular_velocity_radps)
finally:
imu.close()
The Pololu MiniIMU-9 v5 combines an LSM6DS33 accelerometer/gyroscope and an independently addressed LIS3MDL magnetometer. They are reusable component drivers rather than a board-specific wrapper, so the same code works with standalone carriers and with the equivalent components on the AltIMU-10 v5:
from robot_hat import I2CBus, LIS3MDL, LSM6DS33, LSM6DS33Config
bus = I2CBus(1)
imu = LSM6DS33(
bus=bus,
address=0x6B,
config=LSM6DS33Config(
accelerometer_range_g=2,
gyroscope_range_dps=245,
output_data_rate_hz=104,
),
)
magnetometer = LIS3MDL(bus=bus, address=0x1E)
try:
imu.initialize()
magnetometer.initialize()
inertial_sample = imu.read_sample()
magnetic_sample = magnetometer.read_sample()
finally:
# Injected buses are caller-owned, so close the sensors before the bus.
magnetometer.close()
imu.close()
bus.close()
The addresses above are the MiniIMU defaults. Driving the board's SA0 pin low
changes the LSM6DS33 address to 0x6a and the LIS3MDL address to 0x1c.
Both drivers are included from robot-hat 3.3.0. To acquire all nine axes,
configure and initialize both: LSM6DS33 supplies the six inertial axes and
LIS3MDL supplies the three magnetic axes. Selecting LSM6DS33 alone does not
initialize or read the magnetometer. Each component returns its own timestamped
sample through its corresponding ABC.
The Raspberry Pi Sense HAT v1 and v2 use an LSM9DS1. Its six-axis
accelerometer/gyroscope function is available without sense-hat or RTIMULib:
from robot_hat import LSM9DS1, LSM9DS1Config
imu = LSM9DS1(
bus=1,
address=0x6A,
config=LSM9DS1Config(
accelerometer_range_g=2,
gyroscope_range_dps=245,
output_data_rate_hz=119,
),
)
try:
imu.initialize()
sample = imu.read_sample()
finally:
imu.close()
The driver reports the LSM9DS1's native axes; configure the measured transform from the HAT to the robot base in the consuming application.
The Sense HAT's independently addressed magnetometer, humidity/temperature sensor, and pressure/temperature sensor are regular drivers too:
from robot_hat import HTS221, LPS25H, LSM9DS1Magnetometer
sensors = [
HTS221(bus=1, address=0x5F),
LPS25H(bus=1, address=0x5C),
LSM9DS1Magnetometer(bus=1, address=0x1C),
]
try:
for sensor in sensors:
sensor.initialize()
print(sensor.read_sample())
finally:
for sensor in sensors:
sensor.close()
Sense HAT v1 uses the LPS25H and v2 uses the register-compatible LPS25HB. The
same LPS25H driver supports both. See
Sense HAT environmental and magnetic sensors for
units, configuration, shared-bus ownership, test doubles, and board-revision
details. Joystick, LED-matrix, and Sense HAT v2 colour/light support remain
separate future component drivers.
See localization sensor contracts for frame, timestamp, encoder, and driver-implementation requirements. See Pololu MiniIMU-9 v5 support for board wiring, addresses, configuration ranges, ownership, and calibration responsibilities.
Wheel and steering angle encoders
EncoderABC represents one signed cumulative encoder. The complete set of
public implementations is:
| Implementation | Module | Intended source |
|---|---|---|
AS5600LEncoder |
robot_hat.sensors.encoder.as5600l_encoder |
AS5600L absolute I²C angle sensor, software-unwrapped into cumulative ticks |
AS5048AEncoder |
robot_hat.sensors.encoder.as5048a_encoder |
AS5048A 14-bit absolute SPI angle sensor, software-unwrapped into cumulative ticks |
QuadratureEncoder |
robot_hat.sensors.encoder.quadrature_encoder |
Incremental A/B sources through an injected counter backend |
MockEncoder |
robot_hat.mock.encoder |
Deterministic hardware-free application development and tests |
AS5600LAngularPosition and AS5048AAngularPosition are the corresponding
single-turn absolute-position APIs; they implement AngularPositionABC, not
EncoderABC. Incremental A/B devices such as AS5304B, AS5306B, TMAG5110,
optical ABI encoders, and integrated motor encoders use QuadratureEncoder.
A low-rate GPIO implementation is available through
GPIOZeroDigitalEdgeInput and GPIOQuadratureCounterBackend; production
high-rate rings should use a hardware counter backend. Each left or right
outdrive gets its own encoder; applications own their fusion and odometry.
The AS5600L encoder and absolute steering-angle sensor default to Raspberry Pi
I²C bus 1 and the sensor's factory address 0x40:
from robot_hat import AS5600LAngularPosition, AS5600LEncoder
rear_wheel = AS5600LEncoder(invert_direction=False)
steering = AS5600LAngularPosition(zero_offset_degrees=180.0)
try:
rear_wheel.initialize()
steering.initialize()
print(rear_wheel.read_sample())
print(rear_wheel.read_health())
print(steering.read_angle())
finally:
rear_wheel.close()
steering.close()
For application development without hardware, MockEncoder exercises the
generic encoder contract. MockAS5048ASPI additionally runs the real AS5048A
driver and wrappers against a parity- and pipeline-aware SPI emulator:
from robot_hat import AS5048AEncoder, MockAS5048ASPI
mock_spi = MockAS5048ASPI(angle_counts=1000)
encoder = AS5048AEncoder(spi=mock_spi)
encoder.initialize()
mock_spi.advance(120)
print(encoder.read_sample())
encoder.close() # injected mock_spi remains caller-owned
Calling setup_env_vars() on a non-Raspberry-Pi host also sets
ROBOT_HAT_MOCK_SPI=1, so a default AS5048A/AS5048AEncoder uses the
AS5048A emulator instead of importing spidev or opening /dev/spidev*.
For an AS5048A SPI/PWM module, read the dedicated
AS5048A wiring and safety guide before connecting its +5V
breakout to 3.3 V Raspberry Pi GPIO. The guide covers electrical verification,
SPI wiring, diagnostics, cumulative unwrapping, software zeroing, and ownership.
For steering, mount the sensor after the servo gear train when possible so it measures backlash and linkage motion. See localization sensor contracts for unwrapping, quadrature x1/x2/x4 semantics, multipole-ring resolution, backend ownership, high-rate GPIO limitations, mocks, and the separately guarded permanent-address programming procedure.
Motor control
Three types of motors are currently supported: GPIO-driven motors, phase motors, and I2C DC motors. All are controlled the same way using MotorService modules.
Each motor exposes speed as the constrained logical caller command and
applied_speed as the direction- and magnitude-calibrated command represented
by its electrical output. Both use the public speed scale; applied_speed is
not a raw PWM register value. See the v3 migration guide
for examples and custom MotorABC requirements.
GPIO-driven DC motors
GPIO motors are motors that are controlled entirely via direct GPIO calls; no I²C address or external PWM driver is needed. Examples include the Waveshare RPi Motor Driver Board with the MC33886 module.
GPIODCMotor holds its optional active-high enable pin low during initialization,
while stopped, and before releasing GPIO resources. It enables the bridge only
after setting the direction outputs. Each GPIO must belong to one device;
an indicator LED must not share a motor's enable or direction pin.
Motor services stop every motor before releasing resources and attempt every stop even if one output fails. Stop and close failures are reported to the caller. A failed movement command triggers a stop attempt for all managed motors, and closed services reject movement commands.
GPIO cleanup releases pin ownership. Software cannot guarantee the electrical state during boot, OS halt, power loss, or a crashed process. The motor driver must have a hardware-defined disabled state when control inputs are undriven, and an independent power cutoff or inhibit appropriate to the mechanism. Validate startup, shutdown, and loss of control with wheels clear of the ground.
from robot_hat import GPIODCMotorConfig, MotorFactory, MotorService, setup_env_vars
setup_env_vars() # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc
left_motor = MotorFactory.create_motor(
config=GPIODCMotorConfig(
calibration_direction=1,
name="left_motor",
max_speed=100,
forward_pin=6,
backward_pin=13,
enable_pin=12,
pwm=True,
)
)
right_motor = MotorFactory.create_motor(
config=GPIODCMotorConfig(
calibration_direction=1,
name="right_motor",
max_speed=100,
forward_pin=20,
backward_pin=21,
pwm=True,
enable_pin=26,
)
)
motor_service = MotorService(left_motor=left_motor, right_motor=right_motor)
speed = 40
motor_service.move(speed, 1)
# increase speed
motor_service.move(motor_service.speed + 10, 1)
# move backward
motor_service.move(speed, -1)
# stop
motor_service.stop_all()
Single GPIO-driven DC motor
Use SingleMotorService when you need the same move(speed, direction), stop_all(), calibration, and cleanup API for one motor instead of a left/right pair.
from robot_hat import (
GPIODCMotorConfig,
MotorFactory,
SingleMotorService,
setup_env_vars,
)
setup_env_vars() # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc
motor = MotorFactory.create_motor(
config=GPIODCMotorConfig(
calibration_direction=1,
name="lift_motor",
max_speed=100,
forward_pin=6,
backward_pin=13,
enable_pin=12,
pwm=True,
)
)
motor_service = SingleMotorService(motor=motor)
# move forward
motor_service.move(40, 1)
# move backward
motor_service.move(40, -1)
# stop
motor_service.stop_all()
I2C-driven DC motors
I2C-driven motors rely on an external PWM driver (e.g., PCA9685, Sunfounder) to control motor speed via I²C.
from robot_hat import (
I2CDCMotorConfig,
MotorFactory,
MotorService,
PWMDriverConfig,
PWMFactory,
)
setup_env_vars() # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc
driver_cfg = PWMDriverConfig(
name="Sunfounder", # 'PCA9685', 'Sunfounder', or a custom driver.
bus=1,
frame_width=20000,
freq=50,
address=0x14,
)
driver = PWMFactory.create_pwm_driver(driver_cfg, bus=1)
motor_service = MotorService(
left_motor=MotorFactory.create_motor(
config=I2CDCMotorConfig(
calibration_direction=1,
name="left_motor",
max_speed=100,
driver=driver_cfg,
channel="P12", # Either an integer or a string with a numeric suffix.
dir_pin="D4", # Digital output pin used to control the motor's direction.
),
driver=driver,
),
right_motor=MotorFactory.create_motor(
config=I2CDCMotorConfig(
calibration_direction=1,
name="right_motor",
max_speed=100,
driver=driver_cfg,
channel="P13", # Either an integer or a string with a numeric suffix.
dir_pin="D5", # Digital output pin used to control the motor's direction.
),
driver=driver,
),
)
Controlling a servo motor with ServoCalibrationMode
The ServoCalibrationMode enum defines how calibration offsets are applied to a servo's angle. It supports two predefined modes and also allows custom calibration functions for advanced use cases.
Available modes
- SUM: Adds a constant offset (
calibration_offset) to the input angle. This is generally used for steering operations, such as controlling the front wheels of a robotic car.
Formula:
calibrated_angle = input_angle + calibration_offset
- NEGATIVE: Applies an inverted adjustment combined with an offset. This mode may be helpful for servos that require an inverted calibration, such as a camera tilt mechanism. Formula:
calibrated_angle = -1 × (input_angle + (-1 × calibration_offset))
Configuring the ServoService
The ServoService provides a high-level abstraction for managing servo operations. It allows easy configuration of the calibration mode, movement bounds, and custom calibration logic if needed.
Servo separates permitted travel (min_angle, max_angle) from pulse
calibration (real_min_angle at min_pulse, real_max_angle at max_pulse).
An allowed angle produces the same pulse when travel limits change. For
example, with measured calibration points of -45° at 1000 µs and +45° at
2000 µs, limits of -10° to +10° produce approximately 1389–1611 µs.
The default calibration assumes -90° to +90°; verify it for the particular
servo. These are two calibration points, not a request to find mechanical
end stops. Pulse rounding, PWM resolution, backlash, and servo behavior limit
physical accuracy. Wheel steering angle also depends on the linkage geometry.
When using ServoService, set its bounds to the desired movement relative
to the calibrated center. Give its underlying Servo the full calibrated
angle range so that the center offset can be applied without a second travel
clamp. GPIOAngularServo follows gpiozero's convention: its min_angle and
max_angle are the measured pulse endpoints; use ServoService for narrower
travel limits. GPIO servos start with pulses disabled until the first angle
command, including the calibrated center command issued by ServoService.
Direct pulse_width_time() calls are calibration operations and bypass travel
limits. Use angle commands for normal movement. Non-finite angle, offset, and
pulse commands are rejected before hardware writes; closed servos reject
further commands.
Here's how to use ServoCalibrationMode in your servo configuration:
Example 1: Steering servo using ServoCalibrationMode.SUM
For steering purposes (e.g., controlling the front wheels of a robotic car):
from robot_hat import (
PWMDriverConfig,
PWMFactory,
Servo,
ServoCalibrationMode,
ServoService,
setup_env_vars,
)
setup_env_vars() # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc
pwm_config = PWMDriverConfig(
name="PCA9685", # 'PCA9685' or 'Sunfounder', or register a custom driver.
address=0x40, # I2C address of the device
bus=1, # The I2C bus number used to communicate with the PWM driver chip
# The parameters below are optional and have default values:
frame_width=20000,
freq=50,
)
driver = PWMFactory.create_pwm_driver(
bus=pwm_config.bus, # either a bus number or an smbus instance.
config=pwm_config,
)
driver.set_pwm_freq(pwm_config.freq)
steering_servo = ServoService(
servo=Servo(
driver=driver,
channel="P1", # Either an integer or a string with a numeric suffix.
# The parameters below are optional and have default values:
# The minimum and maximum logical angles (in degrees) that can be commanded to the servo.
min_angle=-90.0,
max_angle=90.0,
# The minimum and maximum pulse widths (in microseconds) corresponding to the servo's physical movement.
min_pulse=500,
max_pulse=2500,
# Measured angles at min_pulse and max_pulse, respectively.
real_min_angle=-90.0,
real_max_angle=90.0,
),
name="steering", # A human-readable name for the servo (useful for debugging/logging).
min_angle=-90,
max_angle=90,
calibration_mode=ServoCalibrationMode.SUM,
calibration_offset=-14.4,
)
steering_servo.set_angle(-30) # Turn left.
steering_servo.set_angle(15) # Turn slightly to the right.
steering_servo.reset() # Reset to the center position.
# Calibration
print(steering_servo.calibration_offset) # -14.4
steering_servo.update_calibration(
-10.2
) # temporarly update calibration until reset_calibration is called
print(steering_servo.calibration_offset) # -10.2
steering_servo.reset_calibration()
print(steering_servo.calibration_offset) # -14.4
steering_servo.update_calibration(-1.5, persist=True)
print(steering_servo.calibration_offset) # -1.5
steering_servo.reset_calibration() # resets to persisted value
print(steering_servo.calibration_offset) # -1.5
steering_servo.close() # Close and clean up the servo.
Example 2: Head servos using ServoCalibrationMode.NEGATIVE
For tilting a camera head (e.g., up-and-down movement):
cam_tilt_servo = ServoService(
name="tilt",
servo=Servo(
driver=driver,
channel="P1", # Either an integer or a string with a numeric suffix.
),
min_angle=-35, # Maximum downward tilt
max_angle=65, # Maximum upward tilt
calibration_mode=ServoCalibrationMode.NEGATIVE, # Inverted adjustment
calibration_offset=1.4, # Adjust alignment for neutral center
)
driver.set_pwm_freq(pwm_config.freq)
cam_tilt_servo.set_angle(-20) # Tilt down
cam_tilt_servo.set_angle(25) # Tilt up
cam_tilt_servo.reset() # Center position
Example 3: Custom calibration mode
If the predefined modes (SUM or NEGATIVE) don’t meet your requirements, you can provide a custom calibration function. The function should accept angle and calibration_offset as inputs and return the calibrated angle.
def custom_calibration_function(angle: float, offset: float) -> float:
"""Scale angle by 2 and add offset to fine-tune servo position."""
return (angle * 2) + offset
cam_tilt_servo = ServoService(
name="tilt",
servo=Servo(
driver=driver,
channel="P1", # Either an integer or a string with a numeric suffix.
),
min_angle=-35, # Maximum downward tilt
max_angle=65, # Maximum upward tilt
calibration_mode=custom_calibration_function,
calibration_offset=1.4, # Adjust alignment for neutral center
)
cam_tilt_servo.set_angle(10) # Custom logic will process the input angle
Shared I2C bus instance
Share I2C buses via SMBusManager where possible to avoid device contention and duplicated resources. SMBusManager.get_bus(n) returns the same bus instance for the same bus number, so multiple callers will get a single shared object:
from robot_hat import SMBusManager
bus0 = SMBusManager.get_bus(0)
bus1 = SMBusManager.get_bus(1)
bus0_again = SMBusManager.get_bus(0)
print(bus0 is bus0_again) # True
You can explicitly close a bus or all buses when your program is shutting down:
SMBusManager.close_bus(0) # close bus 0
SMBusManager.close_all() # close all managed buses
Most classes that accept a bus parameter will accept either a bus number or a bus instance. Prefer passing the shared bus instance to ensure all devices use the same underlying SMBus:
from robot_hat import SMBusManager, PWMFactory, I2C
shared_bus = SMBusManager.get_bus(1)
pwm_driver = PWMFactory.create_pwm_driver(
bus=shared_bus,
config=pwm_config,
)
i2c_device = I2C(address=[0x15, 0x17], bus=shared_bus)
Note: only one underlying bus instance is created per bus number (in the example above, bus 1 is created once and reused).
PWM drivers can also be shared by several motors or servos. Resources passed
into I2CDCMotor or Servo are caller-owned by default. Factory-created motor
drivers are owned by the motor, while a driver explicitly passed to
MotorFactory.create_motor(...) is treated as shared and is not closed by that
motor. The owner must close a shared driver after every consumer has stopped:
shared_driver = PWMFactory.create_pwm_driver(driver_config, bus=shared_bus)
left = MotorFactory.create_motor(left_config, driver=shared_driver)
right = MotorFactory.create_motor(right_config, driver=shared_driver)
try:
MotorService(left, right).move(40, 1)
finally:
left.close() # stops only the left channel
right.close() # stops only the right channel
shared_driver.close()
Combined example with vehicle robot (shared bus instance, servos and motors)
This example shows how to share a single I²C/SMBus instance across multiple drivers and devices (servos, PWM controllers, sensors, etc.) in a robot application.
Instead of letting each driver open its own SMBus, the example uses SMBusManager to create or reuse a single I2CBus object and pass it into PWM/motor/ADC drivers. Sharing the bus avoids duplicate opens, file-descriptor leaks, and inconsistent behavior when multiple parts of your program talk to devices on the same physical I²C bus.
Show example
import logging
from typing import Callable, Dict, Optional, Union
from robot_hat import (
GPIOAngularServo,
GPIODCMotorConfig,
MotorABC,
MotorConfigType,
MotorFactory,
MotorService,
MotorServiceDirection,
PWMDriverConfig,
PWMFactory,
Servo,
ServoCalibrationMode,
ServoService,
SMBusManager,
)
_log = logging.getLogger(__name__)
class MyRobotCar:
def __init__(
self,
pwm_config: Optional[PWMDriverConfig] = None,
pan_servo_channel: Union[int, str] = "P0",
tilt_servo_channel: Union[int, str] = "P1",
steering_servo_channel: Union[int, str] = "P2",
left_motor_config: Optional[MotorConfigType] = None,
right_motor_config: Optional[MotorConfigType] = None,
) -> None:
self.smbus_manager = SMBusManager()
self.left_motor: Optional[MotorABC] = None
self.right_motor: Optional[MotorABC] = None
self.cam_pan_servo: Optional[ServoService] = None
self.cam_tilt_servo: Optional[ServoService] = None
self.steering_servo: Optional[ServoService] = None
self.setup(
pwm_config=pwm_config,
pan_servo_channel=pan_servo_channel,
tilt_servo_channel=tilt_servo_channel,
steering_servo_channel=steering_servo_channel,
left_motor_config=left_motor_config,
right_motor_config=right_motor_config,
)
def setup(
self,
pwm_config: Optional[PWMDriverConfig],
pan_servo_channel: Union[int, str],
tilt_servo_channel: Union[int, str],
steering_servo_channel: Union[int, str],
left_motor_config: Optional[MotorConfigType],
right_motor_config: Optional[MotorConfigType],
):
self._setup_servo(
pwm_config=pwm_config,
pan_servo_channel=pan_servo_channel,
tilt_servo_channel=tilt_servo_channel,
steering_servo_channel=steering_servo_channel,
)
self._setup_motors(
left_motor_config=left_motor_config, right_motor_config=right_motor_config
)
def _setup_servo(
self,
pwm_config: Optional[PWMDriverConfig],
pan_servo_channel: Union[int, str],
tilt_servo_channel: Union[int, str],
steering_servo_channel: Union[int, str],
) -> None:
self.cam_pan_servo = self._make_servo(
name="cam_pan", pwm_config=pwm_config, channel=pan_servo_channel
)
self.cam_tilt_servo = self._make_servo(
name="cam_tilt", pwm_config=pwm_config, channel=tilt_servo_channel
)
self.steering_servo = self._make_servo(
name="steering", pwm_config=pwm_config, channel=steering_servo_channel
)
def _setup_motors(
self,
left_motor_config: Optional[MotorConfigType],
right_motor_config: Optional[MotorConfigType],
) -> None:
if left_motor_config and right_motor_config:
self.left_motor = MotorFactory.create_motor(config=left_motor_config)
self.right_motor = MotorFactory.create_motor(config=right_motor_config)
self.motor_controller = MotorService(
left_motor=self.left_motor, right_motor=self.right_motor
)
def _make_servo(
self,
channel: Union[int, str],
name: str,
min_angle=-90,
max_angle=90,
calibration_offset=0.0,
reverse: bool = False,
pwm_config: Optional[PWMDriverConfig] = None,
calibration_mode: Optional[
Union[ServoCalibrationMode, Callable[[float, float], float]]
] = ServoCalibrationMode.SUM,
) -> ServoService:
if pwm_config is not None:
driver = PWMFactory.create_pwm_driver(
bus=self.smbus_manager.get_bus(pwm_config.bus),
config=pwm_config,
)
servo = Servo(
channel=channel,
driver=driver,
)
driver.set_pwm_freq(pwm_config.freq)
else:
servo = GPIOAngularServo(
pin=channel,
)
return ServoService(
servo=servo,
calibration_offset=calibration_offset,
min_angle=min_angle,
max_angle=max_angle,
calibration_mode=calibration_mode,
name=name,
reverse=reverse,
)
def move(self, speed: int, direction: MotorServiceDirection) -> None:
"""
Move the robot forward or backward.
Args:
- speed: The base speed at which to move.
- direction: 1 for forward, -1 for backward, 0 for stop.
"""
self.motor_controller.move(speed, direction)
@property
def state(self) -> Dict[str, float]:
"""
Returns key metrics of the current state as a dictionary.
"""
return {
"speed": self.motor_controller.speed if self.motor_controller else 0,
"direction": (
self.motor_controller.direction if self.motor_controller else 0
),
"steering_servo_angle": (
self.steering_servo.current_angle if self.steering_servo else 0
),
"cam_pan_angle": (
self.cam_pan_servo.current_angle if self.cam_pan_servo else 0
),
"cam_tilt_angle": (
self.cam_tilt_servo.current_angle if self.cam_tilt_servo else 0
),
}
def stop(self) -> None:
"""
Stop the motors.
"""
return self.motor_controller.stop_all()
def cleanup(self):
"""
Clean up hardware resources by stopping all motors and gracefully closing all
associated I2C connections for both motors and servos.
"""
if self.motor_controller:
try:
self.stop()
self.motor_controller.close()
except (TimeoutError, OSError) as e:
err_msg = str(e)
_log.error(err_msg)
except Exception as e:
_log.error(
"Unexpected error while closing motor controller %s",
e,
exc_info=True,
)
else:
for motor in [self.left_motor, self.right_motor]:
if motor:
try:
motor.close()
except Exception as e:
_log.error("Error closing motor %s", e)
self.right_motor = None
self.left_motor = None
for servo_service in [
self.steering_servo,
self.cam_tilt_servo,
self.cam_pan_servo,
]:
if servo_service:
try:
servo_service.close()
except (TimeoutError, OSError) as e:
err_msg = str(e)
_log.error(err_msg)
except Exception as e:
_log.error("Error closing servo %s", e)
if __name__ == "__main__":
from robot_hat.utils import setup_env_vars
setup_env_vars()
robot_car = MyRobotCar(
left_motor_config=GPIODCMotorConfig(
calibration_direction=1,
name="left_motor",
max_speed=100,
forward_pin=6,
backward_pin=13,
enable_pin=12,
pwm=True,
),
right_motor_config=GPIODCMotorConfig(
calibration_direction=1,
name="right_motor",
max_speed=100,
forward_pin=20,
backward_pin=21,
enable_pin=26,
pwm=True,
),
pwm_config=PWMDriverConfig(
name="PCA9685",
address=0x40,
bus=1,
),
)
robot_car.move(50, 1)
robot_car.stop()
robot_car.cleanup()
I2C example
Scan and communicate with connected I2C devices.
from robot_hat import I2C
# Initialize I2C connection
i2c_device = I2C(address=[0x15, 0x17], bus=1)
# Write a byte to the device
i2c_device.write(0x01)
# Read data from the device
data = i2c_device.read(5)
print("I2C Data Read:", data)
# Scan for connected devices
devices = i2c_device.scan()
print("I2C Devices Detected:", devices)
Constructing I2C with one known address does not probe it, so initialization
does not send an unexpected command. Passing an address list or calling
scan() necessarily performs bus transactions. The generic fallback reads one
byte and never performs the old dummy 0x00 write, but no generic I²C probe is
safe for every device: reads can consume status/FIFO data or advance device
state. Prefer a documented identity register when the device has one:
def sh3001_probe(bus, address: int) -> bool:
return bus.read_byte_data(address, 0x0F) == 0x61
imu_i2c = I2C(address=[0x36, 0x37], bus=1, probe=sh3001_probe)
Only unreserved seven-bit addresses (0x08 through 0x77) are accepted or
scanned. Avoid generic scans on a live bus when you do not know every attached
device's protocol.
GPIO Pin
The Pin class wraps gpiozero Input/Output devices and accepts either a GPIO pin number (int) or a string name.
When a string is passed, it must be either:
- a name recognized by gpiozero's pin factory (for example "GPIO17", "BCM17", physical/BOARD names such as "BOARD11", header notation such as "J8:11", or wiringPi names such as "WPI0")
- a name from a custom mapping. By default, Sunfounder's labels ("D0", "D1", ...) are used.
You can also provide your custom mapping via the pin_dict parameter. pin_dict must be a dict that maps string names to GPIO numbers.
Basic example
from robot_hat import Pin, setup_env_vars
setup_env_vars() # optional: set GPIOZERO_PIN_FACTORY, etc
# Create a pin using GPIO number
led = Pin(17, mode=Pin.OUT)
# Turn on / off
led.on()
led.off()
# Alias methods
led.high()
led.low()
# Set value using call/operator syntax
led(1) # set high (returns 1)
led(0) # set low (returns 0)
# Read back (this will switch the pin to input mode internally)
value = led.value()
print("Pin value:", value)
# Clean up when done
led.close()
Initialize from a named mapping or gpiozero names
from robot_hat import Pin
# Using the Sunfounder's mapping (e.g., "D0" -> GPIO17)
pin_d0 = Pin("D0", mode=Pin.OUT)
pin_d0.on()
# Using gpiozero-style names (resolved by the pin factory)
pin_by_name = Pin("GPIO27") # or "BCM27", "BOARD13", "J8:13"
print(pin_by_name.name()) # -> e.g., "GPIO27"
Input with internal pull-up/pull-down, and read value
from robot_hat import Pin
# Configure as input with internal pull-up resistor
button = Pin("GPIO27", mode=Pin.IN, pull=Pin.PULL_UP)
# Read current state (returns 0 or 1)
state = button.value()
print("Button pressed?", bool(state))
# The library will create an InputDevice under the hood
button.close()
Interrupts (irq) with debounce and pull configuration
from robot_hat import Pin
import time
def on_pressed():
print("Pressed!")
def on_released():
print("Released!")
sw = Pin("D1") # mapping or gpiozero name
# Attach interrupt on both rising and falling edges, 200 ms debounce, enable pull-up
sw.irq(
handler=on_pressed, trigger=Pin.IRQ_RISING_FALLING, bouncetime=200, pull=Pin.PULL_UP
)
# Keep running to allow callbacks to run
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
sw.close()
Custom named mapping
from robot_hat import Pin
# Provide your own name -> gpio mapping
my_mapping = {"MOTOR_EN": 12, "MOTOR_DIR": 20}
motor_en = Pin("MOTOR_EN", mode=Pin.OUT, pin_dict=my_mapping)
motor_en.on()
motor_en.close()
Ultrasonic sensor for distance measurement
Measure distance using the HC-SR04 ultrasonic sensor module.
from robot_hat import Pin, Ultrasonic
# Initialize Ultrasonic Sensor
trig_pin = Pin("GPIO27") # or integer or other pin mapping
echo_pin = Pin(17) # or string or other pin mapping
ultrasonic = Ultrasonic(trig_pin, echo_pin)
# Measure distance
distance_cm = ultrasonic.read(times=5)
print(f"Distance: {distance_cm} cm")
Reading battery voltage
Currently, such battery drivers are supported: INA219, INA226 and the built-in driver in Sunfounder's Robot Hat.
INA219
Simple example with INA219 (tested on Waveshare UPS Module 3S)
from robot_hat import INA219Battery
battery = INA219Battery(address=0x41, bus=1)
More about custom INA219 configuration
The INA219 requires a calibration that depends on your shunt resistor and the maximum current you expect to measure. The library exposes INA219Config and a helper constructor INA219Config.from_shunt(shunt_res_ohms, max_expected_current_a, ...), which:
- selects a sensible PGA gain for the expected shunt voltage,
- computes the Current_LSB and CAL register value,
- derives the Power_LSB (per datasheet: 20 × Current_LSB),
- allows tuning ADC averaging/resolution and the device operating mode.
Key points / units
shunt_res_ohms: Ohms of the external shunt resistor (must be > 0).max_expected_current_a: maximum expected current in amperes (> 0).current_lsbreturned in the config is in mA per bit (the dataclass stores it in mA units).calibration_valueis the 16-bit calibration register written to the device.power_lsbis in W per bit.from_shunt()will raiseValueErrorif the expected shunt voltage exceeds the INA219's 320 mV limit.
Show example with custom INA219 config
Example: configure INA219 for a 0.01 Ω shunt and up to 5 A expected current
from robot_hat import INA219Battery
from robot_hat.data_types.config.ina219 import (
ADCResolution,
BusVoltageRange,
INA219Config,
Mode,
)
# Build a configuration from your shunt resistor and expected max current.
# Here: R_shunt = 0.01 Ω, I_max = 5 A => V_shunt_max = 0.05 V (50 mV), fits within INA219 ranges.
custom_cfg = INA219Config.from_shunt(
shunt_res_ohms=0.01, # 10 milliohm shunt
max_expected_current_a=5.0, # up to 5 A
bus_voltage_range=BusVoltageRange.RANGE_32V, # defaults to 32V range (optional)
bus_adc_resolution=ADCResolution.ADCRES_12BIT_128S, # high averaging for noise suppression
shunt_adc_resolution=ADCResolution.ADCRES_12BIT_128S, # same for shunt ADC
mode=Mode.SHUNT_AND_BUS_CONTINUOUS, # continuous shunt + bus measurement
nice_current_lsb_step_mA=0.1, # round Current_LSB up to 0.1 mA/bit steps (optional)
)
# Inspect derived values (helpful for diagnostics)
print("Derived INA219 config:", custom_cfg)
# current_lsb is stored in mA/bit in the dataclass:
print("Current LSB (mA/bit):", custom_cfg.current_lsb)
print("Calibration register value:", custom_cfg.calibration_value)
print("Power LSB (W/bit):", custom_cfg.power_lsb)
# Create Battery instance with custom configuration
battery = INA219Battery(address=0x41, bus=1, config=custom_cfg)
# Read values
bus_v = battery.get_bus_voltage_v() # bus voltage (V)
shunt_mv = battery.get_shunt_voltage_mv() # shunt voltage (mV)
battery_v = battery.get_battery_voltage() # bus + shunt (V)
current_ma = battery.get_current_ma() # current (mA)
power_w = battery.get_power_w() # power (W)
print(f"Bus: {bus_v:.3f} V, Shunt: {shunt_mv:.3f} mV")
print(f"Battery (bus + shunt): {battery_v:.3f} V")
print(f"Current: {current_ma:.3f} mA, Power: {power_w:.3f} W")
# If you need to change calibration / averaging at runtime:
new_cfg = INA219Config.from_shunt(
shunt_res_ohms=0.01,
max_expected_current_a=3.0, # lower I_max -> different calibration
bus_adc_resolution=ADCResolution.ADCRES_12BIT_32S,
shunt_adc_resolution=ADCResolution.ADCRES_12BIT_32S,
)
battery.update_config(new_cfg) # writes new CAL and CONFIG registers
# Close when finished (closes bus if driver opened it)
battery.close()
INA226
The INA226 driver in this library exposes a config dataclass (INA226Config) and a driver (INA226). A small Battery helper (robot_hat.services.battery.ina226_battery.Battery) wraps the driver to provide a battery-focused API (get_battery_voltage, close).
Simple example
from robot_hat.services.battery.ina226_battery import Battery as INA226Battery
from robot_hat.data_types.config.ina226 import INA226Config
# Build a config derived from your shunt resistor and max expected current.
# shunt_ohms must be > 0. max_expected_amps is optional (if omitted the code uses a
# device-limited derivation).
cfg = INA226Config.from_shunt(
shunt_ohms=0.002, # 2 milliohm shunt
max_expected_amps=50.0, # expected up to 50 A (example)
)
# Create Battery helper (driver opens SMBus if you pass bus number)
battery = INA226Battery(bus=1, address=0x40, config=cfg)
bus_v = battery.get_bus_voltage_v() # bus voltage in volts (V)
shunt_mv = battery.get_shunt_voltage_mv() # shunt voltage in millivolts (mV)
battery_v = battery.get_battery_voltage() # bus + shunt in volts (V)
pack_current_a = battery.get_battery_current() # convenience helper in amps (A)
current_ma = battery.get_current_ma() # current in milliamps (mA)
power_mw = battery.get_power_mw() # power in milliwatts (mW)
print(f"Bus: {bus_v:.3f} V, Shunt: {shunt_mv:.3f} mV")
print(f"Battery (bus + shunt): {battery_v:.3f} V")
print(f"Pack current: {pack_current_a:.2f} A")
print(f"Current: {current_ma:.3f} mA, Power: {power_mw:.3f} mW")
metrics = battery.get_battery_metrics()
print(f"Combined metrics helper: {metrics.voltage:.2f} V / {metrics.current:.2f} A")
battery.close()
INA260
The INA260 variant integrates a fixed 2 mΩ shunt, so calibration values are part of the config defaults. The battery helper (robot_hat.services.battery.ina260_battery.Battery) combines bus voltage with the shunt drop to give the pack voltage, just like the INA219/INA226 helpers.
Simple example
from robot_hat.services.battery.ina260_battery import Battery as INA260Battery
from robot_hat.data_types.config.ina260 import (
AveragingCount,
ConversionTime,
INA260Config,
Mode,
)
config = INA260Config(
averaging_count=AveragingCount.COUNT_16,
voltage_conversion_time=ConversionTime.TIME_1_1_MS,
current_conversion_time=ConversionTime.TIME_1_1_MS,
mode=Mode.CONTINUOUS,
)
battery = INA260Battery(bus=1, address=0x40, config=config)
bus_v = battery.get_bus_voltage_v()
shunt_mv = battery.get_shunt_voltage_mv()
battery_v = battery.get_battery_voltage()
pack_current_a = battery.get_battery_current()
current_ma = battery.get_current_ma()
power_mw = battery.get_power_mw()
print(f"Bus: {bus_v:.3f} V, Shunt: {shunt_mv:.3f} mV")
print(f"Battery: {battery_v:.2f} V, Current: {current_ma / 1000:.3f} A")
print(f"Pack current via helper: {pack_current_a:.2f} A")
print(f"Power: {power_mw / 1000:.3f} W")
metrics = battery.get_battery_metrics()
print(f"Combined metrics helper: {metrics.voltage:.2f} V / {metrics.current:.2f} A")
battery.close()
Sunfounder module
from robot_hat import SunfounderBattery
battery = SunfounderBattery(
channel="A4",
address=[0x14, 0x15],
bus=1,
# Optional: add a second ADC channel wired across a shunt to read current.
current_channel="A3",
sense_resistance_ohms=0.01,
)
voltage = battery.get_battery_voltage()
print(f"Battery Voltage: {voltage} V")
current = battery.get_battery_current()
print(f"Battery Current: {current} A")
metrics = battery.get_battery_metrics()
print(f"Combined metrics helper: {metrics.voltage} V / {metrics.current} A")
If you omit `current_channel` and `sense_resistance_ohms`,
`get_battery_current()` raises `NotImplementedError` to signal that the
legacy Sunfounder hardware only exposes voltage out of the box.
Battery Factory
When you need to choose a battery helper dynamically, use the unified factory and
config dataclasses. Each helper has a matching config in
robot_hat.data_types.config.battery.
from robot_hat import BatteryFactory, INA260BatteryConfig
battery = BatteryFactory.create_battery(INA260BatteryConfig(bus=1, address=0x40))
print(battery.get_battery_voltage())
The factory supports INA219, INA226, INA260, and the legacy Sunfounder helper via
SunfounderBatteryConfig.
Adding custom drivers
This library uses a plugin-style registry for PWM drivers so you can add support for new hardware without changing core code.
The base class manages the I2C/SMBus instance when the constructor receives either an int (bus number) or a bus object. Implement the PWMDriverABC interface, give your driver a meaningful name that matches the config, and register it with @register_pwm_driver.
Minimal example
import logging
from typing import Optional
from robot_hat import BusType, PWMDriverABC, register_pwm_driver
_log = logging.getLogger(__name__)
@register_pwm_driver
class MyDriver(PWMDriverABC):
DRIVER_TYPE = "MyDriver" # Must match PWMDriverConfig.name
def __init__(
self,
address: int,
bus: BusType = 1,
frame_width: Optional[int] = 20000,
**kwargs,
) -> None:
# Let the base class resolve or wrap the bus parameter
super().__init__(bus=bus, address=address)
self._frame_width = frame_width if frame_width is not None else 20000
_log.debug("Initialized MyDriver at 0x%02X on bus %s", address, bus)
def set_pwm_freq(self, freq: int) -> None:
# implement frequency setup for your chip
_log.debug("MyDriver.set_pwm_freq(%d)", freq)
def set_servo_pulse(self, channel: int, pulse: int) -> None:
# convert pulse (µs) to whatever units your driver needs and write
_log.debug("MyDriver.set_servo_pulse(channel=%d, pulse=%d)", channel, pulse)
def set_pwm_duty_cycle(self, channel: int, duty: int) -> None:
if not (0 <= duty <= 100):
raise ValueError("Duty must be between 0 and 100")
_log.debug("MyDriver.set_pwm_duty_cycle(channel=%d, duty=%d)", channel, duty)
if __name__ == "__main__":
from robot_hat import PWMDriverConfig, PWMFactory, setup_env_vars
setup_env_vars()
pwm_config = PWMDriverConfig(
name=MyDriver.DRIVER_TYPE,
address=0x40, # I2C address of the device
bus=1,
)
my_driver = PWMFactory.create_pwm_driver(config=pwm_config)
print(my_driver.DRIVER_TYPE)
Use it from config
from robot_hat import PWMFactory, PWMDriverConfig
from .my_driver import MyDriver # ensure your module is imported
pwm_config = PWMDriverConfig(
name=MyDriver.DRIVER_TYPE,
address=0x40,
bus=1,
)
driver = PWMFactory.create_pwm_driver(config=pwm_config)
driver.set_pwm_freq(50)
How to make your driver discoverable
Put the driver in your project and import it before calling PWMFactory.create_pwm_driver. The decorator registers it in the global registry.
Also, contributions are welcome but you don't have to upstream your driver - you can register and use custom drivers anywhere in your own code.
Comparison with Other Libraries
No sudo
For reasons that remain a mystery (and a source of endless frustration), the providers of many popular DRY robotics libraries insist on requiring sudo for the most basic operations. For example:
User = os.popen("echo ${SUDO_USER:-$LOGNAME}").readline().strip()
UserHome = os.popen("getent passwd %s | cut -d: -f 6" % User).readline().strip()
config_file = "%s/.config/robot-hat/robot-hat.conf" % UserHome
And later, they modify file permissions with commands like:
os.popen("sudo chmod %s %s" % (mode, file_path)) # 🤦
os.popen("sudo chown -R %s:%s %s" % (owner, owner, some_path))
This library removes all such archaic and potentially unsafe patterns by leveraging user-friendly Python APIs like pathlib. File-related operations are scoped to user-accessible directories (e.g., ~/.config) rather than requiring administrative access
via sudo.
Type Hints
This version prioritizes:
- Type hints for better developer experience.
- Modular, maintainable, and well-documented code.
Mock Support for Testing
Sunfounder (and similar libraries) offer no direct way to mock their hardware APIs, making it nearly impossible to write meaningful unit tests on non-Raspberry Pi platforms.
This library can be configured either by environment values, either by function setup_env_vars, which will setup them automatically:
from robot_hat.utils import setup_env_vars
setup_env_vars()
Sensor applications can also select the explicit MockEncoder, MockIMU,
MockLidar2D, and MockAngularPosition implementations. Unlike environment
fallbacks, explicit mocks work on a Raspberry Pi too and therefore support
repeatable integration tests without touching attached hardware.
Or:
import os
os.environ["GPIOZERO_PIN_FACTORY"] = (
"mock" # mock for non-raspberry pi, lgpio for Raspberry Pi 5 and rpigpio for other Raspberry versions
)
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1"
Hardware-in-the-loop validation
Mock-backed tests cannot prove real wiring polarity, shaft direction, stop
behavior, or bus electrical compatibility. The operator-assisted validation
runner and the current evidence status are documented in
docs/hardware_validation.md. No passing physical
HIL report is currently committed; the repository does not claim otherwise.
Development Environment Setup
Prerequisites
- Python 3.10 or newer must be installed.
- Ensure you have
pipinstalled (a recent version is recommended):python3 -m pip install --upgrade pip
Steps to Set Up
-
Clone the Repository:
git clone https://github.com/KarimAziev/robot-hat.git cd robot-hat
-
Set Up a Virtual Environment:
python3 -m venv .venv source .venv/bin/activate # Linux/Mac # OR .venv\Scripts\activate # Windows
-
Upgrade Core Tools:
pip install --upgrade pip setuptools wheel
-
Install in Development Mode:
pip install -e ".[dev]" # Installs all dev dependencies (e.g., ruff, pre-commit)
Distribution
To create distributable artifacts (e.g., .tar.gz and .whl files):
-
Install the build tool:
pip install build
-
Build the project:
python -m build
The built files will be located in the
dist/directory:
- Source distribution:
robot_hat-x.y.z.tar.gz - Wheel:
robot_hat-x.y.z-py3-none-any.whl
These can be installed locally for testing or uploaded to PyPI for distribution.
Common Commands
- Clean Build Artifacts:
rm -rf build dist *.egg-info
- Deactivate Virtual Environment:
deactivate
Notes
- The library uses
setuptools_scmfor versioning, which dynamically determines the version based on Git tags. Use proper semantic versioning (e.g.,v1.0.0) in your repository for best results. - Development tools like
ruff(formatter, import organizer, and linter) are automatically installed with[dev]dependencies.
Metadata
Release files for robot-hat 3.3.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| robot_hat-3.3.3.tar.gz | 246.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| robot_hat-3.3.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 512.9 kB
Release files / robot_hat-3.3.3.tar.gz
| Download URL | robot_hat-3.3.3.tar.gz |
|---|---|
| Size | 246.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3bd4cee6e209b822800ba1aab2080e3692e18f9ca7fae72330c23e1510f22a21
|
|
BLAKE2b-256 checksum How to use checksums |
34dca7228541405b37ae64c34ef3e30fa76b80fb190796a7a803eeff120aa21e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / robot_hat-3.3.3-py3-none-any.whl
| Download URL | robot_hat-3.3.3-py3-none-any.whl |
|---|---|
| Size | 266.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
41098384f4156e2dccb6924f8a6a3fbb525d0895f2a2aa12c315763679b276b7
|
|
BLAKE2b-256 checksum How to use checksums |
8d2d48a4cf3bf427b48b446dbc440fa69500dad033dd33aef40d5d73a82bf0f4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log