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.pyviaconfig_from_id('example') - steerer: Manual steering control
- driver: Follows a single spline, and holds the tunable driving parameters
- path_driver: Drives one
DriveSegmentat a time with thedriver, and is what a tool asks to stop over a target - navigation:
StraightLineNavigationplans 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