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
6 changes: 3 additions & 3 deletions blacknode-package.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "blacknode-robot"
version = "0.5.2"
version = "0.5.3"
description = "Robot contracts, connected-device lifecycle, normalized telemetry, profiles, and driver launch."
requires-blacknode = ">=0.3.0"
layer = "robot"
Expand Down Expand Up @@ -83,8 +83,8 @@ nodes = ["blacknode_robot/devices/nodes"]
node-types = ["HardwareCapabilities", "RobotServo"]

[components.devices.dependencies]
pip = ["pyserial>=3.5", "feetech-servo-sdk>=1.0"]
imports = ["serial", "scservo_sdk"]
pip = ["pyserial>=3.5", "feetech-servo-sdk>=1.0", "roslibpy>=1.5"]
imports = ["serial", "scservo_sdk", "roslibpy"]

[components.telemetry]
description = "Normalized robot temperatures, voltage, faults, joint state, and device-status telemetry."
Expand Down
4 changes: 4 additions & 0 deletions blacknode_robot/devices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
)
from .safety import SafetyGate, SafetyLimits
from .adapters import (
ExistingRos2Config,
ExistingRos2Monitor,
I2CMecanumBase,
I2CMecanumConfig,
SerialJointConfig,
Expand All @@ -30,6 +32,8 @@
"MobileBaseProvider",
"SafetyGate",
"SafetyLimits",
"ExistingRos2Config",
"ExistingRos2Monitor",
"I2CMecanumBase",
"I2CMecanumConfig",
"SerialJointConfig",
Expand Down
3 changes: 2 additions & 1 deletion blacknode_robot/devices/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Replaceable hardware providers."""

from .i2c_mecanum import I2CMecanumBase, I2CMecanumConfig
from .existing_ros2 import ExistingRos2Config, ExistingRos2Monitor
from .serial_joint import (
SerialJointConfig,
SerialJointGroup,
Expand All @@ -10,6 +11,6 @@
)

__all__ = [
"I2CMecanumBase", "I2CMecanumConfig", "SerialJointConfig",
"ExistingRos2Config", "ExistingRos2Monitor", "I2CMecanumBase", "I2CMecanumConfig", "SerialJointConfig",
"SerialJointGroup", "SerialJointMonitor", "SerialJointSpec", "probe_serial",
]
144 changes: 144 additions & 0 deletions blacknode_robot/devices/adapters/existing_ros2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Read-only provider for a robot already running its own ROS 2 stack."""

from __future__ import annotations

from dataclasses import dataclass
import time
from typing import Any, Callable

from ..contracts import DeviceState


@dataclass(frozen=True)
class ExistingRos2Config:
"""Connection and observed-interface contract for an existing ROS robot."""

host: str = "127.0.0.1"
port: int = 9090
required_topics: tuple[str, ...] = ()
capabilities: tuple[str, ...] = ()
connect_timeout: float = 5.0


def load_roslibpy() -> Any:
try:
import roslibpy
except Exception as exc:
raise RuntimeError(
"install roslibpy to use the existing ROS 2 adapter"
) from exc
return roslibpy


class ExistingRos2Monitor:
"""Observe an existing ROS graph through rosbridge without publishing.

This provider deliberately exposes no arm, command, or torque methods. The
vendor robot stack retains ownership of actuators and startup services.
"""

exclusive_connection = False

def __init__(
self,
config: ExistingRos2Config,
*,
device_id: str = "device",
client_factory: Callable[[str, int], Any] | None = None,
) -> None:
if not str(config.host).strip():
raise ValueError("ROSBridge host is required")
if not 1 <= int(config.port) <= 65535:
raise ValueError("ROSBridge port must be from 1 to 65535")
if not config.required_topics:
raise ValueError("at least one observed ROS topic is required")
self.config = config
self.capabilities = tuple(config.capabilities)
self.device_id = device_id
self._client_factory = client_factory
self._client: Any | None = None
self._state = DeviceState(
device_id=device_id,
connected=False,
armed=False,
capabilities=list(self.capabilities),
values={
"transport": "rosbridge",
"host": config.host,
"port": config.port,
"required_topics": list(config.required_topics),
},
)

def _new_client(self) -> Any:
if self._client_factory is not None:
return self._client_factory(self.config.host, self.config.port)
roslibpy = load_roslibpy()
return roslibpy.Ros(host=self.config.host, port=self.config.port)

def connect(self) -> DeviceState:
return self.refresh()

def refresh(self) -> DeviceState:
self._state.updated_at = time.time()
try:
if self._client is None:
self._client = self._new_client()
if not bool(getattr(self._client, "is_connected", False)):
self._client.run(timeout=float(self.config.connect_timeout))
if not bool(getattr(self._client, "is_connected", False)):
raise ConnectionError(
f"ROSBridge did not connect at {self.config.host}:{self.config.port}"
)
topics = sorted(
{
str(topic).strip()
for topic in (self._client.get_topics() or [])
if str(topic).strip()
}
)
topic_set = set(topics)
missing = [
topic for topic in self.config.required_topics if topic not in topic_set
]
self._state.connected = not missing
self._state.armed = False
self._state.capabilities = list(self.capabilities)
self._state.values = {
"transport": "rosbridge",
"host": self.config.host,
"port": self.config.port,
"required_topics": list(self.config.required_topics),
"observed_topics": topics,
"missing_topics": missing,
"read_only": True,
"vendor_stack_preserved": True,
}
self._state.error = (
"Required ROS topics are unavailable: " + ", ".join(missing)
if missing
else ""
)
except Exception as exc:
self._state.connected = False
self._state.armed = False
self._state.error = str(exc)
self._discard_client()
return self._state

def state(self) -> DeviceState:
return self._state

def close(self) -> None:
self._discard_client()
self._state.connected = False
self._state.armed = False
self._state.updated_at = time.time()

def _discard_client(self) -> None:
if self._client is not None:
try:
self._client.terminate()
except Exception:
pass
self._client = None
73 changes: 67 additions & 6 deletions blacknode_robot/devices/device_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import tempfile
from typing import Any

from .adapters.existing_ros2 import ExistingRos2Config, ExistingRos2Monitor
from .adapters.serial_joint import (
SerialJointConfig,
SerialJointMonitor,
Expand All @@ -31,21 +32,53 @@ def normalize_device_name(value: Any, *, fallback: str = "") -> str:


def validate_device_config(value: dict[str, Any]) -> dict[str, Any]:
"""Validate and normalize a serial read-only device configuration."""
"""Validate and normalize a read-only hardware provider configuration."""
if value.get("version") != CONFIG_VERSION:
raise ValueError(f"configuration version must be {CONFIG_VERSION}")
if value.get("adapter") != "serial_joint":
raise ValueError("adapter must be serial_joint")
if value.get("mode") != "read_only":
raise ValueError("mode must be read_only")

device_id = value.get("device_id")
port = value.get("port")
baudrate = value.get("baudrate")
servos = value.get("servos")
if not isinstance(device_id, str) or not device_id.strip():
raise ValueError("device_id must be a non-empty string")
name = normalize_device_name(value.get("name"), fallback=device_id)
adapter = value.get("adapter")
if adapter == "existing_ros2":
host = value.get("host")
port = value.get("rosbridge_port")
required_topics = value.get("required_topics")
capabilities = value.get("capabilities")
if not isinstance(host, str) or not host.strip():
raise ValueError("host must be a non-empty string")
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
raise ValueError("rosbridge_port must be a whole number from 1 to 65535")
if not isinstance(required_topics, list) or not required_topics:
raise ValueError("required_topics must contain at least one ROS topic")
if not isinstance(capabilities, list) or not capabilities:
raise ValueError("capabilities must contain at least one capability")
normalized_topics = _normalized_unique_strings(
required_topics, field="required_topics", require_ros_name=True
)
normalized_capabilities = _normalized_unique_strings(
capabilities, field="capabilities"
)
return {
"version": CONFIG_VERSION,
"device_id": device_id.strip(),
"name": name,
"adapter": "existing_ros2",
"mode": "read_only",
"host": host.strip(),
"rosbridge_port": port,
"required_topics": normalized_topics,
"capabilities": normalized_capabilities,
}
if adapter != "serial_joint":
raise ValueError("adapter must be serial_joint or existing_ros2")

port = value.get("port")
baudrate = value.get("baudrate")
servos = value.get("servos")
if not isinstance(port, str) or not port.strip():
raise ValueError("port must be a non-empty string")
if isinstance(baudrate, bool) or not isinstance(baudrate, int) or baudrate <= 0:
Expand Down Expand Up @@ -85,6 +118,21 @@ def validate_device_config(value: dict[str, Any]) -> dict[str, Any]:
}


def _normalized_unique_strings(
values: list[Any], *, field: str, require_ros_name: bool = False
) -> list[str]:
normalized: list[str] = []
for value in values:
clean = str(value or "").strip()
if not clean:
raise ValueError(f"{field} values must be non-empty strings")
if require_ros_name and not clean.startswith("/"):
raise ValueError(f"{field} values must be absolute ROS topic names")
if clean not in normalized:
normalized.append(clean)
return normalized


def load_device_config(path: str | Path = DEFAULT_CONFIG_PATH) -> dict[str, Any]:
config_path = Path(path)
try:
Expand Down Expand Up @@ -137,3 +185,16 @@ def serial_monitor_from_config(value: dict[str, Any]) -> SerialJointMonitor:
joints=joints,
)
return SerialJointMonitor(serial_config, device_id=config["device_id"])


def provider_from_config(value: dict[str, Any]) -> Any:
config = validate_device_config(value)
if config["adapter"] == "serial_joint":
return serial_monitor_from_config(config)
ros_config = ExistingRos2Config(
host=config["host"],
port=config["rosbridge_port"],
required_topics=tuple(config["required_topics"]),
capabilities=tuple(config["capabilities"]),
)
return ExistingRos2Monitor(ros_config, device_id=config["device_id"])
2 changes: 2 additions & 0 deletions blacknode_robot/devices/service/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ def stop(self) -> dict[str, Any]:
def release(self) -> dict[str, Any]:
if self.provider is None:
return {"ok": False, "error": "no hardware adapter configured"}
if getattr(self.provider, "exclusive_connection", True) is False:
return {"ok": True, "status": self.status()}
if hasattr(self.provider, "stop"):
self.provider.stop()
if hasattr(self.provider, "disarm"):
Expand Down
2 changes: 1 addition & 1 deletion configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ if [[ "${1:-}" == "--all" ]]; then
&& -f "$repo_dir/.blacknode-hardware/devices.json" \
&& "$(uname -s)" == "Linux" \
&& -x "$repo_dir/service.sh" ]]; then
echo "Stopping configured hardware services briefly so every serial bus can be rescanned..."
echo "Stopping configured Blacknode Hardware services briefly for provider discovery..."
"$repo_dir/service.sh" --all stop || true
restore_previous_fleet=true
trap restore_fleet_on_failure EXIT
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "blacknode-robot"
version = "0.5.2"
version = "0.5.3"
description = "Robot contracts, connected-device lifecycle, and normalized telemetry for Blacknode."
requires-python = ">=3.11"
dependencies = ["pyserial>=3.5", "feetech-servo-sdk>=1.0", "roslibpy>=1.5"]
Expand Down
12 changes: 9 additions & 3 deletions scripts/configure_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,15 @@ def print_config(config: dict[str, Any], path: Path) -> None:
print(f"Name: {config['name']}")
print(f"Device ID: {config['device_id']}")
print(f"Mode: {config['mode']}")
print(f"Port: {config['port']}")
print(f"Baudrate: {config['baudrate']}")
print(f"Servos: {', '.join(str(servo['id']) for servo in config['servos'])}")
print(f"Adapter: {config['adapter']}")
if config["adapter"] == "existing_ros2":
print(f"ROSBridge: {config['host']}:{config['rosbridge_port']}")
print(f"Observed topics: {', '.join(config['required_topics'])}")
print(f"Capabilities: {', '.join(config['capabilities'])}")
else:
print(f"Port: {config['port']}")
print(f"Baudrate: {config['baudrate']}")
print(f"Servos: {', '.join(str(servo['id']) for servo in config['servos'])}")


def main() -> int:
Expand Down
Loading