Merge branch 'chiyu'
This commit is contained in:
@@ -14,6 +14,7 @@ find_package(builtin_interfaces REQUIRED)
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
"msg/AttitudeRaw.msg"
|
||||
"msg/GnssRaw.msg"
|
||||
"msg/SystemDiagnosticsRaw.msg"
|
||||
"msg/ServiceAckResult.msg"
|
||||
"srv/MavPing.srv"
|
||||
"srv/MavCommandLong.srv"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
builtin_interfaces/Time stamp
|
||||
uint32 sensors_install_mask
|
||||
uint32 sensors_enabled_mask
|
||||
uint32 sensors_health_mask
|
||||
uint16 mcu_load
|
||||
uint16 bus_error_rate
|
||||
uint16 bus_error_count
|
||||
uint16 errors_count1
|
||||
uint16 errors_count2
|
||||
uint16 errors_count3
|
||||
uint16 errors_count4
|
||||
@@ -29,7 +29,7 @@ from .utils import acquireSerial, acquirePort
|
||||
from .utils.acquirePort import find_available_port
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
PROJECT_VER = "v1.10"
|
||||
PROJECT_VER = "v1.20"
|
||||
|
||||
class PanelState:
|
||||
def __init__(self):
|
||||
@@ -1106,7 +1106,7 @@ class ControlPanel:
|
||||
0: "HB", 1: "S_STAT", 2: "S_TIME", 24: "GPS_RAW", 27: "RAW_IMU",
|
||||
30: "ATT", 32: "LOC_POS", 33: "GLB_POS", 62: "NAV_CTL",
|
||||
74: "VFR_HUD", 147: "BATT_ST", 136: "TERRAIN", 241: "VIBRAT",
|
||||
125: "POW_STA",
|
||||
125: "POW_STA", 253: "STAT_TXT",
|
||||
}
|
||||
|
||||
# ardupilot mega
|
||||
@@ -1198,6 +1198,8 @@ class Orchestrator:
|
||||
'vfr_hud': 1.0,
|
||||
'mode': 0.0,
|
||||
'summary': 1.0,
|
||||
'sys_diags': 1.0,
|
||||
'status_text': 1.0,
|
||||
}
|
||||
|
||||
def engageWholeSystem(self):
|
||||
@@ -1642,10 +1644,10 @@ def main():
|
||||
if mo.MODULE_VER != "1.50":
|
||||
print("Module Version Error! : mavlinkObkect")
|
||||
version_check = False
|
||||
if mros.MODULE_VER != "2.10":
|
||||
if mros.MODULE_VER != "2.50":
|
||||
print("Module Version Error! : mavlinkROS2Nodes")
|
||||
version_check = False
|
||||
if mvv.MODULE_VER != "1.00":
|
||||
if mvv.MODULE_VER != "1.10":
|
||||
print("Module Version Error! : mavlinkVehicleView")
|
||||
version_check = False
|
||||
if sm.MODULE_VER != "0.80":
|
||||
|
||||
@@ -51,7 +51,7 @@ from .mavlinkVehicleView import (
|
||||
VehicleView,
|
||||
VehicleComponent,
|
||||
ComponentType,
|
||||
ConnectionType
|
||||
StatusTextEntry,
|
||||
)
|
||||
from .utils import RingBuffer, setup_logger
|
||||
|
||||
@@ -108,13 +108,15 @@ class mavlink_bridge:
|
||||
def _init_message_handlers(self):
|
||||
"""初始化訊息處理器映射表,提高處理效率"""
|
||||
self.message_handlers = {
|
||||
0: self._handle_heartbeat, # HEARTBEAT
|
||||
24: self._handle_gps_raw_int, # GPS_RAW_INT
|
||||
30: self._handle_attitude, # ATTITUDE
|
||||
32: self._handle_local_position, # LOCAL_POSITION_NED
|
||||
33: self._handle_global_position, # GLOBAL_POSITION_INT
|
||||
74: self._handle_vfr_hud, # VFR_HUD
|
||||
147: self._handle_battery_status, # BATTERY_STATUS
|
||||
0: self._handle_heartbeat, # HEARTBEAT
|
||||
1: self._handle_vehicle_sys_status, # SYS_STATUS
|
||||
24: self._handle_gps_raw_int, # GPS_RAW_INT
|
||||
30: self._handle_attitude, # ATTITUDE
|
||||
32: self._handle_local_position, # LOCAL_POSITION_NED
|
||||
33: self._handle_global_position, # GLOBAL_POSITION_INT
|
||||
74: self._handle_vfr_hud, # VFR_HUD
|
||||
147: self._handle_battery_status, # BATTERY_STATUS
|
||||
253: self._handle_status_text, # STATUSTEXT
|
||||
}
|
||||
|
||||
def start(self):
|
||||
@@ -231,6 +233,29 @@ class mavlink_bridge:
|
||||
'vx': msg.vx, 'vy': msg.vy, 'vz': msg.vz
|
||||
}
|
||||
|
||||
def _handle_vehicle_sys_status(self, vehicle, component, msg, timestamp):
|
||||
"""處理 SYS_STATUS 訊息 (msg_id: 1)"""
|
||||
diag = component.status.sys_diag
|
||||
diag.sensors_install_mask = msg.onboard_control_sensors_present
|
||||
diag.sensors_enabled_mask = msg.onboard_control_sensors_enabled
|
||||
diag.sensors_health_mask = msg.onboard_control_sensors_health
|
||||
diag.mcuLoad = msg.load
|
||||
diag.busErrorRate = msg.drop_rate_comm
|
||||
diag.busErrorCount = msg.errors_comm
|
||||
diag.errors_count1 = msg.errors_count1
|
||||
diag.errors_count2 = msg.errors_count2
|
||||
diag.errors_count3 = msg.errors_count3
|
||||
diag.errors_count4 = msg.errors_count4
|
||||
diag.timestamp = timestamp
|
||||
|
||||
def _handle_status_text(self, vehicle, component, msg, timestamp):
|
||||
"""處理 STATUSTEXT 訊息 (msg_id: 253)"""
|
||||
text = msg.text.rstrip('\x00') if msg.text else ''
|
||||
if not text:
|
||||
return
|
||||
component.status.status_text_queue.append(
|
||||
StatusTextEntry(text=text, severity=msg.severity, timestamp=timestamp)
|
||||
)
|
||||
|
||||
def _handle_global_position(self, vehicle, component, msg, timestamp):
|
||||
"""處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)"""
|
||||
@@ -395,9 +420,9 @@ class mavlink_object:
|
||||
self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191)
|
||||
|
||||
# 記錄訊息過濾類型 (可選)
|
||||
# 0 HEARTBEAT, 24 GPS_RAW_INT, 30 ATTITUDE,
|
||||
# 32 LOCAL_POSITION_NED, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS
|
||||
self.bridge_msg_types = set([0, 24, 30, 32, 33, 74, 147])
|
||||
# 0 HEARTBEAT, 1 SYS_STATUS, 24 GPS_RAW_INT, 30 ATTITUDE,
|
||||
# 32 LOCAL_POSITION_NED, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS, 253 STATUSTEXT
|
||||
self.bridge_msg_types = set([0, 1, 24, 30, 32, 33, 74, 147, 253])
|
||||
self.return_msg_types = set([])
|
||||
|
||||
# 轉發到別的 mavlink object 作為目標端口 的列表
|
||||
@@ -834,5 +859,8 @@ if __name__ == '__main__':
|
||||
1. async_io_manager.managed_objects 與 mavlink_object.mavlinkObjects 功能重複整合 保留 mavlink_object.mavlinkObjects
|
||||
2. async_io_manager 的 _stop_event 無效變數移除
|
||||
|
||||
2026年 06月 10日
|
||||
1. 增加 SYS_STATUS 與 STATUSTEXT 訊息的處理機制
|
||||
|
||||
'''
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ from . import mavlinkObject as mo
|
||||
from .utils import setup_logger
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
MODULE_VER = "2.10"
|
||||
MODULE_VER = "2.50"
|
||||
|
||||
FC_ROS_DOMAIN_ID = "0"
|
||||
NODE_KEYS = ("status_publisher", "command_service", "rtcm_relay")
|
||||
@@ -68,14 +68,16 @@ class PublishRateController:
|
||||
# 注意 這邊是定義區 不要把參數寫在這裡 所以預設全部關閉
|
||||
# 以這個專案 請看 mainOrchestrator.py 的 Orchestrator 初始化階段
|
||||
self.topic_intervals = {
|
||||
'position_gnss': 0.0, # GNSS位置
|
||||
'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度)
|
||||
'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態)
|
||||
'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除)
|
||||
'battery': 0.0, # 電池
|
||||
'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門)
|
||||
'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除)
|
||||
'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態)
|
||||
'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態)
|
||||
'position_gnss': 0.0, # GNSS位置 (海拔高度)
|
||||
'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度+相對高度)
|
||||
'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態)
|
||||
'battery': 0.0, # 電池
|
||||
'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門)
|
||||
'sys_diags': 0.0, # SYS_STATUS 系統診斷
|
||||
'status_text': 0.0, # STATUSTEXT 飛控文字(佇列驅動,>0 僅作啟用旗標)
|
||||
'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除)
|
||||
'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除)
|
||||
# 在這裡新增更多 topics...
|
||||
}
|
||||
# 記錄每個 topic 的最後發布時間 {(sysid, topic): timestamp}
|
||||
@@ -112,6 +114,10 @@ class PublishRateController:
|
||||
|
||||
return False
|
||||
|
||||
def is_topic_enabled(self, topic: str) -> bool:
|
||||
"""檢查 topic 是否啟用(interval > 0)"""
|
||||
return self.topic_intervals.get(topic, 0) > 0
|
||||
|
||||
def reset(self):
|
||||
"""重置所有計時器"""
|
||||
self.last_publish_time.clear()
|
||||
@@ -122,7 +128,7 @@ class VehicleStatusPublisher(Node):
|
||||
|
||||
職責:
|
||||
- 定期從 vehicle_registry 讀取載具狀態
|
||||
- 頻率控制(位置/姿態 2Hz,電池/摘要 1Hz)
|
||||
- 頻率控制 (位置/姿態 2Hz, 電池/摘要 1Hz)
|
||||
- 發布標準 ROS2 消息類型
|
||||
- 檢測訂閱者,按需發布
|
||||
"""
|
||||
@@ -181,11 +187,13 @@ class VehicleStatusPublisher(Node):
|
||||
self._publish_position_gnss(sysid, status)
|
||||
self._publish_position_ned(sysid, status)
|
||||
self._publish_attitude(sysid, status)
|
||||
self._publish_velocity(sysid, status)
|
||||
self._publish_battery(sysid, status)
|
||||
self._publish_vfr_hud(sysid, status)
|
||||
self._publish_mode(sysid, status)
|
||||
self._publish_summary(vehicle)
|
||||
self._publish_system_diagnostics(sysid, status)
|
||||
self._publish_status_text(sysid, status)
|
||||
self._publish_velocity(sysid, status)
|
||||
self._publish_mode(sysid, status)
|
||||
# 在這裡新增更多 publish 方法調用...
|
||||
|
||||
def _get_or_create_publisher(self, sysid: int, topic: str, msg_type, qos: int = 1):
|
||||
@@ -439,11 +447,7 @@ class VehicleStatusPublisher(Node):
|
||||
'armed': status.armed if status.armed is not None else False,
|
||||
# 'mode_custom': status.mode.custom_mode if status.mode.custom_mode else 0,
|
||||
'mode_name': status.mode.mode_name if status.mode.mode_name else "UNKNOWN",
|
||||
# 'latitude': status.position.latitude if status.position.latitude else 0.0,
|
||||
# 'longitude': status.position.longitude if status.position.longitude else 0.0,
|
||||
# 'altitude': status.position.altitude if status.position.altitude else 0.0,
|
||||
# 'battery_percent': status.battery.remaining if status.battery.remaining else 0,
|
||||
# 'gps_fix': status.gps.fix_type if status.gps.fix_type else 0,
|
||||
'mav_status': status.system_status if status.system_status is not None else 0,
|
||||
'connection_type': vehicle.connected_via.value,
|
||||
'last_update': component.packet_stats.last_msg_time if component.packet_stats.last_msg_time else 0.0,
|
||||
}
|
||||
@@ -452,6 +456,62 @@ class VehicleStatusPublisher(Node):
|
||||
msg.data = json.dumps(summary)
|
||||
publisher.publish(msg)
|
||||
|
||||
def _publish_system_diagnostics(self, sysid: int, status: mvv.ComponentStatus):
|
||||
"""發布 SYS_STATUS 系統診斷資訊"""
|
||||
if not self.rate_controller.should_publish(sysid, 'sys_diags'):
|
||||
return
|
||||
|
||||
diag = status.sys_diag
|
||||
if diag.sensors_install_mask is None:
|
||||
return
|
||||
|
||||
publisher = self._get_or_create_publisher(
|
||||
sysid, 'sys_diags', fcmsg.SystemDiagnosticsRaw
|
||||
)
|
||||
|
||||
if publisher.get_subscription_count() == 0:
|
||||
return
|
||||
|
||||
msg = fcmsg.SystemDiagnosticsRaw()
|
||||
msg.stamp = self.get_clock().now().to_msg()
|
||||
msg.sensors_install_mask = int(diag.sensors_install_mask)
|
||||
msg.sensors_enabled_mask = int(diag.sensors_enabled_mask or 0)
|
||||
msg.sensors_health_mask = int(diag.sensors_health_mask or 0)
|
||||
msg.mcu_load = int(diag.mcuLoad or 0)
|
||||
msg.bus_error_rate = int(diag.busErrorRate or 0)
|
||||
msg.bus_error_count = int(diag.busErrorCount or 0)
|
||||
msg.errors_count1 = int(diag.errors_count1 or 0)
|
||||
msg.errors_count2 = int(diag.errors_count2 or 0)
|
||||
msg.errors_count3 = int(diag.errors_count3 or 0)
|
||||
msg.errors_count4 = int(diag.errors_count4 or 0)
|
||||
|
||||
publisher.publish(msg)
|
||||
|
||||
def _publish_status_text(self, sysid: int, status: mvv.ComponentStatus):
|
||||
"""發布 STATUSTEXT 飛控文字 (佇列 drain, 無訂閱者直接丟棄) """
|
||||
# 是否啟用
|
||||
if not self.rate_controller.is_topic_enabled('status_text'):
|
||||
return
|
||||
# 是否有資料
|
||||
queue = status.status_text_queue
|
||||
if not queue:
|
||||
return
|
||||
|
||||
publisher = self._get_or_create_publisher(sysid, 'status_text', std_msgs.msg.String)
|
||||
|
||||
# 是否有監聽者
|
||||
if publisher.get_subscription_count() == 0:
|
||||
queue.clear()
|
||||
return
|
||||
|
||||
while queue:
|
||||
entry = queue.popleft()
|
||||
msg = std_msgs.msg.String()
|
||||
ts = entry.timestamp if entry.timestamp is not None else 0.0
|
||||
sev = entry.severity if entry.severity is not None else -1
|
||||
msg.data = f'[{ts:.3f}] [{sev}] {entry.text}'
|
||||
publisher.publish(msg)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 【新增 Topic 位置 3/4】
|
||||
# 若要新增 topic,請在此處實作對應的發布方法
|
||||
@@ -470,29 +530,6 @@ class VehicleStatusPublisher(Node):
|
||||
# # ... 實作發布邏輯
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@staticmethod
|
||||
def _euler_to_quaternion(roll, pitch, yaw):
|
||||
"""
|
||||
歐拉角轉四元數
|
||||
|
||||
Args:
|
||||
roll: 橫滾角 (弧度)
|
||||
pitch: 俯仰角 (弧度)
|
||||
yaw: 偏航角 (弧度)
|
||||
|
||||
Returns:
|
||||
tuple: (qx, qy, qz, qw)
|
||||
"""
|
||||
qx = math.sin(roll/2) * math.cos(pitch/2) * math.cos(yaw/2) - \
|
||||
math.cos(roll/2) * math.sin(pitch/2) * math.sin(yaw/2)
|
||||
qy = math.cos(roll/2) * math.sin(pitch/2) * math.cos(yaw/2) + \
|
||||
math.sin(roll/2) * math.cos(pitch/2) * math.sin(yaw/2)
|
||||
qz = math.cos(roll/2) * math.cos(pitch/2) * math.sin(yaw/2) - \
|
||||
math.sin(roll/2) * math.sin(pitch/2) * math.cos(yaw/2)
|
||||
qw = math.cos(roll/2) * math.cos(pitch/2) * math.cos(yaw/2) + \
|
||||
math.sin(roll/2) * math.sin(pitch/2) * math.sin(yaw/2)
|
||||
return (qx, qy, qz, qw)
|
||||
|
||||
def stop(self):
|
||||
"""停止發布"""
|
||||
self.running = False
|
||||
@@ -530,6 +567,14 @@ class MavlinkCommandService(Node):
|
||||
講白話一點就是
|
||||
每次接到一個 service 請求 要整個系統丟某種指令給載具時
|
||||
會做兩件事 1."丟出mavlink封包" 2."創造一個臨時信箱 Pending"
|
||||
|
||||
然後透過每次 manager spin
|
||||
會去呼叫 return_router() 方法
|
||||
這個方法會監聽 return_packet_ring 跟臨時信箱的 Pending 做配對
|
||||
配對到的解開 Pending
|
||||
|
||||
解開後 相對應的 handle_XXX 就會開始做事
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@@ -1153,6 +1198,10 @@ class fc_ros_manager:
|
||||
- RtcmRelay
|
||||
|
||||
提供統一的啟動/停止介面給 mainOrchestrator
|
||||
|
||||
另外 這邊用到 MultiThreadedExecutor 會開出額外的 thread 的特性
|
||||
使得就算 executor 在跑一些需要等待的方法
|
||||
常態的 spin_once 也不會被 block (spin_thread 是另一個支線)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -1432,6 +1481,10 @@ ros2_manager = fc_ros_manager()
|
||||
2. schedule_restart_node / _restart_node : 手動重啟單一 node (spin thread 內執行)
|
||||
3. orchestrator cmd: ("RESTART_ROS_NODE", node_key), node_key 見 NODE_KEYS
|
||||
|
||||
2026.06.10
|
||||
1. 增加了 _publish_system_diagnostics 與 _publish_status_text 功能
|
||||
|
||||
|
||||
TODO
|
||||
1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ VehicleView - Pure State Container
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections import deque
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -12,7 +13,7 @@ from enum import Enum
|
||||
from .utils import setup_logger
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
MODULE_VER = "1.00"
|
||||
MODULE_VER = "1.10"
|
||||
|
||||
# ====================== Enums =====================
|
||||
|
||||
@@ -117,6 +118,30 @@ class VFR:
|
||||
timestamp: Optional[float] = None # 時間戳記
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusTextEntry:
|
||||
"""飛控狀態文字(來源:MAVLink STATUSTEXT)"""
|
||||
text: str
|
||||
severity: Optional[int] = None
|
||||
timestamp: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemDiagnostics:
|
||||
"""系統診斷資訊(來源:MAVLink SYS_STATUS,不含電池欄位)"""
|
||||
sensors_install_mask: Optional[int] = None
|
||||
sensors_enabled_mask: Optional[int] = None
|
||||
sensors_health_mask: Optional[int] = None
|
||||
mcuLoad: Optional[int] = None # 單位 1%
|
||||
busErrorRate: Optional[int] = None # 單位 0.1%
|
||||
busErrorCount: Optional[int] = None
|
||||
errors_count1: Optional[int] = None
|
||||
errors_count2: Optional[int] = None
|
||||
errors_count3: Optional[int] = None
|
||||
errors_count4: Optional[int] = None
|
||||
timestamp: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentStatus:
|
||||
"""組件狀態容器"""
|
||||
@@ -127,6 +152,8 @@ class ComponentStatus:
|
||||
ekf: EKF = field(default_factory=EKF)
|
||||
gps: GPS = field(default_factory=GPS)
|
||||
vfr: VFR = field(default_factory=VFR)
|
||||
sys_diag: SystemDiagnostics = field(default_factory=SystemDiagnostics)
|
||||
status_text_queue: deque = field(default_factory=lambda: deque(maxlen=64))
|
||||
|
||||
# 系統狀態
|
||||
system_status: Optional[int] = None # MAV_STATE
|
||||
|
||||
@@ -2,17 +2,72 @@ import socket
|
||||
import base64
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSProfile, HistoryPolicy, ReliabilityPolicy, DurabilityPolicy
|
||||
from mavros_msgs.msg import RTCM
|
||||
|
||||
# TODO: ROS_DOMAIN_ID 要補一下
|
||||
|
||||
class GGA_stream():
|
||||
|
||||
@classmethod
|
||||
def nmea_checksum(cls, body: str) -> str:
|
||||
"""body 不含 '$' 與 '*checksum',例如 'GPGGA,123519,...'"""
|
||||
value = 0
|
||||
for ch in body:
|
||||
value ^= ord(ch)
|
||||
return f"{value:02X}"
|
||||
|
||||
@classmethod
|
||||
def decimal_to_nmea_dm(cls, deg: float, *, is_latitude: bool) -> Tuple[str, str]:
|
||||
"""十進位度 → NMEA 的 (d)dmm.mmmm 與半球字元。"""
|
||||
if is_latitude:
|
||||
hemi = "N" if deg >= 0 else "S"
|
||||
deg_width = 2
|
||||
else:
|
||||
hemi = "E" if deg >= 0 else "W"
|
||||
deg_width = 3
|
||||
deg = abs(deg)
|
||||
d = int(deg)
|
||||
m = (deg - d) * 60.0
|
||||
return f"{d:0{deg_width}d}{m:07.4f}", hemi
|
||||
|
||||
@classmethod
|
||||
def build_gga_sentence(cls, lat_deg: float, lon_deg: float, alt_m: float = 100.0) -> bytes:
|
||||
"""
|
||||
組一筆 $GPGGA 句子(含 checksum) 回傳 bytes 可直接 sock.sendall。
|
||||
|
||||
lat_deg / lon_deg : 十進位經緯度(北、東為正)。
|
||||
測試時請改成 mount 服務範圍內的近似位置;正式使用應來自 GNSS 真實定位。
|
||||
"""
|
||||
utc = time.gmtime()
|
||||
t_str = f"{utc.tm_hour:02d}{utc.tm_min:02d}{utc.tm_sec:02d}.00"
|
||||
|
||||
lat_dm, ns = cls.decimal_to_nmea_dm(lat_deg, is_latitude=True)
|
||||
lon_dm, ew = cls.decimal_to_nmea_dm(lon_deg, is_latitude=False)
|
||||
|
||||
# quality=1(GPS fix)、8 顆星、HDOP=1.0 僅供測試示意
|
||||
body = (
|
||||
f"GPGGA,{t_str},{lat_dm},{ns},{lon_dm},{ew},"
|
||||
f"1,08,1.0,{alt_m:.1f},M,0.0,M,,"
|
||||
)
|
||||
sentence = f"${body}*{cls.nmea_checksum(body)}\r\n"
|
||||
return sentence.encode("ascii")
|
||||
|
||||
@classmethod
|
||||
def send_gga(cls, sock: socket.socket, lat_deg: float, lon_deg: float, alt_m: float = 100.0) -> str:
|
||||
"""送出 GGA,回傳可讀句子供列印。"""
|
||||
payload = cls.build_gga_sentence(lat_deg, lon_deg, alt_m)
|
||||
sock.sendall(payload)
|
||||
return payload.decode("ascii").strip()
|
||||
|
||||
class NtripClientNode(Node):
|
||||
"""連線 NTRIP caster,接收 RTCM3 資料流並發布至 ROS2 topic。"""
|
||||
"""連線 NTRIP caster, 接收 RTCM3 資料流並發布至 ROS2 topic """
|
||||
|
||||
RECONNECT_BASE_SEC = 2.0
|
||||
RECONNECT_BASE_SEC = 10.0
|
||||
RECONNECT_MAX_SEC = 60.0
|
||||
|
||||
def __init__(self):
|
||||
@@ -21,9 +76,15 @@ class NtripClientNode(Node):
|
||||
self.declare_parameter('host', 'rtk2go.com')
|
||||
self.declare_parameter('port', 2101)
|
||||
self.declare_parameter('mountpoint', '')
|
||||
self.declare_parameter('username', '')
|
||||
self.declare_parameter('username', 'template@mail.com')
|
||||
self.declare_parameter('password', '')
|
||||
|
||||
self.declare_parameter('gga_on', False)
|
||||
self.declare_parameter('gga_lat', None)
|
||||
self.declare_parameter('gga_lon', None)
|
||||
self.declare_parameter('gga_alt_m', 100)
|
||||
self.declare_parameter('gga_interval_sec', 60)
|
||||
|
||||
rtcm_qos = QoSProfile(
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=1,
|
||||
@@ -32,7 +93,9 @@ class NtripClientNode(Node):
|
||||
)
|
||||
self.publisher_ = self.create_publisher(RTCM, '/fc_network/rtcm_input', rtcm_qos)
|
||||
|
||||
self._running = True
|
||||
self._stop_event = threading.Event()
|
||||
self._sock_lock = threading.Lock()
|
||||
self._active_sock: Optional[socket.socket] = None
|
||||
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@@ -40,31 +103,50 @@ class NtripClientNode(Node):
|
||||
|
||||
# ── NTRIP 連線 + RTCM 接收迴圈 ──────────────────────────────
|
||||
|
||||
def _request_stop(self):
|
||||
self._stop_event.set()
|
||||
with self._sock_lock:
|
||||
if self._active_sock is not None:
|
||||
try:
|
||||
self._active_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _receive_loop(self):
|
||||
backoff = self.RECONNECT_BASE_SEC
|
||||
|
||||
while self._running:
|
||||
host = self.get_parameter('host').value
|
||||
port = self.get_parameter('port').value
|
||||
mount = self.get_parameter('mountpoint').value
|
||||
user = self.get_parameter('username').value
|
||||
pwd = self.get_parameter('password').value
|
||||
host = self.get_parameter('host').value
|
||||
port = self.get_parameter('port').value
|
||||
mountpoint = self.get_parameter('mountpoint').value
|
||||
user = self.get_parameter('username').value
|
||||
pwd = self.get_parameter('password').value
|
||||
|
||||
if not mount:
|
||||
self.get_logger().error('mountpoint 參數未設定,等待重試...')
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
if not mountpoint:
|
||||
self.get_logger().error('mountpoint 參數未設定...')
|
||||
return
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
with self._sock_lock:
|
||||
self._active_sock = sock
|
||||
|
||||
self.gga_lat = self.get_parameter('gga_lat').value
|
||||
self.gga_lon = self.get_parameter('gga_lon').value
|
||||
self.gga_alt_m = self.get_parameter('gga_alt_m').value
|
||||
self.gga_interval_sec = self.get_parameter('gga_interval_sec').value
|
||||
self.gga_on = self.get_parameter('gga_on').value
|
||||
self.gga_on = self.gga_lat is not None and self.gga_lon is not None and self.gga_on
|
||||
|
||||
|
||||
try:
|
||||
self.get_logger().info(f'正在連線 {host}:{port}/{mount} ...')
|
||||
self.get_logger().info(f'正在連線 {host}:{port}/{mountpoint} ...')
|
||||
sock.connect((host, port))
|
||||
|
||||
auth = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
|
||||
request = (
|
||||
f'GET /{mount} HTTP/1.0\r\n'
|
||||
f'GET /{mountpoint} HTTP/1.0\r\n'
|
||||
f'User-Agent: NTRIP PythonClient\r\n'
|
||||
f'Authorization: Basic {auth}\r\n'
|
||||
f'Connection: close\r\n\r\n'
|
||||
@@ -78,29 +160,54 @@ class NtripClientNode(Node):
|
||||
)
|
||||
raise ConnectionError('handshake failed')
|
||||
|
||||
self.get_logger().info(f'已連接至 {mount},開始接收 RTCM 資料流')
|
||||
self.get_logger().info(f'已成功連接至 {mountpoint}')
|
||||
|
||||
|
||||
backoff = self.RECONNECT_BASE_SEC
|
||||
|
||||
self._read_stream(sock)
|
||||
|
||||
except Exception as e:
|
||||
if self._running:
|
||||
self.get_logger().warn(f'連線中斷: {e},{backoff:.0f}s 後重連')
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, self.RECONNECT_MAX_SEC)
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
self.get_logger().warn(f'連線中斷: {e},{backoff:.0f}s 後重連')
|
||||
if self._stop_event.wait(timeout=backoff):
|
||||
break
|
||||
backoff = min(backoff * 2, self.RECONNECT_MAX_SEC)
|
||||
finally:
|
||||
sock.close()
|
||||
with self._sock_lock:
|
||||
if self._active_sock is sock:
|
||||
self._active_sock = None
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _read_stream(self, sock: socket.socket):
|
||||
buf = b''
|
||||
last_gga_time = 0.0
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
now = time.time()
|
||||
if self.gga_on and (now - last_gga_time >= self.gga_interval_sec):
|
||||
line = GGA_stream.send_gga(sock, self.gga_lat, self.gga_lon, self.gga_alt_m)
|
||||
self.get_logger().info(f"[GGA] {line}")
|
||||
last_gga_time = now
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
except socket.timeout:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
continue
|
||||
except OSError:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
raise
|
||||
if not chunk:
|
||||
break
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
raise ConnectionError("stream ended. Empty Chunk")
|
||||
buf += chunk
|
||||
|
||||
while len(buf) >= 6:
|
||||
@@ -126,8 +233,11 @@ class NtripClientNode(Node):
|
||||
self.publisher_.publish(msg)
|
||||
|
||||
def destroy_node(self):
|
||||
self._running = False
|
||||
self._thread.join(timeout=3.0)
|
||||
self._request_stop()
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=5.0)
|
||||
if self._thread.is_alive():
|
||||
self.get_logger().warn('receive thread 未在時限內結束')
|
||||
super().destroy_node()
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
RTK
|
||||
跟 RTK2go 抓取列表 Done
|
||||
從特定 mount point 得到數據 Done
|
||||
做一個 ros2 service 接到數據並包裝
|
||||
做一個 ros2 service 接到數據並包裝 Done
|
||||
RTK 實機測試
|
||||
|
||||
|
||||
下一步
|
||||
@@ -34,7 +35,11 @@ python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p mountpo
|
||||
|
||||
python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p host:=210.241.63.193 -p port:=81 -p mountpoint:=2020_GNSS -p username:=uavlab6061 -p password:=iamsupersmart
|
||||
|
||||
export ROS_DOMAIN_ID=13
|
||||
python3 -m fc_network_module.fc_network_module.ntrip_client --ros-args -p host:=210.241.63.193 -p port:=81 -p mountpoint:=2020_GNSS -p username:=uavlab6061 -p password:=iamsupersmart -p gga_on:=True -p gga_lat:=24.155792000 -p gga_lon:=120.630679000 -p gga_interval_sec:=60
|
||||
|
||||
watch GPS_RAW_INT
|
||||
|
||||
export ROS_DOMAIN_ID=1
|
||||
ros2 daemon stop
|
||||
ros2 daemon start
|
||||
|
||||
|
||||
Reference in New Issue
Block a user