{"id":23007075,"url":"https://github.com/karimaziev/robot-hat","last_synced_at":"2026-03-04T15:01:33.707Z","repository":{"id":268127947,"uuid":"903372932","full_name":"KarimAziev/robot-hat","owner":"KarimAziev","description":"A Python library for controlling hardware peripherals commonly used in robotics.","archived":false,"fork":false,"pushed_at":"2025-11-30T11:23:50.000Z","size":548,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-02-01T23:56:33.882Z","etag":null,"topics":["i2c","python","raspberry-pi","robotics"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/KarimAziev.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-12-14T12:46:26.000Z","updated_at":"2025-11-30T11:23:51.000Z","dependencies_parsed_at":null,"dependency_job_id":"ad05fa9c-d16b-4a4b-85e5-d4698aa12348","html_url":"https://github.com/KarimAziev/robot-hat","commit_stats":null,"previous_names":["karimaziev/robot-hat"],"tags_count":47,"template":false,"template_full_name":null,"purl":"pkg:github/KarimAziev/robot-hat","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KarimAziev%2Frobot-hat","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KarimAziev%2Frobot-hat/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KarimAziev%2Frobot-hat/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KarimAziev%2Frobot-hat/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/KarimAziev","download_url":"https://codeload.github.com/KarimAziev/robot-hat/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KarimAziev%2Frobot-hat/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30084685,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-04T13:22:36.021Z","status":"ssl_error","status_checked_at":"2026-03-04T13:20:45.750Z","response_time":59,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["i2c","python","raspberry-pi","robotics"],"created_at":"2024-12-15T08:14:24.995Z","updated_at":"2026-03-04T15:01:33.683Z","avatar_url":"https://github.com/KarimAziev.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"[![PyPI](https://img.shields.io/pypi/v/robot-hat)](https://pypi.org/project/robot-hat/)\n[![codecov](https://codecov.io/gh/KarimAziev/robot-hat/graph/badge.svg?token=2C863KHRLU)](https://codecov.io/gh/KarimAziev/robot-hat)\n\n\u003e ⚠️ Breaking changes in v2.0.0 - This release contains incompatible API changes. Read the [CHANGELOG](https://github.com/KarimAziev/robot-hat/blob/main/CHANGELOG.md) and the [Migration Guide](https://github.com/KarimAziev/robot-hat/blob/v2.0.0/docs/migration_guide_v2.md) before upgrading.\n\n# Robot Hat\n\nThis 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.\n\nThe 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](https://github.com/sunfounder/robot-hat/tree/v2.0) or [Freenove's Pidog](https://github.com/Freenove/Freenove_Robot_Dog_Kit_for_Raspberry_Pi).\n\nAnother 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.\n\nOriginally written as a replacement for Sunfounder's Robot-HAT, this library now also supports other peripherals and allows users to register custom drivers.\n\nUnlike the aforementioned libraries:\n\n- This library scales well for **both small and large robotics projects**. For example, advanced usage is demonstrated in the [Picar-X Racer](https://github.com/KarimAziev/picar-x-racer) project.\n- It offers type safety and portability.\n- It avoids requiring **sudo calls** or introducing unnecessary system dependencies, focusing instead on clean, self-contained operations.\n- Plugin-style extensibility.\n\n\u003c!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc --\u003e\n\n**Table of Contents**\n\n\u003e - [Robot Hat](#robot-hat)\n\u003e   - [Installation](#installation)\n\u003e   - [Usage examples](#usage-examples)\n\u003e     - [Motor control](#motor-control)\n\u003e     - [GPIO-driven DC motors](#gpio-driven-dc-motors)\n\u003e     - [I2C-driven DC motors](#i2c-driven-dc-motors)\n\u003e     - [Controlling a servo motor with ServoCalibrationMode](#controlling-a-servo-motor-with-servocalibrationmode)\n\u003e     - [Shared I2C bus instance](#shared-i2c-bus-instance)\n\u003e     - [Combined example with vehicle robot (shared bus instance, servos and motors)](#combined-example-with-vehicle-robot-shared-bus-instance-servos-and-motors)\n\u003e     - [I2C example](#i2c-example)\n\u003e     - [GPIO Pin](#gpio-pin)\n\u003e     - [Ultrasonic sensor for distance measurement](#ultrasonic-sensor-for-distance-measurement)\n\u003e     - [Reading battery voltage](#reading-battery-voltage)\n\u003e       - [INA219](#ina219)\n\u003e       - [INA226](#ina226)\n\u003e       - [INA260](#ina260)\n\u003e       - [Sunfounder module](#sunfounder-module)\n\u003e       - [Battery Factory](#battery-factory)\n\u003e   - [Adding custom drivers](#adding-custom-drivers)\n\u003e     - [How to make your driver discoverable](#how-to-make-your-driver-discoverable)\n\u003e   - [Comparison with Other Libraries](#comparison-with-other-libraries)\n\u003e     - [No sudo](#no-sudo)\n\u003e     - [Type Hints](#type-hints)\n\u003e     - [Mock Support for Testing](#mock-support-for-testing)\n\u003e   - [Development Environment Setup](#development-environment-setup)\n\u003e     - [Prerequisites](#prerequisites)\n\u003e     - [Steps to Set Up](#steps-to-set-up)\n\u003e   - [Distribution](#distribution)\n\u003e   - [Common Commands](#common-commands)\n\u003e   - [Notes \u0026 Recommendations](#notes--recommendations)\n\n\u003c!-- markdown-toc end --\u003e\n\n## Installation\n\nInstall this package via `pip` or your preferred package manager:\n\n```bash\npip install robot-hat\n```\n\n## Usage examples\n\n### Motor control\n\nThree types of motors are currently supported: GPIO-driven motors, phase motors, and I2C DC motors. All are controlled the same way using `MotorService` modules.\n\n### GPIO-driven DC motors\n\nGPIO 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.\n\n```python\nfrom robot_hat import GPIODCMotorConfig, MotorFactory, MotorService, setup_env_vars\n\nsetup_env_vars() # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc\n\nleft_motor = MotorFactory.create_motor(\n    config=GPIODCMotorConfig(\n        calibration_direction=1,\n        name=\"left_motor\",\n        max_speed=100,\n        forward_pin=6,\n        backward_pin=13,\n        enable_pin=12,\n        pwm=True,\n    )\n)\nright_motor = MotorFactory.create_motor(\n    config=GPIODCMotorConfig(\n        calibration_direction=1,\n        name=\"right_motor\",\n        max_speed=100,\n        forward_pin=20,\n        backward_pin=21,\n        pwm=True,\n        enable_pin=26,\n    )\n)\n\nspeed = 40\nmotor_service.move(speed, 1)\n# increase speed\nmotor_service.move(motor_service.speed + 10, 1)\n\n# move backward\nmotor_service.move(speed, -1)\n\n# stop\nmotor_service.stop_all()\n\n```\n\n### I2C-driven DC motors\n\nI2C-driven motors rely on an external PWM driver (e.g., PCA9685, Sunfounder) to control motor speed via I²C.\n\n```python\nfrom robot_hat import (\n    I2CDCMotorConfig,\n    MotorFactory,\n    MotorService,\n    PWMDriverConfig,\n    PWMFactory,\n)\n\nsetup_env_vars() # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc\n\ndriver_cfg = PWMDriverConfig(\n    name=\"Sunfounder\",  # 'PCA9685', 'Sunfounder', or a custom driver.\n    bus=1,\n    frame_width=20000,\n    freq=50,\n    address=0x14,\n)\ndriver = PWMFactory.create_pwm_driver(driver_cfg, bus=1)\n\nmotor_service = MotorService(\n    left_motor=MotorFactory.create_motor(\n        config=I2CDCMotorConfig(\n            calibration_direction=1,\n            name=\"left_motor\",\n            max_speed=100,\n            driver=driver_cfg,\n            channel=\"P12\",  # Either an integer or a string with a numeric suffix.\n            dir_pin=\"D4\",  # Digital output pin used to control the motor's direction.\n        ),\n        driver=driver,\n    ),\n    right_motor=MotorFactory.create_motor(\n        config=I2CDCMotorConfig(\n            calibration_direction=1,\n            name=\"right_motor\",\n            max_speed=100,\n            driver=driver_cfg,\n            channel=\"P13\",  # Either an integer or a string with a numeric suffix.\n            dir_pin=\"D5\",  # Digital output pin used to control the motor's direction.\n        ),\n        driver=driver,\n    ),\n)\n```\n\n### Controlling a servo motor with ServoCalibrationMode\n\nThe `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.\n\nAvailable modes\n\n- **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.\n\nFormula:\n\n```\ncalibrated_angle = input_angle + calibration_offset\n```\n\n- **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.\n  Formula:\n\n```python-console\ncalibrated_angle = -1 × (input_angle + (-1 × calibration_offset))\n```\n\n**Configuring the `ServoService`**\n\nThe `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.\n\nHere's how to use `ServoCalibrationMode` in your servo configuration:\n\n**Example 1**: Steering servo using `ServoCalibrationMode.SUM`\n\nFor steering purposes (e.g., controlling the front wheels of a robotic car):\n\n```python\nfrom robot_hat import (\n    PWMDriverConfig,\n    PWMFactory,\n    Servo,\n    ServoCalibrationMode,\n    ServoService,\n    setup_env_vars,\n)\n\nsetup_env_vars()  # autosetup environment, e.g.: GPIOZERO_PIN_FACTORY, ROBOT_HAT_MOCK_SMBUS etc\n\n\npwm_config = PWMDriverConfig(\n    name=\"PCA9685\",  # 'PCA9685' or 'Sunfounder', or register a custom driver.\n    address=0x40,  # I2C address of the device\n    bus=1,  # The I2C bus number used to communicate with the PWM driver chip\n    # The parameters below are optional and have default values:\n    frame_width=20000,\n    freq=50,\n)\ndriver = PWMFactory.create_pwm_driver(\n    bus=pwm_config.bus,  # either a bus number or an smbus instance.\n    config=pwm_config,\n)\n\nsteering_servo = ServoService(\n    servo=Servo(\n        driver=driver,\n        channel=\"P1\",  # Either an integer or a string with a numeric suffix.\n        # The parameters below are optional and have default values:\n        # The minimum and maximum logical angles (in degrees) that can be commanded to the servo.\n        min_angle=-90.0,\n        max_angle=90.0,\n        # The minimum and maximum pulse widths (in microseconds) corresponding to the servo's physical movement.\n        min_pulse=500,\n        max_pulse=2500,\n        # The minimum and maximum physical angles (in degrees) that the servo can achieve.\n        # These values are used to map the logical angle to the physical angle.\n        real_min_angle=-90.0,\n        real_max_angle=90.0,\n    ),\n    name=\"steering\",  # A human-readable name for the servo (useful for debugging/logging).\n    min_angle=-90,\n    max_angle=90,\n    calibration_mode=ServoCalibrationMode.SUM,\n    calibration_offset=-14.4,\n)\ndriver.set_pwm_freq(pwm_config.freq)\n\nsteering_servo.set_angle(-30)  # Turn left.\nsteering_servo.set_angle(15)  # Turn slightly to the right.\nsteering_servo.reset()  # Reset to the center position.\n\n# Calibration\nprint(steering_servo.calibration_offset)  # -14.4\nsteering_servo.update_calibration(\n    -10.2\n)  # temporarly update calibration until reset_calibration is called\nprint(steering_servo.calibration_offset)  # -10.2\nsteering_servo.reset_calibration()\nprint(steering_servo.calibration_offset)  # -14.4\nsteering_servo.update_calibration(-1.5, persist=True)\nprint(steering_servo.calibration_offset)  # -1.5\nsteering_servo.reset_calibration()  # resets to persisted value\nprint(steering_servo.calibration_offset)  # -1.5\n\nsteering_servo.close()  # Close and clean up the servo.\n\n```\n\n**Example 2**: Head servos using `ServoCalibrationMode.NEGATIVE`\n\nFor tilting a camera head (e.g., up-and-down movement):\n\n```python\ncam_tilt_servo = ServoService(\n    name=\"tilt\",\n    servo=Servo(\n        driver=driver,\n        channel=\"P1\",  # Either an integer or a string with a numeric suffix.\n    ),\n    min_angle=-35,  # Maximum downward tilt\n    max_angle=65,  # Maximum upward tilt\n    calibration_mode=ServoCalibrationMode.NEGATIVE,  # Inverted adjustment\n    calibration_offset=1.4,  # Adjust alignment for neutral center\n)\n\ndriver.set_pwm_freq(pwm_config.freq)\n\ncam_tilt_servo.set_angle(-20)  # Tilt down\ncam_tilt_servo.set_angle(25)  # Tilt up\ncam_tilt_servo.reset()  # Center position\n```\n\n**Example 3**: Custom calibration mode\n\nIf 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.\n\n```python\ndef custom_calibration_function(angle: float, offset: float) -\u003e float:\n    \"\"\"Scale angle by 2 and add offset to fine-tune servo position.\"\"\"\n    return (angle * 2) + offset\n\n\ncam_tilt_servo = ServoService(\n    name=\"tilt\",\n    servo=Servo(\n        driver=driver,\n        channel=\"P1\",  # Either an integer or a string with a numeric suffix.\n    ),\n    min_angle=-35,  # Maximum downward tilt\n    max_angle=65,  # Maximum upward tilt\n    calibration_mode=custom_calibration_function,\n    calibration_offset=1.4,  # Adjust alignment for neutral center\n)\n\ncam_tilt_servo.set_angle(10)  # Custom logic will process the input angle\n```\n\n### Shared I2C bus instance\n\nShare 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:\n\n```python\nfrom robot_hat import SMBusManager\n\nbus0 = SMBusManager.get_bus(0)\nbus1 = SMBusManager.get_bus(1)\nbus0_again = SMBusManager.get_bus(0)\n\nprint(bus0 is bus0_again)  # True\n```\n\nYou can explicitly close a bus or all buses when your program is shutting down:\n\n```python\nSMBusManager.close_bus(0)   # close bus 0\nSMBusManager.close_all()    # close all managed buses\n```\n\nMost 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:\n\n```python\nfrom robot_hat import SMBusManager, PWMFactory, I2C\n\nshared_bus = SMBusManager.get_bus(1)\n\npwm_driver = PWMFactory.create_pwm_driver(\n    bus=shared_bus,\n    config=pwm_config,\n)\n\ni2c_device = I2C(address=[0x15, 0x17], bus=shared_bus)\n```\n\nNote: only one underlying bus instance is created per bus number (in the example above, bus 1 is created once and reused).\n\n\u003e [!IMPORTANT]\n\u003e Don't call `SMBusManager.close_bus(...)` while other components still expect the bus to be open. Before calling `SMBusManager.close_bus(...)` or `SMBusManager.close_all()`, make sure all device objects are stopped/closed or otherwise no longer accessing the bus.\n\n### Combined example with vehicle robot (shared bus instance, servos and motors)\n\nThis 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.\n\nInstead 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.\n\n\u003cdetails\u003e\u003csummary\u003eShow example\u003c/summary\u003e\n\u003cp\u003e\n\n```python\nimport logging\nfrom typing import Callable, Dict, Optional, Union\n\nfrom robot_hat import (\n    GPIOAngularServo,\n    GPIODCMotorConfig,\n    MotorABC,\n    MotorConfigType,\n    MotorFactory,\n    MotorService,\n    MotorServiceDirection,\n    PWMDriverConfig,\n    PWMFactory,\n    Servo,\n    ServoCalibrationMode,\n    ServoService,\n    SMBusManager,\n)\n\n_log = logging.getLogger(__name__)\n\n\nclass MyRobotCar:\n    def __init__(\n        self,\n        pwm_config: Optional[PWMDriverConfig] = None,\n        pan_servo_channel: Union[int, str] = \"P0\",\n        tilt_servo_channel: Union[int, str] = \"P1\",\n        steering_servo_channel: Union[int, str] = \"P2\",\n        left_motor_config: Optional[MotorConfigType] = None,\n        right_motor_config: Optional[MotorConfigType] = None,\n    ) -\u003e None:\n\n        self.smbus_manager = SMBusManager()\n\n        self.left_motor: Optional[MotorABC] = None\n        self.right_motor: Optional[MotorABC] = None\n        self.cam_pan_servo: Optional[ServoService] = None\n        self.cam_tilt_servo: Optional[ServoService] = None\n        self.steering_servo: Optional[ServoService] = None\n\n        self.setup(\n            pwm_config=pwm_config,\n            pan_servo_channel=pan_servo_channel,\n            tilt_servo_channel=tilt_servo_channel,\n            steering_servo_channel=steering_servo_channel,\n            left_motor_config=left_motor_config,\n            right_motor_config=right_motor_config,\n        )\n\n    def setup(\n        self,\n        pwm_config: Optional[PWMDriverConfig],\n        pan_servo_channel: Union[int, str],\n        tilt_servo_channel: Union[int, str],\n        steering_servo_channel: Union[int, str],\n        left_motor_config: Optional[MotorConfigType],\n        right_motor_config: Optional[MotorConfigType],\n    ):\n        self._setup_servo(\n            pwm_config=pwm_config,\n            pan_servo_channel=pan_servo_channel,\n            tilt_servo_channel=tilt_servo_channel,\n            steering_servo_channel=steering_servo_channel,\n        )\n\n        self._setup_motors(\n            left_motor_config=left_motor_config, right_motor_config=right_motor_config\n        )\n\n    def _setup_servo(\n        self,\n        pwm_config: Optional[PWMDriverConfig],\n        pan_servo_channel: Union[int, str],\n        tilt_servo_channel: Union[int, str],\n        steering_servo_channel: Union[int, str],\n    ) -\u003e None:\n        self.cam_pan_servo = self._make_servo(\n            name=\"cam_pan\", pwm_config=pwm_config, channel=pan_servo_channel\n        )\n        self.cam_tilt_servo = self._make_servo(\n            name=\"cam_tilt\", pwm_config=pwm_config, channel=tilt_servo_channel\n        )\n        self.steering_servo = self._make_servo(\n            name=\"steering\", pwm_config=pwm_config, channel=steering_servo_channel\n        )\n\n    def _setup_motors(\n        self,\n        left_motor_config: Optional[MotorConfigType],\n        right_motor_config: Optional[MotorConfigType],\n    ) -\u003e None:\n        if left_motor_config and right_motor_config:\n            self.left_motor = MotorFactory.create_motor(config=left_motor_config)\n            self.right_motor = MotorFactory.create_motor(config=right_motor_config)\n            self.motor_controller = MotorService(\n                left_motor=self.left_motor, right_motor=self.right_motor\n            )\n\n    def _make_servo(\n        self,\n        channel: Union[int, str],\n        name: str,\n        min_angle=-90,\n        max_angle=90,\n        calibration_offset=0.0,\n        reverse: bool = False,\n        pwm_config: Optional[PWMDriverConfig] = None,\n        calibration_mode: Optional[\n            Union[ServoCalibrationMode, Callable[[float, float], float]]\n        ] = ServoCalibrationMode.SUM,\n    ) -\u003e ServoService:\n        if pwm_config is not None:\n            driver = PWMFactory.create_pwm_driver(\n                bus=self.smbus_manager.get_bus(pwm_config.bus),\n                config=pwm_config,\n            )\n\n            servo = Servo(\n                channel=channel,\n                driver=driver,\n            )\n            driver.set_pwm_freq(pwm_config.freq)\n\n        else:\n            servo = GPIOAngularServo(\n                pin=channel,\n                min_angle=min_angle,\n                max_angle=max_angle,\n            )\n        return ServoService(\n            servo=servo,\n            calibration_offset=calibration_offset,\n            min_angle=min_angle,\n            max_angle=max_angle,\n            calibration_mode=calibration_mode,\n            name=name,\n            reverse=reverse,\n        )\n\n    def move(self, speed: int, direction: MotorServiceDirection) -\u003e None:\n        \"\"\"\n        Move the robot forward or backward.\n\n        Args:\n        - speed: The base speed at which to move.\n        - direction: 1 for forward, -1 for backward, 0 for stop.\n        \"\"\"\n        self.motor_controller.move(speed, direction)\n\n    @property\n    def state(self) -\u003e Dict[str, float]:\n        \"\"\"\n        Returns key metrics of the current state as a dictionary.\n        \"\"\"\n        return {\n            \"speed\": self.motor_controller.speed if self.motor_controller else 0,\n            \"direction\": (\n                self.motor_controller.direction if self.motor_controller else 0\n            ),\n            \"steering_servo_angle\": (\n                self.steering_servo.current_angle if self.steering_servo else 0\n            ),\n            \"cam_pan_angle\": (\n                self.cam_pan_servo.current_angle if self.cam_pan_servo else 0\n            ),\n            \"cam_tilt_angle\": (\n                self.cam_tilt_servo.current_angle if self.cam_tilt_servo else 0\n            ),\n        }\n\n    def stop(self) -\u003e None:\n        \"\"\"\n        Stop the motors.\n        \"\"\"\n        return self.motor_controller.stop_all()\n\n    def cleanup(self):\n        \"\"\"\n        Clean up hardware resources by stopping all motors and gracefully closing all\n        associated I2C connections for both motors and servos.\n        \"\"\"\n\n        if self.motor_controller:\n            try:\n                self.stop()\n                self.motor_controller.close()\n            except (TimeoutError, OSError) as e:\n                err_msg = str(e)\n                _log.error(err_msg)\n            except Exception as e:\n                _log.error(\n                    \"Unexpected error while closing motor controller %s\",\n                    e,\n                    exc_info=True,\n                )\n        else:\n            for motor in [self.left_motor, self.right_motor]:\n                if motor:\n                    try:\n                        motor.close()\n                    except Exception as e:\n                        _log.error(\"Error closing motor %s\", e)\n\n        self.right_motor = None\n        self.left_motor = None\n\n        for servo_service in [\n            self.steering_servo,\n            self.cam_tilt_servo,\n            self.cam_pan_servo,\n        ]:\n            if servo_service:\n                try:\n                    servo_service.close()\n                except (TimeoutError, OSError) as e:\n                    err_msg = str(e)\n                    _log.error(err_msg)\n                except Exception as e:\n                    _log.error(\"Error closing servo %s\", e)\n\n\nif __name__ == \"__main__\":\n    from robot_hat.utils import setup_env_vars\n\n    setup_env_vars()\n    robot_car = MyRobotCar(\n        left_motor_config=GPIODCMotorConfig(\n            calibration_direction=1,\n            name=\"left_motor\",\n            max_speed=100,\n            forward_pin=6,\n            backward_pin=13,\n            enable_pin=12,\n            pwm=True,\n        ),\n        right_motor_config=GPIODCMotorConfig(\n            calibration_direction=1,\n            name=\"right_motor\",\n            max_speed=100,\n            forward_pin=20,\n            backward_pin=21,\n            enable_pin=26,\n            pwm=True,\n        ),\n        pwm_config=PWMDriverConfig(\n            name=\"PCA9685\",\n            address=0x40,\n            bus=1,\n        ),\n    )\n    robot_car.move(50, 1)\n    robot_car.stop()\n    robot_car.cleanup()\n\n```\n\n\u003c/p\u003e\n\u003c/details\u003e\n\n### I2C example\n\nScan and communicate with connected I2C devices.\n\n```python\nfrom robot_hat import I2C\n\n# Initialize I2C connection\ni2c_device = I2C(address=[0x15, 0x17], bus=1)\n\n# Write a byte to the device\ni2c_device.write(0x01)\n\n# Read data from the device\ndata = i2c_device.read(5)\nprint(\"I2C Data Read:\", data)\n\n# Scan for connected devices\ndevices = i2c_device.scan()\nprint(\"I2C Devices Detected:\", devices)\n\n```\n\n### GPIO Pin\n\nThe `Pin` class wraps gpiozero Input/Output devices and accepts either a GPIO pin number (int) or a string name.\n\nWhen a string is passed, it must be either:\n\n- 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\")\n- a name from a custom mapping. By default, Sunfounder's labels (\"D0\", \"D1\", ...) are used.\n\nYou can also provide your custom mapping via the `pin_dict` parameter. `pin_dict` must be a dict that maps string names to GPIO numbers.\n\n\u003e [!TIP]\n\u003e For testing on non-Raspberry Pi hosts, set the gpiozero pin factory to \"mock\" (e.g. via setup_env_vars() or by setting environment variable `GPIOZERO_PIN_FACTORY=mock`) so the examples can run without real hardware.\n\n**Basic example**\n\n```python\nfrom robot_hat import Pin, setup_env_vars\n\nsetup_env_vars()  # optional: set GPIOZERO_PIN_FACTORY, etc\n\n# Create a pin using GPIO number\nled = Pin(17, mode=Pin.OUT)\n\n# Turn on / off\nled.on()\nled.off()\n\n# Alias methods\nled.high()\nled.low()\n\n# Set value using call/operator syntax\nled(1)   # set high (returns 1)\nled(0)   # set low  (returns 0)\n\n# Read back (this will switch the pin to input mode internally)\nvalue = led.value()\nprint(\"Pin value:\", value)\n\n# Clean up when done\nled.close()\n```\n\n\u003e [!NOTE]\n\u003e If you call `value()` with no argument, and the current mode is None or OUT, Pin will switch to IN mode before reading.\n\u003e Calling value(0) or value(1) will ensure the pin is in OUT mode before setting it.\n\n**Initialize from a named mapping or gpiozero names**\n\n```python\nfrom robot_hat import Pin\n\n# Using the Sunfounder's mapping (e.g., \"D0\" -\u003e GPIO17)\npin_d0 = Pin(\"D0\", mode=Pin.OUT)\npin_d0.on()\n\n# Using gpiozero-style names (resolved by the pin factory)\npin_by_name = Pin(\"GPIO27\")     # or \"BCM27\", \"BOARD13\", \"J8:13\"\nprint(pin_by_name.name())       # -\u003e e.g., \"GPIO27\"\n```\n\n**Input with internal pull-up/pull-down, and read value**\n\n```python\nfrom robot_hat import Pin\n\n# Configure as input with internal pull-up resistor\nbutton = Pin(\"GPIO27\", mode=Pin.IN, pull=Pin.PULL_UP)\n\n# Read current state (returns 0 or 1)\nstate = button.value()\nprint(\"Button pressed?\" , bool(state))\n\n# The library will create an InputDevice under the hood\nbutton.close()\n```\n\n**Interrupts (irq) with debounce and pull configuration**\n\n```python\nfrom robot_hat import Pin\nimport time\n\ndef on_pressed():\n    print(\"Pressed!\")\n\ndef on_released():\n    print(\"Released!\")\n\nsw = Pin(\"D1\")  # mapping or gpiozero name\n# Attach interrupt on both rising and falling edges, 200 ms debounce, enable pull-up\nsw.irq(handler=on_pressed, trigger=Pin.IRQ_RISING_FALLING, bouncetime=200, pull=Pin.PULL_UP)\n\n# Keep running to allow callbacks to run\ntry:\n    while True:\n        time.sleep(1)\nexcept KeyboardInterrupt:\n    pass\nfinally:\n    sw.close()\n```\n\n**Custom named mapping**\n\n```python\nfrom robot_hat import Pin\n\n# Provide your own name -\u003e gpio mapping\nmy_mapping = {\"MOTOR_EN\": 12, \"MOTOR_DIR\": 20}\nmotor_en = Pin(\"MOTOR_EN\", mode=Pin.OUT, pin_dict=my_mapping)\nmotor_en.on()\nmotor_en.close()\n```\n\n### Ultrasonic sensor for distance measurement\n\nMeasure distance using the `HC-SR04` ultrasonic sensor module.\n\n```python\nfrom robot_hat import Pin, Ultrasonic\n\n# Initialize Ultrasonic Sensor\ntrig_pin = Pin(\"GPIO27\")  # or integer or other pin mapping\necho_pin = Pin(17)  # or string or other pin mapping\nultrasonic = Ultrasonic(trig_pin, echo_pin)\n\n# Measure distance\ndistance_cm = ultrasonic.read(times=5)\nprint(f\"Distance: {distance_cm} cm\")\n\n```\n\n### Reading battery voltage\n\nCurrently, such battery drivers are supported: **INA219**, **INA226** and the built-in driver in Sunfounder's Robot Hat.\n\n#### INA219\n\nSimple example with **INA219** (tested on Waveshare UPS Module 3S)\n\n```python\nfrom robot_hat import INA219Battery\n\nbattery = INA219Battery(address=0x41, bus=1)\n```\n\n**More about custom INA219 configuration**\n\nThe 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:\n\n- selects a sensible PGA gain for the expected shunt voltage,\n- computes the Current_LSB and CAL register value,\n- derives the Power_LSB (per datasheet: 20 × Current_LSB),\n- allows tuning ADC averaging/resolution and the device operating mode.\n\nKey points / units\n\n- `shunt_res_ohms`: Ohms of the external shunt resistor (must be \u003e 0).\n- `max_expected_current_a`: maximum expected current in amperes (\u003e 0).\n- `current_lsb` returned in the config is in mA per bit (the dataclass stores it in mA units).\n- `calibration_value` is the 16-bit calibration register written to the device.\n- `power_lsb` is in W per bit.\n- `from_shunt()` will raise `ValueError` if the expected shunt voltage exceeds the INA219's 320 mV limit.\n\n\u003cdetails\u003e\u003csummary\u003eShow example with custom INA219 config\u003c/summary\u003e\n\n**Example**: configure INA219 for a 0.01 Ω shunt and up to 5 A expected current\n\n\u003cp\u003e\n\n```python\nfrom robot_hat import INA219Battery\nfrom robot_hat.data_types.config.ina219 import (\n    ADCResolution,\n    BusVoltageRange,\n    INA219Config,\n    Mode,\n)\n # Build a configuration from your shunt resistor and expected max current.\n # Here: R_shunt = 0.01 Ω, I_max = 5 A =\u003e V_shunt_max = 0.05 V (50 mV), fits within INA219 ranges.\n\ncustom_cfg = INA219Config.from_shunt(\n    shunt_res_ohms=0.01,  # 10 milliohm shunt\n    max_expected_current_a=5.0,  # up to 5 A\n    bus_voltage_range=BusVoltageRange.RANGE_32V,  # defaults to 32V range (optional)\n    bus_adc_resolution=ADCResolution.ADCRES_12BIT_128S,  # high averaging for noise suppression\n    shunt_adc_resolution=ADCResolution.ADCRES_12BIT_128S,  # same for shunt ADC\n    mode=Mode.SHUNT_AND_BUS_CONTINUOUS,  # continuous shunt + bus measurement\n    nice_current_lsb_step_mA=0.1,  # round Current_LSB up to 0.1 mA/bit steps (optional)\n)\n\n# Inspect derived values (helpful for diagnostics)\nprint(\"Derived INA219 config:\", custom_cfg)\n# current_lsb is stored in mA/bit in the dataclass:\nprint(\"Current LSB (mA/bit):\", custom_cfg.current_lsb)\nprint(\"Calibration register value:\", custom_cfg.calibration_value)\nprint(\"Power LSB (W/bit):\", custom_cfg.power_lsb)\n\n# Create Battery instance with custom configuration\nbattery = INA219Battery(address=0x41, bus=1, config=custom_cfg)\n\n# Read values\nbus_v = battery.get_bus_voltage_v()  # bus voltage (V)\nshunt_mv = battery.get_shunt_voltage_mv()  # shunt voltage (mV)\nbattery_v = battery.get_battery_voltage()  # bus + shunt (V)\ncurrent_ma = battery.get_current_ma()  # current (mA)\npower_w = battery.get_power_w()  # power (W)\n\nprint(f\"Bus: {bus_v:.3f} V, Shunt: {shunt_mv:.3f} mV\")\nprint(f\"Battery (bus + shunt): {battery_v:.3f} V\")\nprint(f\"Current: {current_ma:.3f} mA, Power: {power_w:.3f} W\")\n\n# If you need to change calibration / averaging at runtime:\nnew_cfg = INA219Config.from_shunt(\n    shunt_res_ohms=0.01,\n    max_expected_current_a=3.0,  # lower I_max -\u003e different calibration\n    bus_adc_resolution=ADCResolution.ADCRES_12BIT_32S,\n    shunt_adc_resolution=ADCResolution.ADCRES_12BIT_32S,\n)\nbattery.update_config(new_cfg)  # writes new CAL and CONFIG registers\n\n# Close when finished (closes bus if driver opened it)\nbattery.close()\n\n```\n\n\u003c/p\u003e\n\u003c/details\u003e\n\n#### INA226\n\nThe 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).\n\n**Simple example**\n\n```python\nfrom robot_hat.services.battery.ina226_battery import Battery as INA226Battery\nfrom robot_hat.data_types.config.ina226 import INA226Config\n\n# Build a config derived from your shunt resistor and max expected current.\n# shunt_ohms must be \u003e 0. max_expected_amps is optional (if omitted the code uses a\n# device-limited derivation).\ncfg = INA226Config.from_shunt(\n    shunt_ohms=0.002,           # 2 milliohm shunt\n    max_expected_amps=50.0      # expected up to 50 A (example)\n)\n\n# Create Battery helper (driver opens SMBus if you pass bus number)\nbattery = INA226Battery(bus=1, address=0x40, config=cfg)\n\n\nbus_v = battery.get_bus_voltage_v()      # bus voltage in volts (V)\nshunt_mv = battery.get_shunt_voltage_mv()# shunt voltage in millivolts (mV)\nbattery_v = battery.get_battery_voltage()# bus + shunt in volts (V)\npack_current_a = battery.get_battery_current()  # convenience helper in amps (A)\ncurrent_ma = battery.get_current_ma()    # current in milliamps (mA)\npower_mw = battery.get_power_mw()        # power in milliwatts (mW)\n\nprint(f\"Bus: {bus_v:.3f} V, Shunt: {shunt_mv:.3f} mV\")\nprint(f\"Battery (bus + shunt): {battery_v:.3f} V\")\nprint(f\"Pack current: {pack_current_a:.2f} A\")\nprint(f\"Current: {current_ma:.3f} mA, Power: {power_mw:.3f} mW\")\n\nmetrics = battery.get_battery_metrics()\nprint(\n    f\"Combined metrics helper: {metrics.voltage:.2f} V / {metrics.current:.2f} A\"\n)\n\nbattery.close()\n```\n\n#### INA260\n\nThe 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.\n\n**Simple example**\n\n```python\nfrom robot_hat.services.battery.ina260_battery import Battery as INA260Battery\nfrom robot_hat.data_types.config.ina260 import (\n    AveragingCount,\n    ConversionTime,\n    INA260Config,\n    Mode,\n)\n\nconfig = INA260Config(\n    averaging_count=AveragingCount.COUNT_16,\n    voltage_conversion_time=ConversionTime.TIME_1_1_MS,\n    current_conversion_time=ConversionTime.TIME_1_1_MS,\n    mode=Mode.CONTINUOUS,\n)\n\nbattery = INA260Battery(bus=1, address=0x40, config=config)\n\nbus_v = battery.get_bus_voltage_v()\nshunt_mv = battery.get_shunt_voltage_mv()\nbattery_v = battery.get_battery_voltage()\npack_current_a = battery.get_battery_current()\ncurrent_ma = battery.get_current_ma()\npower_mw = battery.get_power_mw()\n\nprint(f\"Bus: {bus_v:.3f} V, Shunt: {shunt_mv:.3f} mV\")\nprint(f\"Battery: {battery_v:.2f} V, Current: {current_ma/1000:.3f} A\")\nprint(f\"Pack current via helper: {pack_current_a:.2f} A\")\nprint(f\"Power: {power_mw/1000:.3f} W\")\n\nmetrics = battery.get_battery_metrics()\nprint(\n    f\"Combined metrics helper: {metrics.voltage:.2f} V / {metrics.current:.2f} A\"\n)\n\nbattery.close()\n```\n\n#### Sunfounder module\n\n```python\nfrom robot_hat import SunfounderBattery\n\nbattery = SunfounderBattery(\n    channel=\"A4\",\n    address=[0x14, 0x15],\n    bus=1,\n    # Optional: add a second ADC channel wired across a shunt to read current.\n    current_channel=\"A3\",\n    sense_resistance_ohms=0.01,\n)\n\nvoltage = battery.get_battery_voltage()\nprint(f\"Battery Voltage: {voltage} V\")\n\ncurrent = battery.get_battery_current()\nprint(f\"Battery Current: {current} A\")\n\nmetrics = battery.get_battery_metrics()\nprint(f\"Combined metrics helper: {metrics.voltage} V / {metrics.current} A\")\n\nIf you omit `current_channel` and `sense_resistance_ohms`,\n`get_battery_current()` raises `NotImplementedError` to signal that the\nlegacy Sunfounder hardware only exposes voltage out of the box.\n```\n\n#### Battery Factory\n\nWhen you need to choose a battery helper dynamically, use the unified factory and\nconfig dataclasses. Each helper has a matching config in\n`robot_hat.data_types.config.battery`.\n\n```python\nfrom robot_hat import BatteryFactory, INA260BatteryConfig\n\nbattery = BatteryFactory.create_battery(\n    INA260BatteryConfig(bus=1, address=0x40)\n)\n\nprint(battery.get_battery_voltage())\n```\n\nThe factory supports INA219, INA226, INA260, and the legacy Sunfounder helper via\n`SunfounderBatteryConfig`.\n\n## Adding custom drivers\n\nThis library uses a plugin-style registry for PWM drivers so you can add support for new hardware without changing core code.\n\nThe 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`.\n\n**Minimal example**\n\n```python\nimport logging\nfrom typing import Optional\n\nfrom robot_hat import BusType, PWMDriverABC, register_pwm_driver\n\n_log = logging.getLogger(__name__)\n\n\n@register_pwm_driver\nclass MyDriver(PWMDriverABC):\n    DRIVER_TYPE = \"MyDriver\"  # Must match PWMDriverConfig.name\n\n    def __init__(\n        self,\n        address: int,\n        bus: BusType = 1,\n        frame_width: Optional[int] = 20000,\n        **kwargs\n    ) -\u003e None:\n        # Let the base class resolve or wrap the bus parameter\n        super().__init__(bus=bus, address=address)\n        self._frame_width = frame_width if frame_width is not None else 20000\n        _log.debug(\"Initialized MyDriver at 0x%02X on bus %s\", address, bus)\n\n    def set_pwm_freq(self, freq: int) -\u003e None:\n        # implement frequency setup for your chip\n        _log.debug(\"MyDriver.set_pwm_freq(%d)\", freq)\n\n    def set_servo_pulse(self, channel: int, pulse: int) -\u003e None:\n        # convert pulse (µs) to whatever units your driver needs and write\n        _log.debug(\"MyDriver.set_servo_pulse(channel=%d, pulse=%d)\", channel, pulse)\n\n    def set_pwm_duty_cycle(self, channel: int, duty: int) -\u003e None:\n        if not (0 \u003c= duty \u003c= 100):\n            raise ValueError(\"Duty must be between 0 and 100\")\n        _log.debug(\"MyDriver.set_pwm_duty_cycle(channel=%d, duty=%d)\", channel, duty)\n\n\nif __name__ == \"__main__\":\n    from robot_hat import PWMDriverConfig, PWMFactory, setup_env_vars\n\n    setup_env_vars()\n    pwm_config = PWMDriverConfig(\n        name=MyDriver.DRIVER_TYPE,\n        address=0x40,  # I2C address of the device\n        bus=1,\n    )\n\n    my_driver = PWMFactory.create_pwm_driver(config=pwm_config)\n    print(my_driver.DRIVER_TYPE)\n\n```\n\nUse it from config\n\n```python\nfrom robot_hat import PWMFactory, PWMDriverConfig\nfrom .my_driver import MyDriver  # ensure your module is imported\n\npwm_config = PWMDriverConfig(\n    name=MyDriver.DRIVER_TYPE,\n    address=0x40,\n    bus=1,\n)\n\ndriver = PWMFactory.create_pwm_driver(config=pwm_config)\ndriver.set_pwm_freq(50)\n```\n\n### How to make your driver discoverable\n\nPut the driver in your project and import it before calling `PWMFactory.create_pwm_driver`. The decorator registers it in the global registry.\n\nAlso, contributions are welcome but you don't have to upstream your driver - you can register and use custom drivers anywhere in your own code.\n\n## Comparison with Other Libraries\n\n### No sudo\n\nFor 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:\n\n```python\nUser = os.popen('echo ${SUDO_USER:-$LOGNAME}').readline().strip()\nUserHome = os.popen('getent passwd %s | cut -d: -f 6' % User).readline().strip()\nconfig_file = '%s/.config/robot-hat/robot-hat.conf' % UserHome\n```\n\nAnd later, they modify file permissions with commands like:\n\n```python\nos.popen('sudo chmod %s %s' % (mode, file_path))  # 🤦\nos.popen('sudo chown -R %s:%s %s' % (owner, owner, some_path))\n```\n\nThis 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\nvia `sudo`.\n\n### Type Hints\n\nThis version prioritizes:\n\n- **Type hints** for better developer experience.\n- Modular, maintainable, and well-documented code.\n\n### Mock Support for Testing\n\n`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.\n\nThis library can be configured either by environment values, either by function `setup_env_vars`, which will setup them automatically:\n\n```python\nfrom robot_hat.utils import setup_env_vars\nsetup_env_vars()\n```\n\nOr:\n\n```python\nimport os\nos.environ[\"GPIOZERO_PIN_FACTORY\"] = \"mock\" # mock for non-raspberry pi, lgpio for Raspberry Pi 5 and rpigpio for other Raspberry versions\nos.environ[\"PYGAME_HIDE_SUPPORT_PROMPT\"] = \"1\"\n```\n\n---\n\n## Development Environment Setup\n\n### Prerequisites\n\n1. **Python 3.10 or newer** must be installed.\n2. Ensure you have `pip` installed (a recent version is recommended):\n   ```bash\n   python3 -m pip install --upgrade pip\n   ```\n\n### Steps to Set Up\n\n1. **Clone the Repository**:\n\n   ```bash\n   git clone https://github.com/KarimAziev/robot-hat.git\n   cd robot-hat\n   ```\n\n2. **Set Up a Virtual Environment**:\n\n   ```bash\n   python3 -m venv .venv\n   source .venv/bin/activate  # Linux/Mac\n   # OR\n   .venv\\Scripts\\activate     # Windows\n   ```\n\n3. **Upgrade Core Tools**:\n\n   ```bash\n   pip install --upgrade pip setuptools wheel\n   ```\n\n4. **Install in Development Mode**:\n   ```bash\n   pip install -e \".[dev]\"  # Installs all dev dependencies (e.g., ruff, pre-commit)\n   ```\n\n---\n\n## Distribution\n\nTo create distributable artifacts (e.g., `.tar.gz` and `.whl` files):\n\n1. Install the build tool:\n\n   ```bash\n   pip install build\n   ```\n\n2. Build the project:\n   ```bash\n   python -m build\n   ```\n   The built files will be located in the `dist/` directory:\n\n- Source distribution: `robot_hat-x.y.z.tar.gz`\n- Wheel: `robot_hat-x.y.z-py3-none-any.whl`\n\nThese can be installed locally for testing or uploaded to PyPI for distribution.\n\n---\n\n## Common Commands\n\n- **Clean Build Artifacts**:\n  ```bash\n  rm -rf build dist *.egg-info\n  ```\n- **Deactivate Virtual Environment**:\n  ```bash\n  deactivate\n  ```\n\n---\n\n## Notes \u0026 Recommendations\n\n- The library uses `setuptools_scm` for 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.\n- Development tools like `ruff` (formatter, import organizer, and linter) are automatically installed with `[dev]` dependencies.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkarimaziev%2Frobot-hat","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkarimaziev%2Frobot-hat","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkarimaziev%2Frobot-hat/lists"}