Skip to content

Getting Started

Installation

Clone the repository and install dependencies with uv:

git clone https://github.com/zauberzeug/feldfreund_devkit.git
cd feldfreund_devkit
uv sync

Running the Example

The repository includes a minimal example in main.py that demonstrates:

  • Robot simulation with keyboard control
  • A single straight line navigation, driven as an automation
  • Real-time 3D visualization

Start it with:

uv run main.py

Open http://localhost:8080 in your browser. Hold SHIFT and use the arrow keys to steer the robot, or use the automation controls to run a straight line navigation.

Understanding the Example

#! /usr/bin/env python
import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, NoReturn

import rosys
from nicegui import app, ui
from rosys.automation import Automator, automation_controls
from rosys.driving import Driver, Steerer, keyboard_control, robot_object
from rosys.geometry import Point, Point3d, Pose3d

import feldfreund_devkit
from feldfreund_devkit import Implement, RobotLocator, WorkContext, activated
from feldfreund_devkit.config import FeldfreundConfiguration, ImplementConfiguration, Secrets, config_from_id
from feldfreund_devkit.navigation import (
    CannotStop,
    PathDriver,
    RecordedTrackNavigation,
    RecordedTrackProvider,
    StraightLineNavigation,
    TrackRecordingController,
    drive_and_work,
)

DemoNavigation = StraightLineNavigation | RecordedTrackNavigation

SIMULATION = os.environ.get('SIMULATION', '').lower() in ('1', 'true', 'yes')
if SIMULATION:
    rosys.enter_simulation()

log = logging.getLogger('feldfreund_devkit.demo')


@dataclass
class IntervalRun:
    """What the demo tool keeps for one run."""

    stops: int = 0


class IntervalImplement(Implement[IntervalRun]):
    """A demo tool that comes to rest every ``interval`` metres and holds there for ``dwell`` seconds."""

    def __init__(self, *, interval: float = 0.5, dwell: float = 1.0) -> None:
        super().__init__(ImplementConfiguration(lizard_name='None', display_name='Interval',
                                                default_offset=Pose3d(x=0.3), work_radius=0.0))
        self.interval = interval
        self.dwell = dwell

    @property
    def modules(self) -> list[rosys.hardware.Module]:
        return []

    async def stop(self) -> None:
        pass

    @asynccontextmanager
    async def activated(self) -> AsyncGenerator[IntervalRun, None]:  # pylint: disable=invalid-overridden-method
        run = IntervalRun()
        try:
            yield run
        finally:
            rosys.notify(f'{self.name} stopped {run.stops} times')

    async def work(self, ctx: WorkContext, context: IntervalRun) -> NoReturn:
        while True:
            target = ctx.locator.pose.transform(Point(x=self.offset.x + self.interval, y=0.0))
            try:
                async with ctx.motion.stop_over(target, self.offset.x):
                    context.stops += 1
                    await rosys.sleep(self.dwell)
            except CannotStop:  # We have already driven over the stop, skip it
                await rosys.sleep(0.1)

    def can_reach(self, local_point: Point3d) -> bool:
        return True

    def backup_to_dict(self) -> dict[str, Any]:
        return {}

    def restore_from_dict(self, data: dict[str, Any]) -> None:
        ...


class System(feldfreund_devkit.System):
    LINEAR_SPEED_LIMIT: float = 0.13

    def __init__(self, config: FeldfreundConfiguration, secrets: Secrets) -> None:
        super().__init__(config, secrets=secrets)
        self.robot_locator = RobotLocator(self.feldfreund.wheels,
                                          gnss=self.feldfreund.gnss,
                                          imu=self.feldfreund.imu,
                                          gnss_config=self.config.gnss)
        self.steerer = Steerer(self.feldfreund.wheels, speed_scaling=0.25)
        self.driver = Driver(self.feldfreund.wheels, self.robot_locator, parameters=self.config.driver)
        self.shape = rosys.geometry.Prism.default_robot_shape()
        self.automator = Automator(self.steerer, on_interrupt=self.feldfreund.stop, notify=False)

        self.recorded_track_provider = RecordedTrackProvider().persistent()
        self.track_recording_controller = TrackRecordingController(
            self.recorded_track_provider, pose_provider=self.robot_locator, gnss=self.feldfreund.gnss)

        self.path_driver = PathDriver(self.driver)
        self.implement = IntervalImplement()
        self.navigations: dict[str, DemoNavigation] = {
            'Straight Line': StraightLineNavigation(self.robot_locator),
            'Recorded Track': RecordedTrackNavigation(
                recorded_track_provider=self.recorded_track_provider,
                track_recording_controller=self.track_recording_controller,
                gnss=self.feldfreund.gnss,
                pose_provider=self.robot_locator),
        }
        self.navigation_name = next(iter(self.navigations))
        self.linear_speed_limit = self.LINEAR_SPEED_LIMIT
        self.automator.default_automation = self._drive

    @property
    def navigation(self) -> DemoNavigation:
        return self.navigations[self.navigation_name]

    async def _drive(self) -> None:
        async with activated([self.implement]) as readied:
            await drive_and_work(self.navigation, self.path_driver, self.robot_locator,
                                 speed_limit=self.linear_speed_limit, work=[tool.work for tool in readied])

    def backup_to_dict(self) -> dict[str, Any]:
        return super().backup_to_dict() | {'linear_speed_limit': self.linear_speed_limit}

    def restore_from_dict(self, data: dict[str, Any]) -> None:
        super().restore_from_dict(data)
        self.linear_speed_limit = data.get('linear_speed_limit', self.linear_speed_limit)


def speed_setting(system: System) -> None:
    minimum = system.driver.parameters.throttle_at_end_min_speed
    maximum = system.driver.parameters.linear_speed_limit
    ui.number('Linear Speed', step=0.01, min=minimum, max=maximum, format='%.2f', suffix='m/s',
              on_change=system.request_backup) \
        .bind_value(system, 'linear_speed_limit') \
        .props('dense outlined') \
        .classes('w-24') \
        .tooltip(f'Forward speed limit between {minimum:.2f} and {maximum:.2f} m/s '
                 f'(default: {System.LINEAR_SPEED_LIMIT:.2f})')


def startup() -> None:
    log.info('Starting the devkit demo in %s mode', 'simulation' if SIMULATION else 'hardware')
    secrets = Secrets()
    config = config_from_id('example', secrets=secrets)
    system = System(config, secrets).persistent()
    if not SIMULATION and rosys.is_simulation():
        log.info('No serial port found, running in simulation mode anyway')

    @ui.page('/')
    def ui_content() -> None:
        keyboard_control(system.steerer)
        with ui.scene():
            robot_object(system.shape, system.odometer)

        @ui.refreshable
        def navigation_settings() -> None:
            system.navigation.settings_ui()

        with ui.card():
            ui.label('hold SHIFT to steer with the keyboard arrow keys or use the automation controls')
            ui.select(list(system.navigations), label='Navigation',
                      on_change=lambda _: navigation_settings.refresh()) \
                .bind_value(system, 'navigation_name') \
                .classes('w-64')
            with ui.row():
                navigation_settings()
            with ui.row().classes('items-center'):
                speed_setting(system)
                automation_controls(system.automator)


app.on_startup(startup)

ui.run(title='Feldfreund_devkit')

The System class extends feldfreund_devkit.System which initializes the robot hardware (or simulation) based on the configuration. Key components:

  • config: Loaded from config/example.py via config_from_id('example')
  • steerer: Manual steering control
  • driver: Follows a single spline, and holds the tunable driving parameters
  • path_driver: Drives one DriveSegment at a time with the driver, and is what a tool asks to stop over a target
  • navigation: StraightLineNavigation plans the segments to drive — here a single one, forward for a configurable distance
  • automator: Manages automation lifecycle (play/pause/stop)

A navigation only plans; it never drives. drive(navigation, path_driver, speed_limit=...) is what runs it, pulling segments from the navigation and handing each to the path_driver. The speed limit is passed in per run rather than stored on the navigation, so the caller decides how fast a job may go while a navigation may still ask for less on an individual segment. To work the ground while driving, use drive_and_work(...) instead and pass an Implement.

Configuration

Robot configurations live in the config/ directory. See config/example.py:

from rosys.geometry import Rectangle
from rosys.vision import ImageSize

from feldfreund_devkit.config import (
    BluetoothConfiguration,
    BumperConfiguration,
    CameraConfiguration,
    FeldfreundConfiguration,
    GnssConfiguration,
    ImuConfiguration,
    LightConfiguration,
    MjpegCameraConfig,
    ODriveTracksConfiguration,
    RobotBrainConfiguration,
    Secrets,
    UsbCameraConfig,
    create_calibration,
)


def build_config(secrets: Secrets) -> FeldfreundConfiguration:
    return FeldfreundConfiguration(
        robot_id='Example',
        bluetooth=BluetoothConfiguration(name='example', pin_code=123456),
        bumper=BumperConfiguration(pin_front_top=21, pin_front_bottom=35, pin_back=18),
        cameras=CameraConfiguration(
            # streams the full sensor and crops to the working area; the calibration was fit at 1280x960
            main=UsbCameraConfig(camera_id='example-usb-0', fps=10,
                                 calibration=create_calibration(fx=720.0, fy=720.0, cx=640.0, cy=480.0,
                                                                distortion=[0.0, 0.0, 0.0, 0.0, 0.0],
                                                                width=1280, height=960,
                                                                x=0.4, y=0.0, z=0.6,
                                                                roll=3.14, pitch=0.0, yaw=-1.57),
                                 stream_size=ImageSize(width=2560, height=1920),
                                 crop=Rectangle(x=640, y=480, width=1280, height=960)),
            front=MjpegCameraConfig(camera_id='example-mac-4', image_size=ImageSize(width=1280, height=720),
                                    password=secrets.MJPEG_CAMERA_PASSWORD),
            back=MjpegCameraConfig(camera_id='example-mac-3', image_size=ImageSize(width=1280, height=720),
                                   password=secrets.MJPEG_CAMERA_PASSWORD),
            right=MjpegCameraConfig(camera_id='example-mac-1', image_size=ImageSize(width=1280, height=720),
                                    password=secrets.MJPEG_CAMERA_PASSWORD),
            left=MjpegCameraConfig(camera_id='example-mac-2', image_size=ImageSize(width=1280, height=720),
                                   password=secrets.MJPEG_CAMERA_PASSWORD),
        ),
        gnss=GnssConfiguration(),
        imu=ImuConfiguration(),
        robot_brain=RobotBrainConfiguration(name='rbexample', nand=True),
        wheels=ODriveTracksConfiguration(is_left_reversed=True,
                                         is_right_reversed=False,
                                         left_back_can_address=0x000,
                                         left_front_can_address=0x100,
                                         right_back_can_address=0x200,
                                         right_front_can_address=0x300,
                                         odrive_version=6),
        work_lights=[LightConfiguration(name='work_light_front', pin=12),
                     LightConfiguration(name='work_light_back', pin=23)],
    )

In simulation mode (when no hardware is detected), mock implementations are used automatically.

Next Steps

  • Browse the Module Reference in the navigation for API documentation
  • Check the Tutorials for hardware calibration guides
  • See Troubleshooting for common issues