GoWelcome/config.py

358 lines
15 KiB
Python

"""Central configuration for GoWelcome.
Every tunable lives here so field tuning never means hunting through modules.
Grouped by subsystem; all values are plain Python (no heavy imports) so this
file is safe to import anywhere, including unit tests.
Paths are derived from this file's location (``PROJECT_ROOT``) -- never
hardcode an absolute project path. Override at runtime via ``main.py`` CLI
flags or by editing the dataclass defaults below.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Tuple
# Resolve the project root dynamically (this file lives at the repo root).
PROJECT_ROOT: Path = Path(__file__).resolve().parent
ASSETS_DIR: Path = PROJECT_ROOT / "assets"
@dataclass
class NetworkConfig:
"""How to reach the Go2 over CycloneDDS (official SDK, the ``dds`` transport)."""
interface: str = "eth0" # host NIC on the robot LAN (e.g. eth0 / wlan0 / enpXs0)
domain_id: int = 0 # DDS domain; 0 for a physical Go2
@dataclass
class WebRTCConfig:
"""How to reach the Go2 over WebRTC (the default ``webrtc`` transport).
Uses ``unitree_webrtc_connect`` -- the same protocol as the Unitree app, so
it works on Go2 AIR/PRO/EDU over wifi and (uniquely) can play the greeting
from the dog's own speaker via AudioHub. No jailbreak required.
"""
connection_method: str = "localsta" # localsta | localap | remote
ip: str = "" # robot IP for localsta (e.g. 192.168.x.x); "" -> discover by serial
serial_number: str = "" # for localsta discovery / remote signaling
aes_128_key: str = "" # 32-hex per-device key; REQUIRED on Go2 firmware >= 1.1.15
username: str = "" # remote (cloud) signaling only
password: str = "" # remote (cloud) signaling only
region: str = "global" # global | cn
# How to play the greeting clip on the dog:
# "audiohub" -> upload greeting.wav to the robot once, play by uuid (default)
# "stream" -> stream the file live each greeting via an aiortc MediaPlayer
audio_method: str = "audiohub"
@dataclass
class PerceptionConfig:
"""YOLO detection + HSV road masking."""
# --- YOLO ---
# Resolve a bundled weights file relative to the project so GoWelcome runs
# from ANY working directory; fall back to the bare name (ultralytics then
# auto-downloads it) when no local copy is present.
model_path: str = (
str(PROJECT_ROOT / "yolov8n.pt")
if (PROJECT_ROOT / "yolov8n.pt").exists()
else "yolov8n.pt"
)
device: str = "cpu" # "cpu", "cuda", "cuda:0" ... ("cuda" on Jetson)
use_half: bool = False # FP16 (discrete-GPU only; leave off on Jetson/CPU)
use_tracking: bool = False # model.track(persist=True) vs model.predict
tracker: str = "bytetrack.yaml"
infer_imgsz: int = 640
person_conf: float = 0.80 # spec: APPROACH only when person conf > 0.80
danger_conf: float = 0.40 # lower bar for vehicles (safety-biased)
person_classes: Tuple[str, ...] = ("person",)
danger_classes: Tuple[str, ...] = ("car", "truck", "bus", "motorcycle", "train")
# A danger box is only "near enough to matter" once it fills this much of
# the frame height (ignore tiny far-away cars on the horizon).
danger_min_height_ratio: float = 0.25
# --- HSV road / asphalt mask ---
road_enabled: bool = True
road_crop_frac: float = 0.30 # analyse the bottom 30% of the frame
# Asphalt/concrete is low-saturation grey. HSV ranges (OpenCV: H 0-179).
road_hsv_lower: Tuple[int, int, int] = (0, 0, 40)
road_hsv_upper: Tuple[int, int, int] = (179, 60, 200)
road_morph_kernel: int = 5 # opening kernel to de-speckle the mask
# Coverage of the crop above which we consider "road ahead".
road_trigger_coverage: float = 0.35
@dataclass
class CameraConfig:
"""Frame source sizing (mock backend / resize)."""
width: int = 640
height: int = 480
# Mock backend frame source: integer webcam index, or a path to a video file.
mock_source: str = "0"
target_fps: float = 15.0
@dataclass
class ServoConfig:
"""Visual-servoing P(ID) controller: bbox centroid -> velocity."""
# Yaw: turn to centre the target horizontally.
kp_yaw: float = 1.2 # gain on normalised horizontal error (-1..1)
ki_yaw: float = 0.0
kd_yaw: float = 0.05
max_yaw_rate: float = 0.9 # rad/s clamp
yaw_deadband: float = 0.06 # |norm err| below this -> no turn
# Sign: with default Unitree convention (+vyaw = CCW/left), a target to the
# RIGHT (err>0) needs a right turn (vyaw<0), so vyaw = -kp*err. Flip if your
# robot/camera mounting inverts this.
yaw_sign: float = -1.0
# Forward: approach until the box fills the frame vertically.
kp_forward: float = 1.0
max_forward: float = 0.45 # m/s (gentle backyard pace)
min_forward: float = 0.06 # below this, treat as stopped
# spec: trigger stop when the box fills ~50% of the vertical frame.
stop_height_ratio: float = 0.50
# Throttle forward speed when heading error is large (don't charge sideways):
# scale = exp(-forward_heading_falloff * |norm_yaw_err|).
forward_heading_falloff: float = 2.0
@dataclass
class WanderConfig:
"""Open-loop roaming when no person is in view."""
forward_speed: float = 0.30 # m/s cruise
# Gentle sinusoidal yaw sweep so the dog scans the yard.
yaw_sweep_rate: float = 0.35 # rad/s peak
yaw_sweep_period: float = 6.0 # s per full left-right cycle
@dataclass
class AvoidConfig:
"""Road/car avoidance: a soft 'keep away' steer in WANDER plus the hard
AVOID_DANGER reaction up close."""
# --- hard reaction (AVOID_DANGER) ---
backup_speed: float = 0.15 # m/s reverse while turning away
turn_rate: float = 0.7 # rad/s turn-away
# Stay in AVOID until the road/danger has been clear this many frames.
clear_frames: int = 8
backup_duration: float = 0.8 # s of reverse before pivoting in place
# --- soft 'stay away from the road/cars' steer (applied during WANDER) ---
# This is the vision-only area-containment: the dog veers away from pavement
# and slows down BEFORE it reaches the hard AVOID_DANGER trigger, so it keeps
# its distance from the road/cars rather than only reacting at the edge.
soft_avoid_enabled: bool = True
# Road coverage (in the bottom-crop thirds) above which the soft steer kicks
# in. Must be below perception.road_trigger_coverage (the hard trigger).
soft_road_coverage: float = 0.12
road_repulsion_gain: float = 1.4 # rad/s of yaw bias per unit (left-right) road imbalance
road_slowdown_gain: float = 1.5 # forward-speed reduction per unit centre-road coverage
# Steer away from any detected vehicle (even far/small ones) by this much
# yaw per unit of its normalised horizontal offset; near cars still hard-avoid.
car_repulsion_gain: float = 0.7
@dataclass
class GreetConfig:
"""The greeting payload + re-greet gating."""
wav_path: str = str(ASSETS_DIR / "greeting.wav")
# Gestures performed during a greeting, in order.
gestures: Tuple[str, ...] = ("hello", "heart")
gesture_gap: float = 2.5 # s between gestures (let each finish)
audio_volume: int = 85 # 0-100 (applied where the backend supports it)
# After greeting, ignore people for this long so we don't loop on one person.
cooldown: float = 12.0 # s
settle_time: float = 0.6 # s to come to a full stop before gesturing
@dataclass
class AudioConfig:
"""Greeting audio backend selection.
backend:
"host" -> play on the machine running GoWelcome (aplay/simpleaudio).
Reliable; works regardless of robot model. DEFAULT.
"go2" -> EXPERIMENTAL: stream to the Go2 'audiohub' over DDS. Unverified
against firmware; may be a no-op. Test on hardware first.
"null" -> log only, no sound (used by the mock backend / CI).
"""
backend: str = "host"
# Host backend: external player command (used if simpleaudio is absent or
# when output_device is set). Default is aplay (ALSA); paplay is used
# automatically when output_device names a PulseAudio sink.
host_player_cmd: Tuple[str, ...] = ("aplay", "-q")
# Optional output device to pin a *specific* speaker (e.g. a USB/Bluetooth
# speaker mounted on the Go2). When set, HostSpeakerAudio plays via
# `paplay --device=<output_device>` so playback always lands on that sink
# regardless of the system default. This mirrors the proven pattern from
# the team's G1 "Sanad" stack (a USB Anker PowerConf speaker targeted by
# its PulseAudio sink name). Empty -> use the system default sink.
# Example: "alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo"
output_device: str = ""
@dataclass
class SafetyConfig:
"""Cross-cutting safety limits + the firmware avoidance toggle."""
use_lidar_avoidance: bool = True # route drive() through ObstaclesAvoidClient
# Hard caps applied to *every* velocity command, after the controllers.
max_vx: float = 0.5
max_vy: float = 0.3
max_vyaw: float = 1.0
# If perception goes stale (no new frame) longer than this, stop the robot.
perception_timeout: float = 0.8 # s
# Watchdog: if drive() isn't called within this period the robot decays to
# stop on its own (Unitree firmware behaviour); we also explicitly stop.
command_timeout: float = 0.5 # s
@dataclass
class WebConfig:
"""Optional MJPEG web viewer -- watch the live annotated camera in a browser.
When enabled, GoWelcome serves the latest frame (with YOLO person/danger
boxes, road coverage and the current state drawn on it) as an MJPEG stream
over plain HTTP. Open ``http://<host-ip>:<port>/`` in any browser. Useful
for headless operation on the dog (no local display needed).
Stdlib-only (``http.server`` + ``cv2.imencode``); no extra dependency.
"""
enabled: bool = False
host: str = "0.0.0.0" # bind all interfaces so other machines can view
port: int = 8080
fps: float = 10.0 # stream pacing (cap; never exceeds perception rate)
jpeg_quality: int = 80 # 1-100; lower = less bandwidth
@dataclass
class GpsConfig:
"""External GPS receiver used for the area geofence.
NOTE: the Go2 has NO built-in GPS -- this requires a USB/serial GPS receiver
on the onboard computer (read via gpsd or NMEA over serial). Standard GPS is
accurate to ~2-5 m, so keep the geofence well inside the real edge; use RTK
for tight bounds near roads.
"""
enabled: bool = False # OFF by default -- GoWelcome is vision-only unless GPS is added
source: str = "auto" # auto | gpsd | serial | mock
serial_port: str = "/dev/ttyACM0"
serial_baud: int = 9600
gpsd_host: str = "127.0.0.1"
gpsd_port: int = 2947
min_satellites: int = 4 # reject fixes with fewer sats
max_hdop: float = 5.0 # reject fixes worse (larger) than this
stale_after: float = 3.0 # s; a fix older than this counts as "no fix"
# Mock source (off-robot testing): a virtual receiver that integrates the
# commanded velocity into a fake lat/lon so the geofence can be exercised.
mock_start_lat: float = 25.2048
mock_start_lon: float = 55.2708
@dataclass
class GeoFenceConfig:
"""Optional GPS keep-in-area geofence + the BOUNDARY return behaviour.
OFF by default: GoWelcome keeps the dog in the area with vision (road/car
avoidance). Enable this only when an external GPS receiver is fitted.
"""
enabled: bool = False
center_mode: str = "onstart" # onstart (first good fix) | fixed
center_lat: float = 0.0 # used when center_mode == "fixed"
center_lon: float = 0.0
radius_m: float = 15.0 # keep within this radius of the centre
margin_m: float = 3.0 # start homing back this far before the edge
release_m: float = 2.0 # extra hysteresis: only resume once this far back inside
return_speed: float = 0.35 # m/s while homing toward centre
homing_kp: float = 1.2 # vyaw per rad of (bearing-to-centre - course)
max_homing_yaw: float = 0.9 # rad/s clamp while homing
min_speed_for_course: float = 0.15 # m/s; below this GPS course is unreliable
# What to do when GPS is unavailable/stale while the fence is enabled:
# "stop" -> halt (safe; don't roam blind near a boundary). DEFAULT.
# "ignore" -> keep operating on perception-only avoidance (riskier).
no_fix_behavior: str = "stop"
@dataclass
class PlayConfig:
"""Idle 'act like a dog' behaviour scheduler (runs during WANDER).
``mode`` is runtime-settable from the dashboard. Each mode sets the mean
seconds between random dog actions; actions are gestures the active robot
backend supports (unsupported ones are skipped).
"""
mode: str = "moderate" # calm | moderate | playful
calm_interval: float = 75.0
moderate_interval: float = 30.0
playful_interval: float = 15.0
jitter: float = 0.4 # +/- fraction randomisation of the interval
action_hold: float = 3.0 # s the dog pauses (stops roaming) to perform a trick
actions: Tuple[str, ...] = ("stretch", "wiggle", "scrape", "dance1", "wallow")
def interval_for(self, mode: str) -> float:
return {
"calm": self.calm_interval,
"moderate": self.moderate_interval,
"playful": self.playful_interval,
}.get(mode, self.moderate_interval)
@dataclass
class LoopConfig:
"""Main control-loop cadence + lost-target handling."""
rate_hz: float = 15.0
# Frames a locked person may be missing before APPROACH falls back to WANDER.
person_lost_frames: int = 12
@dataclass
class GoWelcomeConfig:
"""Top-level config aggregating every subsystem."""
# Which robot transport to use on real hardware:
# "webrtc" -> Go2WebRTCRobot (default; app protocol, wifi, dog-speaker audio)
# "dds" -> Go2Robot (official unitree_sdk2py over CycloneDDS, wired/EDU)
transport: str = "webrtc"
network: NetworkConfig = field(default_factory=NetworkConfig)
webrtc: WebRTCConfig = field(default_factory=WebRTCConfig)
perception: PerceptionConfig = field(default_factory=PerceptionConfig)
camera: CameraConfig = field(default_factory=CameraConfig)
servo: ServoConfig = field(default_factory=ServoConfig)
wander: WanderConfig = field(default_factory=WanderConfig)
avoid: AvoidConfig = field(default_factory=AvoidConfig)
greet: GreetConfig = field(default_factory=GreetConfig)
audio: AudioConfig = field(default_factory=AudioConfig)
safety: SafetyConfig = field(default_factory=SafetyConfig)
loop: LoopConfig = field(default_factory=LoopConfig)
web: WebConfig = field(default_factory=WebConfig)
gps: GpsConfig = field(default_factory=GpsConfig)
geofence: GeoFenceConfig = field(default_factory=GeoFenceConfig)
play: PlayConfig = field(default_factory=PlayConfig)
# Runtime mode flags (set by main.py).
mock: bool = False # True -> MockRobot (no hardware)
headless: bool = False # True -> no cv2 debug window
dry_run: bool = False # True -> perception + decisions, but never move
def default_config() -> GoWelcomeConfig:
"""Return a fresh fully-defaulted config."""
return GoWelcomeConfig()