feat: RPLIDAR safety zone detector (Issue #575) #580
@ -0,0 +1,44 @@
|
||||
# safety_zone_params.yaml — RPLIDAR 360° safety zone detector (Issue #575)
|
||||
#
|
||||
# Node: saltybot_safety_zone
|
||||
#
|
||||
# Usage:
|
||||
# ros2 launch saltybot_safety_zone safety_zone.launch.py
|
||||
#
|
||||
# Zone thresholds:
|
||||
# DANGER < danger_range_m → latching e-stop (if in forward arc)
|
||||
# WARN < warn_range_m → caution / speed reduction (advisory)
|
||||
# CLEAR otherwise
|
||||
#
|
||||
# E-stop clear:
|
||||
# ros2 service call /saltybot/safety_zone/clear_estop std_srvs/srv/Trigger
|
||||
|
||||
safety_zone:
|
||||
ros__parameters:
|
||||
|
||||
# ── Zone thresholds ──────────────────────────────────────────────────────
|
||||
danger_range_m: 0.30 # m — obstacle closer than this → DANGER
|
||||
warn_range_m: 1.00 # m — obstacle closer than this → WARN
|
||||
|
||||
# ── Sector grid ──────────────────────────────────────────────────────────
|
||||
n_sectors: 36 # 360 / 36 = 10° per sector
|
||||
|
||||
# ── E-stop trigger arc ───────────────────────────────────────────────────
|
||||
forward_arc_deg: 60.0 # ±30° from robot forward (+X / 0°)
|
||||
estop_all_arcs: false # true = any sector triggers (360° e-stop)
|
||||
estop_debounce_frames: 2 # consecutive DANGER scans before latch
|
||||
|
||||
# ── Range validity ───────────────────────────────────────────────────────
|
||||
min_valid_range_m: 0.05 # ignore readings closer than this (sensor noise)
|
||||
max_valid_range_m: 12.00 # RPLIDAR A1M8 nominal max range
|
||||
|
||||
# ── Publish rate ─────────────────────────────────────────────────────────
|
||||
publish_rate: 10.0 # Hz — /saltybot/safety_zone/status publish rate
|
||||
# /saltybot/safety_zone publishes every scan
|
||||
|
||||
# ── cmd_vel topics ───────────────────────────────────────────────────────
|
||||
# Safety zone node intercepts cmd_vel from upstream, overrides to zero on estop.
|
||||
# Typical chain:
|
||||
# cmd_vel_mux → /cmd_vel_safe → [safety_zone: cmd_vel_input] → /cmd_vel → STM32
|
||||
cmd_vel_input_topic: /cmd_vel_input # upstream velocity (remap as needed)
|
||||
cmd_vel_output_topic: /cmd_vel # downstream (to STM32 bridge)
|
||||
@ -0,0 +1,28 @@
|
||||
"""Launch file for saltybot_safety_zone (Issue #575)."""
|
||||
|
||||
import os
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description() -> LaunchDescription:
|
||||
config = os.path.join(
|
||||
get_package_share_directory("saltybot_safety_zone"),
|
||||
"config",
|
||||
"safety_zone_params.yaml",
|
||||
)
|
||||
|
||||
safety_zone_node = Node(
|
||||
package="saltybot_safety_zone",
|
||||
executable="safety_zone",
|
||||
name="safety_zone",
|
||||
parameters=[config],
|
||||
remappings=[
|
||||
# Remap if the upstream mux publishes to a different topic:
|
||||
# ("/cmd_vel_input", "/cmd_vel_safe"),
|
||||
],
|
||||
output="screen",
|
||||
)
|
||||
|
||||
return LaunchDescription([safety_zone_node])
|
||||
32
jetson/ros2_ws/src/saltybot_safety_zone/package.xml
Normal file
32
jetson/ros2_ws/src/saltybot_safety_zone/package.xml
Normal file
@ -0,0 +1,32 @@
|
||||
<?xml version="1.0"?>
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>saltybot_safety_zone</name>
|
||||
<version>0.1.0</version>
|
||||
<description>
|
||||
RPLIDAR 360° safety zone detector (Issue #575).
|
||||
Divides the full 360° scan into 10° sectors, classifies each as
|
||||
DANGER/WARN/CLEAR, latches an e-stop on DANGER in the forward arc,
|
||||
overrides /cmd_vel to zero while latched, and exposes a service to clear
|
||||
the latch once obstacles are gone.
|
||||
</description>
|
||||
<maintainer email="sl-perception@saltylab.local">sl-perception</maintainer>
|
||||
<license>MIT</license>
|
||||
|
||||
<buildtool_depend>ament_python</buildtool_depend>
|
||||
|
||||
<depend>rclpy</depend>
|
||||
<depend>geometry_msgs</depend>
|
||||
<depend>sensor_msgs</depend>
|
||||
<depend>std_msgs</depend>
|
||||
<depend>std_srvs</depend>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
</export>
|
||||
</package>
|
||||
@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
safety_zone_node.py — RPLIDAR 360° safety zone detector (Issue #575).
|
||||
|
||||
Processes /scan into three concentric safety zones and publishes per-sector
|
||||
classification, a latching e-stop on DANGER in the forward arc, and a zero
|
||||
cmd_vel override while the e-stop is active.
|
||||
|
||||
Zone thresholds (configurable):
|
||||
DANGER < danger_range_m (default 0.30 m) — immediate halt
|
||||
WARN < warn_range_m (default 1.00 m) — caution / slow-down
|
||||
CLEAR otherwise
|
||||
|
||||
Sectors:
|
||||
360° is divided into N_SECTORS (default 36) sectors of 10° each.
|
||||
Sector 0 is centred on 0° (robot forward = base_link +X axis).
|
||||
Sector indices increase counter-clockwise (ROS convention).
|
||||
|
||||
E-stop behaviour:
|
||||
1. If any sector in the forward arc has DANGER for >= estop_debounce_frames
|
||||
consecutive scans, the e-stop latches.
|
||||
2. While latched:
|
||||
- A zero Twist is published to /cmd_vel every scan cycle.
|
||||
- Incoming cmd_vel_input messages are silently dropped.
|
||||
3. The latch is cleared ONLY via the ROS service:
|
||||
/saltybot/safety_zone/clear_estop (std_srvs/Trigger)
|
||||
— and only if no DANGER sectors remain at the time of the call.
|
||||
|
||||
Published topics:
|
||||
/saltybot/safety_zone (std_msgs/String) — JSON per-sector data
|
||||
/saltybot/safety_zone/status (std_msgs/String) — JSON summary + e-stop state
|
||||
/cmd_vel (geometry_msgs/Twist) — zero override when e-stopped
|
||||
|
||||
Subscribed topics:
|
||||
/scan (sensor_msgs/LaserScan) — RPLIDAR data
|
||||
/cmd_vel_input (geometry_msgs/Twist) — upstream cmd_vel (pass-through or block)
|
||||
|
||||
Services:
|
||||
/saltybot/safety_zone/clear_estop (std_srvs/Trigger)
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
|
||||
|
||||
from geometry_msgs.msg import Twist
|
||||
from sensor_msgs.msg import LaserScan
|
||||
from std_msgs.msg import String
|
||||
from std_srvs.srv import Trigger
|
||||
|
||||
|
||||
# Zone levels (int)
|
||||
CLEAR = 0
|
||||
WARN = 1
|
||||
DANGER = 2
|
||||
|
||||
_ZONE_NAME = {CLEAR: "CLEAR", WARN: "WARN", DANGER: "DANGER"}
|
||||
|
||||
|
||||
class SafetyZoneNode(Node):
|
||||
"""360° RPLIDAR safety zone detector with latching e-stop."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("safety_zone")
|
||||
|
||||
# ── Parameters ────────────────────────────────────────────────────────
|
||||
self.declare_parameter("danger_range_m", 0.30)
|
||||
self.declare_parameter("warn_range_m", 1.00)
|
||||
self.declare_parameter("n_sectors", 36) # 360/36 = 10° each
|
||||
self.declare_parameter("forward_arc_deg", 60.0) # ±30° e-stop window
|
||||
self.declare_parameter("estop_all_arcs", False) # true = any sector triggers
|
||||
self.declare_parameter("estop_debounce_frames", 2) # consecutive DANGER frames
|
||||
self.declare_parameter("min_valid_range_m", 0.05) # ignore closer readings
|
||||
self.declare_parameter("max_valid_range_m", 12.0) # RPLIDAR A1M8 max
|
||||
self.declare_parameter("publish_rate", 10.0) # Hz — sector publish rate
|
||||
self.declare_parameter("cmd_vel_input_topic", "/cmd_vel_input")
|
||||
self.declare_parameter("cmd_vel_output_topic", "/cmd_vel")
|
||||
|
||||
self._danger_r = self.get_parameter("danger_range_m").value
|
||||
self._warn_r = self.get_parameter("warn_range_m").value
|
||||
self._n_sectors = self.get_parameter("n_sectors").value
|
||||
self._fwd_arc = self.get_parameter("forward_arc_deg").value
|
||||
self._all_arcs = self.get_parameter("estop_all_arcs").value
|
||||
self._debounce = self.get_parameter("estop_debounce_frames").value
|
||||
self._min_r = self.get_parameter("min_valid_range_m").value
|
||||
self._max_r = self.get_parameter("max_valid_range_m").value
|
||||
self._pub_rate = self.get_parameter("publish_rate").value
|
||||
_in_topic = self.get_parameter("cmd_vel_input_topic").value
|
||||
_out_topic = self.get_parameter("cmd_vel_output_topic").value
|
||||
|
||||
self._sector_deg = 360.0 / self._n_sectors # degrees per sector
|
||||
|
||||
# Precompute which sector indices are in the forward arc
|
||||
self._forward_sectors = self._compute_forward_sectors()
|
||||
|
||||
# ── State ─────────────────────────────────────────────────────────────
|
||||
self._lock = threading.Lock()
|
||||
self._sector_zones: List[int] = [CLEAR] * self._n_sectors
|
||||
self._sector_ranges: List[float] = [float("inf")] * self._n_sectors
|
||||
self._estop_latched = False
|
||||
self._estop_reason = ""
|
||||
self._danger_frame_count = 0 # consecutive DANGER frames in forward arc
|
||||
self._scan_count = 0
|
||||
self._last_scan_stamp: Optional[float] = None
|
||||
|
||||
# ── Subscriptions ─────────────────────────────────────────────────────
|
||||
sensor_qos = QoSProfile(
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=1,
|
||||
)
|
||||
self._scan_sub = self.create_subscription(
|
||||
LaserScan, "/scan", self._on_scan, sensor_qos
|
||||
)
|
||||
self._cmd_in_sub = self.create_subscription(
|
||||
Twist, _in_topic, self._on_cmd_vel_input, 10
|
||||
)
|
||||
|
||||
# ── Publishers ────────────────────────────────────────────────────────
|
||||
self._zone_pub = self.create_publisher(String, "/saltybot/safety_zone", 10)
|
||||
self._status_pub = self.create_publisher(String, "/saltybot/safety_zone/status", 10)
|
||||
self._cmd_pub = self.create_publisher(Twist, _out_topic, 10)
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────────
|
||||
self._clear_srv = self.create_service(
|
||||
Trigger,
|
||||
"/saltybot/safety_zone/clear_estop",
|
||||
self._handle_clear_estop,
|
||||
)
|
||||
|
||||
# ── Periodic status publish ───────────────────────────────────────────
|
||||
self.create_timer(1.0 / self._pub_rate, self._publish_status)
|
||||
|
||||
self.get_logger().info(
|
||||
f"SafetyZoneNode ready — "
|
||||
f"danger={self._danger_r}m warn={self._warn_r}m "
|
||||
f"sectors={self._n_sectors}({self._sector_deg:.0f}°each) "
|
||||
f"fwd_arc=±{self._fwd_arc/2:.0f}° "
|
||||
f"debounce={self._debounce}"
|
||||
)
|
||||
|
||||
# ── Sector geometry ───────────────────────────────────────────────────────
|
||||
|
||||
def _compute_forward_sectors(self) -> List[int]:
|
||||
"""Return sector indices that lie within the forward arc."""
|
||||
half = self._fwd_arc / 2.0
|
||||
fwd = []
|
||||
for i in range(self._n_sectors):
|
||||
centre_deg = i * self._sector_deg
|
||||
# Normalise to (−180, 180]
|
||||
if centre_deg > 180.0:
|
||||
centre_deg -= 360.0
|
||||
if abs(centre_deg) <= half:
|
||||
fwd.append(i)
|
||||
return fwd
|
||||
|
||||
@staticmethod
|
||||
def _angle_to_sector(angle_rad: float, n_sectors: int) -> int:
|
||||
"""Convert a bearing (rad) to sector index [0, n_sectors)."""
|
||||
deg = math.degrees(angle_rad) % 360.0
|
||||
return int(deg / (360.0 / n_sectors)) % n_sectors
|
||||
|
||||
# ── Scan processing ───────────────────────────────────────────────────────
|
||||
|
||||
def _on_scan(self, msg: LaserScan) -> None:
|
||||
"""Process incoming LaserScan into per-sector zone classification."""
|
||||
n = len(msg.ranges)
|
||||
if n == 0:
|
||||
return
|
||||
|
||||
# Accumulate min range per sector
|
||||
sector_min = [float("inf")] * self._n_sectors
|
||||
|
||||
for i, r in enumerate(msg.ranges):
|
||||
if not math.isfinite(r) or r < self._min_r or r > self._max_r:
|
||||
continue
|
||||
angle_rad = msg.angle_min + i * msg.angle_increment
|
||||
s = self._angle_to_sector(angle_rad, self._n_sectors)
|
||||
if r < sector_min[s]:
|
||||
sector_min[s] = r
|
||||
|
||||
# Classify each sector
|
||||
sector_zones = []
|
||||
for r in sector_min:
|
||||
if r < self._danger_r:
|
||||
sector_zones.append(DANGER)
|
||||
elif r < self._warn_r:
|
||||
sector_zones.append(WARN)
|
||||
else:
|
||||
sector_zones.append(CLEAR)
|
||||
|
||||
with self._lock:
|
||||
self._sector_zones = sector_zones
|
||||
self._sector_ranges = sector_min
|
||||
self._scan_count += 1
|
||||
self._last_scan_stamp = self.get_clock().now().nanoseconds * 1e-9
|
||||
|
||||
# E-stop detection
|
||||
if not self._estop_latched:
|
||||
danger_in_trigger = self._has_danger_in_trigger_arc(sector_zones)
|
||||
if danger_in_trigger:
|
||||
self._danger_frame_count += 1
|
||||
if self._danger_frame_count >= self._debounce:
|
||||
self._estop_latched = True
|
||||
danger_sectors = [
|
||||
i for i in (range(self._n_sectors) if self._all_arcs
|
||||
else self._forward_sectors)
|
||||
if sector_zones[i] == DANGER
|
||||
]
|
||||
self._estop_reason = (
|
||||
f"DANGER in sectors {danger_sectors} "
|
||||
f"(min range {min(sector_min[i] for i in danger_sectors if math.isfinite(sector_min[i])):.2f}m)"
|
||||
)
|
||||
self.get_logger().error(
|
||||
f"E-STOP LATCHED: {self._estop_reason}"
|
||||
)
|
||||
else:
|
||||
self._danger_frame_count = 0
|
||||
|
||||
# Publish zero cmd_vel immediately if e-stopped (time-critical)
|
||||
if self._estop_latched:
|
||||
self._cmd_pub.publish(Twist())
|
||||
|
||||
# Publish sector data every scan
|
||||
self._publish_sectors(sector_zones, sector_min)
|
||||
|
||||
def _has_danger_in_trigger_arc(self, zones: List[int]) -> bool:
|
||||
"""True if any DANGER sector exists in the trigger arc."""
|
||||
if self._all_arcs:
|
||||
return any(z == DANGER for z in zones)
|
||||
return any(zones[i] == DANGER for i in self._forward_sectors)
|
||||
|
||||
# ── cmd_vel pass-through / override ──────────────────────────────────────
|
||||
|
||||
def _on_cmd_vel_input(self, msg: Twist) -> None:
|
||||
"""Pass cmd_vel through unless e-stop is latched."""
|
||||
with self._lock:
|
||||
latched = self._estop_latched
|
||||
if latched:
|
||||
# Override: publish zero (already done in scan callback, belt-and-braces)
|
||||
self._cmd_pub.publish(Twist())
|
||||
else:
|
||||
self._cmd_pub.publish(msg)
|
||||
|
||||
# ── Service: clear e-stop ─────────────────────────────────────────────────
|
||||
|
||||
def _handle_clear_estop(
|
||||
self, request: Trigger.Request, response: Trigger.Response
|
||||
) -> Trigger.Response:
|
||||
with self._lock:
|
||||
if not self._estop_latched:
|
||||
response.success = True
|
||||
response.message = "E-stop was not active."
|
||||
return response
|
||||
|
||||
# Only allow clear if no current DANGER sectors
|
||||
if self._has_danger_in_trigger_arc(self._sector_zones):
|
||||
response.success = False
|
||||
response.message = (
|
||||
"Cannot clear: DANGER sectors still present. "
|
||||
"Remove obstacle first."
|
||||
)
|
||||
return response
|
||||
|
||||
self._estop_latched = False
|
||||
self._estop_reason = ""
|
||||
self._danger_frame_count = 0
|
||||
|
||||
self.get_logger().warning("E-stop cleared via service.")
|
||||
response.success = True
|
||||
response.message = "E-stop cleared. Resuming normal operation."
|
||||
return response
|
||||
|
||||
# ── Publishers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _publish_sectors(self, zones: List[int], ranges: List[float]) -> None:
|
||||
"""Publish per-sector JSON on /saltybot/safety_zone."""
|
||||
sectors_data = []
|
||||
for i, (zone, r) in enumerate(zip(zones, ranges)):
|
||||
centre_deg = i * self._sector_deg
|
||||
sectors_data.append({
|
||||
"sector": i,
|
||||
"angle_deg": round(centre_deg, 1),
|
||||
"zone": _ZONE_NAME[zone],
|
||||
"min_range_m": round(r, 3) if math.isfinite(r) else None,
|
||||
"forward": i in self._forward_sectors,
|
||||
})
|
||||
|
||||
payload = {
|
||||
"sectors": sectors_data,
|
||||
"estop_active": self._estop_latched,
|
||||
"estop_reason": self._estop_reason,
|
||||
"danger_sectors": [i for i, z in enumerate(zones) if z == DANGER],
|
||||
"warn_sectors": [i for i, z in enumerate(zones) if z == WARN],
|
||||
}
|
||||
self._zone_pub.publish(String(data=json.dumps(payload)))
|
||||
|
||||
def _publish_status(self) -> None:
|
||||
"""10 Hz JSON summary on /saltybot/safety_zone/status."""
|
||||
with self._lock:
|
||||
zones = list(self._sector_zones)
|
||||
ranges = list(self._sector_ranges)
|
||||
latched = self._estop_latched
|
||||
reason = self._estop_reason
|
||||
scans = self._scan_count
|
||||
|
||||
danger_cnt = sum(1 for z in zones if z == DANGER)
|
||||
warn_cnt = sum(1 for z in zones if z == WARN)
|
||||
fwd_zone = max(
|
||||
(zones[i] for i in self._forward_sectors),
|
||||
default=CLEAR,
|
||||
)
|
||||
|
||||
# Closest obstacle in any direction
|
||||
all_finite = [r for r in ranges if math.isfinite(r)]
|
||||
closest_m = min(all_finite) if all_finite else None
|
||||
|
||||
status = {
|
||||
"estop_active": latched,
|
||||
"estop_reason": reason,
|
||||
"forward_zone": _ZONE_NAME[fwd_zone],
|
||||
"danger_sector_count": danger_cnt,
|
||||
"warn_sector_count": warn_cnt,
|
||||
"closest_obstacle_m": round(closest_m, 3) if closest_m is not None else None,
|
||||
"scan_count": scans,
|
||||
"forward_sector_ids": self._forward_sectors,
|
||||
}
|
||||
self._status_pub.publish(String(data=json.dumps(status)))
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = SafetyZoneNode()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
jetson/ros2_ws/src/saltybot_safety_zone/setup.cfg
Normal file
5
jetson/ros2_ws/src/saltybot_safety_zone/setup.cfg
Normal file
@ -0,0 +1,5 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/saltybot_safety_zone
|
||||
|
||||
[install]
|
||||
install_scripts=$base/lib/saltybot_safety_zone
|
||||
30
jetson/ros2_ws/src/saltybot_safety_zone/setup.py
Normal file
30
jetson/ros2_ws/src/saltybot_safety_zone/setup.py
Normal file
@ -0,0 +1,30 @@
|
||||
import os
|
||||
from glob import glob
|
||||
from setuptools import setup
|
||||
|
||||
package_name = "saltybot_safety_zone"
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version="0.1.0",
|
||||
packages=[package_name],
|
||||
data_files=[
|
||||
("share/ament_index/resource_index/packages",
|
||||
["resource/" + package_name]),
|
||||
("share/" + package_name, ["package.xml"]),
|
||||
(os.path.join("share", package_name, "launch"), glob("launch/*.py")),
|
||||
(os.path.join("share", package_name, "config"), glob("config/*.yaml")),
|
||||
],
|
||||
install_requires=["setuptools"],
|
||||
zip_safe=True,
|
||||
maintainer="sl-perception",
|
||||
maintainer_email="sl-perception@saltylab.local",
|
||||
description="RPLIDAR 360° safety zone detector with latching e-stop (Issue #575)",
|
||||
license="MIT",
|
||||
tests_require=["pytest"],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"safety_zone = saltybot_safety_zone.safety_zone_node:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user