Skip to content
Open
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
15 changes: 5 additions & 10 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
{
"name": "Hawkeye ROS 2 Humble",

"dockerComposeFile": [
"../docker-compose.yml",
"../docker-compose.override.yml"
],
"service": "ros2_workspace",
"workspaceFolder": "/ros2_ws",

"overrideCommand": true,

"remoteUser": "ros",

"customizations": {
"vscode": {
"extensions": [
Expand Down Expand Up @@ -47,10 +43,9 @@
}
}
},

"postCreateCommand": "sudo chown -R ros:ros /ros2_ws/build /ros2_ws/install 2>/dev/null || true && sudo apt-get update && rosdep update && sudo rosdep install --from-paths src --ignore-src -y && sudo /opt/ros/humble/lib/mavros/install_geographiclib_datasets.sh && colcon build --symlink-install",

"postCreateCommand": "sudo chown -R ros:ros /ros2_ws/build /ros2_ws/install 2>/dev/null || true && sudo apt-get update && rosdep update && sudo rosdep install --from-paths src --ignore-src -y && sudo /opt/ros/humble/lib/mavros/install_geographiclib_datasets.sh && bash -c \"source /opt/ros/humble/setup.bash && colcon build --symlink-install\"",
"forwardPorts": [],

"runServices": ["ros2_workspace"]
}
"runServices": [
"ros2_workspace"
]
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ received_stream

log/
install/
build/
build/
venv/
22 changes: 16 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
FROM ros:humble

# Build arguments for user configuration
ARG USERNAME=ros
ARG USER_UID=1000
ARG USER_GID=$USER_UID

# Set environment variables
ENV DEBIAN_FRONTEND=noninteractive
ENV ROS_DISTRO=humble

# Create non-root user with sudo
# Fix date/clock-skew issues (common in VMs/CI) — keeps GPG verification intact
RUN echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99no-check \
&& echo 'Acquire::Check-Date "false";' >> /etc/apt/apt.conf.d/99no-check

# Re-import the ROS GPG key explicitly so it is always fresh
RUN apt-get update -o Acquire::AllowInsecureRepositories=true \
&& apt-get install -y --no-install-recommends curl ca-certificates gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
| gpg --dearmor -o /usr/share/keyrings/ros-archive-keyring.gpg

# Create non-root user
RUN apt-get clean && rm -rf /var/lib/apt/lists/* \
&& groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
&& apt-get update -o Acquire::AllowInsecureRepositories=true -o Acquire::AllowDowngradeToInsecureRepositories=true \
&& apt-get install -y --allow-unauthenticated sudo \
&& apt-get update \
&& apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME


# Install dependencies
RUN apt-get update && apt-get install -y \
python3-pip \
Expand Down Expand Up @@ -71,4 +81,4 @@ RUN chown -R $USERNAME:$USERNAME /ros2_ws
# Switch to non-root user
USER $USERNAME

CMD ["/bin/bash"]
CMD ["/bin/bash"]
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Make sure you're in the project's root directory (../Hawkeye-OS)

In a Normal Terminal:
```bash
py mock_gcom.py
python3 mock_gcom.py
```

Inside the Docker Workspace (see previous section for setup):
Expand All @@ -77,6 +77,31 @@ For testing, mock queues are available
ros2 run orchestrator mock_object_detection ("on another terminal")
```

### Unit Tests

Set up the virtual environment (first time only):
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

Run all tests:
```bash
source venv/bin/activate
pytest
```

Run with coverage:
```bash
pytest --cov=src --cov-report=term-missing
```

Run a specific file (example):
```bash
pytest tests/streaming/test_streaming.py
```

### Automated Build/Test
For bash shells, there are files you can run to automate the test setups.

Expand Down
4 changes: 0 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ services:
- DISPLAY=${DISPLAY}
- ROS_DOMAIN_ID=0

# Orchestrator configuration
# This is only for LOCAL DEVELOPMENT where the signaling server is running on the host machine - in prod this should be set to the actual remote signaling server URL
- WEBRTC_SIGNALING_URL=ws://host.docker.internal:8081

# Topics
- OBJECT_DETECTION_TOPIC=object_detection/image
- IMAGE_REQUEST_TOPIC=image_request
Expand Down
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ paho-mqtt
opencv-python-headless
numpy<2
python-socketio
aiohttp
aiohttp
pytest
pytest-asyncio
Empty file.
2 changes: 1 addition & 1 deletion src/orchestrator/orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import os
import rclpy
from rclpy.node import Node
from rclpy.executors import SingleThreadedExecutor
from rclpy.executors import SingleThreadedExecutor
from std_msgs.msg import String
from sensor_msgs.msg import Image
import paho.mqtt.client as mqtt
Expand Down
2 changes: 1 addition & 1 deletion src/streaming/streaming/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os

WEBRTC_SIGNALING_URL = str(os.getenv("WEBRTC_SIGNALING_URL"))
WEBRTC_SIGNALING_URL = os.getenv("WEBRTC_SIGNALING_URL")
if WEBRTC_SIGNALING_URL is None or WEBRTC_SIGNALING_URL == "":
raise ValueError("WEBRTC_SIGNALING_URL environment variable is not set")
61 changes: 59 additions & 2 deletions src/streaming/streaming/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@
import traceback
import cv2
import numpy as np

import rclpy
from rclpy.node import Node
from rclpy.executors import SingleThreadedExecutor
from rcl_interfaces.msg import Log

import socketio

from aiortc import (
RTCConfiguration,
RTCIceServer,
RTCPeerConnection,
RTCSessionDescription,
)

from aiortc.sdp import candidate_from_sdp
from rclpy.executors import SingleThreadedExecutor
from rclpy.node import Node
from sensor_msgs.msg import Image

from hawkeye_msgs.msg import TaggedImage
Expand All @@ -39,6 +45,7 @@ def __init__(self, signaling_url: str):
# WebRTC state
self.peer_connection = None
self.data_channel = None
self.log_data_channel = None
self.ice_candidate_queue = [] # Queue ICE candidates until ready
self.ice_gathering_complete = False

Expand All @@ -60,6 +67,11 @@ def __init__(self, signaling_url: str):
Image, "object_detection/image", self._route_image_to_track, 10
)

# Subscribe to logs from other nodes (using /rosout)
self.log_subscription = self.create_subscription(
Log, "/rosout", self._route_log_to_frontend, 10
)

# Subscribe to tagged images from object detection
self.tagged_image_subscription = self.create_subscription(
TaggedImage, "object_detection/tagged_image", self._route_tagged_image, 10
Expand All @@ -74,12 +86,44 @@ def __init__(self, signaling_url: str):
self.get_logger().info("Streaming node initialized")
self.get_logger().info(f"Signaling server URL: {self.signaling_url}")
self.get_logger().info("Subscribed to: object_detection/image")
self.get_logger().info("Subscribed to: /rosout")
self.get_logger().info("Subscribed to: object_detection/tagged_image")

def _route_image_to_track(self, msg: Image):
"""Route incoming images to the current video track"""
if self.video_track:
self.video_track.put_image(msg)

def _route_log_to_frontend(self, msg: Log):
"""Route incoming log messages to the current video track (for overlay)"""
if self.log_data_channel is not None and self.log_data_channel.readyState == "open":
if msg.name == "streaming":
return # Don't send logs from this node to avoid feedback loop
log_message = json.dumps({ "level": msg.level, "node": msg.name, "message": msg.msg })
self.log_data_channel.send(log_message)

def _route_tagged_image(self, msg: TaggedImage):
"""Forward tagged image and metadata to GCOM via the data channel"""
if not self.data_channel or self.data_channel.readyState != "open":
return

img = msg.image_data
channels = 3 if img.encoding == "rgb8" else 1
frame = np.frombuffer(img.data, dtype=np.uint8).reshape(
(img.height, img.width, channels)
)
_, jpeg_bytes = cv2.imencode(".jpg", frame)
image_b64 = base64.b64encode(jpeg_bytes.tobytes()).decode("utf-8")

payload = json.dumps(
{
"image_data": image_b64,
"color_detection": [msg.color_r, msg.color_g, msg.color_b],
"bounding_box": [[pt.x, pt.y] for pt in msg.bounding_box],
"confidence_level": msg.confidence_level,
}
)
self.data_channel.send(payload)

def _route_tagged_image(self, msg: TaggedImage):
"""Forward tagged image and metadata to GCOM via the data channel"""
Expand Down Expand Up @@ -120,6 +164,7 @@ async def _send_webrtc_offer(self):
await self.peer_connection.close()
self.peer_connection = None
self.data_channel = None
self.log_data_channel = None

self.get_logger().info("Creating WebRTC peer connection")

Expand Down Expand Up @@ -191,6 +236,18 @@ def on_close():
def on_message(message):
self.get_logger().info(f"Received message on data channel: {message}")

# Create data channel for logging
self.log_data_channel = pc.createDataChannel("logs")
self.get_logger().info("Log data channel created")

@self.log_data_channel.on("open")
def on_log_open():
self.get_logger().info("Log data channel opened")

@self.log_data_channel.on("close")
def on_log_close():
self.get_logger().info("Log data channel closed")

# Create offer
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
Expand Down
Loading