165 lines
7.1 KiB
Python
165 lines
7.1 KiB
Python
|
|
# -*- 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 Reader(DDS 无原生 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}")
|