Merge branch 'chiyu'

Chiyu Chen 2 weeks ago
commit bc841156cb

@ -83,17 +83,16 @@ python gui.py
建立、維護與飛控韌體的連接 建立、維護與飛控韌體的連接
構築 mavlink 封包 構築 mavlink 封包
處理無線模組的通訊格式 (XBee) 處理無線模組的通訊格式 (XBee)
--同時處理與 Gazebo 的 ardupilot_plugin 溝通的 FDM/JSON 訊息 (移除)-- 2. fc_interfaces (必要)
2. fc_interfaces (重要) 自定義的 ROS2 介面檔 沒有這個核心會運作不了
自定義的 ROS2 介面檔 沒啥好說的 沒有這個核心會運作不了
3. fc_network_module (重要) 3. fc_network_module (重要)
非核心 但是支援載具的重要附屬功能 非核心 但是支援載具的重要附屬功能
需要該功能時 作為一個 ros2 節點打開 需要該功能時 作為一個 ros2 節點打開
例如 : ntrip rtk 訊號轉接 例如 : ntrip rtk 訊號轉接
4. fc_network_apps 4. fc_network_apps
與 fc_network_adapter 銜接做高階功能包裝的應用小程式 是 fc_network_adapter 高階包裝API
利於開發GUI或其他應用 使用者的外層包裝 利於開發GUI或其他應用 使用者的外層包裝
這裡的定位是 "核心功能的高階包裝" 可以完全不去用 可以不使用 或者當作一個範例程式來看
5. someotherpkg 5. someotherpkg
如何使用 fc_network_apps 的範例檔案 如何使用 fc_network_apps 的範例檔案
6. GUI 6. GUI

@ -14,6 +14,7 @@ find_package(builtin_interfaces REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME} rosidl_generate_interfaces(${PROJECT_NAME}
"msg/AttitudeRaw.msg" "msg/AttitudeRaw.msg"
"msg/GnssRaw.msg" "msg/GnssRaw.msg"
"msg/SystemDiagnosticsRaw.msg"
"msg/ServiceAckResult.msg" "msg/ServiceAckResult.msg"
"srv/MavPing.srv" "srv/MavPing.srv"
"srv/MavCommandLong.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 from .utils.acquirePort import find_available_port
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
PROJECT_VER = "v1.10" PROJECT_VER = "v1.20"
class PanelState: class PanelState:
def __init__(self): def __init__(self):
@ -1106,7 +1106,7 @@ class ControlPanel:
0: "HB", 1: "S_STAT", 2: "S_TIME", 24: "GPS_RAW", 27: "RAW_IMU", 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", 30: "ATT", 32: "LOC_POS", 33: "GLB_POS", 62: "NAV_CTL",
74: "VFR_HUD", 147: "BATT_ST", 136: "TERRAIN", 241: "VIBRAT", 74: "VFR_HUD", 147: "BATT_ST", 136: "TERRAIN", 241: "VIBRAT",
125: "POW_STA", 125: "POW_STA", 253: "STAT_TXT",
} }
# ardupilot mega # ardupilot mega
@ -1198,6 +1198,8 @@ class Orchestrator:
'vfr_hud': 1.0, 'vfr_hud': 1.0,
'mode': 0.0, 'mode': 0.0,
'summary': 1.0, 'summary': 1.0,
'sys_diags': 1.0,
'status_text': 1.0,
} }
def engageWholeSystem(self): def engageWholeSystem(self):
@ -1642,10 +1644,10 @@ def main():
if mo.MODULE_VER != "1.50": if mo.MODULE_VER != "1.50":
print("Module Version Error! : mavlinkObkect") print("Module Version Error! : mavlinkObkect")
version_check = False version_check = False
if mros.MODULE_VER != "2.10": if mros.MODULE_VER != "2.50":
print("Module Version Error! : mavlinkROS2Nodes") print("Module Version Error! : mavlinkROS2Nodes")
version_check = False version_check = False
if mvv.MODULE_VER != "1.00": if mvv.MODULE_VER != "1.10":
print("Module Version Error! : mavlinkVehicleView") print("Module Version Error! : mavlinkVehicleView")
version_check = False version_check = False
if sm.MODULE_VER != "0.80": if sm.MODULE_VER != "0.80":

@ -51,7 +51,7 @@ from .mavlinkVehicleView import (
VehicleView, VehicleView,
VehicleComponent, VehicleComponent,
ComponentType, ComponentType,
ConnectionType StatusTextEntry,
) )
from .utils import RingBuffer, setup_logger from .utils import RingBuffer, setup_logger
@ -109,12 +109,14 @@ class mavlink_bridge:
"""初始化訊息處理器映射表,提高處理效率""" """初始化訊息處理器映射表,提高處理效率"""
self.message_handlers = { self.message_handlers = {
0: self._handle_heartbeat, # HEARTBEAT 0: self._handle_heartbeat, # HEARTBEAT
1: self._handle_vehicle_sys_status, # SYS_STATUS
24: self._handle_gps_raw_int, # GPS_RAW_INT 24: self._handle_gps_raw_int, # GPS_RAW_INT
30: self._handle_attitude, # ATTITUDE 30: self._handle_attitude, # ATTITUDE
32: self._handle_local_position, # LOCAL_POSITION_NED 32: self._handle_local_position, # LOCAL_POSITION_NED
33: self._handle_global_position, # GLOBAL_POSITION_INT 33: self._handle_global_position, # GLOBAL_POSITION_INT
74: self._handle_vfr_hud, # VFR_HUD 74: self._handle_vfr_hud, # VFR_HUD
147: self._handle_battery_status, # BATTERY_STATUS 147: self._handle_battery_status, # BATTERY_STATUS
253: self._handle_status_text, # STATUSTEXT
} }
def start(self): def start(self):
@ -231,6 +233,29 @@ class mavlink_bridge:
'vx': msg.vx, 'vy': msg.vy, 'vz': msg.vz '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): def _handle_global_position(self, vehicle, component, msg, timestamp):
"""處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)""" """處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)"""
@ -395,9 +420,9 @@ class mavlink_object:
self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191) self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191)
# 記錄訊息過濾類型 (可選) # 記錄訊息過濾類型 (可選)
# 0 HEARTBEAT, 24 GPS_RAW_INT, 30 ATTITUDE, # 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 # 32 LOCAL_POSITION_NED, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS, 253 STATUSTEXT
self.bridge_msg_types = set([0, 24, 30, 32, 33, 74, 147]) self.bridge_msg_types = set([0, 1, 24, 30, 32, 33, 74, 147, 253])
self.return_msg_types = set([]) self.return_msg_types = set([])
# 轉發到別的 mavlink object 作為目標端口 的列表 # 轉發到別的 mavlink object 作為目標端口 的列表
@ -834,5 +859,8 @@ if __name__ == '__main__':
1. async_io_manager.managed_objects mavlink_object.mavlinkObjects 功能重複整合 保留 mavlink_object.mavlinkObjects 1. async_io_manager.managed_objects mavlink_object.mavlinkObjects 功能重複整合 保留 mavlink_object.mavlinkObjects
2. async_io_manager _stop_event 無效變數移除 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 from .utils import setup_logger
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "2.10" MODULE_VER = "2.50"
FC_ROS_DOMAIN_ID = "0" FC_ROS_DOMAIN_ID = "0"
NODE_KEYS = ("status_publisher", "command_service", "rtcm_relay") NODE_KEYS = ("status_publisher", "command_service", "rtcm_relay")
@ -68,14 +68,16 @@ class PublishRateController:
# 注意 這邊是定義區 不要把參數寫在這裡 所以預設全部關閉 # 注意 這邊是定義區 不要把參數寫在這裡 所以預設全部關閉
# 以這個專案 請看 mainOrchestrator.py 的 Orchestrator 初始化階段 # 以這個專案 請看 mainOrchestrator.py 的 Orchestrator 初始化階段
self.topic_intervals = { self.topic_intervals = {
'position_gnss': 0.0, # GNSS位置 'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態)
'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度) 'position_gnss': 0.0, # GNSS位置 (海拔高度)
'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度+相對高度)
'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態) 'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態)
'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除)
'battery': 0.0, # 電池 'battery': 0.0, # 電池
'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門) 'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門)
'sys_diags': 0.0, # SYS_STATUS 系統診斷
'status_text': 0.0, # STATUSTEXT 飛控文字(佇列驅動,>0 僅作啟用旗標)
'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除) 'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除)
'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態) 'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除)
# 在這裡新增更多 topics... # 在這裡新增更多 topics...
} }
# 記錄每個 topic 的最後發布時間 {(sysid, topic): timestamp} # 記錄每個 topic 的最後發布時間 {(sysid, topic): timestamp}
@ -112,6 +114,10 @@ class PublishRateController:
return False return False
def is_topic_enabled(self, topic: str) -> bool:
"""檢查 topic 是否啟用interval > 0"""
return self.topic_intervals.get(topic, 0) > 0
def reset(self): def reset(self):
"""重置所有計時器""" """重置所有計時器"""
self.last_publish_time.clear() self.last_publish_time.clear()
@ -122,7 +128,7 @@ class VehicleStatusPublisher(Node):
職責: 職責:
- 定期從 vehicle_registry 讀取載具狀態 - 定期從 vehicle_registry 讀取載具狀態
- 頻率控制位置/姿態 2Hz電池/摘要 1Hz - 頻率控制 (位置/姿態 2Hz, 電池/摘要 1Hz)
- 發布標準 ROS2 消息類型 - 發布標準 ROS2 消息類型
- 檢測訂閱者按需發布 - 檢測訂閱者按需發布
""" """
@ -181,11 +187,13 @@ class VehicleStatusPublisher(Node):
self._publish_position_gnss(sysid, status) self._publish_position_gnss(sysid, status)
self._publish_position_ned(sysid, status) self._publish_position_ned(sysid, status)
self._publish_attitude(sysid, status) self._publish_attitude(sysid, status)
self._publish_velocity(sysid, status)
self._publish_battery(sysid, status) self._publish_battery(sysid, status)
self._publish_vfr_hud(sysid, status) self._publish_vfr_hud(sysid, status)
self._publish_mode(sysid, status)
self._publish_summary(vehicle) 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 方法調用... # 在這裡新增更多 publish 方法調用...
def _get_or_create_publisher(self, sysid: int, topic: str, msg_type, qos: int = 1): 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, '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_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", 'mode_name': status.mode.mode_name if status.mode.mode_name else "UNKNOWN",
# 'latitude': status.position.latitude if status.position.latitude else 0.0, 'mav_status': status.system_status if status.system_status is not None else 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,
'connection_type': vehicle.connected_via.value, 'connection_type': vehicle.connected_via.value,
'last_update': component.packet_stats.last_msg_time if component.packet_stats.last_msg_time else 0.0, '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) msg.data = json.dumps(summary)
publisher.publish(msg) 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 位置 3/4】
# 若要新增 topic請在此處實作對應的發布方法 # 若要新增 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): def stop(self):
"""停止發布""" """停止發布"""
self.running = False self.running = False
@ -531,6 +568,14 @@ class MavlinkCommandService(Node):
每次接到一個 service 請求 要整個系統丟某種指令給載具時 每次接到一個 service 請求 要整個系統丟某種指令給載具時
會做兩件事 1."丟出mavlink封包" 2."創造一個臨時信箱 Pending" 會做兩件事 1."丟出mavlink封包" 2."創造一個臨時信箱 Pending"
然後透過每次 manager spin
會去呼叫 return_router() 方法
這個方法會監聽 return_packet_ring 跟臨時信箱的 Pending 做配對
配對到的解開 Pending
解開後 相對應的 handle_XXX 就會開始做事
""" """
serviceString_prefix = '/fc_network/vehicle' serviceString_prefix = '/fc_network/vehicle'
@ -1153,6 +1198,10 @@ class fc_ros_manager:
- RtcmRelay - RtcmRelay
提供統一的啟動/停止介面給 mainOrchestrator 提供統一的啟動/停止介面給 mainOrchestrator
另外 這邊用到 MultiThreadedExecutor 會開出額外的 thread 的特性
使得就算 executor 在跑一些需要等待的方法
常態的 spin_once 也不會被 block (spin_thread 是另一個支線)
""" """
def __init__(self): def __init__(self):
@ -1432,6 +1481,10 @@ ros2_manager = fc_ros_manager()
2. schedule_restart_node / _restart_node : 手動重啟單一 node (spin thread 內執行 2. schedule_restart_node / _restart_node : 手動重啟單一 node (spin thread 內執行
3. orchestrator cmd: ("RESTART_ROS_NODE", node_key), node_key NODE_KEYS 3. orchestrator cmd: ("RESTART_ROS_NODE", node_key), node_key NODE_KEYS
2026.06.10
1. 增加了 _publish_system_diagnostics _publish_status_text 功能
TODO TODO
1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期 1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期

@ -5,6 +5,7 @@ VehicleView - Pure State Container
""" """
import os import os
from collections import deque
from typing import Dict, Optional, Any, Tuple from typing import Dict, Optional, Any, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
@ -12,7 +13,7 @@ from enum import Enum
from .utils import setup_logger from .utils import setup_logger
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "1.00" MODULE_VER = "1.10"
# ====================== Enums ===================== # ====================== Enums =====================
@ -117,6 +118,30 @@ class VFR:
timestamp: Optional[float] = None # 時間戳記 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 @dataclass
class ComponentStatus: class ComponentStatus:
"""組件狀態容器""" """組件狀態容器"""
@ -127,6 +152,8 @@ class ComponentStatus:
ekf: EKF = field(default_factory=EKF) ekf: EKF = field(default_factory=EKF)
gps: GPS = field(default_factory=GPS) gps: GPS = field(default_factory=GPS)
vfr: VFR = field(default_factory=VFR) 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 system_status: Optional[int] = None # MAV_STATE

@ -2,17 +2,72 @@ import socket
import base64 import base64
import threading import threading
import time import time
from typing import Optional, Tuple
import rclpy import rclpy
from rclpy.node import Node from rclpy.node import Node
from rclpy.qos import QoSProfile, HistoryPolicy, ReliabilityPolicy, DurabilityPolicy from rclpy.qos import QoSProfile, HistoryPolicy, ReliabilityPolicy, DurabilityPolicy
from mavros_msgs.msg import RTCM 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=1GPS 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): 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 RECONNECT_MAX_SEC = 60.0
def __init__(self): def __init__(self):
@ -21,9 +76,15 @@ class NtripClientNode(Node):
self.declare_parameter('host', 'rtk2go.com') self.declare_parameter('host', 'rtk2go.com')
self.declare_parameter('port', 2101) self.declare_parameter('port', 2101)
self.declare_parameter('mountpoint', '') self.declare_parameter('mountpoint', '')
self.declare_parameter('username', '') self.declare_parameter('username', 'template@mail.com')
self.declare_parameter('password', '') 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( rtcm_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST, history=HistoryPolicy.KEEP_LAST,
depth=1, depth=1,
@ -32,7 +93,9 @@ class NtripClientNode(Node):
) )
self.publisher_ = self.create_publisher(RTCM, '/fc_network/rtcm_input', rtcm_qos) 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 = threading.Thread(target=self._receive_loop, daemon=True)
self._thread.start() self._thread.start()
@ -40,31 +103,50 @@ class NtripClientNode(Node):
# ── NTRIP 連線 + RTCM 接收迴圈 ────────────────────────────── # ── 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): def _receive_loop(self):
backoff = self.RECONNECT_BASE_SEC backoff = self.RECONNECT_BASE_SEC
while self._running:
host = self.get_parameter('host').value host = self.get_parameter('host').value
port = self.get_parameter('port').value port = self.get_parameter('port').value
mount = self.get_parameter('mountpoint').value mountpoint = self.get_parameter('mountpoint').value
user = self.get_parameter('username').value user = self.get_parameter('username').value
pwd = self.get_parameter('password').value pwd = self.get_parameter('password').value
if not mount: if not mountpoint:
self.get_logger().error('mountpoint 參數未設定,等待重試...') self.get_logger().error('mountpoint 參數未設定...')
time.sleep(backoff) return
continue
while not self._stop_event.is_set():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10) 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: try:
self.get_logger().info(f'正在連線 {host}:{port}/{mount} ...') self.get_logger().info(f'正在連線 {host}:{port}/{mountpoint} ...')
sock.connect((host, port)) sock.connect((host, port))
auth = base64.b64encode(f'{user}:{pwd}'.encode()).decode() auth = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
request = ( 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'User-Agent: NTRIP PythonClient\r\n'
f'Authorization: Basic {auth}\r\n' f'Authorization: Basic {auth}\r\n'
f'Connection: close\r\n\r\n' f'Connection: close\r\n\r\n'
@ -78,29 +160,54 @@ class NtripClientNode(Node):
) )
raise ConnectionError('handshake failed') raise ConnectionError('handshake failed')
self.get_logger().info(f'已連接至 {mount},開始接收 RTCM 資料流') self.get_logger().info(f'已成功連接至 {mountpoint}')
backoff = self.RECONNECT_BASE_SEC backoff = self.RECONNECT_BASE_SEC
self._read_stream(sock) self._read_stream(sock)
except Exception as e: except Exception as e:
if self._running: if self._stop_event.is_set():
break
self.get_logger().warn(f'連線中斷: {e}{backoff:.0f}s 後重連') self.get_logger().warn(f'連線中斷: {e}{backoff:.0f}s 後重連')
time.sleep(backoff) if self._stop_event.wait(timeout=backoff):
break
backoff = min(backoff * 2, self.RECONNECT_MAX_SEC) backoff = min(backoff * 2, self.RECONNECT_MAX_SEC)
finally: finally:
with self._sock_lock:
if self._active_sock is sock:
self._active_sock = None
try:
sock.close() sock.close()
except OSError:
pass
def _read_stream(self, sock: socket.socket): def _read_stream(self, sock: socket.socket):
buf = b'' 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: try:
chunk = sock.recv(4096) chunk = sock.recv(4096)
except socket.timeout: except socket.timeout:
if self._stop_event.is_set():
break
continue continue
except OSError:
if self._stop_event.is_set():
break
raise
if not chunk: if not chunk:
if self._stop_event.is_set():
break break
raise ConnectionError("stream ended. Empty Chunk")
buf += chunk buf += chunk
while len(buf) >= 6: while len(buf) >= 6:
@ -126,8 +233,11 @@ class NtripClientNode(Node):
self.publisher_.publish(msg) self.publisher_.publish(msg)
def destroy_node(self): def destroy_node(self):
self._running = False self._request_stop()
self._thread.join(timeout=3.0) 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() super().destroy_node()

@ -8,7 +8,8 @@
RTK RTK
跟 RTK2go 抓取列表 Done 跟 RTK2go 抓取列表 Done
從特定 mount point 得到數據 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 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 stop
ros2 daemon start ros2 daemon start

Loading…
Cancel
Save