"""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=` 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://:/`` 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()