Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/build_wheels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Build Python Wheels

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: ubuntu-22.04
- runner: ubuntu-22.04-arm
- runner: macos-14
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/setup-pixi@v0.9.1
with:
pixi-version: v0.63.2
environments: "py"
- name: Build wheels
run: pixi run py-build-wheel
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.platform.runner }}
path: booster_sdk_py/dist/
19 changes: 19 additions & 0 deletions .github/workflows/checks_rust.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Rust Checks

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
rust-checks:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/setup-pixi@v0.9.1
with:
pixi-version: v0.63.2
- name: Rust checks
run: pixi run rs-check
20 changes: 20 additions & 0 deletions .github/workflows/python_checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Python Checks

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
python-checks:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/setup-pixi@v0.9.1
with:
pixi-version: v0.63.2
environments: "py"
- name: Python lint
run: pixi run py-lint
47 changes: 47 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Release

on:
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- runner: ubuntu-22.04
- runner: ubuntu-22.04-arm
- runner: macos-14
steps:
- uses: actions/checkout@v4
- uses: prefix-dev/setup-pixi@v0.9.1
with:
pixi-version: v0.63.2
environments: "py"
- name: Build wheels
run: pixi run py-build-wheel
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.platform.runner }}
path: booster_sdk_py/dist/

publish:
runs-on: ubuntu-22.04
needs: build
environment: release
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
pattern: wheels-*
merge-multiple: true
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Publish to PyPI
run: uv publish dist/*
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["booster_sdk", "booster_sdk_py"]
resolver = "2"

[workspace.package]
version = "0.1.0-alpha.4"
version = "0.1.0-alpha.5"
edition = "2024"
authors = ["Team whIRLwind"]
license = "MIT OR Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion booster_sdk/examples/locomotion_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

tracing::info!("Starting locomotion control example");

// Create client with 2-second timeout
// Create client
let client = BoosterClient::new()?;

// Change to walking mode
Expand Down
7 changes: 6 additions & 1 deletion booster_sdk/src/client/loco_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ use serde::{Deserialize, Serialize};
const CHANGE_MODE_API_ID: i32 = 2000;
const MOVE_API_ID: i32 = 2001;

// The controller may send an intermediate pending status (-1) before the
// final success response. Mode transitions (especially PREPARE) can take
// several seconds.
const CHANGE_MODE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

#[derive(Deserialize)]
struct EmptyResponse {}

Expand Down Expand Up @@ -59,7 +64,7 @@ impl BoosterClient {
&Params {
mode: i32::from(mode),
},
None,
Some(CHANGE_MODE_TIMEOUT),
)
.await?;

Expand Down
1 change: 0 additions & 1 deletion booster_sdk/src/dds/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ pub struct RpcRespMsg {
pub uuid: String,
pub header: String,
pub body: String,
pub status_code: i32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
93 changes: 82 additions & 11 deletions booster_sdk/src/dds/rpc.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! RPC client for high-level API requests over DDS.

use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
use std::time::{Duration, Instant};
use uuid::Uuid;

Expand All @@ -23,7 +24,9 @@ impl Default for RpcClientOptions {
fn default() -> Self {
Self {
domain_id: 0,
default_timeout: Duration::from_millis(1000),
// 5 s is a safe default for most commands. Mode changes are slow,
// so change_mode passes its own longer timeout.
default_timeout: Duration::from_secs(5),
}
}
}
Expand All @@ -35,6 +38,32 @@ pub struct RpcClient {
default_timeout: Duration,
}

fn parse_status_value(value: &Value) -> Option<i32> {
match value {
Value::Number(n) => n.as_i64().and_then(|v| i32::try_from(v).ok()),
Value::String(s) => s.parse::<i32>().ok(),
_ => None,
}
}

fn parse_status_from_header(raw_json: &str) -> Option<i32> {
let value: Value = serde_json::from_str(raw_json.trim()).ok()?;
let object = value.as_object()?;
object.get("status").and_then(parse_status_value)
}

fn decode_response_body<R>(body: &str) -> std::result::Result<R, serde_json::Error>
where
R: DeserializeOwned,
{
let trimmed = body.trim();
if trimmed.is_empty() {
return serde_json::from_str("{}");
}

serde_json::from_str(trimmed)
}

impl RpcClient {
pub fn new(options: RpcClientOptions) -> Result<Self> {
let node = DdsNode::new(super::DdsConfig {
Expand Down Expand Up @@ -102,22 +131,25 @@ impl RpcClient {
continue;
}

if response.status_code == -1 {
let status_code = parse_status_from_header(&response.header).unwrap_or(0);

if status_code == -1 {
continue;
}

if response.status_code != 0 {
return Err(RpcError::from_status_code(
response.status_code,
response.body,
)
.into());
if status_code != 0 {
let message = if response.body.trim().is_empty() {
response.header
} else {
response.body
};
return Err(RpcError::from_status_code(status_code, message).into());
}

let result: R = serde_json::from_str(&response.body).map_err(|err| {
let result: R = decode_response_body(&response.body).map_err(|err| {
RpcError::RequestFailed {
status: response.status_code,
message: format!("Failed to deserialize response: {err}"),
status: status_code,
message: format!("Failed to deserialize response body: {err}"),
}
})?;

Expand All @@ -134,3 +166,42 @@ impl RpcClient {
.map_err(|err| DdsError::ReceiveFailed(err.to_string()))?
}
}

#[cfg(test)]
mod tests {
use super::{decode_response_body, parse_status_from_header, parse_status_value};
use serde_json::json;

#[derive(serde::Deserialize)]
struct EmptyResponse {}

#[test]
fn parse_status_from_header_reads_status_field() {
assert_eq!(parse_status_from_header(r#"{"status":0}"#), Some(0));
assert_eq!(parse_status_from_header(r#"{"status":"-1"}"#), Some(-1));
}

#[test]
fn parse_status_value_handles_number_and_string() {
assert_eq!(parse_status_value(&json!(0)), Some(0));
assert_eq!(parse_status_value(&json!("-1")), Some(-1));
assert_eq!(parse_status_value(&json!("not-a-number")), None);
}

#[test]
fn parse_status_from_header_ignores_other_fields() {
assert_eq!(parse_status_from_header(r#"{"status_code":0}"#), None);
assert_eq!(parse_status_from_header(r#"{"code":0}"#), None);
}

#[test]
fn empty_body_deserializes_as_empty_object() {
let _: EmptyResponse = decode_response_body("").expect("empty body should parse");
}

#[test]
fn non_json_body_fails_deserialization() {
let parsed = decode_response_body::<EmptyResponse>("not-json");
assert!(parsed.is_err());
}
}
26 changes: 13 additions & 13 deletions booster_sdk/src/dds/topics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,22 @@ impl TopicSpec {
}
}

pub const TYPE_RPC_REQ: &str = "booster::msg::RpcReqMsg";
pub const TYPE_RPC_RESP: &str = "booster::msg::RpcRespMsg";
pub const TYPE_ROBOT_STATUS: &str = "booster::msg::RobotStatusDdsMsg";
pub const TYPE_RPC_REQ: &str = "booster_msgs::msg::dds_::RpcReqMsg_";
pub const TYPE_RPC_RESP: &str = "booster_msgs::msg::dds_::RpcRespMsg_";
pub const TYPE_ROBOT_STATUS: &str = "booster_interface::msg::dds_::RobotStatusDdsMsg_";
pub const TYPE_MOTION_STATE: &str = "booster::msg::MotionState";
pub const TYPE_BATTERY_STATE: &str = "booster::msg::BatteryState";
pub const TYPE_BUTTON_EVENT: &str = "booster::msg::ButtonEventMsg";
pub const TYPE_REMOTE_CONTROLLER: &str = "booster::msg::RemoteControllerState";
pub const TYPE_PROCESS_STATE: &str = "booster::msg::RobotProcessStateMsg";
pub const TYPE_BINARY_DATA: &str = "booster::msg::BinaryData";
pub const TYPE_GRIPPER_CONTROL: &str = "booster::msg::GripperControl";
pub const TYPE_LIGHT_CONTROL: &str = "booster::msg::LightControlMsg";
pub const TYPE_SAFE_MODE: &str = "booster::msg::SafeMode";
pub const TYPE_BATTERY_STATE: &str = "booster_interface::msg::dds_::BatteryState_";
pub const TYPE_BUTTON_EVENT: &str = "booster_interface::msg::dds_::ButtonEventMsg_";
pub const TYPE_REMOTE_CONTROLLER: &str = "booster_interface::msg::dds_::RemoteControllerState_";
pub const TYPE_PROCESS_STATE: &str = "booster_interface::msg::dds_::RobotProcessStateMsg_";
pub const TYPE_BINARY_DATA: &str = "booster_msgs::msg::dds_::BinaryData_";
pub const TYPE_GRIPPER_CONTROL: &str = "booster_interface::msg::dds_::GripperControl_";
pub const TYPE_LIGHT_CONTROL: &str = "booster_interface::msg::dds_::LightControlMsg_";
pub const TYPE_SAFE_MODE: &str = "booster_msgs::msg::dds_::BinaryData_";

pub fn loco_request_topic() -> TopicSpec {
TopicSpec {
name: "LocoApiTopicReq",
name: "rt/LocoApiTopicReq",
type_name: TYPE_RPC_REQ,
qos: qos_reliable_keep_last(10),
kind: TopicKind::NoKey,
Expand All @@ -52,7 +52,7 @@ pub fn loco_request_topic() -> TopicSpec {

pub fn loco_response_topic() -> TopicSpec {
TopicSpec {
name: "LocoApiTopicResp",
name: "rt/LocoApiTopicResp",
type_name: TYPE_RPC_RESP,
qos: qos_reliable_keep_last(10),
kind: TopicKind::NoKey,
Expand Down
2 changes: 1 addition & 1 deletion booster_sdk_py/booster_sdk_bindings/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from __future__ import annotations

from .booster_sdk_bindings import *
from .booster_sdk_bindings import * # noqa: F403
5 changes: 1 addition & 4 deletions booster_sdk_py/booster_sdk_bindings/booster_sdk_bindings.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,19 @@ class GripperCommand:
motion_param: int,
speed: int | None = ...,
) -> None: ...

@staticmethod
def open(hand: Hand) -> GripperCommand: ...

@staticmethod
def close(hand: Hand) -> GripperCommand: ...

@staticmethod
def grasp(hand: Hand, force: int) -> GripperCommand: ...

def __repr__(self) -> str: ...

class BoosterClient:
"""High-level robot client."""

def __init__(self) -> None: ...
def wait_for_discovery(self, timeout_secs: float) -> None: ...
def change_mode(self, mode: RobotMode) -> None: ...
def move_robot(self, vx: float, vy: float, vyaw: float) -> None: ...
def publish_gripper_command(self, command: GripperCommand) -> None: ...
Expand Down
Loading