base_agent/uas/comm/adapters/dds_adapter.py

165 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
DDS (Data Distribution Service) 适配器
基于 cyclonedds-python 实现
支持Publisher/Subscriber、QoS 策略、Domain 隔离
DDS 是实时分布式系统的工业标准(无人机、机器人、航空航天广泛使用)
"""
import asyncio
import json
import logging
from typing import Dict, Any
try:
import cyclonedds.domain as dds_domain
import cyclonedds.pub as dds_pub
import cyclonedds.sub as dds_sub
import cyclonedds.topic as dds_topic
import cyclonedds.core as dds_core
from cyclonedds.idl import IdlStruct
from cyclonedds.idl.types import array, sequence
from cyclonedds.qos import Qos, Policy
HAS_DDS = True
except ImportError:
HAS_DDS = False
logging.getLogger(__name__).warning(
"⚠️ cyclonedds 未安装DDS 适配器运行在模拟模式。"
" 安装: pip install cyclonedds"
)
from uas.comm.base import CommBaseAdapter, ConnectionConfig, MessageHandler
from uas.comm.message import Message
class DDSAdapterComm(CommBaseAdapter):
"""
DDS 通信适配器(基于 CycloneDDS
URL 格式: dds://[domain_id@]host[:port][/topic_prefix][?qos_options]
示例: dds://0@localhost:7400/rt?reliability=RELIABLE&durability=TRANSIENT_LOCAL
"""
def __init__(self, config: ConnectionConfig):
super().__init__(config)
self._domain_id = int(config.username) if config.username.isdigit() else 0
self._topic_prefix = config.database or ""
self._participant = None
self._publishers: Dict[str, Any] = {}
self._subscribers: Dict[str, Any] = {}
self._writers: Dict[str, Any] = {}
self._readers: Dict[str, Any] = {}
self._poll_tasks: Dict[str, asyncio.Task] = {}
def _build_qos(self) -> Any:
"""根据 URL options 构建 QoS 策略"""
if not HAS_DDS:
return None
opts = self.config.options
reliability = opts.get("reliability", "BEST_EFFORT")
durability = opts.get("durability", "VOLATILE")
policies = []
if reliability == "RELIABLE":
policies.append(Policy.Reliability.Reliable())
else:
policies.append(Policy.Reliability.BestEffort())
if durability == "TRANSIENT_LOCAL":
policies.append(Policy.Durability.TransientLocal())
elif durability == "PERSISTENT":
policies.append(Policy.Durability.Persistent())
else:
policies.append(Policy.Durability.Volatile())
return Qos(*policies)
# ── 生命周期 ──────────────────────────────────────────────────
async def connect(self) -> None:
if HAS_DDS:
self._participant = dds_domain.DomainParticipant(self._domain_id)
self.logger.info(
f"✅ DDS 已连接: domain={self._domain_id} prefix={self._topic_prefix!r}"
)
else:
self.logger.warning("⚠️ DDS 模拟模式已启动cyclonedds 未安装)")
self._running = True
async def disconnect(self) -> None:
self._running = False
for task in self._poll_tasks.values():
task.cancel()
# CycloneDDS 资源自动释放GC
self._participant = None
self.logger.info("🔌 DDS 连接已关闭")
# ── 核心功能 ──────────────────────────────────────────────────
async def publish(self, message: Message) -> bool:
full_topic = f"{self._topic_prefix}/{message.topic}".lstrip("/")
if HAS_DDS:
try:
writer = self._get_or_create_writer(full_topic)
# 序列化为 JSON bytes 写入
writer.write(json.dumps(message.to_dict()).encode())
self.logger.debug(f"📤 DDS 发布 topic={full_topic!r}")
return True
except Exception as e:
self.logger.error(f"DDS 发布失败: {e}")
return False
else:
# 模拟模式:直接本地分发
self.logger.debug(f"[MOCK] DDS 发布 topic={full_topic!r}")
await self._dispatch(message)
return True
async def subscribe(self, topic: str, handler: MessageHandler) -> None:
full_topic = f"{self._topic_prefix}/{topic}".lstrip("/")
self._register_handler(topic, handler)
if HAS_DDS and full_topic not in self._readers:
reader = self._get_or_create_reader(full_topic)
self._poll_tasks[full_topic] = asyncio.create_task(
self._poll_loop(topic, full_topic, reader)
)
self.logger.info(f"📥 订阅 DDS topic: {full_topic!r} domain={self._domain_id}")
async def unsubscribe(self, topic: str) -> None:
full_topic = f"{self._topic_prefix}/{topic}".lstrip("/")
if full_topic in self._poll_tasks:
self._poll_tasks[full_topic].cancel()
del self._poll_tasks[full_topic]
self._readers.pop(full_topic, None)
self._handlers.pop(topic, None)
self.logger.info(f"🚫 取消订阅 DDS topic: {full_topic!r}")
# ── 内部工具 ──────────────────────────────────────────────────
def _get_or_create_writer(self, topic_name: str):
if topic_name not in self._writers:
tp = dds_topic.Topic(self._participant, topic_name, bytes)
pub = dds_pub.Publisher(self._participant)
self._writers[topic_name] = dds_pub.DataWriter(pub, tp, qos=self._build_qos())
return self._writers[topic_name]
def _get_or_create_reader(self, topic_name: str):
if topic_name not in self._readers:
tp = dds_topic.Topic(self._participant, topic_name, bytes)
sub = dds_sub.Subscriber(self._participant)
self._readers[topic_name] = dds_sub.DataReader(sub, tp, qos=self._build_qos())
return self._readers[topic_name]
async def _poll_loop(self, logical_topic: str, full_topic: str, reader):
"""轮询 DDS ReaderDDS 无原生 asyncio 支持,用 run_in_executor 桥接)"""
loop = asyncio.get_event_loop()
self.logger.info(f"🔄 DDS 轮询循环启动: {full_topic!r}")
try:
while self._running:
samples = await loop.run_in_executor(None, reader.take, 10)
for sample in samples:
try:
data = json.loads(bytes(sample))
message = Message.from_dict(data)
await self._dispatch(message)
except Exception as e:
self.logger.error(f"DDS 消息解析异常: {e}")
await asyncio.sleep(0.01) # 10ms 轮询间隔
except asyncio.CancelledError:
self.logger.info(f"🛑 DDS 轮询循环停止: {full_topic!r}")