84 Commits
Author SHA1 Message Date
ken910606 e93c73b9ec 2.9.1: new mission_orch 2026-09-16 01:32:09 +08:00
ken910606 a345436008 2.9.0 交戰 2026-09-09 13:41:14 +08:00
ken910606 4d0d1366ac 2.8.2 grid mission 2026-08-31 16:28:51 +08:00
ken910606 3cede3aeca 2.8.2 grid mission 2026-08-31 15:38:36 +08:00
ken910606 ed4e4dce10 2.8.1 2026-08-24 15:08:55 +08:00
ken910606 ebd219c88e 2.8.0 xbee and ws command 2026-08-18 15:56:16 +08:00
wenchunandClaude Opus 4.8 1db3445e86 refactor(GUI): 群組切mode/套用速度改並發(asyncio.gather),清per-drone client過期註解
- gui.py: _handle_group_mode_change 與 _on_engagement_apply_speed 由
  單一 async 函式內 for 迴圈逐台 await(序列化,最壞 N×timeout)改為
  asyncio.gather 並發,各自等各自 ACK;行為語意不變。
- communication.py: set_mode/arm_drone/set_speed 前 3 處過期註解
  「避免多機並行時的競態條件」更正為共用 client + target_sysid 路由
  (OOM 修復後已非 per-drone、無 per-drone 狀態)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 14:47:31 +08:00
wenchunandClaude Opus 4.8 6ef7d3a7a6 feat(GUI): 交戰任務編排器 MissionOrchestrator(S0-S2 + 高度分層/前置攔截/速度比例限速)
在既有 MissionExecutor 之上加一層「任務大腦」,串起 ABC 對抗流程:
A 組偵察 → 偵測敵機 → 手動確認重編隊 → A+B 四機前進追敵 → 足夠近展開包圍。
全部落在 src/GUI/。

新增 mission_orchestrator.py:
- MissionOrchestrator 階段狀態機 IDLE→WATCH→DETECTED→PURSUE→ENCIRCLE。
- PURSUE/ENCIRCLE 用「瞬時隊形」而非路徑跟隨(每 tick 給每台目標點),
  修掉移動目標下 wp_index 歸零 + follower 外推造成的往返振盪。
- 偵測 latch(≤R_detect 持續 dwell)、moving-target 重算(位移≥2m 或封頂 1s)、
  切圓 hysteresis(R_engage=15 進、R_engage_exit=20 退)。
- 高度分層 _alt_for:A 組走上層、B 組走下層(不低於地面下限),
  PURSUE/ENCIRCLE 都套用 → 消掉同高度交叉撞機風險。
- 前置攔截 _aim_point:GPS 差分估敵速,瞄「敵位+敵速×lead_time」的攔截點,
  治純追尾追不上;敵機靜止時退化為瞄當下。
- EngagementPanel 交戰面板:敵機下拉、A/B 組下拉、即時距離讀數、階段顯示、
  重編隊/停止、以及「套用速度」(友機 V + 敵/友比例 r → DO_CHANGE_SPEED)。

mission_executor.py:加 complete_stops=False(hover-hold)+ update_plan()
(熱替換航點、保留 RUNNING 閉環狀態)。

communication.py:加 set_speed()(用共用 client 泛用方法發 DO_CHANGE_SPEED(178),
維持 GUI-only scope)。

gui.py:新增「交戰」分頁、engagement timer(5Hz)、orchestrator 接線、
_on_engagement_apply_speed(友方下 V、敵方下 r×V、同步 friendly_speed)。

SITL 實測通過:高度分層 A/B 正確分開、前置攔截追得上、套用速度後追擊/包圍穩定收斂。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 22:57:11 +08:00
wenchunandClaude Opus 4.8 8d65e92634 fix(GUI): 共用 command/position client 啟動預建,修切mode觸發的OOM/CPU暴衝
實飛/SITL 對無人機下第一個控制指令(切 mode)時,python GUI 行程
CPU 衝到 112%、記憶體一路爬向 30GB 被 OOM killer 幹掉。

根因(切 mode 是執行期第一個走 get_or_create_client 的動作):
- get_or_create_client / get_or_create_position_client 原本 per-drone
  於執行期新建 ROS2 node 並 executor.add_node()。
- CPU:add_node 從 asyncio/Qt 執行緒呼叫,同時 _ros_spin_thread 在另一
  執行緒 spin_once 同一個 SingleThreadedExecutor → wait set 損毀 → 空轉。
- 記憶體:執行期新建 node = 新 DDS participant,discovery 撞壞封包 →
  FastRTPS bad_alloc。解釋了「開機沒事、切 mode 才炸」。

兩個 client 都只打單一 service(send_command_long / pos_global_int),
靠 request 的 target_sysid 路由、無 per-drone 狀態 → 每台一個節點無意義。

修法:改成「共用節點 + 啟動預建 + 執行期只重用」。
- communication.py: 兩個 per-drone dict → 單一 command_long_client /
  position_target_client;新增 init_shared_command_clients();
  get_or_create_*(drone_id=None) 回傳共用 client(僅留一次性 lazy fallback)。
- gui.py: executor 設好後、spin thread 啟動前於主執行緒預建;cleanup 改
  銷毀兩個共用節點。

飛行中不再新建 participant + add_node 不再跨執行緒 → 兩症狀同時消。
SITL 切 mode 實測不再暴衝。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:15:35 +08:00
wenchunandClaude Opus 4.8 4e476f80f6 fix(GUI): 多機跑點閉環化,解決實飛只有一台跑點的問題
根因:adapter 的 pos_global_int service 被序列化(預設 mutually-exclusive
callback group) + 阻塞等 msg87 echo。實飛慢速鏈路下多機序列化,只有頭一台
能在 client timeout 內拿到 echo,其餘被判失敗 → 重試 → fallback LOITER 停飛。
(SITL echo 快故全過)。指令其實在等 echo 前就已送達飛控。

閉環改法(全在 src/GUI/,繞過 adapter 瓶頸):
- mission_executor: 每點只送一次 → 每 resend_interval_sec(預設2.5s) 週期性
  重送當前 setpoint(GUIDED 目標冪等,掉包自癒);換點純靠 GPS 到達判定;
  _on_send_result 降級為觀測,不再累計失敗切 LOITER。
- command_sender: Ros2CommandSender echo timeout 2.0→0.5s,echo miss 快速
  放棄換下一台,避免序列化塞爆 client poll。
- gui: 建構子接 resend_interval_sec。

順帶:總覽表新增「任務狀態」列,每台一格即時顯示 WP 進度 + 通訊狀態
(送出OK/送出未驗證/等待同伴),實飛可一眼分辨哪台通訊不穩 vs 卡住;
此列獨立保存,不被遙測 refresh 蓋掉。

SITL 驗證通過。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:49:31 +08:00
ken910606 79e95d7547 2.7.1 map movement 2026-07-08 06:57:32 +08:00
ken910606 f141311173 2.7.0 logs 2026-07-06 07:29:56 +08:00
chiyu1468 fd949fca47 (modify) serialManager.py
- 修正 POLL-DONE 程序的 timeout 判定方式
- 修正為 py3.8 語法
- 不在判定要求量跟實收量一致性
2026-06-29 15:53:03 +08:00
chiyu1468 d734a7bfee 完善Xbee新功能整合到 mainOrchestrator
- Added support for a new XBee communication type (XBee(API-API) espv1) in mainOrchestrator.py.
- Changed logging level for publisher creation in mavlinkROS2Nodes.py from info to debug for improved verbosity.
- 移除 mainOrchestrator 啟動時的版本檢驗 改為用 logger 紀錄
- Py3.8 語法修正
2026-06-22 15:44:09 +08:00
chiyu1468 4c3fe226d6 Update serialManager.py to support XBEE new mode (combine ESP32 function)
- Introduce XBeeFrameProcessor_ESPv1 class for handling send and process special MAGIC header for esp32
- Refactored request_discovery and request_poll methods for thread-safe operation.
2026-06-19 04:53:22 +08:00
chiyu1468 ab616ceb54 add vehicleDiagShow.py for vehicle diagnostics
- Introduced vehicleDiagShow.py, 這個簡易的終端機介面 顯示載具的自檢驗與狀態
2026-06-11 09:09:42 +08:00
chiyu1468 bc841156cb Merge branch 'chiyu' 2026-06-11 07:58:03 +08:00
chiyu1468 324fced754 Enhance mavlink handling and add status text support
- Added handling for SYS_STATUS and STATUSTEXT messages in mainOrchestrator.py and mavlinkObject.py.
- Introduced a new StatusTextEntry data class in mavlinkVehicleView.py to manage status text messages.
- Updated topic management in mavlinkROS2Nodes.py to include new status text functionality.
2026-06-11 07:50:50 +08:00
chiyu1468 0a84bb68fe Add SystemDiagnostics message and integrate into network adapter
- Added SystemDiagnosticsRaw.msg to define system diagnostics data structure.
- Enhanced mainOrchestrator.py and mavlinkObject.py to handle SYS_STATUS messages and publish system diagnostics.
- Introduced a new method in VehicleStatusPublisher to publish system diagnostics data.
- Updated mavlinkVehicleView.py to include SystemDiagnostics data class.
2026-06-11 02:50:33 +08:00
wenchunandClaude Opus 4.8 d15c2d0a2d chore: 忽略 MAVProxy/SITL 產生的 tlog 與參數 dump
誤在專案目錄開 SITL 會產生 mav.tlog / mav.tlog.raw / mav.parm,
加入忽略規則避免誤入版控。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:05:59 +08:00
wenchunandClaude Opus 4.8 ae984ecad4 refactor(GUI): 移除規劃路徑的死高度,不再把 GNSS 絕對高度餵進規劃
_collect_drone_gps 原本把 drone_gps['alt'](GNSS AMSL)放進規劃輸入的 z,
但四種任務的指令高度一律取自 mission params,從不讀飛機回報的當下高度,
planner 與驗證視窗也都丟棄該 z。改填 0.0 placeholder、移除沒用的 base_alt
參數,讓「高度非規劃輸入」語意明確化;並移除 gps 更新路徑未使用的 alt 變數。
行為不變,SITL 四模式驗證通過。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:05:59 +08:00
wenchunandClaude Opus 4.8 32aa489462 revert: 還原 mavlinkROS2Nodes 高度發布改動,回到 GUI-only 修改範圍
415aed0 為修 Circle 高度爆衝曾把 position_gnss topic 改發 relative_altitude
(GUI 外的 ROS2 node),違反「修改只動 src/GUI/」的範圍規則。還原回發
AMSL(pos.altitude)。Bug A 的實際修法在 GUI 端(mission_planner stage1.z
改用 final_z)仍保留,故還原後 Circle 高度問題不會復發;後續高度 debug 一律
在 GUI 內處理(需相對高度時讀 NED local_pose z,不讀 drone_gps['alt'])。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:05:26 +08:00
chiyu1468 cc9d005392 (update) ntrip_client.py
Introduced threading locks and stop event for safer socket management. Updated the receive loop to handle connection interruptions.
2026-06-09 14:06:44 +08:00
chiyu1468 2c7f2afc45 (Add) ntrip_client.py GGA sentence generation and sending 2026-06-08 23:09:14 +08:00
wenchunandClaude Opus 4.7 415aed088a fix: 修正實飛 Circle 高度爆衝與 Leader-Follower 卡點
1. mavlinkROS2Nodes: position_gnss topic 改發 relative_altitude 而非 AMSL,
   讓 drone_gps['alt'] 在實飛與 SITL 都代表「相對 home 高度」
2. mission_planner: Circle stage1.z 改用 final_z(不再用 current_z 做中點插值),
   避免實飛時 current_z 是 AMSL 而把過渡高度衝到海拔值的一半
3. mission_executor: 到達判定改為 hover-stable(radius * 1.5 hysteresis +
   穩定計時),預設 arrival_radius 2→4m,解決實飛 GPS 抖動永遠進不了
   2m radius 的卡死問題
4. mission_executor: 新增 progress log,每 3 秒印一行距離與穩定度,
   方便實飛 debug 卡點問題
5. mission_group + gui: 暴露 arrival_radius / hover_stable_sec 給 GUI 任務參數面板,
   每次 start 重讀以支援中途調整

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 16:51:19 +08:00
ken910606 5231cffcb2 Update GUI 2.6.0: trajectory, color, window, serial JSON 2026-06-03 20:15:20 +08:00
chiyu1468 0ee7dd1fcf (hot_fix)
1. 把測試用參數關閉
2. 修正錯誤回傳值
3. 臨時加入 node 失效後 自動重啟功能
2026-06-01 21:09:49 +08:00
chiyu1468 61e283cd25 node重啟功能 與 定義檔優化
---
(Adding)
1. mavlinkROS2Nodes.py 各別 node 重啟的功能
2.  mainRochestrator.py 開發對應介面
(modify)
 1. 定義檔優化 移除 ackEnum.py 新增 ServiceAckResult.msg
2026-05-30 15:15:25 +08:00
ken910606 4e08b18a03 Update GUI 2.5.0: Drone reboot 2026-05-27 19:16:38 +08:00
ken910606 81de964778 Merge branch 'master' of http://140.120.108.238:49308/chiyu1468/AirTrapMine 2026-05-27 19:11:12 +08:00
ken910606 c75537a977 Update GUI 2.5.0: Drone reboot 2026-05-27 19:10:36 +08:00
wenchunandClaude Opus 4.6 8c62f891a8 fix(GUI): 修正實飛三個關鍵 bug — 高度偏移累加、Leader-Follower 不動、起點重疊
1. mission_planner: origin_alt 固定為 0,避免 GPS 橢球高度疊加到相對高度指令
2. mission_planner: 移除圓弧插值,直接用路徑點作為航點,消除微航點導致飛控無法執行
3. mission_planner: lookup 改為外推(extrapolate),解決偶數 rank 起終點重疊
4. communication: UDP/Serial 的 GLOBAL_POSITION_INT 補上 relative_alt 欄位

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 18:39:05 +08:00
ken910606 003f8988c5 Update GUI 2.4.1 2026-05-20 20:28:19 +08:00
ken910606 d2aef78a4b Update GUI 2.4.0: GPS Status 2026-05-20 20:02:04 +08:00
ken910606 0b41ea0a1e Update GUI 2.3.1 2026-05-19 11:05:15 +08:00
chiyu1468 5c14c45823 # RTK function update
---
1. mavlinkROS2Node 新增 RtcmRelay Node 監聽 RTCM topic 並轉發到 mavlinkObject bus (GPS_RTCM_DATA)
2. 新增 fc_network_module ros2 package 作為協同附屬模組的放置空間
3. Create a new fc_network_module with NtripClientNode for connecting to NTRIP casters and publishing RTCM data to ROS2 topics.
4. (modify) mainOrchestrator adjust the publishing rates for GNSS topics
2026-05-13 18:14:47 +08:00
chiyu1468 c8f070bfb9 1. (modify) 更新 ros2 對於 GNSS topic 的發佈, 使其包涵 epv eph 衛星數與 fix_type
2. (adding) 新增相應的 msg 檔案與修改 CMakeLists, package
3. (adding) test_vehicleStatusPublisher 相對應修正
2026-05-13 18:14:47 +08:00
ken910606 d2a5f6fad7 Update GUI 2.3.0: JSON in Serial 2026-05-06 20:58:18 +08:00
wenchunandClaude Opus 4.6 730c3e2420 feat(GUI): 新增模擬驗證滑動開關(ToggleSwitch)
在清除軌跡按鈕左邊加入滑動開關,可控制執行任務時是否開啟 matplotlib 驗證視窗。
預設開啟,OFF 時跳過驗證。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:56:49 +08:00
ken910606 1426a618f4 Update GUI 2.2.0: Settings tab 2026-04-30 06:24:09 +08:00
ken910606 edd15df3fc Update GUI 2.1.0: logs history tab, drone panel attitude, linear twist overview 2026-04-29 20:47:39 +08:00
chiyu1468 7f8babfa5e (update)
1. mavlinkROS2Nodes.py 更新姿態發佈的 topic
2. 新增 interface AttitudeRaw 與對應的 package.xml & CMakeList
2026-04-29 11:37:06 +08:00
lenting89 75d9d260e1 add esp32 and udptest8-2 to unitdev04 2026-04-28 16:30:58 +08:00
lenting89 b720bb6ffd Merge branch 'lunu' 2026-04-28 15:48:37 +08:00
chiyu1468 38d8d23514 Merge branch 'chiyu' 2026-04-28 15:45:56 +08:00
chiyu1468 cc00427848 # (update)
1. serialManager.py - AT command 支援與架構重整
2. mainOrchestrator.py - serial 介面功能開啟
2026-04-28 15:41:50 +08:00
lenting89 6ef06e7cb7 Merge branch 'lunu' of http://140.120.108.238:49308/chiyu1468/AirTrapMine into lunu 2026-04-28 15:35:35 +08:00
lenting89 1cb818b3a3 Merge branch 'master' into lunu 2026-04-28 15:34:36 +08:00
lenting89 415cf1585f update before merging 2026-04-28 15:34:07 +08:00
chiyu1468 7b7e02a9e7 (update) serialManager: 增加 AT 指令處理功能與串口寫入支持,更新版本號至 0.80 2026-04-27 15:12:38 +08:00
ken910606 be52c4bd22 Update GUI 2.0.8 group selected list 2026-04-24 18:27:16 +08:00
ken910606 b9d6e0e2e0 Update GUI 2.0.7 group selected list 2026-04-23 03:24:13 +08:00
chiyu1468 06463d71bc (modify) serialManager 架構調整 詳見改版記錄 2026-04-20 18:02:50 +08:00
chiyu1468 7ce094d211 (temp) 整理檔案與註解 2026-04-20 15:02:47 +08:00
wenchunandClaude Opus 4.6 0edc477df8 feat(mission): virtual-leader path following + 固定領隊 + barrier
- mission_planner: Leader-Follower 改用 arc-length 參數化(virtual
  leader),_build_center_path 以同心圓弧連接直線段,銳角退化成 hover
  單點並在該點放 rendezvous barrier,避免暴衝與編隊交叉
- mission_planner: rank 改以輸入順序為準,不再對投影排序,避免浮點噪音
  導致 run-to-run leader 漂移
- mission_group / gui: 新增 leader_drone_id 與「領隊」下拉選單,handle_
  route_confirmed 把指定領隊重排到 rank 0,整個縱隊順序固定如軍隊行進
- mission_executor: rendezvous barrier 釋放邏輯、fallback LOITER、send
  retry;加 barrier_timeout 保護
- command_sender / communication / navigation: 配合 setpoint 送達與
  Ros2 bridge 更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:13:31 +08:00
wenchun 5c0d21fc1d Merge remote-tracking branch 'origin/master' into wenchun 2026-04-14 15:23:13 +08:00
wenchunandClaude Opus 4.6 3483864a2e chore: 移除無關的模擬資料檔與 README 過時段落
刪除歷史 merge 夾帶進來的 obstacles_data.mat 與 uav_simulation_data.mat
(屬於另一個研究的資料,與本專案無關),並移除 README 末尾 0328 的
test_transform / close_loop 敘述。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:15:15 +08:00
wenchunandClaude Opus 4.6 ae80820419 chore: gitignore notes/ 資料夾以收納本地設計筆記
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:15:10 +08:00
ken910606 fc00ecb762 Update GUI 2.0.6 components and longCommand 2026-04-14 15:09:25 +08:00
ken910606 fc2c642d6c Force sync local master to remote ken 2026-04-14 14:37:56 +08:00
ken910606 2c38376f9c Update master with local 2.0.5 2026-04-14 14:10:57 +08:00
wenchun 32e1ce8fb2 Merge remote-tracking branch 'origin/master' into wenchun 2026-04-14 13:54:08 +08:00
wenchun 33d6d59139 chore: ignore CLAUDE.md, 本地筆記與 eeprom dump 2026-04-14 13:53:40 +08:00
ken910606 7ae4c56f86 Merge branch 'master' of http://140.120.108.238:49308/chiyu1468/AirTrapMine 2026-04-14 13:49:33 +08:00
ken910606 20e8397f5a Update master with local 2.0.4 2026-04-14 13:48:24 +08:00
ken910606 a700e62de1 Update 'src/GUI/gui.py' 2026-04-14 13:41:21 +08:00
ken910606 f28dcca361 Update master with local 2.0.4 2026-04-14 13:40:25 +08:00
chiyu1468 b0f1bd56f5 Merge branch 'master' of http://140.120.108.238:49308/chiyu1468/AirTrapMine
主專案整合
2026-04-14 11:11:42 +08:00
chiyu1468 2990de2f3f 1. (update) mavlinkROS2Nodes 新增 local_position 資訊 2026-04-14 11:00:54 +08:00
chiyu1468 cf213fc556 1. (adding) mainOrchestrator 增加版本先驗程序
2. (adding) mavlinkVehicleView 增加 read_all 快速讀取
3. (modify) 優化 longCommand 與 navigation 使其更好引用
4. (adding) 提供一個完整的 example_wholeMoving 作為範例
4. (remove) 移除 fc_network_app 重複功能
5. (modify) 修改 overview_table 部分顯示文字
2026-04-13 21:34:42 +08:00
chiyu1468 017dd4923d 更新說明檔 fc_network_adapter 2026-04-09 17:58:56 +08:00
ken910606 f34693a400 longCommand 2026-04-08 16:59:35 +08:00
ken910606 989d3ad2d2 Update GUI 2.0.2 from local 2026-04-08 14:54:49 +08:00
ken910606 a1efcf5664 Update GUI 2.0.1 from local 2026-04-08 14:33:01 +08:00
ken910606 a303a3538e Update GUI 2.0.1 from local 2026-04-08 14:03:46 +08:00
ken910606 cb58ba2121 Update GUI 2.0.1 from local 2026-04-08 13:56:33 +08:00
ken910606 dbcd76be35 Update GUI 2.0.0 from local 2026-04-08 07:26:28 +08:00
ken910606 71321d4839 Update GUI 2.0.0 from local 2026-04-08 07:25:26 +08:00
chiyu1468 c4f178bb75 Merge branch 'chiyu'
1. 初步完善 ros2 service 功能 包含解鎖起飛降落與 goto
2. 為了整合到 GUI 去使用
2026-04-07 12:35:12 +08:00
chiyu1468 eb2f0af58e (tested)
1. 新增 ros2 接收 SET_POSITION_TARGET_LOCAL_NED 功能, 因應新增 MavPositionTargetGlobalInt.srv 的介面定義檔, 並相應新增使用者的外層包裝 navigation.py
2. 改善 ros2 service 的 response ack 數值對應, 新增 ackEnum.py
3. 改善 longCommand.py 內的呼叫層次, 把 longCommand 相關整合到同樣的 class 下, 僅以 def function 區分內部套用那種 MAV_CMD
4. 新增 example_position_goto.py, example_takeoff_land.py 作為使用範例 ( land.py arm_disarm.py ... 這些下個版本會被刪除)
5. 剩下就是一些註解的修正與更新
2026-04-07 12:28:11 +08:00
lenting1027 f92bad780d UDP_test
新增觀察RSSI及Packet Loss的版本
2025-08-20 14:04:43 +08:00
lenting1027 d1fd94e8d8 serial_udp_update 2025-06-11 14:53:20 +08:00
lenting1027 02ee5d8378 serial_udp_update 2025-06-11 14:51:46 +08:00
lenting1027 29269811c4 udp_serial_update 2025-06-11 14:50:35 +08:00
80 changed files with 17623 additions and 2300 deletions
+11
View File
@@ -26,3 +26,14 @@ Makefile
**/*.pyc
**/*.pyo
**/.cursor/
CLAUDE.md
# 本地筆記與硬體 dump
my_env_notes.md
notes/
**/eeprom.bin
# MAVProxy / SITL 執行產生的 log 與參數 dump
*.tlog
*.tlog.raw
mav.parm
+75 -59
View File
@@ -1,43 +1,50 @@
這是天雷系統的專案
===
專案核心框架
## 功能簡介
1. mavlink 多對多串流與交換
2. 支援 xbee 與 telemetry
3. 支援 udp 管理
4. ROS2 介面監控載具
===
使用限制
1. 不允許進到 ros 系統有相同 sysid
2. 假設一台載具上所有 component 共用同一 socket
===
## 運行環境
### 專案核心框架
1. ROS2 Humble
2. Python 3.8.10
### 必要相依套件及版本
- Python
[Package] fc_network_adapter
1. pymavlink -> Version: 2.4.42
2. conda-forge 中的 pyserial-asyncio
3. importlib_metadata -> Version: 8.5.0
4. setuptools -> Version: 58.2.0 (版本太新不行)
5. pyserial-asyncio
[Package] GUI
1. testresources
2. websockets
3. PyQt6
4. PyQt6-WebEngine
===
必要相依套件 順便記錄我開發時的環境版本
Python
1. pymavlink -> Version: 2.4.42
2. conda-forge 中的 pyserial-asyncio
3. importlib_metadata -> Version: 8.5.0
4. setuptools -> Version: 58.2.0 (版本太新不行)
5. pyserial-asyncio
ROS2
- ROS2
1. source ~/ros2_humble/install/setup.bash
2.
2. geographic_info (https://github.com/ros-geographic-info/geographic_info.git) 已經作為 submodule 放在 external
3. angles (https://github.com/ros/angles.git) 已經作為 submodule 放在 external
4. mavros_msgs (https://github.com/mavlink/mavros) 這個專案中的一個資料夾 這邊手動複製的
===
功能簡介
1. mavlink 多對多支援平台
2. 不允許進到 ros 系統有相同 sysid
3. 假設所有 component 共用同一 socket
===
開發用輔助專案
### 開發用輔助專案
1. Gazebo Garden
2. Ardupilot
===
依賴的 ROS 庫
1. https://github.com/ros-geographic-info/geographic_info.git 記得要搞 ros2 版本的
2. https://github.com/ros/angles.git
3. mavros_msgs 是 https://github.com/mavlink/mavros 這個專案中的一個資料夾 這邊手動複製的
## 使用說明
Clone 專案後 請先執行這些指令
```bash
# 1.同步 submodule
@@ -50,44 +57,53 @@ colcon build --packages-select mavros_msgs # 這個依賴前面的
colcon build --packages-select fc_interfaces # 自己定義的
```
===
Package 簡述
<<<<<<< HEAD
===
簡述0328
主要是透過test_transform這隻程式執行fdm_switch_example_one_with_remote_forwarding這個副函式,透過UDP將封包轉接到Matlab,並在Matlab進行演算法迭代後,打包JSON檔,並再次透過port口傳回封包,傳回來的封包會在close_loop這隻程式被解析,並提取其中有關velocity的資料儲存成變數,以mavlink的方式打入Ardupilot,以實現封閉迴路的構思。
=======
1. unitdev 為各自協作者做開發時的測試區
01 -> 晉凱(ken)
02 -> 其宇(chiyu)
03 -> 文鈞
04 -> 倫渝
2. fc_network_adapter
建立、維護與飛控韌體的連接
構築 mavlink 封包
處理無線模組的通訊格式 (XBee)
--同時處理與 Gazebo 的 ardupilot_plugin 溝通的 FDM/JSON 訊息 (移除)--
3. fc_interfaces
自定義的介面檔
4. fc_network_apps
與 fc_network_adapter 銜接做高階功能包裝的應用小程式 利於開發GUI或其他應用
N. logs 是執行時期的記錄檔
===
主要專案 fc_network_adapter 請一律在 ~/AirTrapMine/src/ 資料夾下 以模組化啟動程式
例如 在 ~/AirTrapMine/src/ 資料夾下
1.
主要專案 fc_network_adapter 請一律在 ~/AirTrapMine/src/ 路徑下 以模組化啟動程式
```bash
# 記得先開啟 依賴 Package 到 overlay
. ./install/local_setup.bash
# 範例
cd ~/AirTrapMine/src/ # 這是範例!!!
python -m fc_network_adapter.fc_network_adapter.mainOrchestrator
python -m fc_network_adapter.tests.demo_integration
python -m someotherpkg.src.example_wholeMoving
python fc_network_apps/vehicleDiagShow.py
```
>>>>>>> chiyu
2.
GUI 介面
在 ~/AirTrapMine/src/GUI 路徑下 直接啟動
```bash
cd ~/AirTrapMine/src/GUI
python gui.py
```
===
## 資料夾說明
1. fc_network_adapter (核心)
建立、維護與飛控韌體的連接
構築 mavlink 封包
處理無線模組的通訊格式 (XBee)
2. fc_interfaces (必要)
自定義的 ROS2 介面檔 沒有這個核心會運作不了
3. fc_network_module (重要)
非核心 但是支援載具的重要附屬功能
需要該功能時 作為一個 ros2 節點打開
例如 : ntrip rtk 訊號轉接
4. fc_network_apps
是 fc_network_adapter 高階包裝API
利於開發GUI或其他應用 使用者的外層包裝
可以不使用 或者當作一個範例程式來看
5. someotherpkg
如何使用 fc_network_apps 的範例檔案
6. GUI
由 PyQt6 開發的互動式介面
7. unitdev 為各自協作者做開發時的測試區
01 -> 晉凱(ken)
02 -> 其宇(chiyu)
03 -> 文鈞
04 -> 倫渝
8. logs 是執行時期的記錄檔
===
Binary file not shown.
View File
+126 -39
View File
@@ -1,10 +1,78 @@
#!/usr/bin/env python3
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QLineEdit, QComboBox)
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QLineEdit, QComboBox, QApplication)
from PyQt6.QtGui import QFont
from PyQt6.QtCore import pyqtSignal
import glob
import os
def _get_font_scale():
app = QApplication.instance()
if app is None:
return 1.0
scale = app.property("font_scale")
try:
return float(scale) if scale is not None else 1.0
except (TypeError, ValueError):
return 1.0
def _scale_stylesheet_font_sizes(stylesheet, scale):
if not stylesheet or 'font-size' not in stylesheet:
return stylesheet
import re
def repl(match):
size = float(match.group(1))
unit = match.group(2)
scaled = max(1.0, size * scale)
text = f"{scaled:.2f}".rstrip('0').rstrip('.')
return f"font-size: {text}{unit}"
return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet)
def _set_scaled_stylesheet(widget, stylesheet):
widget._base_stylesheet = stylesheet
scaled = _scale_stylesheet_font_sizes(stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
widget.setStyleSheet(scaled)
def _reapply_scaled_stylesheet(widget):
current_stylesheet = widget.styleSheet()
base_stylesheet = getattr(widget, '_base_stylesheet', None)
applied_stylesheet = getattr(widget, '_applied_stylesheet', None)
if current_stylesheet != applied_stylesheet:
base_stylesheet = current_stylesheet
widget._base_stylesheet = base_stylesheet
if base_stylesheet is not None:
scaled = _scale_stylesheet_font_sizes(base_stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
if current_stylesheet != scaled:
widget.setStyleSheet(scaled)
def _apply_scaled_font(widget):
base_font = getattr(widget, '_base_font_for_scale', None)
if base_font is None:
app = QApplication.instance()
app_base_font = app.property("base_app_font") if app else None
base_font = QFont(app_base_font) if app_base_font is not None else QFont(widget.font())
widget._base_font_for_scale = QFont(base_font)
scaled_font = QFont(base_font)
scale = _get_font_scale()
if base_font.pointSizeF() > 0:
scaled_font.setPointSizeF(max(1.0, base_font.pointSizeF() * scale))
elif base_font.pointSize() > 0:
scaled_font.setPointSize(max(1, int(round(base_font.pointSize() * scale))))
widget.setFont(scaled_font)
class CommPanel(QWidget):
"""通讯设置面板类"""
@@ -26,6 +94,7 @@ class CommPanel(QWidget):
self.ws_connections = []
self.serial_connections = []
self._init_ui()
self.apply_font_scale()
def _init_ui(self):
"""初始化UI"""
@@ -35,7 +104,7 @@ class CommPanel(QWidget):
# ========== UDP MAVLink 區域 ==========
udp_title = QLabel("UDP")
udp_title.setStyleSheet("""
_set_scaled_stylesheet(udp_title, """
color: #DDD;
font-size: 14px;
font-weight: bold;
@@ -60,7 +129,7 @@ class CommPanel(QWidget):
self.udp_ip_input = QLineEdit()
self.udp_ip_input.setText("127.0.0.1")
self.udp_ip_input.setPlaceholderText("IP")
self.udp_ip_input.setStyleSheet("""
_set_scaled_stylesheet(self.udp_ip_input, """
QLineEdit {
background-color: #333;
color: #DDD;
@@ -74,7 +143,7 @@ class CommPanel(QWidget):
self.udp_port_input.setText("14550")
self.udp_port_input.setPlaceholderText("Port")
self.udp_port_input.setFixedWidth(80)
self.udp_port_input.setStyleSheet("""
_set_scaled_stylesheet(self.udp_port_input, """
QLineEdit {
background-color: #333;
color: #DDD;
@@ -86,7 +155,7 @@ class CommPanel(QWidget):
add_udp_btn = QPushButton("添加")
add_udp_btn.clicked.connect(self._handle_add_udp)
add_udp_btn.setStyleSheet("""
_set_scaled_stylesheet(add_udp_btn, """
QPushButton {
background-color: #4CAF50;
color: white;
@@ -98,9 +167,13 @@ class CommPanel(QWidget):
QPushButton:hover { background-color: #45a049; }
""")
add_udp_layout.addWidget(QLabel("IP:", styleSheet="color: #DDD;"))
ip_label = QLabel("IP:")
_set_scaled_stylesheet(ip_label, "color: #DDD;")
add_udp_layout.addWidget(ip_label)
add_udp_layout.addWidget(self.udp_ip_input)
add_udp_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;"))
port_label = QLabel("Port:")
_set_scaled_stylesheet(port_label, "color: #DDD;")
add_udp_layout.addWidget(port_label)
add_udp_layout.addWidget(self.udp_port_input)
add_udp_layout.addWidget(add_udp_btn)
@@ -113,7 +186,7 @@ class CommPanel(QWidget):
# ========== Serial 區域 ==========
serial_title = QLabel("Serial")
serial_title.setStyleSheet("""
_set_scaled_stylesheet(serial_title, """
color: #DDD;
font-size: 14px;
font-weight: bold;
@@ -136,7 +209,7 @@ class CommPanel(QWidget):
add_serial_layout.setContentsMargins(0, 0, 0, 0)
self.serial_port_combo = QComboBox()
self.serial_port_combo.setStyleSheet("""
_set_scaled_stylesheet(self.serial_port_combo, """
QComboBox {
background-color: #333;
color: #DDD;
@@ -160,7 +233,7 @@ class CommPanel(QWidget):
refresh_ports_btn.setFixedWidth(35)
refresh_ports_btn.clicked.connect(self._refresh_serial_ports)
refresh_ports_btn.setToolTip("重新掃描串口")
refresh_ports_btn.setStyleSheet("""
_set_scaled_stylesheet(refresh_ports_btn, """
QPushButton {
background-color: #444;
color: #DDD;
@@ -174,9 +247,9 @@ class CommPanel(QWidget):
self.serial_baudrate_combo = QComboBox()
self.serial_baudrate_combo.addItems(['9600', '19200', '38400', '57600', '115200'])
self.serial_baudrate_combo.setCurrentText('57600')
self.serial_baudrate_combo.setCurrentText('115200')
self.serial_baudrate_combo.setFixedWidth(100)
self.serial_baudrate_combo.setStyleSheet("""
_set_scaled_stylesheet(self.serial_baudrate_combo, """
QComboBox {
background-color: #333;
color: #DDD;
@@ -197,7 +270,7 @@ class CommPanel(QWidget):
add_serial_btn = QPushButton("添加")
add_serial_btn.clicked.connect(self._handle_add_serial)
add_serial_btn.setStyleSheet("""
_set_scaled_stylesheet(add_serial_btn, """
QPushButton {
background-color: #4CAF50;
color: white;
@@ -209,10 +282,14 @@ class CommPanel(QWidget):
QPushButton:hover { background-color: #45a049; }
""")
add_serial_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;"))
serial_port_label = QLabel("Port:")
_set_scaled_stylesheet(serial_port_label, "color: #DDD;")
add_serial_layout.addWidget(serial_port_label)
add_serial_layout.addWidget(self.serial_port_combo)
add_serial_layout.addWidget(refresh_ports_btn)
add_serial_layout.addWidget(QLabel("Baud:", styleSheet="color: #DDD;"))
baud_label = QLabel("Baud:")
_set_scaled_stylesheet(baud_label, "color: #DDD;")
add_serial_layout.addWidget(baud_label)
add_serial_layout.addWidget(self.serial_baudrate_combo)
add_serial_layout.addWidget(add_serial_btn)
@@ -225,7 +302,7 @@ class CommPanel(QWidget):
# ========== WebSocket 區域 ==========
ws_title = QLabel("WebSocket")
ws_title.setStyleSheet("""
_set_scaled_stylesheet(ws_title, """
color: #DDD;
font-size: 14px;
font-weight: bold;
@@ -249,7 +326,7 @@ class CommPanel(QWidget):
self.ws_url_input = QLineEdit()
self.ws_url_input.setPlaceholderText("host")
self.ws_url_input.setStyleSheet("""
_set_scaled_stylesheet(self.ws_url_input, """
QLineEdit {
background-color: #333;
color: #DDD;
@@ -261,7 +338,7 @@ class CommPanel(QWidget):
add_ws_btn = QPushButton("添加")
add_ws_btn.clicked.connect(self._handle_add_ws)
add_ws_btn.setStyleSheet("""
_set_scaled_stylesheet(add_ws_btn, """
QPushButton {
background-color: #4CAF50;
color: white;
@@ -273,7 +350,9 @@ class CommPanel(QWidget):
QPushButton:hover { background-color: #45a049; }
""")
add_ws_layout.addWidget(QLabel("URL:", styleSheet="color: #DDD;"))
url_label = QLabel("URL:")
_set_scaled_stylesheet(url_label, "color: #DDD;")
add_ws_layout.addWidget(url_label)
add_ws_layout.addWidget(self.ws_url_input)
add_ws_layout.addWidget(add_ws_btn)
@@ -437,7 +516,7 @@ class CommPanel(QWidget):
def create_udp_connection_panel(self, conn):
"""創建 UDP 連接面板"""
panel = QWidget()
panel.setStyleSheet("""
_set_scaled_stylesheet(panel, """
QWidget {
background-color: #2A2A2A;
border-radius: 6px;
@@ -451,22 +530,22 @@ class CommPanel(QWidget):
# 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['ip']}:{conn['port']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;")
_set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;")
# 狀態指示器
status_label = QLabel("●")
if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;")
_set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中")
else:
status_label.setStyleSheet("color: #888; font-size: 16px;")
_set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;")
status_label.setToolTip("已停止")
# 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.udp_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet("""
_set_scaled_stylesheet(toggle_btn, """
QPushButton {
background-color: #444;
color: #DDD;
@@ -481,7 +560,7 @@ class CommPanel(QWidget):
remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.udp_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet("""
_set_scaled_stylesheet(remove_btn, """
QPushButton {
background-color: #d32f2f;
color: white;
@@ -509,7 +588,7 @@ class CommPanel(QWidget):
def create_ws_connection_panel(self, conn):
"""創建 WebSocket 連接面板"""
panel = QWidget()
panel.setStyleSheet("""
_set_scaled_stylesheet(panel, """
QWidget {
background-color: #2A2A2A;
border-radius: 6px;
@@ -523,22 +602,22 @@ class CommPanel(QWidget):
# 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['url']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;")
_set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;")
# 狀態指示器
status_label = QLabel("●")
if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;")
_set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中")
else:
status_label.setStyleSheet("color: #888; font-size: 16px;")
_set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;")
status_label.setToolTip("已停止")
# 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.ws_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet("""
_set_scaled_stylesheet(toggle_btn, """
QPushButton {
background-color: #444;
color: #DDD;
@@ -553,7 +632,7 @@ class CommPanel(QWidget):
remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.ws_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet("""
_set_scaled_stylesheet(remove_btn, """
QPushButton {
background-color: #d32f2f;
color: white;
@@ -605,7 +684,7 @@ class CommPanel(QWidget):
def create_serial_connection_panel(self, conn):
"""創建 Serial 連接面板"""
panel = QWidget()
panel.setStyleSheet("""
_set_scaled_stylesheet(panel, """
QWidget {
background-color: #2A2A2A;
border-radius: 6px;
@@ -619,22 +698,22 @@ class CommPanel(QWidget):
# 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['port']} @ {conn['baudrate']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;")
_set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;")
# 狀態指示器
status_label = QLabel("●")
if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;")
_set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中")
else:
status_label.setStyleSheet("color: #888; font-size: 16px;")
_set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;")
status_label.setToolTip("已停止")
# 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.serial_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet("""
_set_scaled_stylesheet(toggle_btn, """
QPushButton {
background-color: #444;
color: #DDD;
@@ -649,7 +728,7 @@ class CommPanel(QWidget):
remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.serial_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet("""
_set_scaled_stylesheet(remove_btn, """
QPushButton {
background-color: #d32f2f;
color: white;
@@ -684,4 +763,12 @@ class CommPanel(QWidget):
def remove_serial_connection_from_list(self, conn):
"""從列表中移除 Serial 連接"""
if conn in self.serial_connections:
self.serial_connections.remove(conn)
self.serial_connections.remove(conn)
def apply_font_scale(self):
"""重新套用目前字體倍率到通訊面板。"""
_apply_scaled_font(self)
_reapply_scaled_stylesheet(self)
for child in self.findChildren(QWidget):
_apply_scaled_font(child)
_reapply_scaled_stylesheet(child)
+98 -15
View File
@@ -3,22 +3,33 @@
指令發送模組
負責將目標位置轉成具體的通訊指令發送到飛控
現階段: MavlinkSender (直接 pymavlink 發送)
未來: 替換為 ROS2Sender (發到 ROS2 topic,由 fc_network_adapter 轉發)
目前使用 Ros2CommandSender,經 fc_network_adapter 的
/fc_network/vehicle/pos_global_int service 發送。
MavlinkSender 保留作為直連 SITL 的 fallback 工具,但已不是預設路徑。
"""
import asyncio
import traceback
from abc import ABC, abstractmethod
from PyQt6.QtCore import QObject, pyqtSignal
from pymavlink import mavutil
def _log(level, message):
print(f"[{level}] {message}")
class CommandSender(ABC):
"""指令發送抽象介面"""
@abstractmethod
def send_position_global(self, sysid, lat, lon, alt):
def send_position_global(self, drone_id, sysid, lat, lon, alt):
"""
發送全球座標位置指令
Args:
drone_id: GUI 用的 drone 識別字串 (如 's0_11')
sysid: 目標無人機的 MAVLink system ID
lat: 緯度 (度)
lon: 經度 (度)
@@ -56,18 +67,10 @@ class MavlinkSender(CommandSender):
"""
self.connection_string = connection_string
self.mav = mavutil.mavlink_connection(connection_string)
print(f"MavlinkSender 已建立連線: {connection_string}")
_log("INFO", f"MavlinkSender 已建立連線: {connection_string}")
def send_position_global(self, sysid, lat, lon, alt):
"""
發送 SET_POSITION_TARGET_GLOBAL_INT
注意:
- coordinate_frame 使用 MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
高度是相對起飛點的高度 (公尺)
- 如果 FormationPlanner 產出的 alt 是 AMSL 絕對高度,
需要在外部先減去起飛點高度再傳入
"""
def send_position_global(self, drone_id, sysid, lat, lon, alt):
"""發送 SET_POSITION_TARGET_GLOBAL_INT。drone_id 未使用,保留為介面相容。"""
self.mav.mav.set_position_target_global_int_send(
0, # time_boot_ms (不使用)
sysid, # target_system
@@ -87,4 +90,84 @@ class MavlinkSender(CommandSender):
if self.mav:
self.mav.close()
self.mav = None
print("MavlinkSender 已關閉")
_log("INFO", "MavlinkSender 已關閉")
class Ros2CommandSender(QObject):
"""
透過 fc_network_apps.PositionTargetGlobalIntClient 送 goto 指令。
設計重點:
- send_position_global 是同步介面,立刻回傳。結果透過 send_result signal 回報
- 每台 drone 維護自己的 asyncio.Task pipeline,新的目標會 cancel 掉舊的 in-flight
- client 由 DroneMonitor.get_or_create_position_client 管理,per-drone 獨立節點
"""
# (drone_id, sysid, success, message)
send_result = pyqtSignal(str, int, bool, str)
# 閉環設計:goto setpoint 由 mission_executor 週期性重送、換點靠 GPS 到達判定,
# 所以這裡的 timeout 只影響「echo 驗證要等多久」,不影響指令是否送達飛控
# (adapter 是先送 setpoint 再等 echo)。故意設短,讓 adapter 序列化的 service
# 在 echo miss 時快速放棄、換下一台,避免多機時排隊塞爆 client poll。
DEFAULT_TIMEOUT_SEC = 0.5
def __init__(self, monitor, timeout_sec: float = DEFAULT_TIMEOUT_SEC):
super().__init__()
self._monitor = monitor
self._timeout_sec = timeout_sec
self._pending = {} # drone_id -> asyncio.Task
def send_position_global(self, drone_id, sysid, lat, lon, alt):
"""同步介面。立刻回,結果透過 send_result signal 回報。"""
# 取消該機上一個未完成的 task,避免舊指令覆蓋新指令
old = self._pending.get(drone_id)
if old is not None and not old.done():
old.cancel()
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(
self._do_send(drone_id, int(sysid), float(lat), float(lon), float(alt))
)
self._pending[drone_id] = task
async def _do_send(self, drone_id, sysid, lat, lon, alt):
try:
client = self._monitor.get_or_create_position_client(drone_id)
if client is None:
self.send_result.emit(
drone_id, sysid, False,
"PositionTargetGlobalIntClient 無法建立"
)
return
result = await client.goto_position_only_async(
target_sysid=sysid,
target_compid=0,
latitude_deg=lat,
longitude_deg=lon,
alt=alt,
timeout_sec=self._timeout_sec,
)
self.send_result.emit(
drone_id, sysid, bool(result.success), str(result.message or "")
)
except asyncio.CancelledError:
# 被新的目標取代,正常狀況,不用回報
pass
except Exception as e:
traceback.print_exc()
self.send_result.emit(drone_id, sysid, False, f"exception: {e}")
def close(self):
"""取消所有未完成的 task。PositionTargetGlobalIntClient 由 DroneMonitor 負責 destroy。"""
for task in self._pending.values():
if not task.done():
task.cancel()
self._pending.clear()
_log("INFO", "Ros2CommandSender 已關閉")
+1329 -172
View File
File diff suppressed because it is too large Load Diff
+164 -39
View File
@@ -1,10 +1,77 @@
#!/usr/bin/env python3
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox)
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QApplication)
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QPolygonF
from PyQt6.QtCore import QPointF, Qt
import math
def _get_font_scale():
app = QApplication.instance()
if app is None:
return 1.0
scale = app.property("font_scale")
try:
return float(scale) if scale is not None else 1.0
except (TypeError, ValueError):
return 1.0
def _scale_stylesheet_font_sizes(stylesheet, scale):
if not stylesheet or 'font-size' not in stylesheet:
return stylesheet
import re
def repl(match):
size = float(match.group(1))
unit = match.group(2)
scaled = max(1.0, size * scale)
text = f"{scaled:.2f}".rstrip('0').rstrip('.')
return f"font-size: {text}{unit}"
return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet)
def _set_scaled_stylesheet(widget, stylesheet):
widget._base_stylesheet = stylesheet
scaled = _scale_stylesheet_font_sizes(stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
widget.setStyleSheet(scaled)
def _reapply_scaled_stylesheet(widget):
current_stylesheet = widget.styleSheet()
base_stylesheet = getattr(widget, '_base_stylesheet', None)
applied_stylesheet = getattr(widget, '_applied_stylesheet', None)
if current_stylesheet != applied_stylesheet:
base_stylesheet = current_stylesheet
widget._base_stylesheet = base_stylesheet
if base_stylesheet is not None:
scaled = _scale_stylesheet_font_sizes(base_stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
if current_stylesheet != scaled:
widget.setStyleSheet(scaled)
def _apply_scaled_font(widget):
base_font = getattr(widget, '_base_font_for_scale', None)
if base_font is None:
app = QApplication.instance()
app_base_font = app.property("base_app_font") if app else None
base_font = QFont(app_base_font) if app_base_font is not None else QFont(widget.font())
widget._base_font_for_scale = QFont(base_font)
scaled_font = QFont(base_font)
scale = _get_font_scale()
if base_font.pointSizeF() > 0:
scaled_font.setPointSizeF(max(1.0, base_font.pointSizeF() * scale))
elif base_font.pointSize() > 0:
scaled_font.setPointSize(max(1, int(round(base_font.pointSize() * scale))))
widget.setFont(scaled_font)
class DronePanel(QWidget):
"""單個無人機面板類別"""
@@ -32,12 +99,13 @@ class DronePanel(QWidget):
self.attitude_indicator = None
self._init_ui()
self.apply_font_scale()
def _init_ui(self):
"""初始化UI"""
self.setObjectName(f"panel_{self.drone_id}")
self.setFixedHeight(140)
self.setStyleSheet("""
self.setFixedHeight(160)
_set_scaled_stylesheet(self, """
background-color: #2A2A2A;
border-radius: 8px;
""")
@@ -49,7 +117,7 @@ class DronePanel(QWidget):
# 創建內容容器(包含 info 和 control)
content_widget = QWidget()
content_widget.setStyleSheet("background-color: #333; border-radius: 6px;")
_set_scaled_stylesheet(content_widget, "background-color: #333; border-radius: 6px;")
content_layout = QHBoxLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setSpacing(8)
@@ -82,7 +150,7 @@ class DronePanel(QWidget):
# 勾選框
self.checkbox = QCheckBox()
self.checkbox.setObjectName(f"{self.drone_id}_checkbox")
self.checkbox.setStyleSheet("""
_set_scaled_stylesheet(self.checkbox, """
QCheckBox {
color: #DDD;
}
@@ -103,8 +171,8 @@ class DronePanel(QWidget):
)
# ID 顯示
id_label = QLabel(self.display_id)
id_label.setStyleSheet("""
self.id_label = QLabel(self.display_id)
_set_scaled_stylesheet(self.id_label, """
font-weight: bold;
font-size: 14px;
color: #7FFFD4;
@@ -112,7 +180,7 @@ class DronePanel(QWidget):
""")
header_layout.addWidget(self.checkbox)
header_layout.addWidget(id_label)
header_layout.addWidget(self.id_label)
header_layout.addStretch()
info_layout.addWidget(header)
@@ -128,8 +196,12 @@ class DronePanel(QWidget):
# 第三行:高度
altitude_row = self._create_altitude_row()
info_layout.addWidget(altitude_row)
# 第四行:GNSS 定位狀態
gnss_row = self._create_gnss_row()
info_layout.addWidget(gnss_row)
# 第四行:航向 + 速度
# 第五行:航向 + 速度
nav_row = self._create_nav_row()
info_layout.addWidget(nav_row)
@@ -148,15 +220,15 @@ class DronePanel(QWidget):
status_layout.setContentsMargins(0, 0, 0, 0)
status_title = QLabel("狀態:")
status_title.setStyleSheet("color: #888; min-width: 50px;")
_set_scaled_stylesheet(status_title, "color: #888; min-width: 50px;")
self.mode_label = QLabel("--")
self.mode_label = QLabel("-")
self.mode_label.setObjectName(f"{self.drone_id}_mode")
self.mode_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.mode_label, "color: #DDD;")
self.armed_label = QLabel("--")
self.armed_label = QLabel("-")
self.armed_label.setObjectName(f"{self.drone_id}_armed")
self.armed_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.armed_label, "color: #DDD;")
status_layout.addWidget(status_title)
status_layout.addWidget(self.mode_label)
@@ -172,15 +244,15 @@ class DronePanel(QWidget):
connection_layout.setContentsMargins(0, 0, 0, 0)
connection_title = QLabel("Socket:")
connection_title.setStyleSheet("color: #888; min-width: 50px;")
_set_scaled_stylesheet(connection_title, "color: #888; min-width: 50px;")
# 根據解析的 drone_id 資訊設定初始值
self.socket_seq_label = QLabel(self.socket_seq)
self.socket_seq_label.setObjectName(f"{self.drone_id}_socket_seq")
self.socket_seq_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.socket_seq_label, "color: #DDD;")
connection_sep = QLabel(" - ")
connection_sep.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(connection_sep, "color: #DDD;")
# 設定連接方式顯示
connection_type_map = {
@@ -193,7 +265,7 @@ class DronePanel(QWidget):
self.connection_type_label = QLabel(connection_type)
self.connection_type_label.setObjectName(f"{self.drone_id}_connection_type")
self.connection_type_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.connection_type_label, "color: #DDD;")
connection_layout.addWidget(connection_title)
connection_layout.addWidget(self.socket_seq_label)
@@ -210,29 +282,31 @@ class DronePanel(QWidget):
battery_layout.setContentsMargins(0, 0, 0, 0)
# 顯示百分比
battery_title = QLabel("電池:")
battery_title.setStyleSheet("color: #888; min-width: 50px;")
_set_scaled_stylesheet(battery_title, "color: #888; min-width: 50px;")
self.battery_pct_label = QLabel("--")
self.battery_pct_label = QLabel("-")
self.battery_pct_label.setObjectName(f"{self.drone_id}_battery_pct")
self.battery_pct_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.battery_pct_label, "color: #DDD;")
# 分隔符
separator1 = QLabel(" - ")
separator1.setStyleSheet("color: #DDD;")
separator1.setObjectName(f"{self.drone_id}_battery_sep1")
_set_scaled_stylesheet(separator1, "color: #DDD;")
# 顯示電壓
self.battery_vol_label = QLabel("--")
self.battery_vol_label = QLabel("-")
self.battery_vol_label.setObjectName(f"{self.drone_id}_battery_vol")
self.battery_vol_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.battery_vol_label, "color: #DDD;")
# 分隔符
separator2 = QLabel(" - ")
separator2.setStyleSheet("color: #DDD;")
separator2.setObjectName(f"{self.drone_id}_battery_sep2")
_set_scaled_stylesheet(separator2, "color: #DDD;")
# 顯示電池節數 (S count)
self.battery_cells_label = QLabel("--")
self.battery_cells_label = QLabel("-")
self.battery_cells_label.setObjectName(f"{self.drone_id}_battery_cells")
self.battery_cells_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.battery_cells_label, "color: #DDD;")
battery_layout.addWidget(battery_title)
battery_layout.addWidget(self.battery_pct_label)
@@ -251,18 +325,18 @@ class DronePanel(QWidget):
altitude_layout.setContentsMargins(0, 0, 0, 0)
altitude_title = QLabel("高度:")
altitude_title.setStyleSheet("color: #888; min-width: 50px;")
_set_scaled_stylesheet(altitude_title, "color: #888; min-width: 50px;")
self.altitude_label = QLabel("--")
self.altitude_label = QLabel("-")
self.altitude_label.setObjectName(f"{self.drone_id}_altitude")
self.altitude_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.altitude_label, "color: #DDD;")
speed_title = QLabel("速度:")
speed_title.setStyleSheet("color: #888; margin-left: 10px;")
_set_scaled_stylesheet(speed_title, "color: #888; margin-left: 10px;")
self.speed_label = QLabel("--")
self.speed_label = QLabel("-")
self.speed_label.setObjectName(f"{self.drone_id}_speed")
self.speed_label.setStyleSheet("color: #DDD;")
_set_scaled_stylesheet(self.speed_label, "color: #DDD;")
altitude_layout.addWidget(altitude_title)
altitude_layout.addWidget(self.altitude_label)
@@ -271,6 +345,34 @@ class DronePanel(QWidget):
altitude_layout.addStretch()
return altitude_row
def _create_gnss_row(self):
"""創建 GNSS 定位狀態行。"""
gnss_row = QWidget()
gnss_layout = QHBoxLayout(gnss_row)
gnss_layout.setContentsMargins(0, 0, 0, 0)
fix_title = QLabel("定位:")
_set_scaled_stylesheet(fix_title, "color: #888; min-width: 50px;")
self.fix_type_label = QLabel("-")
self.fix_type_label.setObjectName(f"{self.drone_id}_fix_type_label")
_set_scaled_stylesheet(self.fix_type_label, "color: #DDD;")
error_title = QLabel("誤差(H/V):")
_set_scaled_stylesheet(error_title, "color: #888; margin-left: 8px;")
self.gnss_error_label = QLabel("-")
self.gnss_error_label.setObjectName(f"{self.drone_id}_gnss_error")
_set_scaled_stylesheet(self.gnss_error_label, "color: #DDD;")
gnss_layout.addWidget(fix_title)
gnss_layout.addWidget(self.fix_type_label)
gnss_layout.addWidget(error_title)
gnss_layout.addWidget(self.gnss_error_label)
gnss_layout.addStretch()
return gnss_row
def _create_position_row(self):
"""位置行已移除(位置座標不再顯示於面板)。"""
@@ -320,6 +422,16 @@ class DronePanel(QWidget):
def is_checked(self):
"""獲取勾選狀態"""
return self.checkbox.isChecked()
def apply_font_scale(self):
"""重新套用目前字體倍率到 panel 內所有元件。"""
_apply_scaled_font(self)
_reapply_scaled_stylesheet(self)
for child in self.findChildren(QWidget):
_apply_scaled_font(child)
_reapply_scaled_stylesheet(child)
if self.attitude_indicator:
self.attitude_indicator.update()
class SocketGroupPanel(QWidget):
# 定義信號
@@ -331,11 +443,12 @@ class SocketGroupPanel(QWidget):
self.color = color
self.socket_type = socket_type
self._init_ui()
self.apply_font_scale()
def _init_ui(self):
"""初始化UI"""
self.setObjectName(f"socket_group_{self.socket_id}")
self.setStyleSheet("""
_set_scaled_stylesheet(self, """
background-color: #1E1E1E;
border-radius: 12px;
""")
@@ -352,7 +465,7 @@ class SocketGroupPanel(QWidget):
# 分組勾選框
self.group_checkbox = QCheckBox()
self.group_checkbox.setObjectName(f"socket_{self.socket_id}_checkbox")
self.group_checkbox.setStyleSheet(f"""
_set_scaled_stylesheet(self.group_checkbox, f"""
QCheckBox {{ color: #DDD; }}
QCheckBox::indicator {{
width: 14px;
@@ -380,7 +493,7 @@ class SocketGroupPanel(QWidget):
else:
title_text = f"Socket {self.socket_id}"
self.title_label = QLabel(title_text)
self.title_label.setStyleSheet(f"""
_set_scaled_stylesheet(self.title_label, f"""
font-weight: bold;
font-size: 16px;
color: {self.color};
@@ -430,6 +543,14 @@ class SocketGroupPanel(QWidget):
"""設置分組勾選狀態(支持半選)"""
self.group_checkbox.setCheckState(state)
def apply_font_scale(self):
"""重新套用目前字體倍率到 socket 分組面板。"""
_apply_scaled_font(self)
_reapply_scaled_stylesheet(self)
for child in self.findChildren(QWidget):
_apply_scaled_font(child)
_reapply_scaled_stylesheet(child)
class AttitudeIndicator(QWidget):
"""
@@ -503,7 +624,9 @@ class AttitudeIndicator(QWidget):
# pitch ladder (every 10°, ±30°)
p.setPen(QPen(QColor(255, 255, 255, 180), 1))
p.setFont(QFont("Arial", 6))
ladder_font = QFont("Arial")
ladder_font.setPointSizeF(max(1.0, 6 * _get_font_scale()))
p.setFont(ladder_font)
for deg in range(-30, 31, 10):
if deg == 0:
continue
@@ -581,6 +704,8 @@ class AttitudeIndicator(QWidget):
# heading text centred (bigger)
p.setPen(QPen(QColor("#FFFFFF")))
p.setFont(QFont("Arial", 10, QFont.Weight.Bold))
heading_font = QFont("Arial", weight=QFont.Weight.Bold)
heading_font.setPointSizeF(max(1.0, 10 * _get_font_scale()))
p.setFont(heading_font)
hdg_str = f"{int(self.heading)}°"
p.drawText(0, int(strip_y), w, strip_h, Qt.AlignmentFlag.AlignCenter, hdg_str)
p.drawText(0, int(strip_y), w, strip_h, Qt.AlignmentFlag.AlignCenter, hdg_str)
+2417 -446
View File
File diff suppressed because it is too large Load Diff
+290 -78
View File
@@ -1,8 +1,15 @@
#!/usr/bin/env python3
import json
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QTimer, pyqtSignal, QObject, pyqtSlot
from PyQt6.QtWebChannel import QWebChannel
def _log(level, message):
print(f"[{level}] {message}")
class DroneMap:
"""無人機地圖類別 - 負責管理 Leaflet 地圖顯示"""
@@ -11,6 +18,11 @@ class DroneMap:
self.map_view = QWebEngineView()
self.map_loaded = False
self.pending_map_updates = {}
self.font_scale = 1.0
self.socket_colors = {}
self.drone_group_colors = {}
self.mission_following_enabled = False
self.mission_following_drone_id = None
# 創建橋接對象
self.bridge = MapBridge()
@@ -31,6 +43,7 @@ class DroneMap:
<script src="https://unpkg.com/leaflet-rotatedmarker/leaflet.rotatedMarker.js"></script>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
:root { --ui-font-scale: 1; }
html, body, #map { height: 100%; margin: 0; }
#map {
user-select: none;
@@ -65,7 +78,7 @@ class DroneMap:
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-size: calc(13px * var(--ui-font-scale));
font-weight: bold;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
@@ -85,7 +98,7 @@ class DroneMap:
}
.mission-info-row {
margin-bottom: 8px;
font-size: 12px;
font-size: calc(12px * var(--ui-font-scale));
color: #333;
}
.mission-info-label {
@@ -104,7 +117,7 @@ class DroneMap:
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-size: calc(13px * var(--ui-font-scale));
font-weight: bold;
margin-top: 8px;
}
@@ -131,7 +144,7 @@ class DroneMap:
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-size: calc(13px * var(--ui-font-scale));
font-weight: bold;
transition: background-color 0.2s;
}
@@ -149,7 +162,7 @@ class DroneMap:
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-size: calc(13px * var(--ui-font-scale));
font-weight: bold;
transition: background-color 0.2s;
}
@@ -167,7 +180,7 @@ class DroneMap:
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-size: calc(13px * var(--ui-font-scale));
font-weight: bold;
transition: background-color 0.2s;
}
@@ -188,16 +201,18 @@ class DroneMap:
bridge = channel.objects.bridge;
});
var map = L.map('map').setView([0, 0], 19);
var map = L.map('map', {maxZoom: 20}).setView([0, 0], 19);
// 創建不同的地圖圖層
var streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
maxNativeZoom: 19,
maxZoom: 20,
attribution: '© OpenStreetMap contributors'
});
var satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
maxNativeZoom: 19,
maxZoom: 20,
attribution: 'Tiles © Esri'
});
@@ -206,7 +221,6 @@ class DroneMap:
currentLayer.addTo(map);
var isSatellite = true;
// 地圖點擊事件
map.on('click', function(e) {
if (selectionMode === 'polygon') {
@@ -274,38 +288,37 @@ class DroneMap:
}, 100);
});
function createArrowIcon(color) {
function createArrowIcon(color, groupColor) {
const outlineColor = groupColor || 'transparent';
return L.divIcon({
className: 'drone-arrow',
html: `
<div style="
width: 30px; height: 30px;
width: 34px; height: 34px;
display: flex;
align-items: center;
justify-content: center;
">
<svg width="30" height="30" viewBox="0 0 30 30">
<polygon points="15,0 30,30 15,25 0,30" fill="${color || '#FF0000'}" />
<svg width="34" height="34" viewBox="0 0 34 34">
<polygon
points="17,3 31,31 17,26 3,31"
fill="${color || '#FF0000'}"
stroke="${outlineColor}"
stroke-width="3"
stroke-linejoin="round"
/>
</svg>
</div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15]
iconSize: [34, 34],
iconAnchor: [17, 17]
});
}
function getColorBySocketId(id) {
if (id.startsWith("s0_")) return "#00BFFF";
if (id.startsWith("s1_")) return "#FFD700";
if (id.startsWith("s2_")) return "#FF6969";
if (id.startsWith("s3_")) return "#FF69B4";
if (id.startsWith("s4_")) return "#00FA9A";
if (id.startsWith("s5_")) return "#9370DB";
if (id.startsWith("s6_")) return "#FFA500";
if (id.startsWith("s7_")) return "#20B2AA";
if (id.startsWith("s8_")) return "#7CFC00";
if (id.startsWith("s9_")) return "#FF8C00";
return "#AAAAAA";
const match = /^s([^_]+)_/.exec(id || '');
const socketId = match ? match[1] : 'default';
return socketColors[socketId] || socketColors.default || "#E0E0E0";
}
function createIdIcon(id) {
@@ -322,7 +335,7 @@ class DroneMap:
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
font-size: calc(12px * var(--ui-font-scale));
text-shadow: 1px 1px 2px rgba(255,255,255,0.8), -1px -1px 2px rgba(255,255,255,0.8);
">${sysid}</div>`,
iconSize: [16, 16],
@@ -333,8 +346,13 @@ class DroneMap:
var markers = {};
var idLabels = {};
var trajectories = {};
const maxTrajectoryPoints = 2000;
const trajectoryMinDistanceMeters = 0.05;
var trajectoryLines = {};
var socketColors = {};
var droneGroupColors = {};
var focusedId = null;
var missionFollowingEnabled = false;
var initialized = false;
var trajectoryGroup = L.layerGroup().addTo(map);
@@ -350,6 +368,8 @@ class DroneMap:
// 多群組任務規劃 — 每個 group 各自的 layer group
var groupMissionLayers = {}; // group_id → L.layerGroup
// Grid Sweep 框選範圍也依群組保存,停止時可單獨清除。
var gridSweepAreaLayers = {}; // group_id → L.layerGroup
// 選擇工具變量
var selectionMode = null; // 'drones', 'rect', 'polygon', 'route', null
@@ -361,6 +381,54 @@ class DroneMap:
var drawStartPoint = null;
var mapDragStarted = false;
var clickStartPos = null;
var lastManualMapMoveAt = 0;
var autoCenteringMap = false;
const autoFollowPanThresholdRatio = 0.25;
var lastAutoFollowPanAt = 0;
function markManualMapMove() {
if (!autoCenteringMap) {
lastManualMapMoveAt = Date.now();
}
}
map.on('dragstart drag dragend zoomstart zoomend', markManualMapMove);
function isOutsideAutoFollowWindow(latlng) {
if (!latlng) {
return false;
}
var size = map.getSize();
if (!size || size.x <= 0 || size.y <= 0) {
return false;
}
var centerPoint = map.latLngToContainerPoint(map.getCenter());
var targetPoint = map.latLngToContainerPoint(latlng);
var dx = Math.abs(targetPoint.x - centerPoint.x);
var dy = Math.abs(targetPoint.y - centerPoint.y);
return dx >= size.x * autoFollowPanThresholdRatio ||
dy >= size.y * autoFollowPanThresholdRatio;
}
function autoFollowPanTo(latlng) {
if (!isOutsideAutoFollowWindow(latlng)) {
return;
}
if (Date.now() - lastManualMapMoveAt < 1000) {
return;
}
if (Date.now() - lastAutoFollowPanAt < 250) {
return;
}
lastAutoFollowPanAt = Date.now();
autoCenteringMap = true;
map.panTo(latlng, {animate: true});
setTimeout(() => { autoCenteringMap = false; }, 300);
}
// 路徑標記變量 (跟隨模式用)
var routePoints = [];
@@ -515,23 +583,6 @@ class DroneMap:
[bounds.getSouth(), bounds.getEast()],
[bounds.getSouth(), bounds.getWest()]
];
L.rectangle(bounds, {
color: '#FF6B6B',
fillColor: '#FF6B6B',
fillOpacity: 0.2,
weight: 2
}).addTo(selectionLayer);
rectPoints.forEach(point => {
L.circleMarker(point, {
radius: 5,
color: '#FF6B6B',
fillColor: '#FF6B6B',
fillOpacity: 0.8
}).addTo(selectionLayer);
});
if (bridge) {
var pointsStr = JSON.stringify(rectPoints);
bridge.rectangleSelected(pointsStr);
@@ -564,7 +615,7 @@ class DroneMap:
'align-items: center;' +
'justify-content: center;' +
'font-weight: bold;' +
'font-size: 11px;' +
'font-size: calc(11px * var(--ui-font-scale));' +
'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">' + idx + '</div>',
@@ -617,6 +668,10 @@ class DroneMap:
}
}
// ================================================================================
function setFontScale(scale) {
document.documentElement.style.setProperty('--ui-font-scale', scale);
}
// 開始任務
function startMission() {
@@ -681,7 +736,7 @@ class DroneMap:
color: color,
weight: 3,
opacity: 0.7,
smoothFactor: 1
smoothFactor: 0.1
}).addTo(trajectoryGroup);
}
}
@@ -691,24 +746,46 @@ class DroneMap:
const point = [lat, lon];
trajectories[id].push(point);
if (trajectories[id].length > 1000) {
if (trajectories[id].length > maxTrajectoryPoints) {
trajectories[id].shift();
trajectoryLines[id].setLatLngs(trajectories[id]);
} else {
trajectoryLines[id].addLatLng(point);
}
trajectoryLines[id].setLatLngs([...trajectories[id]]);
}
function focusOn(id) {
if (!markers[id]) return;
focusedId = id;
// 非任務狀態只移動一次;任務執行中才記住焦點持續跟隨。
focusedId = missionFollowingEnabled ? id : null;
var latlng = markers[id].getLatLng();
autoCenteringMap = true;
map.flyTo(latlng, map.getZoom());
setTimeout(() => { autoCenteringMap = false; }, 500);
}
function setMissionFollowingEnabled(enabled, preferredId) {
missionFollowingEnabled = Boolean(enabled);
if (!missionFollowingEnabled) {
// 非任務狀態不跟隨 UAV 或目標點,保留使用者手動地圖視角。
focusedId = null;
return;
}
// 任務已在執行時保留原有焦點;尚無焦點時跟隨指定 UAV。
if (focusedId && markers[focusedId]) {
return;
}
if (preferredId && markers[preferredId]) {
focusOn(preferredId);
initialized = true;
}
}
setInterval(() => {
if (focusedId && markers[focusedId]) {
if (missionFollowingEnabled && focusedId && markers[focusedId]) {
var latlng = markers[focusedId].getLatLng();
map.panTo(latlng);
autoFollowPanTo(latlng);
}
}, 1000);
@@ -716,7 +793,7 @@ class DroneMap:
if (markers[id]) {
const lastPos = markers[id].getLatLng();
const distance = lastPos.distanceTo([lat, lon]);
if (distance > 1) {
if (distance >= trajectoryMinDistanceMeters) {
addTrajectoryPoint(id, lat, lon);
}
@@ -724,13 +801,16 @@ class DroneMap:
.setLatLng([lat, lon])
.setRotationAngle(heading);
idLabels[id].setLatLng([lat, lon]);
if (missionFollowingEnabled && id === focusedId) {
autoFollowPanTo(markers[id].getLatLng());
}
} else {
initTrajectory(id);
addTrajectoryPoint(id, lat, lon);
const color = getColorBySocketId(id);
markers[id] = L.marker([lat, lon], {
icon: createArrowIcon(color),
icon: createArrowIcon(color, droneGroupColors[id]),
rotationAngle: heading,
rotationOrigin: 'center'
})
@@ -741,7 +821,6 @@ class DroneMap:
if (bridge) {
bridge.emitDroneClicked(id);
}
focusOn(id);
})
.addTo(map);
@@ -756,18 +835,54 @@ class DroneMap:
if (bridge) {
bridge.emitDroneClicked(id);
}
focusOn(id);
})
.addTo(map);
if (!initialized || id < focusedId) {
if (!initialized) {
// 第一次收到 UAV 位置時只做一次初始置中,
// 非任務狀態不設 focusedId,所以後續不會跟隨。
if (missionFollowingEnabled) {
focusedId = id;
}
autoCenteringMap = true;
map.setView([lat, lon], 19);
setTimeout(() => { autoCenteringMap = false; }, 300);
initialized = true;
} else if (missionFollowingEnabled &&
(!focusedId || id < focusedId)) {
focusedId = id;
map.setView([lat, lon], 19);
initialized = true;
}
}
}
function setDroneGroupColors(groupColors) {
droneGroupColors = groupColors || {};
Object.keys(markers).forEach(id => {
markers[id].setIcon(
createArrowIcon(getColorBySocketId(id), droneGroupColors[id])
);
if (typeof markers[id].setRotationAngle === 'function') {
markers[id].setRotationAngle(markers[id].options.rotationAngle || 0);
}
});
}
function setSocketColors(colors) {
socketColors = colors || {};
Object.keys(markers).forEach(id => {
markers[id].setIcon(
createArrowIcon(getColorBySocketId(id), droneGroupColors[id])
);
if (typeof markers[id].setRotationAngle === 'function') {
markers[id].setRotationAngle(markers[id].options.rotationAngle || 0);
}
});
Object.keys(trajectoryLines).forEach(id => {
trajectoryLines[id].setStyle({ color: getColorBySocketId(id) });
});
}
function clearAllTrajectories() {
trajectories = {};
Object.values(trajectoryLines).forEach(line => {
@@ -802,7 +917,7 @@ class DroneMap:
'width: 22px; height: 22px;' +
'border-radius: 50%;' +
'display: flex; align-items: center; justify-content: center;' +
'font-weight: bold; font-size: 10px;' +
'font-weight: bold; font-size: calc(10px * var(--ui-font-scale));' +
'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">' + groupId + '</div>',
@@ -822,7 +937,7 @@ class DroneMap:
'width: 22px; height: 22px;' +
'border-radius: 50%;' +
'display: flex; align-items: center; justify-content: center;' +
'font-weight: bold; font-size: 14px;' +
'font-weight: bold; font-size: calc(14px * var(--ui-font-scale));' +
'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">★</div>',
@@ -844,6 +959,41 @@ class DroneMap:
}
}
function drawGridSweepAreaForGroup(groupId, color, rectPoints) {
clearGridSweepAreaForGroup(groupId);
var layerGroup = L.layerGroup().addTo(map);
gridSweepAreaLayers[groupId] = layerGroup;
var bounds = L.latLngBounds(rectPoints);
L.rectangle(bounds, {
color: color,
fillColor: color,
fillOpacity: 0.2,
weight: 2
}).addTo(layerGroup);
rectPoints.forEach(point => {
L.circleMarker(point, {
radius: 5,
color: color,
fillColor: color,
fillOpacity: 0.8
}).addTo(layerGroup);
});
}
function clearGridSweepAreaForGroup(groupId, restartSelection) {
if (gridSweepAreaLayers[groupId]) {
map.removeLayer(gridSweepAreaLayers[groupId]);
delete gridSweepAreaLayers[groupId];
}
if (restartSelection) {
clearSelectionMode();
selectionMode = 'rect';
map.dragging.disable();
console.log('Grid Sweep: 已清除範圍並重新進入框選模式');
}
}
function clearAllMissionPlans() {
for (var gid in groupMissionLayers) {
map.removeLayer(groupMissionLayers[gid]);
@@ -867,14 +1017,20 @@ class DroneMap:
# 設置地圖更新計時器
self.map_update_timer = QTimer()
self.map_update_timer.timeout.connect(self.update_map_positions)
self.map_update_timer.start(200) # 每 200ms 更新一次
self.map_update_timer.start(100) # 每 100ms 更新一次(10Hz)
def _on_map_loaded(self, ok: bool):
"""地圖加載完成回調"""
if ok:
self.map_loaded = True
self.set_font_scale(self.font_scale)
self.set_socket_colors(self.socket_colors)
self.set_drone_group_colors(self.drone_group_colors)
self.set_mission_following(
self.mission_following_enabled,
self.mission_following_drone_id)
else:
print("⚠️ 地圖加載失敗")
_log("ERROR", "地圖載入失敗")
def update_drone_position(self, drone_id, lat, lon, heading):
"""更新無人機位置(加入待處理隊列)"""
@@ -887,7 +1043,7 @@ class DroneMap:
js_commands = []
for drone_id, (lat, lon, heading) in self.pending_map_updates.items():
js_commands.append(f"updateDrone({lat:.6f}, {lon:.6f}, '{drone_id}', {heading:.1f});")
js_commands.append(f"updateDrone({lat:.8f}, {lon:.8f}, '{drone_id}', {heading:.1f});")
if js_commands:
combined_js = "\n".join(js_commands)
@@ -899,11 +1055,42 @@ class DroneMap:
"""清除所有軌跡"""
if self.map_loaded:
self.map_view.page().runJavaScript("clearAllTrajectories();")
def set_drone_group_colors(self, drone_group_colors):
"""設定每台無人機的 group 外框顏色;未分組者不傳入即為透明。"""
self.drone_group_colors = dict(drone_group_colors or {})
if self.map_loaded:
payload = json.dumps(self.drone_group_colors)
self.map_view.page().runJavaScript(f"setDroneGroupColors({payload});")
def set_socket_colors(self, socket_colors):
"""設定 socket 顏色,供地圖箭頭本體與軌跡線使用。"""
self.socket_colors = dict(socket_colors or {})
if self.map_loaded:
payload = json.dumps(self.socket_colors)
self.map_view.page().runJavaScript(f"setSocketColors({payload});")
def focus_on_drone(self, drone_id):
"""聚焦到指定無人機"""
if self.map_loaded:
self.map_view.page().runJavaScript(f"focusOn('{drone_id}');")
def set_mission_following(self, enabled, preferred_drone_id=None):
"""只在任務執行中啟用 UAV 自動跟隨。"""
self.mission_following_enabled = bool(enabled)
self.mission_following_drone_id = preferred_drone_id
if self.map_loaded:
preferred_json = json.dumps(preferred_drone_id)
self.map_view.page().runJavaScript(
"setMissionFollowingEnabled("
f"{str(self.mission_following_enabled).lower()}, "
f"{preferred_json});")
def set_font_scale(self, scale):
"""設定地圖 HTML 控制項的字體倍率。"""
self.font_scale = scale
if self.map_loaded:
self.map_view.page().runJavaScript(f"setFontScale({scale:.3f});")
# ================================================================================
# 任務規劃視覺化方法
@@ -924,20 +1111,41 @@ class DroneMap:
f"{target_lat:.6f}, {target_lon:.6f});"
)
self.map_view.page().runJavaScript(js_code)
print(f"📍 地圖已繪製 Group {group_id} 任務規劃: "
f"C({center_lat:.6f}, {center_lon:.6f}) -> T({target_lat:.6f}, {target_lon:.6f})")
_log(
"INFO",
f"地圖已繪製 Group {group_id} 任務規劃: "
f"C({center_lat:.6f}, {center_lon:.6f}) -> "
f"T({target_lat:.6f}, {target_lon:.6f})",
)
def clear_mission_plan(self):
"""清除地圖上所有任務規劃標記"""
if self.map_loaded:
self.map_view.page().runJavaScript("clearAllMissionPlans();")
print("🗑️ 地圖已清除所有任務規劃")
_log("INFO", "地圖已清除所有任務規劃")
def clear_mission_plan_for_group(self, group_id):
"""清除指定群組的任務規劃標記"""
if self.map_loaded:
self.map_view.page().runJavaScript(f"clearMissionPlanForGroup('{group_id}');")
print(f"🗑️ 地圖已清除 Group {group_id} 任務規劃")
_log("INFO", f"地圖已清除 Group {group_id} 任務規劃")
def draw_grid_sweep_area_for_group(self, group_id, color, rect_corners):
"""繪製指定群組的 Grid Sweep 框選範圍。"""
if self.map_loaded:
group_json = json.dumps(group_id)
color_json = json.dumps(color)
corners_json = json.dumps(rect_corners)
self.map_view.page().runJavaScript(
f"drawGridSweepAreaForGroup({group_json}, {color_json}, {corners_json});")
def clear_grid_sweep_area_for_group(self, group_id, restart_selection=False):
"""清除指定群組的 Grid Sweep 範圍,可選擇重新進入框選模式。"""
if self.map_loaded:
group_json = json.dumps(group_id)
restart_js = str(bool(restart_selection)).lower()
self.map_view.page().runJavaScript(
f"clearGridSweepAreaForGroup({group_json}, {restart_js});")
def set_mission_mode(self, mode):
"""從 Python 端切換地圖的任務模式(觸發框選/路徑標記等)"""
@@ -1023,7 +1231,7 @@ class MapBridge(QObject):
def emitGpsSignal(self, lat, lon):
"""供 JavaScript 調用的方法"""
self.gps_signal.emit(lat, lon)
@pyqtSlot(str)
def emitDroneClicked(self, drone_id):
"""供 JavaScript 調用的方法 - 當無人機被點擊時"""
@@ -1033,52 +1241,56 @@ class MapBridge(QObject):
def clearAllDroneSelection(self):
"""供 JavaScript 調用的方法 - 清除所有無人機選擇"""
self.clear_all_drone_selection.emit()
print("🗑️ 清除所有無人機選擇")
_log("INFO", "已清除所有無人機選擇")
@pyqtSlot()
def toggleSelectAllDrones(self):
"""供 JavaScript 調用的方法 - 切換全選/取消全選所有無人機"""
self.select_all_drones.emit()
print("🔄 切換全選無人機")
_log("INFO", "已切換全選無人機")
@pyqtSlot(float, float, float, float)
def startMissionSignal(self, center_lat, center_lon, target_lat, target_lon):
"""供 JavaScript 調用的方法 - 開始任務"""
self.start_mission_signal.emit(center_lat, center_lon, target_lat, target_lon)
print(f"🚀 開始任務信號已發出: C({center_lat}, {center_lon}) -> T({target_lat}, {target_lon})")
_log(
"INFO",
f"已發出開始任務信號: "
f"C({center_lat}, {center_lon}) -> T({target_lat}, {target_lon})",
)
@pyqtSlot()
def pauseMissionSignal(self):
"""供 JavaScript 調用的方法 - 暫停任務"""
self.pause_mission_signal.emit()
print("⏸️ 暫停任務信號已發出")
_log("INFO", "已發出暫停任務信號")
@pyqtSlot(str)
def rectangleSelected(self, points_json):
"""供 JavaScript 調用的方法 - 矩形選擇完成"""
self.rectangle_selected.emit(points_json)
print(f"📦 矩形區域已選擇: {points_json}")
_log("INFO", f"矩形區域已選擇: {points_json}")
@pyqtSlot(str)
def polygonSelected(self, points_json):
"""供 JavaScript 調用的方法 - 多邊形選擇完成"""
self.polygon_selected.emit(points_json)
print(f"🔷 多邊形區域已選擇: {points_json}")
_log("INFO", f"多邊形區域已選擇: {points_json}")
@pyqtSlot(str)
def missionModeChanged(self, mode):
"""供 JavaScript 調用的方法 - 任務模式切換"""
self.mission_mode_changed.emit(mode)
print(f"🔄 任務模式切換: {mode}")
_log("INFO", f"任務模式已切換: {mode}")
@pyqtSlot(str)
def routeConfirmed(self, points_json):
"""供 JavaScript 調用的方法 - 路徑確認"""
self.route_confirmed.emit(points_json)
print(f"📍 路徑已確認: {points_json}")
_log("INFO", f"路徑已確認: {points_json}")
@pyqtSlot(str)
def droneBoxSelected(self, drone_ids_json):
"""供 JavaScript 調用的方法 - 框選無人機完成"""
self.drone_box_selected.emit(drone_ids_json)
print(f"📦 框選無人機: {drone_ids_json}")
_log("INFO", f"框選無人機完成: {drone_ids_json}")
+355 -58
View File
@@ -3,18 +3,31 @@
任務執行模組
管理多架無人機的 GUIDED 模式飛行控制迴圈
設計:
設計 (閉環 / closed-loop):
- 每架無人機持有一個航點序列,逐點推進
- 各自到達就各自切換到下一個航點
- 用 QTimer 驅動,在 Qt 主線程執行
- 暫停 = 停止發送 setpoint,飛控自動懸停
- 相容舊的 2 階段任務與新的多航點任務 (Grid Sweep)
- 閉環發送:以固定週期「持續重送」當前目標 setpoint(GUIDED 位置目標冪等),
掉包會在下個週期自癒;換點完全靠 GPS 到達判定驅動,不依賴通訊 ACK
- send_result(echo 驗證)僅作觀測/UI 顯示,不影響飛行控制:
驗證失敗不會 fallback LOITER,指令其實已送達飛控,交給 GPS 續飛
- Rendezvous barrier:在指定 wp index 等所有活著的機到齊才一起推進,有 timeout 保護
- 用 QTimer 驅動到達判定,在 Qt 主線程執行
- 暫停 = 停止到達判定 + 停止新指令
背景:adapter 的 pos_global_int service 被序列化 + 阻塞等 msg87 echo,實飛慢速鏈路下
多機會只有一台在 client timeout 內拿到回應。閉環設計繞過此瓶頸(見專案記憶)。
"""
import asyncio
import math
import time
from enum import Enum
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
def _log(level, message):
print(f"[{level}] {message}")
class MissionState(Enum):
"""整體任務狀態"""
IDLE = "idle"
@@ -22,22 +35,35 @@ class MissionState(Enum):
PAUSED = "paused"
class TaskStatus(Enum):
"""單機任務狀態"""
NORMAL = "normal"
RETRYING = "retrying"
WAITING_AT_BARRIER = "waiting_at_barrier"
FALLBACK_LOITER = "fallback_loiter"
class DroneTask:
"""單架無人機的任務資料"""
__slots__ = ('drone_id', 'sysid', 'waypoints', 'wp_index', 'done')
__slots__ = (
'drone_id', 'sysid', 'waypoints', 'wp_index', 'done',
'last_sent_at', 'fail_count', 'status', 'waiting_since',
'entered_radius_at', 'last_log_at',
)
def __init__(self, drone_id, sysid, waypoints):
"""
Args:
drone_id: GUI 用的 ID (如 's0_11')
sysid: MAVLink system ID (如 11)
waypoints: 航點序列 [(lat, lon, alt), ...]
"""
self.drone_id = drone_id
self.sysid = sysid
self.waypoints = waypoints
self.wp_index = 0
self.done = len(waypoints) == 0
# 上次送出當前目標的 monotonic 時間;0.0 = 尚未送過(下個 tick 立即送)
self.last_sent_at = 0.0
self.fail_count = 0
self.status = TaskStatus.NORMAL
self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間
self.entered_radius_at = 0.0 # 進入 arrival_radius 的時間,0 = 尚未進入
self.last_log_at = 0.0 # 上次印 distance log 的時間
@property
def current_target(self):
@@ -52,7 +78,7 @@ class DroneTask:
class MissionExecutor(QObject):
"""
任務執行器
任務執行器 (事件驅動版 + rendezvous barrier)
planned_waypoints 格式:
{
@@ -60,39 +86,67 @@ class MissionExecutor(QObject):
'waypoints': [
[(lat,lon,alt), ...], # drone 0
[(lat,lon,alt), ...], # drone 1
]
],
'rendezvous_indices': [3, 7, ...], # 選填;缺省 = 全程不同步(各自跑)
}
"""
# 信號
drone_waypoint_reached = pyqtSignal(str, int, int) # (drone_id, wp_index, total)
MAX_RETRY = 3 # 已停用:閉環下 echo 驗證失敗不再累計切 LOITER(保留常數以防外部引用)
drone_waypoint_reached = pyqtSignal(str, int, int) # (drone_id, wp_index, total)
task_status_changed = pyqtSignal(str, str, str) # (drone_id, status, message)
mission_completed = pyqtSignal()
def __init__(self, sender, drone_gps,
arrival_radius=2.0, send_rate_hz=2.0):
def __init__(self, sender, drone_gps, monitor=None,
arrival_radius=4.0, tick_rate_hz=2.0,
barrier_timeout_sec=20.0,
hover_stable_sec=2.0,
progress_log_interval_sec=3.0,
resend_interval_sec=2.5,
complete_stops=True):
super().__init__()
self.sender = sender
# complete_stops=False:全員到達也不停(hover-hold 持續重送),供
# orchestrator 的瞬時隊形追蹤使用(靠 update_plan 持續餵新 slot)。
self.complete_stops = complete_stops
self.drone_gps = drone_gps
self.monitor = monitor # 保留:僅供外部手動 fallback 使用,閉環下不自動切 LOITER
self.arrival_radius = arrival_radius
# 閉環重送週期:每隔這麼久重送一次當前目標 setpoint(掉包自癒)。
# 注意:需 > N架 × client_timeout,否則多機序列化會塞不完(見 command_sender)。
self.resend_interval_sec = resend_interval_sec
self.barrier_timeout_sec = barrier_timeout_sec
# hover-stable 判定:進入 radius 後須穩定停留 hover_stable_sec 秒才算到達,
# 容忍 GPS 抖動跨越邊界(用 radius * 1.5 作 hysteresis)
self.hover_stable_sec = hover_stable_sec
self.hysteresis_factor = 1.5
self.progress_log_interval_sec = progress_log_interval_sec
self.state = MissionState.IDLE
self.tasks = {}
self.rendezvous_indices = set()
self._interval_ms = int(1000 / send_rate_hz)
self._interval_ms = int(1000 / tick_rate_hz)
self._timer = QTimer()
self._timer.timeout.connect(self._tick)
# 只有 Ros2CommandSender 有 send_result signal;MavlinkSender 沒有
if hasattr(sender, 'send_result'):
sender.send_result.connect(self._on_send_result)
# ------------------------------------------------------------------ 公開方法
def start(self, planned_waypoints):
"""啟動任務"""
if self.state == MissionState.RUNNING:
print("任務已在執行中")
_log("WARN", "任務已在執行中")
return
self.tasks.clear()
drone_ids = planned_waypoints['drone_ids']
waypoints_list = planned_waypoints['waypoints']
self.rendezvous_indices = set(
planned_waypoints.get('rendezvous_indices', []) or []
)
for i, drone_id in enumerate(drone_ids):
sysid = int(drone_id.split('_')[1])
@@ -102,86 +156,329 @@ class MissionExecutor(QObject):
self._timer.start(self._interval_ms)
total_wps = sum(t.total_waypoints for t in self.tasks.values())
print(f"任務啟動: {len(self.tasks)} 架無人機, "
f"共 {total_wps} 個航點, "
f"到達半徑={self.arrival_radius}m, "
f"發送週期={self._interval_ms}ms")
rv_info = (
f"rendezvous WP={sorted(self.rendezvous_indices)}"
if self.rendezvous_indices else "無 rendezvous (各自跑)"
)
_log(
"INFO",
f"任務已啟動: {len(self.tasks)} 架無人機, "
f"共 {total_wps} 個航點, "
f"到達半徑={self.arrival_radius}m (hover-stable {self.hover_stable_sec}s), "
f"tick 週期={self._interval_ms}ms, "
f"重送週期={self.resend_interval_sec}s (閉環,GPS 驅動換點), "
f"barrier timeout={self.barrier_timeout_sec}s, "
f"{rv_info}",
)
def update_plan(self, planned_waypoints):
"""
熱替換航點(moving-target 用)。保留 RUNNING 狀態與 timer 不中斷,
只換各機的目標座標並重置到達/送出狀態,讓下個 tick 立即朝新目標。
- 未在執行中 → 等同 start()
- 沿用 start() 的 planned_waypoints 格式
- drone 集合可變動:新機補 task、消失的機移除;FALLBACK_LOITER 的機不強制復原
"""
if self.state != MissionState.RUNNING:
self.start(planned_waypoints)
return
drone_ids = planned_waypoints['drone_ids']
waypoints_list = planned_waypoints['waypoints']
self.rendezvous_indices = set(
planned_waypoints.get('rendezvous_indices', []) or []
)
for i, drone_id in enumerate(drone_ids):
wps = waypoints_list[i]
task = self.tasks.get(drone_id)
if task is None:
sysid = int(drone_id.split('_')[1])
self.tasks[drone_id] = DroneTask(drone_id, sysid, wps)
continue
task.waypoints = wps
task.wp_index = 0
task.done = (len(wps) == 0)
task.last_sent_at = 0.0 # 下個 tick 立即重送新目標
task.entered_radius_at = 0.0
if task.status != TaskStatus.FALLBACK_LOITER:
task.status = TaskStatus.NORMAL
# 移除不在新計畫中的機
keep = set(drone_ids)
for drone_id in list(self.tasks.keys()):
if drone_id not in keep:
del self.tasks[drone_id]
def pause(self):
"""暫停任務"""
if self.state == MissionState.RUNNING:
self._timer.stop()
self.state = MissionState.PAUSED
print("任務暫停")
_log("INFO", "任務已暫停")
def resume(self):
"""恢復任務"""
if self.state == MissionState.PAUSED:
self._timer.start(self._interval_ms)
self.state = MissionState.RUNNING
print("任務恢復")
_log("INFO", "任務已恢復")
def stop(self):
"""停止任務"""
self._timer.stop()
self.tasks.clear()
self.rendezvous_indices = set()
self.state = MissionState.IDLE
print("任務停止")
_log("INFO", "任務已停止")
# ------------------------------------------------------------------ 控制迴圈
def _tick(self):
"""控制迴圈"""
all_done = True
"""
控制 tick:
Phase 1: 每台機做到達判定;非 barrier 點直接推進,barrier 點改成 WAITING
Phase 2: 檢查 barrier 能不能釋放(全員到齊或 timeout)
Phase 3: 每台未送過當前航點的機送一次
Phase 4: 全部完成檢查
"""
now = time.monotonic()
# ---- Phase 1: 到達判定(hover-stable) ----
for task in self.tasks.values():
if task.done:
if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue
# 已在 barrier 等待的不再跑到達判定
if task.status == TaskStatus.WAITING_AT_BARRIER:
continue
all_done = False
target = task.current_target
if target is None:
continue
# 讀取當前 GPS
gps = self.drone_gps.get(task.drone_id)
if gps is None:
continue
tgt_lat, tgt_lon, tgt_alt = target
tgt_lat, tgt_lon, _ = target
distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon)
# 到達判定
# hover-stable 判定:
# - 首次進入 radius → 記錄時間
# - 在 radius * hysteresis 內持續 → 保留計時
# - 離開 hysteresis → 重置
# - 在 radius 內穩定 hover_stable_sec 秒 → 視為到達
hysteresis_radius = self.arrival_radius * self.hysteresis_factor
if distance < self.arrival_radius:
task.wp_index += 1
if task.wp_index >= task.total_waypoints:
task.done = True
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
print(f" {task.drone_id} → DONE "
f"({task.total_waypoints}/{task.total_waypoints})")
if task.entered_radius_at == 0.0:
task.entered_radius_at = now
stable_for = now - task.entered_radius_at
if stable_for < self.hover_stable_sec:
self._maybe_log_progress(task, now, distance, stable_for)
continue
else:
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
print(f" {task.drone_id} → WP {task.wp_index}/{task.total_waypoints} "
f"(距離: {distance:.1f}m)")
# 更新目標
tgt_lat, tgt_lon, tgt_alt = task.current_target
elif distance < hysteresis_radius and task.entered_radius_at > 0.0:
# 還在 hysteresis 內,計時不重置(容忍 GPS 抖動跨越邊界)
self._maybe_log_progress(task, now, distance, now - task.entered_radius_at)
continue
else:
# 離開 hysteresis:重置計時,繼續等待到達
task.entered_radius_at = 0.0
self._maybe_log_progress(task, now, distance, 0.0)
continue
# 發送 setpoint
# 到達當前 wp_index
task.entered_radius_at = 0.0 # 重置給下個 wp 用
if (task.wp_index in self.rendezvous_indices
and task.wp_index < task.total_waypoints - 1):
# rendezvous 點 → 不推進,進入 barrier 等待
task.status = TaskStatus.WAITING_AT_BARRIER
task.waiting_since = now
_log("INFO", f"{task.drone_id} 抵達 barrier WP {task.wp_index},等待同伴")
self.task_status_changed.emit(
task.drone_id, task.status.value,
f"waiting at WP {task.wp_index}"
)
continue
# 一般點 → 直接推進
self._advance_waypoint(task, distance)
# ---- Phase 2: barrier 釋放檢查 ----
self._check_barriers(now)
# ---- Phase 3: 週期性重送當前目標(閉環)----
# 每 resend_interval_sec 重送一次目前 wp 的 setpoint,不靠 send_result 決定前進。
# GUIDED 位置目標冪等,重送安全;掉包會在下個週期自癒。first send: last_sent_at=0 → 立即送。
for task in self.tasks.values():
if task.done or task.status in (
TaskStatus.FALLBACK_LOITER, TaskStatus.WAITING_AT_BARRIER
):
continue
target = task.current_target
if target is None:
continue
if now - task.last_sent_at < self.resend_interval_sec:
continue
tgt_lat, tgt_lon, tgt_alt = target
task.last_sent_at = now
self.sender.send_position_global(
task.sysid, tgt_lat, tgt_lon, tgt_alt
task.drone_id, task.sysid, tgt_lat, tgt_lon, tgt_alt
)
# 全部完成檢查
# ---- Phase 4: 完成檢查 ----
if not self.complete_stops:
return # hover-hold 模式:不自動結束,持續重送(Phase 3 已處理)
all_done = all(
t.done or t.status == TaskStatus.FALLBACK_LOITER
for t in self.tasks.values()
)
if all_done and self.tasks:
self._timer.stop()
self.state = MissionState.IDLE
self.mission_completed.emit()
print("===== 任務全部完成 =====")
_log("INFO", "任務全部完成")
def _maybe_log_progress(self, task, now, distance, stable_for):
"""週期性印出 drone 與當前 wp 的距離,方便實飛 debug 卡點問題。"""
if now - task.last_log_at < self.progress_log_interval_sec:
return
task.last_log_at = now
if stable_for > 0:
stable_info = f", 在內側 {stable_for:.1f}s/{self.hover_stable_sec:.1f}s"
else:
stable_info = ""
_log(
"INFO",
f"{task.drone_id} → WP {task.wp_index}: "
f"距離 {distance:.1f}m (半徑 {self.arrival_radius:.1f}m{stable_info})",
)
def _advance_waypoint(self, task, arrived_distance):
"""把 task 推進一個航點,重置發送計時。不處理 barrier 邏輯。"""
task.wp_index += 1
task.last_sent_at = 0.0 # 新 wp 下個 tick 立即送
task.fail_count = 0
task.entered_radius_at = 0.0
task.last_log_at = 0.0
if task.status == TaskStatus.RETRYING:
task.status = TaskStatus.NORMAL # 清掉上個 wp 殘留的「送出未驗證」狀態
if task.wp_index >= task.total_waypoints:
task.done = True
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
_log(
"INFO",
f"{task.drone_id} 已完成所有航點 "
f"({task.total_waypoints}/{task.total_waypoints})",
)
return
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
_log(
"INFO",
f"{task.drone_id} 前往 WP {task.wp_index}/{task.total_waypoints} "
f"(到達距離: {arrived_distance:.1f}m)",
)
def _check_barriers(self, now):
"""檢查每個有人在等的 barrier 能不能釋放。"""
# 蒐集目前有哪些 barrier 有人卡著
waiting_groups = {} # barrier_idx -> [task, ...]
for task in self.tasks.values():
if task.status == TaskStatus.WAITING_AT_BARRIER:
waiting_groups.setdefault(task.wp_index, []).append(task)
if not waiting_groups:
return
for barrier_idx, waiting_tasks in waiting_groups.items():
# 判斷是否「全員到齊」:所有仍活著的機都要麼在此 barrier 等,要麼早已越過此 idx
everyone_arrived = True
for task in self.tasks.values():
if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue # 失敗機/完成機不擋
if (task.status == TaskStatus.WAITING_AT_BARRIER
and task.wp_index == barrier_idx):
continue # 在這裡等
if task.wp_index > barrier_idx:
continue # 早已越過(barrier 先前已對他釋放)
# 還在路上
everyone_arrived = False
break
force_reason = None
if not everyone_arrived:
# 檢查 timeout:以該 barrier 上最早進入等待的時間為準
oldest_wait = min(t.waiting_since for t in waiting_tasks)
if now - oldest_wait >= self.barrier_timeout_sec:
force_reason = f"timeout {self.barrier_timeout_sec:.0f}s"
_log("WARN", f"barrier WP {barrier_idx} {force_reason},強制放行")
else:
continue # 還沒到齊、也還沒 timeout → 繼續等
# 釋放:把所有在此 barrier 等待的機一起推進
tag = "全員到齊" if force_reason is None else force_reason
_log("INFO", f"barrier WP {barrier_idx} 已釋放 ({tag}),推進 {len(waiting_tasks)} 架")
for task in waiting_tasks:
task.status = TaskStatus.NORMAL
msg = f"barrier WP {barrier_idx} released ({tag})"
self.task_status_changed.emit(
task.drone_id, task.status.value, msg
)
self._advance_waypoint(task, self.arrival_radius)
# ------------------------------------------------------------------ 結果回呼
def _on_send_result(self, drone_id, sysid, success, message):
"""
Ros2CommandSender.send_result 的 slot(閉環:僅觀測,不影響飛行控制)。
指令在 adapter 端等 echo 前就已送達飛控,echo 驗證失敗不代表沒送到;
因此這裡不再累計失敗切 LOITER,只更新 UI 狀態。真正的前進由 GPS 到達判定驅動,
重送由 Phase 3 的週期性重送負責。
"""
task = self.tasks.get(drone_id)
if task is None or task.done:
return
# 若已在 barrier 等待,舊指令的遲到回應不要再干擾狀態
if task.status == TaskStatus.WAITING_AT_BARRIER:
return
if success:
if task.fail_count > 0 or task.status == TaskStatus.RETRYING:
task.status = TaskStatus.NORMAL
self.task_status_changed.emit(drone_id, task.status.value, "send verified")
task.fail_count = 0
return
# echo 驗證失敗:僅記錄 + UI 提示,不改變飛行(交給 GPS 續飛、下個週期重送)
task.fail_count += 1
task.status = TaskStatus.RETRYING # 純觀測用途,Phase 1/3 不會因此停飛
_log(
"WARN",
f"{drone_id} 送出未驗證 x{task.fail_count}(指令已送達飛控,由 GPS 續飛): {message}",
)
self.task_status_changed.emit(
drone_id, task.status.value,
f"send unverified x{task.fail_count} (GPS 續飛): {message}"
)
def _fallback_to_loiter(self, drone_id):
"""
用 monitor.set_mode 切 LOITER。set_mode 是 coroutine,透過 event loop 派送。
閉環改版後不再由 _on_send_result 自動呼叫(送指令失敗不代表沒送到);
保留此方法供外部(例如 GUI 手動安全按鈕)需要時使用。
"""
if self.monitor is None:
_log("WARN", f"無 monitor,無法將 {drone_id} 切換至 LOITER")
return
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.create_task(self.monitor.set_mode(drone_id, "LOITER"))
# ------------------------------------------------------------------ 工具函式
@@ -196,4 +493,4 @@ def _haversine(lat1, lon1, lat2, lon2):
a = (math.sin(dlat / 2) ** 2 +
math.cos(lat1_r) * math.cos(lat2_r) * math.sin(dlon / 2) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
+255 -67
View File
@@ -13,28 +13,52 @@ from mission_executor import MissionExecutor, MissionState
# 群組顏色(循環使用)
GROUP_COLORS = [
'#4A9EFF', # 藍
'#FF8C42', # 橘
'#56C87A', # 綠
'#E85D75', # 紅
'#B07CED', # 紫
'#F5C542', # 黃
'#42C9C9', # 青
'#FF6B9D', # 粉
"#E53935", # Deep Red
"#FB8C00", # Deep Orange
"#FDD835", # Deep Yellow
"#43A047", # Deep Green
"#1E88E5", # Deep Blue
"#8E24AA", # Deep Purple
"#D81B60", # Deep Pink
]
DEFAULT_MISSION_PARAM_VALUES = {
'spacing': '5.0',
'base_altitude': '30.0',
'altitude_diff': '2.0',
'radius': '10.0',
'altitude': '30.0',
'start_angle': '0',
'lateral_offset': '3.0',
'longitudinal_spacing': '5.0',
'line_spacing': '5.0',
# 執行參數(所有任務類型共用)
'arrival_radius': '4.0', # 到達半徑 (m),實飛建議 3~5,SITL 可調小到 2
'hover_stable_sec': '2.0', # 在 radius 內穩定停留多久才視為到達 (s)
}
# 執行參數的 key 集合(所有 mission type 共用,不隨類型切換)
EXECUTION_PARAM_KEYS = ('arrival_radius', 'hover_stable_sec')
class MissionGroup:
"""單一任務群組的資料"""
def __init__(self, group_id, color):
self.group_id = group_id # 'A', 'B', 'C', ...
self.color = color # 顏色 hex
self.drone_ids = set() # 已分配的無人機 ID
self.mission_type = 'M_FORMATION' # 預設任務類型
self.planned_waypoints = None # 規劃結果 dict
self.executor = None # MissionExecutor 實例(延遲建立)
self.center_origin = None # 規劃原點
self.group_id = group_id # 'A', 'B', 'C', ...
self.color = color # 顏色 hex
# 唯一真實資料來源:該 group 選中的 drone ID
# 代表:group 擁有的 drone、被選中的 drone、可以操作的 drone
self.selected_drone_ids = set()
self.mission_type = 'M_FORMATION' # 預設任務類型
self.planned_waypoints = None # 規劃結果 dict
self.executor = None # MissionExecutor 實例(延遲建立)
self.center_origin = None # 規劃原點
self.mission_target = None # (lat, lon),最近點擊的地圖目標供 XBee GOTO 使用
self.leader_drone_id = None # LEADER_FOLLOWER 專用:指定的領隊無人機 ID
@property
def state(self):
@@ -50,13 +74,13 @@ class MissionGroup:
class DroneAssignDialog(QDialog):
"""無人機分配對話框"""
def __init__(self, parent, all_drone_ids, current_assigned, other_assigned):
def __init__(self, parent, all_drone_ids, current_assigned, other_assigned=None):
"""
Args:
parent: 父 widget
all_drone_ids: 所有可用無人機 ID 列表
current_assigned: 當前群組已分配的無人機 set
other_assigned: 其他群組已佔用的無人機 dict {drone_id: group_id}
other_assigned: (忽略 — 現在允許多 group 分配)
"""
super().__init__(parent)
self.setWindowTitle("分配無人機")
@@ -66,7 +90,6 @@ class DroneAssignDialog(QDialog):
QLabel { color: #DDD; }
QCheckBox { color: #DDD; spacing: 8px; padding: 4px; }
QCheckBox::indicator { width: 16px; height: 16px; }
QCheckBox:disabled { color: #666; }
""")
layout = QVBoxLayout(self)
@@ -89,12 +112,9 @@ class DroneAssignDialog(QDialog):
for drone_id in sorted_ids:
cb = QCheckBox(drone_id)
# 所有 drone 都能被選摘(支持多 group 分配)
if drone_id in current_assigned:
cb.setChecked(True)
elif drone_id in other_assigned:
cb.setEnabled(False)
cb.setToolTip(f"已分配到 Group {other_assigned[drone_id]}")
cb.setText(f"{drone_id} (Group {other_assigned[drone_id]})")
self.checkboxes[drone_id] = cb
scroll_layout.addWidget(cb)
@@ -143,9 +163,13 @@ class GroupPanel(QWidget):
mode_change_requested = pyqtSignal(str, str) # group_id, mode
arm_requested = pyqtSignal(str) # group_id
takeoff_requested = pyqtSignal(str, float) # group_id, altitude
reboot_requested = pyqtSignal(str) # group_id
xbee_command_requested = pyqtSignal(str, str) # group_id, START/STOP/GOTO
box_select_requested = pyqtSignal(str) # group_id — 框選直接分配
select_all_requested = pyqtSignal(str) # group_id — 全選直接分配
clear_group_requested = pyqtSignal(str) # group_id — 清除分組
add_group_requested = pyqtSignal() # 新增群組
delete_group_requested = pyqtSignal(str) # group_id — 刪除群組
BUTTON_STYLE = """
QPushButton {{ background-color: {bg}; color: {fg}; border: none;
@@ -154,9 +178,13 @@ class GroupPanel(QWidget):
QPushButton:disabled {{ background-color: #444; color: #666; }}
"""
def __init__(self, group: MissionGroup, parent=None):
def __init__(self, group: MissionGroup, parent=None, default_params=None):
super().__init__(parent)
self.group = group
self.all_btn_ref = None # 保存全選按鈕的參考
self.default_params = dict(DEFAULT_MISSION_PARAM_VALUES)
if default_params:
self.default_params.update(default_params)
self._build_ui()
def _make_sep(self):
@@ -221,8 +249,8 @@ class GroupPanel(QWidget):
takeoff_row.setSpacing(3)
self.alt_input = QComboBox()
self.alt_input.setEditable(True)
self.alt_input.addItems(["5", "10", "15", "20"])
self.alt_input.setCurrentText("10")
self.alt_input.addItems(["5", "10", "15", "20", "25", "30"])
self.alt_input.setCurrentText("30")
self.alt_input.setStyleSheet(COMBO)
alt_lbl = QLabel("m")
alt_lbl.setStyleSheet(LBL)
@@ -296,7 +324,7 @@ class GroupPanel(QWidget):
mid.addLayout(mid_body)
# ============================
# 選取與分組(2x2 按鈕)
# 選取與分組(3x2 按鈕)
# ============================
right = QVBoxLayout()
right.setSpacing(3)
@@ -310,9 +338,28 @@ class GroupPanel(QWidget):
self.drone_list_label.setWordWrap(True)
right.addWidget(self.drone_list_label)
# 2x2 按鈕網格
# 3x2 按鈕網格:第一行 框選 全選 新增群組
grid_r1 = QHBoxLayout()
grid_r1.setSpacing(3)
box_btn = QPushButton("框選")
box_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5'))
box_btn.clicked.connect(
lambda: self.box_select_requested.emit(self.group.group_id))
all_btn = QPushButton("全選/取消")
all_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5'))
all_btn.clicked.connect(self._on_all_select_clicked)
self.all_btn_ref = all_btn # 保存按鈕參考(備用)
add_group_btn = QPushButton("+ 新增群組")
add_group_btn.setStyleSheet(BTN.format(bg='#4A9EFF', fg='white', hover='#3A8EEF'))
add_group_btn.clicked.connect(lambda: self.add_group_requested.emit())
grid_r1.addWidget(box_btn)
grid_r1.addWidget(all_btn)
grid_r1.addWidget(add_group_btn)
right.addLayout(grid_r1)
# 第二行 編輯分配 清除分組 刪除群組
grid_r2 = QHBoxLayout()
grid_r2.setSpacing(3)
assign_btn = QPushButton("編輯分配")
assign_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666'))
assign_btn.clicked.connect(
@@ -321,26 +368,71 @@ class GroupPanel(QWidget):
clear_btn.setStyleSheet(BTN.format(bg='#777', fg='white', hover='#888'))
clear_btn.clicked.connect(
lambda: self.clear_group_requested.emit(self.group.group_id))
grid_r1.addWidget(assign_btn)
grid_r1.addWidget(clear_btn)
right.addLayout(grid_r1)
grid_r2 = QHBoxLayout()
grid_r2.setSpacing(3)
box_btn = QPushButton("框選")
box_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5'))
box_btn.clicked.connect(
lambda: self.box_select_requested.emit(self.group.group_id))
all_btn = QPushButton("全選")
all_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5'))
all_btn.clicked.connect(
lambda: self.select_all_requested.emit(self.group.group_id))
grid_r2.addWidget(box_btn)
grid_r2.addWidget(all_btn)
self.delete_group_btn = QPushButton("刪除群組")
self.delete_group_btn.setStyleSheet(BTN.format(bg='#EF5350', fg='white', hover='#E53935'))
self.delete_group_btn.clicked.connect(
lambda: self.delete_group_requested.emit(self.group.group_id))
grid_r2.addWidget(assign_btn)
grid_r2.addWidget(clear_btn)
grid_r2.addWidget(self.delete_group_btn)
right.addLayout(grid_r2)
right.addStretch()
# ============================
# 飛控操作
# ============================
reboot_col = QVBoxLayout()
reboot_col.setSpacing(3)
reboot_title = QLabel("飛控操作")
reboot_title.setStyleSheet(TITLE)
reboot_col.addWidget(reboot_title)
reboot_btn = QPushButton("重啟飛控")
reboot_btn.setStyleSheet(BTN.format(bg='#8A3A3A', fg='#FFF', hover='#A84646'))
reboot_btn.clicked.connect(
lambda: self.reboot_requested.emit(self.group.group_id))
reboot_col.addWidget(reboot_btn)
reboot_col.addStretch()
# ============================
# XBee JSON 指令(GOTO 使用最近點擊的地圖目標點)
# ============================
xbee_col = QVBoxLayout()
xbee_col.setSpacing(3)
xbee_title = QLabel("XBee/WS 指令")
xbee_title.setStyleSheet(TITLE)
xbee_col.addWidget(xbee_title)
xbee_buttons = QVBoxLayout()
xbee_buttons.setSpacing(3)
start_xbee_btn = QPushButton("START")
start_xbee_btn.setStyleSheet(
BTN.format(bg='#2E7D32', fg='white', hover='#388E3C'))
start_xbee_btn.clicked.connect(
lambda: self.xbee_command_requested.emit(
self.group.group_id, 'START'))
stop_xbee_btn = QPushButton("STOP")
stop_xbee_btn.setStyleSheet(
BTN.format(bg='#C62828', fg='white', hover='#D32F2F'))
stop_xbee_btn.clicked.connect(
lambda: self.xbee_command_requested.emit(
self.group.group_id, 'STOP'))
goto_xbee_btn = QPushButton("GOTO")
goto_xbee_btn.setToolTip("前往目前 Group 最近一次點擊的地圖目標點")
goto_xbee_btn.setStyleSheet(
BTN.format(bg='#1565C0', fg='white', hover='#1976D2'))
goto_xbee_btn.clicked.connect(
lambda: self.xbee_command_requested.emit(
self.group.group_id, 'GOTO'))
xbee_buttons.addWidget(start_xbee_btn)
xbee_buttons.addWidget(stop_xbee_btn)
xbee_buttons.addWidget(goto_xbee_btn)
xbee_col.addLayout(xbee_buttons)
xbee_col.addStretch()
# ============================
# 第四欄:任務參數
# ============================
@@ -358,26 +450,32 @@ class GroupPanel(QWidget):
# 每種任務類型的參數定義: (key, label, default_value)
self._param_defs = {
'M_FORMATION': [
('spacing', '間距 (m)', '5.0'),
('base_altitude', '基準高度 (m)', '10.0'),
('altitude_diff', '高低差 (m)', '2.0'),
('spacing', '間距 (m)', self.default_params['spacing']),
('base_altitude', '基準高度 (m)', self.default_params['base_altitude']),
('altitude_diff', '高低差 (m)', self.default_params['altitude_diff']),
],
'CIRCLE_FORMATION': [
('radius', '半徑 (m)', '10.0'),
('altitude', '高度 (m)', '10.0'),
('start_angle', '起始角 (°)', '0'),
('radius', '半徑 (m)', self.default_params['radius']),
('altitude', '高度 (m)', self.default_params['altitude']),
('start_angle', '起始角 (°)', self.default_params['start_angle']),
],
'LEADER_FOLLOWER': [
('lateral_offset', '橫向偏移 (m)', '3.0'),
('longitudinal_spacing', '縱向間距 (m)', '5.0'),
('altitude', '高度 (m)', '10.0'),
('lateral_offset', '橫向偏移 (m)', self.default_params['lateral_offset']),
('longitudinal_spacing', '縱向間距 (m)', self.default_params['longitudinal_spacing']),
('altitude', '高度 (m)', self.default_params['altitude']),
],
'GRID_SWEEP': [
('line_spacing', '掃描線距 (m)', '5.0'),
('altitude', '高度 (m)', '10.0'),
('line_spacing', '掃描線距 (m)', self.default_params['line_spacing']),
('altitude', '高度 (m)', self.default_params['altitude']),
],
}
# 所有 mission type 共用的執行參數(不隨類型切換隱藏)
self._exec_param_defs = [
('arrival_radius', '到達半徑 (m)', self.default_params['arrival_radius']),
('hover_stable_sec', '停留判定 (s)', self.default_params['hover_stable_sec']),
]
# 建立所有參數列的 widget(先全部建好,切換時顯示/隱藏)
self._param_widgets = {} # key → (label_widget, input_widget)
self._param_rows = [] # 所有 row layout 對應的 container widget
@@ -401,6 +499,38 @@ class GroupPanel(QWidget):
self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w)
# 執行參數列(所有 mission type 共用,永遠顯示)
for key, label_text, default in self._exec_param_defs:
row_w = QWidget()
row_l = QHBoxLayout(row_w)
row_l.setContentsMargins(0, 0, 0, 0)
row_l.setSpacing(3)
lbl = QLabel(label_text)
lbl.setStyleSheet(LBL)
inp = QLineEdit(default)
inp.setStyleSheet(INPUT)
inp.setFixedWidth(50)
row_l.addWidget(lbl, 1)
row_l.addWidget(inp)
param_col.addWidget(row_w)
self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w)
# LEADER_FOLLOWER 專用:領隊下拉選單
self._leader_row = QWidget()
leader_layout = QHBoxLayout(self._leader_row)
leader_layout.setContentsMargins(0, 0, 0, 0)
leader_layout.setSpacing(3)
leader_lbl = QLabel("領隊")
leader_lbl.setStyleSheet(LBL)
self._leader_combo = QComboBox()
self._leader_combo.setStyleSheet(COMBO)
self._leader_combo.setFixedWidth(70)
self._leader_combo.currentTextChanged.connect(self._on_leader_changed)
leader_layout.addWidget(leader_lbl, 1)
leader_layout.addWidget(self._leader_combo)
param_col.addWidget(self._leader_row)
param_col.addStretch()
# 初始顯示對應的參數
@@ -409,28 +539,55 @@ class GroupPanel(QWidget):
# 當任務類型切換時更新參數顯示
self.type_combo.currentTextChanged.connect(self._update_param_visibility)
# ── 組裝四欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ──
cols.addLayout(left, 1)
# ── 組裝欄位:控制指令 > 任務規劃 > 任務參數 > 選取與分組 > 飛控操作 > XBee ──
# 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配
cols.addLayout(left, 0)
cols.addWidget(self._make_sep())
cols.addLayout(mid, 1)
cols.addLayout(mid, 0)
cols.addWidget(self._make_sep())
cols.addLayout(param_col, 1)
cols.addLayout(param_col, 0)
cols.addWidget(self._make_sep())
cols.addLayout(right, 1)
cols.addLayout(right, 0)
cols.addWidget(self._make_sep())
cols.addLayout(reboot_col, 0)
cols.addWidget(self._make_sep())
cols.addLayout(xbee_col, 0)
cols.addStretch() # 填充剩餘空間,使欄位置左
layout.addLayout(cols)
def update_drone_list(self):
"""更新無人機列表顯示"""
if not self.group.drone_ids:
if not self.group.selected_drone_ids:
self.drone_list_label.setText("尚未分配")
self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;")
else:
sorted_ids = sorted(self.group.drone_ids,
sorted_ids = sorted(self.group.selected_drone_ids,
key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
self.drone_list_label.setText("、".join(sorted_ids))
self.drone_list_label.setStyleSheet(
f"color: {self.group.color}; font-size: 11px; font-weight: bold;")
self._refresh_leader_options()
def _refresh_leader_options(self):
"""依目前群組成員重新填充領隊下拉選單,保留目前選擇若仍有效"""
sorted_ids = sorted(self.group.selected_drone_ids,
key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
current = self.group.leader_drone_id
self._leader_combo.blockSignals(True)
self._leader_combo.clear()
self._leader_combo.addItems(sorted_ids)
if current and current in sorted_ids:
self._leader_combo.setCurrentText(current)
elif sorted_ids:
self._leader_combo.setCurrentText(sorted_ids[0])
self.group.leader_drone_id = sorted_ids[0]
else:
self.group.leader_drone_id = None
self._leader_combo.blockSignals(False)
def _on_leader_changed(self, drone_id):
self.group.leader_drone_id = drone_id if drone_id else None
def update_status(self):
"""更新任務狀態顯示"""
@@ -445,21 +602,32 @@ class GroupPanel(QWidget):
self.status_label.setText("⏸ 已暫停")
self.status_label.setStyleSheet("color: #FFA000; font-size: 11px; font-weight: bold;")
else:
n = len(self.group.drone_ids)
n = len(self.group.selected_drone_ids)
total_wps = sum(len(wps) for wps in self.group.planned_waypoints['waypoints'])
self.status_label.setText(f"● 已規劃 ({n}架/{total_wps}點)")
self.status_label.setStyleSheet(
f"color: {self.group.color}; font-size: 11px; font-weight: bold;")
def _on_all_select_clicked(self):
"""全選按鈕點擊 - 發送信號給 gui.py 處理 toggle 邏輯"""
self.select_all_requested.emit(self.group.group_id)
def set_delete_enabled(self, enabled):
"""啟用或禁用刪除群組按鈕"""
self.delete_group_btn.setEnabled(enabled)
def _update_param_visibility(self, _=None):
"""根據當前任務類型,顯示/隱藏對應的參數列"""
"""根據當前任務類型,顯示/隱藏對應的參數列。執行參數永遠顯示。"""
mission_type = self.type_combo.currentText()
visible_keys = {d[0] for d in self._param_defs.get(mission_type, [])}
mission_keys = {d[0] for d in self._param_defs.get(mission_type, [])}
exec_keys = {d[0] for d in self._exec_param_defs}
visible_keys = mission_keys | exec_keys
for key, (row_w, _inp) in self._param_widgets.items():
row_w.setVisible(key in visible_keys)
self._leader_row.setVisible(mission_type == 'LEADER_FOLLOWER')
def get_mission_params(self):
"""讀取當前顯示的參數值,回傳 dict"""
"""讀取當前任務類型的規劃參數,回傳 dict(不含執行參數)"""
mission_type = self.type_combo.currentText()
params = {}
for key, _label, default in self._param_defs.get(mission_type, []):
@@ -471,8 +639,28 @@ class GroupPanel(QWidget):
params[key] = float(default)
return params
def get_execution_params(self):
"""讀取執行參數(arrival_radius, hover_stable_sec 等),回傳 dict"""
params = {}
for key, _label, default in self._exec_param_defs:
if key in self._param_widgets:
_row_w, inp = self._param_widgets[key]
try:
params[key] = float(inp.text())
except ValueError:
params[key] = float(default)
return params
def set_param_value(self, key, value):
"""更新指定參數欄位的文字值。"""
if key not in self._param_widgets:
return
_row_w, inp = self._param_widgets[key]
inp.setText(str(value))
def update_mission_info(self, center_lat, center_lon, target_lat, target_lon):
"""更新中心點 / 目標點顯示"""
self.group.mission_target = (float(target_lat), float(target_lon))
info_style = f"color: {self.group.color}; font-size: 11px; font-weight: bold;"
self.center_label.setText(f"中心: {center_lat:.6f}°, {center_lon:.6f}°")
self.center_label.setStyleSheet(info_style)
@@ -481,6 +669,7 @@ class GroupPanel(QWidget):
def clear_mission_info(self):
"""清除中心點 / 目標點顯示"""
self.group.mission_target = None
self.center_label.setText("中心: --")
self.center_label.setStyleSheet("color: #AAA; font-size: 11px;")
self.target_label.setText("目標: --")
@@ -490,6 +679,5 @@ class GroupPanel(QWidget):
try:
alt = float(self.alt_input.currentText())
except ValueError:
alt = 10.0
alt = 30.0
self.takeoff_requested.emit(self.group.group_id, alt)
File diff suppressed because it is too large Load Diff
+710
View File
@@ -0,0 +1,710 @@
#!/usr/bin/env python3
"""
任務編排器 (MissionOrchestrator) —— ABC 對抗任務的階段狀態機。
角色:坐在既有 MissionExecutor 之上的「任務大腦」。偵察飛行由操作員用既有的
群組執行按鈕控制(A 組 GRID_SWEEP、B 組待機);orchestrator 只做兩件事:
1. 被動監看「A↔敵機」距離,達 R_detect 持續 dwell → latch,提示操作員。
2. 操作員按「重編隊」→ 接管 A+B 四機,用「瞬時隊形」追移動敵機、足夠近時展開包圍。
追蹤採「瞬時隊形」而非路徑跟隨:每 tick 給每台一個目標點(此刻該站的隊形位置),
敵機移動就更新。避免了路徑跟隨在移動目標下 index 歸零/往後外推造成的往返振盪。
階段:IDLE → WATCH(監看偵測)→ DETECTED(latch,等確認)
→ PURSUE(4 機瞬時 leader-follow 朝敵,B 在後方 slot 自然追上會合)
→ ENCIRCLE(形心距敵 ≤ R_engage 展開包圍,圓心追敵;逃出 R_engage_exit 退回)
設計定案見 memory: design_mission_orchestrator。修改限定 src/GUI/(scope_gui_only)。
"""
import math
import time
from enum import Enum
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QFrame, QPushButton,
QDoubleSpinBox
)
from PyQt6.QtCore import Qt, QObject, pyqtSignal
def _log(level, message):
print(f"[{level}] {message}", flush=True)
def _haversine(lat1, lon1, lat2, lon2):
"""兩經緯度間的水平地面距離 (m)。"""
R = 6371000.0
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = (math.sin(dphi / 2) ** 2
+ math.cos(p1) * math.cos(p2) * math.sin(dlam / 2) ** 2)
return 2 * R * math.asin(min(1.0, math.sqrt(a)))
def _ll_to_m(lat, lon, ref_lat, ref_lon):
"""經緯度 → 以 ref 為原點的本地平面公尺 (x=東, y=北)。等距近似,小範圍夠用。"""
x = math.radians(lon - ref_lon) * 6371000.0 * math.cos(math.radians(ref_lat))
y = math.radians(lat - ref_lat) * 6371000.0
return x, y
def _m_to_ll(x, y, ref_lat, ref_lon):
"""本地平面公尺 → 經緯度。"""
lat = ref_lat + math.degrees(y / 6371000.0)
lon = ref_lon + math.degrees(x / (6371000.0 * math.cos(math.radians(ref_lat))))
return lat, lon
# 預設參數
DEFAULT_R_DETECT = 40.0 # 偵測範圍 (m)
DEFAULT_DETECT_DWELL = 1.0 # 偵測 dwell (s)
DEFAULT_R_ENGAGE = 25.0 # 接戰半徑 (m):形心↔敵 ≤ 此值 → 切包圍
DEFAULT_R_ENGAGE_EXIT = 35.0 # 包圍退出半徑 (m):> 此值 → 退回前進(hysteresis)
DEFAULT_REPLAN_MOVE_M = 2.0 # 敵機位移 ≥ 此值即重算(moving target)
DEFAULT_REPLAN_CAP_SEC = 1.0 # 重算週期上限 (s)
DEFAULT_CIRCLE_RADIUS = 15.0 # 包圍半徑 (m)
DEFAULT_LON_SPACING = 8.0 # 隊形縱向間距 (m)
DEFAULT_LAT_OFFSET = 5.0 # 隊形橫向錯開 (m)
DEFAULT_FORMATION_ALT = 30.0 # 隊形基準高度 (m,相對 home)
DEFAULT_ALT_LAYER_GAP = 10.0 # A/B 上下層垂直間隔 (m):A=基準+gap/2、B=基準-gap/2
DEFAULT_MIN_ALT_FLOOR = 10.0 # 相對 home 的最低安全高度 (m):B 下層不得低於此值
DEFAULT_LEAD_TIME_CAP = 4.0 # 前置攔截 lead_time 上限 (s)
DEFAULT_FRIENDLY_SPEED = 5.0 # 友機假設水平速度 (m/s):估 lead_time = gap/此值
DEFAULT_ENEMY_VEL_WINDOW = 1.0 # 敵速估計的 GPS 差分視窗 (s)
_NONE_LABEL = "(未選擇)"
class MissionPhase(Enum):
IDLE = "idle" # 未設定敵機/A 組
WATCH = "watch" # 監看偵測(偵察飛行由操作員的群組任務負責)
DETECTED = "detected" # 偵測 latch,等操作員確認重編隊
PURSUE = "pursue" # 4 機瞬時 leader-follow 朝敵機
ENCIRCLE = "encircle" # 4 機包圍敵機
class MissionOrchestrator(QObject):
"""
階段狀態機。透過注入的 callback 與 GUI 解耦(便於 headless 測試):
make_executor() -> MissionExecutor(complete_stops=False,hover-hold)
gps_provider() -> {drone_id: {'lat','lon','alt'}}
stop_groups_fn() -> 停掉操作員的 A/B 群組任務(重編隊時避免雙重送點)
"""
phase_changed = pyqtSignal(str) # MissionPhase.value
detection_prompt = pyqtSignal(str) # 偵測提示(含最近友機/距離)
status_message = pyqtSignal(str)
def __init__(self, make_executor, gps_provider, stop_groups_fn=None,
r_detect=DEFAULT_R_DETECT,
detect_dwell_sec=DEFAULT_DETECT_DWELL,
r_engage=DEFAULT_R_ENGAGE,
r_engage_exit=DEFAULT_R_ENGAGE_EXIT,
replan_move_m=DEFAULT_REPLAN_MOVE_M,
replan_cap_sec=DEFAULT_REPLAN_CAP_SEC,
circle_radius=DEFAULT_CIRCLE_RADIUS,
lon_spacing=DEFAULT_LON_SPACING,
lat_offset=DEFAULT_LAT_OFFSET,
formation_alt=DEFAULT_FORMATION_ALT,
alt_layer_gap=DEFAULT_ALT_LAYER_GAP,
min_alt_floor=DEFAULT_MIN_ALT_FLOOR,
lead_time_cap_sec=DEFAULT_LEAD_TIME_CAP,
friendly_speed=DEFAULT_FRIENDLY_SPEED,
enemy_vel_window_sec=DEFAULT_ENEMY_VEL_WINDOW,
parent=None):
super().__init__(parent)
self._make_executor = make_executor
self._gps_provider = gps_provider
self._stop_groups_fn = stop_groups_fn
self.r_detect = float(r_detect)
self.detect_dwell_sec = float(detect_dwell_sec)
self.r_engage = float(r_engage)
self.r_engage_exit = float(r_engage_exit)
self.replan_move_m = float(replan_move_m)
self.replan_cap_sec = float(replan_cap_sec)
self.circle_radius = float(circle_radius)
self.lon_spacing = float(lon_spacing)
self.lat_offset = float(lat_offset)
self.formation_alt = float(formation_alt)
self.alt_layer_gap = float(alt_layer_gap)
self.min_alt_floor = float(min_alt_floor)
self.lead_time_cap_sec = float(lead_time_cap_sec)
self.friendly_speed = float(friendly_speed)
self.enemy_vel_window_sec = float(enemy_vel_window_sec)
self.phase = MissionPhase.IDLE
self.a_drone_ids = [] # 偵察組
self.b_drone_ids = [] # 待機組
self.enemy_id = None
self._detect_since = 0.0
self._merged_ids = [] # PURSUE/ENCIRCLE 的 4 機(領隊優先,固定順序)
self._merged_ex = None # 合併 executor(hover-hold)
self._last_replan_at = 0.0
self._last_plan_enemy = None # 上次重算用的敵機 (lat, lon)
self._last_heading = (0.0, 1.0) # 上次隊形朝向(敵機太近時沿用,避免抖動)
self._enemy_hist = [] # [(t, lat, lon)] 敵機軌跡(估速用)
self._enemy_vel = (0.0, 0.0) # 估得的敵速 (east, north) m/s
# ------------------------------------------------------------- 設定(可連續呼叫)
def configure(self, a_drone_ids, b_drone_ids, enemy_id):
"""設定 A/B 組與敵機。監看/待機階段可隨時更新;進入 PURSUE 後鎖定不受影響。"""
if self.phase in (MissionPhase.PURSUE, MissionPhase.ENCIRCLE):
return False
self.a_drone_ids = list(a_drone_ids)
self.b_drone_ids = list(b_drone_ids)
new_enemy = enemy_id or None
if new_enemy != self.enemy_id:
self._detect_since = 0.0 # 換敵機重置 dwell
self.enemy_id = new_enemy
return True
def _configured(self):
return bool(self.enemy_id) and bool(self.a_drone_ids) \
and self.enemy_id not in set(self.a_drone_ids) | set(self.b_drone_ids) \
and not (set(self.a_drone_ids) & set(self.b_drone_ids))
# ------------------------------------------------------------- 重編隊 / 停止
def confirm_detection(self):
"""DETECTED → PURSUE:操作員手動確認。停掉群組掃描,接管 4 機瞬時追蹤。"""
if self.phase != MissionPhase.DETECTED:
self.status_message.emit("目前不在待確認狀態")
return
gps = self._gps_provider()
epos = gps.get(self.enemy_id)
if not epos:
self.status_message.emit("敵機定位遺失,無法重編隊")
return
merged = self._merged_ids_leader_first(gps, epos)
if self._positions_for(merged, gps) is None:
self.status_message.emit("有無人機尚未定位,無法重編隊")
return
# 先停掉操作員的 A/B 群組任務,避免與合併 executor 雙重送點塞爆 adapter
if self._stop_groups_fn:
try:
self._stop_groups_fn()
except Exception as e:
_log("WARN", f"[orchestrator] 停止群組任務失敗: {e}")
self._merged_ids = merged
self._last_heading = (0.0, 1.0)
self._enemy_hist = []
self._enemy_vel = (0.0, 0.0)
self._update_enemy_velocity(epos)
slots = self._pursue_slots(gps, epos)
self._merged_ex = self._make_executor()
self._merged_ex.start(self._slots_to_plan(slots))
self._mark_replanned(epos)
self._set_phase(MissionPhase.PURSUE)
self.status_message.emit("重編隊確認:4 機前進中(B 追上會合)")
def stop(self):
"""全體停止,回 IDLE。"""
if self._merged_ex is not None:
try:
self._merged_ex.stop()
except Exception:
pass
self._merged_ex = None
self._merged_ids = []
self._detect_since = 0.0
self._enemy_hist = []
self._enemy_vel = (0.0, 0.0)
self._set_phase(MissionPhase.IDLE)
self.status_message.emit("任務已停止")
# ------------------------------------------------------------- tick
def tick(self):
"""GUI 週期呼叫。"""
# 自動進出監看(偵察飛行由操作員群組任務負責,這裡只監看偵測)
if self.phase == MissionPhase.IDLE and self._configured():
self._set_phase(MissionPhase.WATCH)
elif self.phase == MissionPhase.WATCH and not self._configured():
self._detect_since = 0.0
self._set_phase(MissionPhase.IDLE)
if self.phase == MissionPhase.WATCH:
self._tick_watch()
elif self.phase == MissionPhase.PURSUE:
self._tick_pursue()
elif self.phase == MissionPhase.ENCIRCLE:
self._tick_encircle()
def _tick_watch(self):
gps = self._gps_provider()
epos = gps.get(self.enemy_id)
if not epos:
self._detect_since = 0.0
return
nearest_id, nearest_d = None, None
for did in self.a_drone_ids:
pos = gps.get(did)
if not pos:
continue
d = _haversine(epos['lat'], epos['lon'], pos['lat'], pos['lon'])
if nearest_d is None or d < nearest_d:
nearest_id, nearest_d = did, d
now = time.monotonic()
if nearest_d is not None and nearest_d <= self.r_detect:
if self._detect_since == 0.0:
self._detect_since = now
elif now - self._detect_since >= self.detect_dwell_sec:
self._set_phase(MissionPhase.DETECTED) # latch,等操作員手動確認
self.detection_prompt.emit(
f"偵測到敵機({nearest_id} 距 {nearest_d:.1f} m)— 請確認重編隊")
else:
self._detect_since = 0.0
def _tick_pursue(self):
gps = self._gps_provider()
epos = gps.get(self.enemy_id)
if not epos:
return # 敵機定位遺失(S4 加 stale-timeout hold)
self._update_enemy_velocity(epos)
d = self._centroid_dist(gps, epos)
if d is None:
return
if d <= self.r_engage:
self._apply_slots(self._circle_slots(gps, epos), epos)
self._set_phase(MissionPhase.ENCIRCLE)
self.status_message.emit("進入包圍")
return
if self._should_replan(time.monotonic(), epos):
self._apply_slots(self._pursue_slots(gps, epos), epos)
def _tick_encircle(self):
gps = self._gps_provider()
epos = gps.get(self.enemy_id)
if not epos:
return
self._update_enemy_velocity(epos)
d = self._centroid_dist(gps, epos)
if d is None:
return
if d > self.r_engage_exit:
self._apply_slots(self._pursue_slots(gps, epos), epos)
self._set_phase(MissionPhase.PURSUE)
self.status_message.emit("敵機逃離,退回前進")
return
if self._should_replan(time.monotonic(), epos):
self._apply_slots(self._circle_slots(gps, epos), epos)
# ------------------------------------------------------------- 高度分層
def _alt_for(self, did):
"""S3 高度分層:A 組(含領隊)走上層、B 組走下層(不低於地面下限)。
同高度繞圈/追擊會交叉撞機,分層後 A/B 垂直錯開自然避開。"""
if did in set(self.b_drone_ids):
return max(self.min_alt_floor,
self.formation_alt - self.alt_layer_gap / 2.0)
return self.formation_alt + self.alt_layer_gap / 2.0
# ------------------------------------------------------------- 前置攔截(估敵速)
def _update_enemy_velocity(self, epos):
"""用敵機 GPS 差分估水平速度 (east, north) m/s,視窗 enemy_vel_window_sec。"""
now = time.monotonic()
self._enemy_hist.append((now, epos['lat'], epos['lon']))
while (len(self._enemy_hist) >= 2
and now - self._enemy_hist[0][0] > self.enemy_vel_window_sec):
self._enemy_hist.pop(0)
if len(self._enemy_hist) >= 2:
t0, la0, lo0 = self._enemy_hist[0]
t1, la1, lo1 = self._enemy_hist[-1]
dt = t1 - t0
if dt > 1e-3:
dx, dy = _ll_to_m(la1, lo1, la0, lo0) # sample0→sample1 位移 (m)
self._enemy_vel = (dx / dt, dy / dt)
def enemy_speed(self):
"""估得的敵機水平速率 (m/s)。"""
return math.hypot(*self._enemy_vel)
def _aim_point(self, gps, epos):
"""前置攔截點:敵位 + 敵速×lead_time。lead_time = gap/friendly_speed(封頂
lead_time_cap_sec)。敵速尚未估到時退化為瞄當下位置(等同原純追尾)。"""
evx, evy = self._enemy_vel
lp = gps.get(self._merged_ids[0]) if self._merged_ids else None
gap = (_haversine(lp['lat'], lp['lon'], epos['lat'], epos['lon'])
if lp else 0.0)
lead_t = min(self.lead_time_cap_sec, gap / max(self.friendly_speed, 0.1))
return _m_to_ll(evx * lead_t, evy * lead_t, epos['lat'], epos['lon'])
# ------------------------------------------------------------- 瞬時隊形
def _pursue_slots(self, gps, epos):
"""朝『前置攔截點』的瞬時 leader-follow:領隊朝攔截點,僚機沿隊軸後方 rank×間距、左右錯開。"""
ids = self._merged_ids
tlat, tlon = self._aim_point(gps, epos) # 前置攔截點取代敵機當下位置
lp = gps.get(ids[0])
if lp is None:
return None
lx, ly = _ll_to_m(lp['lat'], lp['lon'], tlat, tlon) # 領隊相對攔截點
hx, hy = -lx, -ly # 領隊 → 攔截點
n = math.hypot(hx, hy)
if n < 1.0:
h = self._last_heading # 太近,沿用上次朝向避免抖動
else:
h = (hx / n, hy / n)
self._last_heading = h
px, py = -h[1], h[0] # 左垂直
slots = {}
for rank, d in enumerate(ids):
lat_sign = -1 if rank % 2 == 0 else 1
# 領隊(rank0)目標≈攔截點;僚機沿 -h 後退 rank×間距,並左右錯開
sx = -h[0] * (rank * self.lon_spacing) + px * (lat_sign * self.lat_offset)
sy = -h[1] * (rank * self.lon_spacing) + py * (lat_sign * self.lat_offset)
lat, lon = _m_to_ll(sx, sy, tlat, tlon)
slots[d] = (lat, lon, self._alt_for(d))
return slots
def _circle_slots(self, gps, epos):
"""繞敵機的瞬時包圍:N 機均分在半徑 circle_radius 的圓周上。"""
ids = self._merged_ids
elat, elon = epos['lat'], epos['lon']
n = len(ids)
slots = {}
for i, d in enumerate(ids):
theta = 2.0 * math.pi * i / max(1, n)
x = self.circle_radius * math.cos(theta)
y = self.circle_radius * math.sin(theta)
lat, lon = _m_to_ll(x, y, elat, elon)
slots[d] = (lat, lon, self._alt_for(d))
return slots
def _apply_slots(self, slots, epos):
"""把瞬時 slot 熱替換給合併 executor 並更新重算基準。"""
if slots is None or self._merged_ex is None:
return
self._merged_ex.update_plan(self._slots_to_plan(slots))
self._mark_replanned(epos)
def _slots_to_plan(self, slots):
return {
'drone_ids': list(self._merged_ids),
'waypoints': [[slots[d]] for d in self._merged_ids],
'rendezvous_indices': [],
}
# ------------------------------------------------------------- 內部
def _centroid_dist(self, gps, epos):
m_pos = self._positions_for(self._merged_ids, gps)
if m_pos is None:
return None
cen_lat = sum(p[0] for p in m_pos) / len(m_pos)
cen_lon = sum(p[1] for p in m_pos) / len(m_pos)
return _haversine(cen_lat, cen_lon, epos['lat'], epos['lon'])
def _positions_for(self, drone_ids, gps):
out = []
for did in drone_ids:
p = gps.get(did)
if not p:
return None
out.append((p['lat'], p['lon'], p.get('alt', 0.0)))
return out
def _merged_ids_leader_first(self, gps, epos):
"""A+B 合併;A 中離敵最近者當領隊(rank0),其餘 A 維持原序,再接 B。"""
def dist(did):
p = gps.get(did)
return float('inf') if not p else _haversine(
epos['lat'], epos['lon'], p['lat'], p['lon'])
if not self.a_drone_ids:
return list(self.b_drone_ids)
leader = min(self.a_drone_ids, key=dist)
rest_a = [d for d in self.a_drone_ids if d != leader]
return [leader] + rest_a + list(self.b_drone_ids)
def _mark_replanned(self, epos):
self._last_replan_at = time.monotonic()
self._last_plan_enemy = (epos['lat'], epos['lon'])
def _should_replan(self, now, epos):
if self._last_plan_enemy is None:
return True
moved = _haversine(self._last_plan_enemy[0], self._last_plan_enemy[1],
epos['lat'], epos['lon'])
return (moved >= self.replan_move_m
or (now - self._last_replan_at) >= self.replan_cap_sec)
def _set_phase(self, phase):
if phase == self.phase:
return
self.phase = phase
_log("INFO", f"[orchestrator] phase → {phase.value}")
self.phase_changed.emit(phase.value)
class EngagementPanel(QWidget):
"""
交戰控制面板。
- 敵機下拉 + 即時「敵機↔最近友機」距離
- A/B 組指派下拉
- 階段顯示、重編隊 / 停止 按鈕
偵察飛行用既有的群組執行按鈕控制,這裡不含「開始」。面板只發訊號,邏輯在 GUI/orchestrator。
"""
enemy_changed = pyqtSignal(str) # drone_id(未選擇=空字串)
confirm_requested = pyqtSignal() # 重編隊
stop_requested = pyqtSignal()
groups_changed = pyqtSignal(str, str) # (a_group_id, b_group_id)
apply_speed_requested = pyqtSignal(float, float) # (friendly_speed, enemy_ratio)
def __init__(self, parent=None, r_detect=DEFAULT_R_DETECT,
friendly_speed=DEFAULT_FRIENDLY_SPEED):
super().__init__(parent)
self.r_detect = float(r_detect)
self._init_friendly_speed = float(friendly_speed)
self._enemy_id = ""
self._suppress_enemy = False
self._suppress_group = False
self._group_sig = None # 目前下拉內容的簽章,變了才重建(避免 5Hz 狂刷)
self._build_ui()
# ------------------------------------------------------------------ UI
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(12, 12, 12, 12)
root.setSpacing(10)
root.setAlignment(Qt.AlignmentFlag.AlignTop)
title = QLabel("交戰設定")
title.setStyleSheet(
"color: #DDD; font-size: 14px; font-weight: bold; padding: 2px;")
root.addWidget(title)
combo_css = (
"QComboBox { background-color: #333; color: #EEE; border: 1px solid #555;"
" border-radius: 3px; padding: 3px 6px; font-size: 12px; min-width: 120px; }")
enemy_row = QHBoxLayout()
enemy_row.setSpacing(8)
el = QLabel("敵機 sysid:")
el.setStyleSheet("color: #CCC; font-size: 12px;")
self.enemy_combo = QComboBox()
self.enemy_combo.setStyleSheet(combo_css)
self.enemy_combo.addItem(_NONE_LABEL)
self.enemy_combo.currentTextChanged.connect(self._on_enemy_changed)
enemy_row.addWidget(el)
enemy_row.addWidget(self.enemy_combo)
enemy_row.addStretch()
root.addLayout(enemy_row)
a_row = QHBoxLayout()
a_row.setSpacing(8)
al = QLabel("偵察組 A:")
al.setStyleSheet("color: #CCC; font-size: 12px;")
self.a_group_combo = QComboBox()
self.a_group_combo.setStyleSheet(combo_css)
self.a_group_combo.addItem(_NONE_LABEL, None)
self.a_group_combo.currentTextChanged.connect(self._on_group_changed)
a_row.addWidget(al)
a_row.addWidget(self.a_group_combo)
a_row.addStretch()
root.addLayout(a_row)
b_row = QHBoxLayout()
b_row.setSpacing(8)
bl = QLabel("待機組 B:")
bl.setStyleSheet("color: #CCC; font-size: 12px;")
self.b_group_combo = QComboBox()
self.b_group_combo.setStyleSheet(combo_css)
self.b_group_combo.addItem(_NONE_LABEL, None)
self.b_group_combo.currentTextChanged.connect(self._on_group_changed)
b_row.addWidget(bl)
b_row.addWidget(self.b_group_combo)
b_row.addStretch()
root.addLayout(b_row)
# 速度比例限速:友方 V、敵方 r×V,按鈕一次套用(DO_CHANGE_SPEED)
spin_css = (
"QDoubleSpinBox { background-color: #333; color: #EEE;"
" border: 1px solid #555; border-radius: 3px; padding: 2px 4px;"
" font-size: 12px; min-width: 70px; }")
speed_row = QHBoxLayout()
speed_row.setSpacing(8)
sl = QLabel("友機速度:")
sl.setStyleSheet("color: #CCC; font-size: 12px;")
self.friendly_speed_spin = QDoubleSpinBox()
self.friendly_speed_spin.setStyleSheet(spin_css)
self.friendly_speed_spin.setRange(0.5, 20.0)
self.friendly_speed_spin.setSingleStep(0.5)
self.friendly_speed_spin.setDecimals(1)
self.friendly_speed_spin.setSuffix(" m/s")
self.friendly_speed_spin.setValue(self._init_friendly_speed)
rl = QLabel("敵/友比例:")
rl.setStyleSheet("color: #CCC; font-size: 12px;")
self.enemy_ratio_spin = QDoubleSpinBox()
self.enemy_ratio_spin.setStyleSheet(spin_css)
self.enemy_ratio_spin.setRange(0.1, 1.0)
self.enemy_ratio_spin.setSingleStep(0.05)
self.enemy_ratio_spin.setDecimals(2)
self.enemy_ratio_spin.setValue(0.6)
speed_row.addWidget(sl)
speed_row.addWidget(self.friendly_speed_spin)
speed_row.addWidget(rl)
speed_row.addWidget(self.enemy_ratio_spin)
speed_row.addStretch()
root.addLayout(speed_row)
apply_speed_row = QHBoxLayout()
self.apply_speed_btn = QPushButton("套用速度")
self.apply_speed_btn.setStyleSheet(
"QPushButton { background-color: #00796B; color: white; border: none;"
" padding: 6px 14px; border-radius: 4px; font-weight: bold; }"
" QPushButton:hover { background-color: #00897B; }")
self.apply_speed_btn.clicked.connect(self._on_apply_speed)
self.enemy_speed_hint = QLabel("")
self.enemy_speed_hint.setStyleSheet("color: #777; font-size: 11px;")
self._refresh_enemy_speed_hint()
self.friendly_speed_spin.valueChanged.connect(self._refresh_enemy_speed_hint)
self.enemy_ratio_spin.valueChanged.connect(self._refresh_enemy_speed_hint)
apply_speed_row.addWidget(self.apply_speed_btn)
apply_speed_row.addWidget(self.enemy_speed_hint)
apply_speed_row.addStretch()
root.addLayout(apply_speed_row)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #444;")
root.addWidget(sep)
self.distance_label = QLabel("敵機 ↔ 最近友機:—")
self.distance_label.setStyleSheet(
"color: #AAA; font-size: 13px; padding: 4px 2px;")
root.addWidget(self.distance_label)
hint = QLabel(f"(偵測範圍 R_detect = {self.r_detect:.0f} m)")
hint.setStyleSheet("color: #777; font-size: 11px;")
root.addWidget(hint)
self.phase_label = QLabel("階段:待機")
self.phase_label.setStyleSheet(
"color: #64B5F6; font-size: 13px; font-weight: bold; padding: 4px 2px;")
root.addWidget(self.phase_label)
btn_css = ("QPushButton {{ background-color: {bg}; color: {fg}; border: none;"
" padding: 8px 16px; border-radius: 4px; font-weight: bold; }}"
" QPushButton:hover {{ background-color: {hover}; }}"
" QPushButton:disabled {{ background-color: #444; color: #888; }}")
btn_row = QHBoxLayout()
btn_row.setSpacing(8)
self.confirm_btn = QPushButton("重編隊")
self.confirm_btn.setStyleSheet(btn_css.format(bg='#F57C00', fg='white', hover='#FB8C00'))
self.confirm_btn.clicked.connect(lambda: self.confirm_requested.emit())
self.confirm_btn.setEnabled(False)
self.stop_btn = QPushButton("停止")
self.stop_btn.setStyleSheet(btn_css.format(bg='#555', fg='#DDD', hover='#666'))
self.stop_btn.clicked.connect(lambda: self.stop_requested.emit())
btn_row.addWidget(self.confirm_btn)
btn_row.addWidget(self.stop_btn)
btn_row.addStretch()
root.addLayout(btn_row)
# -------------------------------------------------------------- 對外 API
def current_enemy(self):
return self._enemy_id or None
def refresh_drone_list(self, drone_ids):
items = [_NONE_LABEL] + sorted(drone_ids)
current = self._enemy_id if self._enemy_id in drone_ids else ""
self._suppress_enemy = True
self.enemy_combo.clear()
self.enemy_combo.addItems(items)
idx = self.enemy_combo.findText(current) if current else 0
self.enemy_combo.setCurrentIndex(idx if idx >= 0 else 0)
self._suppress_enemy = False
if current != self._enemy_id:
self._enemy_id = current
self.enemy_changed.emit(self._enemy_id)
def refresh_group_list(self, group_labels):
"""group_labels: list[(group_id, display_text)]。只有內容變動才重建,避免 5Hz 狂刷。"""
sig = tuple(group_labels)
if sig == self._group_sig:
return
self._group_sig = sig
self._suppress_group = True
for combo in (self.a_group_combo, self.b_group_combo):
prev = combo.currentData()
combo.clear()
combo.addItem(_NONE_LABEL, None)
for gid, text in group_labels:
combo.addItem(text, gid)
if prev is not None:
i = combo.findData(prev)
combo.setCurrentIndex(i if i >= 0 else 0)
self._suppress_group = False
def selected_group_ids(self):
return self.a_group_combo.currentData(), self.b_group_combo.currentData()
def set_phase_text(self, text):
self.phase_label.setText(f"階段:{text}")
def set_confirm_enabled(self, enabled):
self.confirm_btn.setEnabled(enabled)
def tick(self, drone_positions):
"""{drone_id: (lat, lon)} 更新距離讀數。"""
enemy = self._enemy_id
base = "color: #AAA; font-size: 13px; padding: 4px 2px;"
if not enemy:
self.distance_label.setText("敵機 ↔ 最近友機:(尚未選擇敵機)")
self.distance_label.setStyleSheet(base)
return
epos = drone_positions.get(enemy)
if not epos:
self.distance_label.setText(f"敵機 {enemy} ↔ 最近友機:(等待敵機定位)")
self.distance_label.setStyleSheet(base)
return
nearest_id, nearest_d = None, None
for did, pos in drone_positions.items():
if did == enemy or not pos:
continue
d = _haversine(epos[0], epos[1], pos[0], pos[1])
if nearest_d is None or d < nearest_d:
nearest_id, nearest_d = did, d
if nearest_id is None:
self.distance_label.setText(f"敵機 {enemy} ↔ 最近友機:(無友機定位)")
self.distance_label.setStyleSheet(base)
return
color = "#E57373" if nearest_d <= self.r_detect else "#81C784"
self.distance_label.setText(
f"敵機 {enemy} ↔ 最近友機 {nearest_id}:{nearest_d:.1f} m")
self.distance_label.setStyleSheet(
f"color: {color}; font-size: 13px; font-weight: bold; padding: 4px 2px;")
# ----------------------------------------------------------------- 內部
def _on_enemy_changed(self, text):
if self._suppress_enemy:
return
self._enemy_id = "" if text == _NONE_LABEL else text
_log("INFO", f"[交戰] 敵機選擇:{self._enemy_id or '(未選擇)'}")
self.enemy_changed.emit(self._enemy_id)
def _on_group_changed(self, _text):
if self._suppress_group:
return
a_id, b_id = self.selected_group_ids()
self.groups_changed.emit(a_id or "", b_id or "")
def _refresh_enemy_speed_hint(self, *_):
v = self.friendly_speed_spin.value()
r = self.enemy_ratio_spin.value()
self.enemy_speed_hint.setText(f"→ 友機 {v:.1f} m/s、敵機 {v * r:.1f} m/s")
def _on_apply_speed(self):
v = self.friendly_speed_spin.value()
r = self.enemy_ratio_spin.value()
_log("INFO", f"[交戰] 套用速度:友機 {v:.1f} m/s、敵機 {v * r:.1f} m/s (比例 {r:.2f})")
self.apply_speed_requested.emit(v, r)
+162 -150
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
飛行任務規劃模組
支援: M-Formation, Circle, Leader-Follower (Bezier 轉彎), Grid Sweep
支援: M-Formation, Circle, Leader-Follower (arc-length virtual leader), Grid Sweep
"""
import math
from typing import List, Tuple, Optional, Dict, Any
@@ -58,7 +58,7 @@ class FormationPlanner:
"""隊形規劃器"""
def __init__(self, spacing: float = 5.0,
base_altitude: float = 10.0,
base_altitude: float = 30.0,
altitude_diff: float = 2.0):
self.spacing = spacing
self.base_altitude = base_altitude
@@ -72,16 +72,22 @@ class FormationPlanner:
mission_type: MissionType = MissionType.M_FORMATION,
params: Optional[Dict[str, Any]] = None
) -> Tuple[List[List[Tuple[float, float, float]]],
Tuple[float, float, float]]:
Tuple[float, float, float],
List[int]]:
"""
回傳 (waypoints_gps, origin, rendezvous_indices)。
rendezvous_indices:航點序列裡該群組需要等全員到齊才推進的 index 清單。
"""
if len(drone_gps_positions) == 0:
raise ValueError("無人機位置列表不能為空")
center_lat = sum(pos[0] for pos in drone_gps_positions) / len(drone_gps_positions)
center_lon = sum(pos[1] for pos in drone_gps_positions) / len(drone_gps_positions)
center_alt = sum(pos[2] for pos in drone_gps_positions) / len(drone_gps_positions)
self.current_origin = (center_lat, center_lon, center_alt)
self.converter = CoordinateConverter(center_lat, center_lon, center_alt)
# origin_alt = 0:送給飛控的高度透過 MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
# 直接就是「相對 home 高度」,不需要加 GPS 橢球高度偏移
self.current_origin = (center_lat, center_lon, 0.0)
self.converter = CoordinateConverter(center_lat, center_lon, 0.0)
drone_local = [self.converter.gps_to_local(*pos) for pos in drone_gps_positions]
target_local = self.converter.gps_to_local(*target_gps)
@@ -89,10 +95,14 @@ class FormationPlanner:
if mission_type == MissionType.M_FORMATION:
s1, s2 = self._calculate_m_formation(drone_local, target_local, params)
waypoints_local = [[s1[i], s2[i]] for i in range(len(drone_local))]
# 起點集合後一起出發到 stage2
rendezvous_indices = [0]
elif mission_type == MissionType.CIRCLE_FORMATION:
s1, s2 = self._calculate_circle_formation(drone_local, target_local, params)
waypoints_local = [[s1[i], s2[i]] for i in range(len(drone_local))]
# 半程集合後一起進最終圓環位置
rendezvous_indices = [0]
elif mission_type == MissionType.LEADER_FOLLOWER:
params = params or {}
@@ -103,7 +113,9 @@ class FormationPlanner:
self.converter.gps_to_local(wp[0], wp[1], 0)[:2]
for wp in route_wps_gps
]
waypoints_local = self._calculate_leader_follower(drone_local, route_wps_local, params)
waypoints_local, rendezvous_indices = self._calculate_leader_follower(
drone_local, route_wps_local, params
)
elif mission_type == MissionType.GRID_SWEEP:
params = params or {}
@@ -114,7 +126,9 @@ class FormationPlanner:
self.converter.gps_to_local(c[0], c[1], 0)[:2]
for c in rect_corners_gps
]
waypoints_local = self._calculate_grid_sweep(drone_local, rect_corners_local, params)
waypoints_local, rendezvous_indices = self._calculate_grid_sweep(
drone_local, rect_corners_local, params
)
else:
raise ValueError(f"不支援的任務類型: {mission_type}")
@@ -123,7 +137,7 @@ class FormationPlanner:
gps_wps = [self.converter.local_to_gps(*wp) for wp in drone_wps]
waypoints_gps.append(gps_wps)
return waypoints_gps, self.current_origin
return waypoints_gps, self.current_origin, rendezvous_indices
# ------------------------------------------------------------------ M-Formation
@@ -165,7 +179,7 @@ class FormationPlanner:
params = params or {}
N = len(drone_positions)
radius = params.get('radius', 10.0)
altitude = params.get('altitude', 10.0)
altitude = params.get('altitude', 30.0)
start_angle = params.get('start_angle', 0.0)
center_x, center_y, center_z = target_point
@@ -179,187 +193,177 @@ class FormationPlanner:
final_y = center_y + radius * math.sin(angle_rad)
final_z = altitude
current_x, current_y, current_z = drone_positions[i]
current_x, current_y, _current_z = drone_positions[i]
# stage1 高度直接用 final_z:先爬升到目標高度再水平集合,
# 避免用 current_z 做中點插值(current_z 在實飛時可能是 AMSL 而非相對 home,會導致衝高)
stage1_positions.append((
current_x + (final_x - current_x) * 0.5,
current_y + (final_y - current_y) * 0.5,
current_z + (final_z - current_z) * 0.5
final_z,
))
stage2_positions.append((final_x, final_y, final_z))
return stage1_positions, stage2_positions
# ------------------------------------------------------------------ 路徑跟隨 (Bezier 轉彎)
# ------------------------------------------------------------------ 路徑跟隨 (虛擬領隊)
def _calculate_leader_follower(self, drone_positions, route_wps_local, params):
"""
路徑跟隨編隊 — Bezier 曲線轉彎版
路徑跟隨編隊 — 虛擬領隊 / 弧長參數化
步驟:
1. _build_center_path: 在轉折 WP 處用二次 Bezier 曲線平滑轉彎
2. 固定排序,每架無人機沿中心路徑套用橫向+縱向偏移
center_path = 使用者畫的路徑點(直線段連接,不插弧),
每架 drone 沿弧長 s 參數化做隊形偏移:
s_follower = s_leader - lon_k
position = lookup(s_follower) + lat_k × perp(tangent)
s 超出 path 範圍時外推(不 clip),確保每架 drone 起終點不重疊。
Rank = 輸入順序(GUI 選取順序),drone_positions[0] = leader。
隊形 (俯視):
| (前進方向) |
| D1 | ← 左偏移, 後 0m
| D2 | ← 右偏移, 後 5m
| D3 | ← 左偏移, 後 10m
| D4 | ← 右偏移, 後 15m
D0 (lat=-L, lon=0) ← leader
D1 (lat=+L, lon=5)
D2 (lat=-L, lon=10)
D3 (lat=+L, lon=15)
"""
N = len(drone_positions)
lateral_offset = params.get('lateral_offset', 3.0)
longitudinal_spacing = params.get('longitudinal_spacing', 5.0)
altitude = params.get('altitude', self.base_altitude)
turn_margin = params.get('turn_margin', 0.35) # 轉彎切入距離佔段長比例
curve_resolution = params.get('curve_resolution', 8) # 每個彎道的插值點數
# Step 1: 建立帶 Bezier 轉彎的中心路徑
center_path = self._build_center_path(
route_wps_local, turn_margin, curve_resolution
)
# Step 1: 建立中心路徑(直線段連接,每個中間航點設 barrier)
center_path, barrier_indices = self._build_center_path(route_wps_local)
# Step 2: 固定排序
first_dir = (center_path[0][2], center_path[0][3])
first_perp = (-first_dir[1], first_dir[0])
C_x = sum(p[0] for p in drone_positions) / N
C_y = sum(p[1] for p in drone_positions) / N
s_list = [pt['s'] for pt in center_path]
s_max = s_list[-1]
n_pts = len(center_path)
projections = [
((pos[0] - C_x) * first_perp[0] + (pos[1] - C_y) * first_perp[1], i)
for i, pos in enumerate(drone_positions)
]
projections.sort()
def lookup(s_target):
"""在 center path 上以弧長 s 回傳 (x, y, tangent_dir)。超出範圍時沿端點方向外推。"""
if n_pts == 1:
pt = center_path[0]
return pt['x'], pt['y'], pt['dir']
# Step 3: 偏移
if s_target <= 0.0:
a = center_path[0]
b = center_path[1]
sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
dx, dy = sdx / slen, sdy / slen
return a['x'] + s_target * dx, a['y'] + s_target * dy, (dx, dy)
return a['x'], a['y'], a['dir']
if s_target >= s_max:
a = center_path[-2]
b = center_path[-1]
sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
dx, dy = sdx / slen, sdy / slen
overshoot = s_target - s_max
return b['x'] + overshoot * dx, b['y'] + overshoot * dy, (dx, dy)
return b['x'], b['y'], b['dir']
# Binary search:s_list[lo] <= s_target < s_list[hi]
lo, hi = 0, n_pts - 1
while lo + 1 < hi:
mid = (lo + hi) // 2
if s_list[mid] <= s_target:
lo = mid
else:
hi = mid
a = center_path[lo]
b = center_path[hi]
ds = s_list[hi] - s_list[lo]
if ds < 1e-9:
return a['x'], a['y'], a['dir']
t = (s_target - s_list[lo]) / ds
x = a['x'] + t * (b['x'] - a['x'])
y = a['y'] + t * (b['y'] - a['y'])
sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
return x, y, (sdx / slen, sdy / slen)
return x, y, a['dir']
# Step 2: rank = 輸入順序(GUI 選取順序),不再做 projection sort
# 避免浮點噪音在投影值相近時翻轉 leader,保證 run-to-run 穩定
all_waypoints = [None] * N
for rank, (_, original_idx) in enumerate(projections):
for rank in range(N):
lat_sign = -1 if rank % 2 == 0 else 1
lat = lat_sign * lateral_offset
lon = rank * longitudinal_spacing
waypoints = []
for (cx, cy, dx, dy) in center_path:
for i in range(n_pts):
s_follower = s_list[i] - lon
x, y, (dx, dy) = lookup(s_follower)
perp_x, perp_y = -dy, dx
ox = cx + lat * perp_x - lon * dx
oy = cy + lat * perp_y - lon * dy
waypoints.append((ox, oy, altitude))
waypoints.append((
x + lat * perp_x,
y + lat * perp_y,
altitude,
))
all_waypoints[original_idx] = waypoints
all_waypoints[rank] = waypoints
return all_waypoints
return all_waypoints, barrier_indices
def _build_center_path(self, waypoints, turn_margin, curve_resolution):
def _build_center_path(self, waypoints):
"""
建立帶 Bezier 曲線轉彎的中心路徑
建立中心路徑 — 直接用路徑點連接,不插弧。
在每個轉折 WP 處:
1. 計算 pre_turn = WP - d_in × T
2. 計算 post_turn = WP + d_out × T
3. 用二次 Bezier 曲線: B(t) = (1-t)²·pre + 2t(1-t)·WP + t²·post
4. 方向從導數得到: B'(t) = 2(1-t)(WP-pre) + 2t(post-WP)
Args:
waypoints: 路徑航點 [(x, y), ...]
turn_margin: 轉彎切入距離佔相鄰段長的比例 (0~0.5)
curve_resolution: 每個彎道的 Bezier 插值點數
每個中間航點設為 barrier(轉彎集合點),讓隊伍到齊後再推進。
飛控在 GUIDED 模式下自行處理直線飛行,不需要 GUI 預先平滑。
Returns:
[(x, y, dir_x, dir_y), ...] 中心路徑點 + 單位方向
(path, barrier_indices)
"""
num_wps = len(waypoints)
if num_wps < 2:
return [(waypoints[0][0], waypoints[0][1], 0.0, 1.0)]
# 計算每段方向和長度
segments = []
for i in range(num_wps - 1):
dx = waypoints[i + 1][0] - waypoints[i][0]
dy = waypoints[i + 1][1] - waypoints[i][1]
length = math.sqrt(dx ** 2 + dy ** 2)
if length < 0.01:
segments.append((0.0, 1.0, length))
else:
segments.append((dx / length, dy / length, length))
return [{
'seg': 'straight',
'x': waypoints[0][0], 'y': waypoints[0][1],
'dir': (0.0, 1.0),
's': 0.0,
}], []
path = []
barrier_indices = []
# 第一個航點
path.append((waypoints[0][0], waypoints[0][1],
segments[0][0], segments[0][1]))
for i in range(num_wps):
if i < num_wps - 1:
dx = waypoints[i + 1][0] - waypoints[i][0]
dy = waypoints[i + 1][1] - waypoints[i][1]
else:
dx = waypoints[i][0] - waypoints[i - 1][0]
dy = waypoints[i][1] - waypoints[i - 1][1]
# 中間航點 (轉彎處)
for i in range(1, num_wps - 1):
d_in_x, d_in_y, len_in = segments[i - 1]
d_out_x, d_out_y, len_out = segments[i]
length = math.hypot(dx, dy)
if length < 0.01:
direction = (0.0, 1.0)
else:
direction = (dx / length, dy / length)
# 切入距離 T: 取相鄰兩段中較短的 × turn_margin
T = min(len_in, len_out) * turn_margin
path.append({
'seg': 'straight',
'x': waypoints[i][0], 'y': waypoints[i][1],
'dir': direction,
})
if T < 0.5:
# 段太短,不插彎,直接加一個平均方向點
avg_dx = d_in_x + d_out_x
avg_dy = d_in_y + d_out_y
avg_len = math.sqrt(avg_dx ** 2 + avg_dy ** 2)
if avg_len > 0.01:
avg_dx /= avg_len
avg_dy /= avg_len
else:
avg_dx, avg_dy = d_in_x, d_in_y
path.append((waypoints[i][0], waypoints[i][1], avg_dx, avg_dy))
continue
if 0 < i < num_wps - 1:
barrier_indices.append(len(path) - 1)
# P0 = pre_turn (弧線起始)
p0_x = waypoints[i][0] - d_in_x * T
p0_y = waypoints[i][1] - d_in_y * T
path[0]['s'] = 0.0
for i in range(1, len(path)):
prev = path[i - 1]
cur = path[i]
cur['s'] = prev['s'] + math.hypot(
cur['x'] - prev['x'], cur['y'] - prev['y']
)
# P1 = WP 本身 (控制點)
p1_x = waypoints[i][0]
p1_y = waypoints[i][1]
# P2 = post_turn (弧線結束)
p2_x = waypoints[i][0] + d_out_x * T
p2_y = waypoints[i][1] + d_out_y * T
# 加入 pre_turn 點 (方向 = incoming)
path.append((p0_x, p0_y, d_in_x, d_in_y))
# 加入 Bezier 插值點
for j in range(1, curve_resolution):
t = j / curve_resolution
# B(t) = (1-t)²·P0 + 2t(1-t)·P1 + t²·P2
one_minus_t = 1.0 - t
bx = one_minus_t * one_minus_t * p0_x + \
2 * t * one_minus_t * p1_x + \
t * t * p2_x
by = one_minus_t * one_minus_t * p0_y + \
2 * t * one_minus_t * p1_y + \
t * t * p2_y
# B'(t) = 2(1-t)(P1-P0) + 2t(P2-P1) → 切線方向
tdx = 2 * one_minus_t * (p1_x - p0_x) + 2 * t * (p2_x - p1_x)
tdy = 2 * one_minus_t * (p1_y - p0_y) + 2 * t * (p2_y - p1_y)
# 正規化
t_len = math.sqrt(tdx ** 2 + tdy ** 2)
if t_len > 0.01:
tdx /= t_len
tdy /= t_len
else:
tdx, tdy = d_in_x, d_in_y
path.append((bx, by, tdx, tdy))
# 加入 post_turn 點 (方向 = outgoing)
path.append((p2_x, p2_y, d_out_x, d_out_y))
# 最後一個航點
path.append((waypoints[-1][0], waypoints[-1][1],
segments[-1][0], segments[-1][1]))
return path
return path, barrier_indices
# ------------------------------------------------------------------ 柵狀掃描
@@ -474,7 +478,15 @@ class FormationPlanner:
all_waypoints[original_idx] = waypoints_list
return all_waypoints
# 每一條掃描線的「起點」都做一次同步:
# wp 0 = gather, wp 1 = 第一條線起點, wp 2 = 第一條線終點,
# wp 3 = 第二條線起點, wp 4 = 第二條線終點, ...
# 每條線的「起點 index」= 1, 3, 5, ... = 2*i + 1(i 從 0 開始)
# 所有 drone 的 waypoint 數量相同(num_lines 對所有 strip 都一致),
# 所以用同一份 rendezvous_indices
rendezvous_indices = [2 * i + 1 for i in range(num_lines)]
return all_waypoints, rendezvous_indices
# ================================================================================
@@ -492,12 +504,12 @@ if __name__ == "__main__":
target = (24.12400, 120.56795, 10.0)
# M-Formation
wps, origin = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION)
wps, origin, rv = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION)
print("M-Formation:")
for i, wp_list in enumerate(wps):
print(f" Drone {i}: {len(wp_list)} waypoints")
# Leader-Follower (Bezier 轉彎)
# Leader-Follower (虛擬領隊 / 弧長參數化)
route = [
(24.12345, 120.56780),
(24.12370, 120.56800),
@@ -505,18 +517,18 @@ if __name__ == "__main__":
(24.12400, 120.56800),
(24.12420, 120.56790),
]
wps_lf, origin_lf = planner.plan_formation_mission(
wps_lf, origin_lf, rv_lf = planner.plan_formation_mission(
drones, target, MissionType.LEADER_FOLLOWER,
params={
'route_waypoints': route,
'lateral_offset': 3.0,
'longitudinal_spacing': 5.0,
'turn_margin': 0.35,
'curve_resolution': 8,
'min_inner_radius': 3.0,
'arc_resolution': 8,
'altitude': 10.0
}
)
print(f"\nLeader-Follower (Bezier turns):")
print(f"\nLeader-Follower (arc-length virtual leader):")
for i, wp_list in enumerate(wps_lf):
print(f" Drone {i}: {len(wp_list)} waypoints")
for j, wp in enumerate(wp_list):
@@ -529,10 +541,10 @@ if __name__ == "__main__":
(24.1240, 120.5683),
(24.1240, 120.5679)
]
wps2, origin2 = planner.plan_formation_mission(
wps2, origin2, rv2 = planner.plan_formation_mission(
drones, target, MissionType.GRID_SWEEP,
params={'rect_corners': rect, 'line_spacing': 5.0, 'altitude': 10.0}
)
print(f"\nGrid Sweep:")
for i, wp_list in enumerate(wps2):
print(f" Drone {i}: {len(wp_list)} waypoints")
print(f" Drone {i}: {len(wp_list)} waypoints")
+44 -25
View File
@@ -6,29 +6,38 @@ class OverviewTable(QTableWidget):
"""總覽表格,顯示所有無人機的狀態資訊"""
# 默認的資訊類型和映射
DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "位置", "速度", "地速", "航向",
"空速", "油門", "HUD ALT", "爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"]
# 「任務狀態」放最上面:閉環跑點時一眼看每台的 WP 進度 + 通訊(送出)狀態。
# 此列不來自遙測 QLabel,由 MissionExecutor 訊號經 update_table(field='mission') 餵入。
DEFAULT_INFO_TYPES = ["任務狀態",
"模式", "ARM", "電壓", "經度", "緯度", "FixType", "衛星數", "EPH", "EPV",
"高度", "XY位置", "XY速度", "地速", "航向", "空速", "油門", "海拔高度",
"爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"]
DEFAULT_INFO_TYPE_MAP = {
"mode": 0,
"armed": 1,
"battery": 2,
"longitude": 3,
"latitude": 4,
"altitude": 5,
"local": 6,
"velocity": 7,
"groundspeed": 8,
"heading": 9,
"airspeed": 10,
"throttle": 11,
"hud_alt": 12,
"climb": 13,
"roll": 14,
"pitch": 15,
"yaw": 16,
"loss_rate": 17,
"ping": 18
"mission": 0,
"mode": 1,
"armed": 2,
"battery": 3,
"longitude": 4,
"latitude": 5,
"fix_type": 6,
"satellites_visible": 7,
"eph": 8,
"epv": 9,
"altitude": 10,
"local": 11,
"velocity": 12,
"groundspeed": 13,
"heading": 14,
"airspeed": 15,
"throttle": 16,
"hud_alt": 17,
"climb": 18,
"roll": 19,
"pitch": 20,
"yaw": 21,
"loss_rate": 22,
"ping": 23
}
def __init__(self, info_types=None, info_type_map=None, parent=None):
@@ -38,6 +47,8 @@ class OverviewTable(QTableWidget):
self.info_types = info_types if info_types is not None else self.DEFAULT_INFO_TYPES
self.info_type_map = info_type_map if info_type_map is not None else self.DEFAULT_INFO_TYPE_MAP
self.drones = {} # 存儲無人機面板的引用
# 任務狀態不是遙測,不在 panel QLabel 內;獨立保存,避免 refresh_all 用遙測把它蓋掉
self.mission_status = {} # drone_id -> 任務狀態文字
# 初始化表格
self.setColumnCount(1)
@@ -70,7 +81,11 @@ class OverviewTable(QTableWidget):
if drone_id and field and value:
if drone_id not in self.drones:
return
# 任務狀態另存一份,讓 refresh_all(用遙測重繪)不會把它清掉
if field == 'mission':
self.mission_status[drone_id] = value
col = 1 + list(self.drones.keys()).index(drone_id)
row = self.info_type_map.get(field, -1)
@@ -99,8 +114,12 @@ class OverviewTable(QTableWidget):
for col, did in enumerate(self.drones, start=1):
panel = self.drones[did]
for field, row in self.info_type_map.items():
lbl = panel.findChild(QLabel, f"{did}_{field}")
val = lbl.text() if lbl else "--"
if field == 'mission':
# 任務狀態非遙測,取自獨立保存,避免被 "-" 蓋掉
val = self.mission_status.get(did, "-")
else:
lbl = panel.findChild(QLabel, f"{did}_{field}")
val = lbl.text() if lbl else "-"
val_item = QTableWidgetItem(val)
val_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.setItem(row, col, val_item)
+2 -2
View File
@@ -210,7 +210,7 @@ def main():
target_gps = (target_lat, target_lon, BASE_ALTITUDE)
print(f" 目標點: ({target_lat:.6f}, {target_lon:.6f})")
waypoints_per_drone, origin = planner.plan_formation_mission(
waypoints_per_drone, origin, _ = planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.M_FORMATION
)
@@ -232,7 +232,7 @@ def main():
target_gps = (rect_center_lat, rect_center_lon, BASE_ALTITUDE)
print(f" 矩形中心: ({rect_center_lat:.6f}, {rect_center_lon:.6f})")
waypoints_per_drone, origin = planner.plan_formation_mission(
waypoints_per_drone, origin, _ = planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.GRID_SWEEP,
params={
'rect_corners': rect_corners,
+3 -3
View File
@@ -365,7 +365,7 @@ def visualize_standalone():
fig.suptitle('Mission Planner Verification (standalone)', fontsize=14, fontweight='bold')
# M-Formation
wps_m, origin_m = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION)
wps_m, origin_m, _ = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION)
conv_m = CoordinateConverter(origin_m[0], origin_m[1], 0)
data_m = {
'drone_ids': [f's0_{i + 1}' for i in range(len(drones))],
@@ -380,7 +380,7 @@ def visualize_standalone():
# Grid Sweep 5m
target_gs = (sum(c[0] for c in rect_corners) / 4,
sum(c[1] for c in rect_corners) / 4, 10.0)
wps_g, origin_g = planner.plan_formation_mission(
wps_g, origin_g, _ = planner.plan_formation_mission(
drones, target_gs, MissionType.GRID_SWEEP,
params={'rect_corners': rect_corners, 'line_spacing': 5.0, 'altitude': 10.0}
)
@@ -403,7 +403,7 @@ def visualize_standalone():
(24.12410, 120.56800),
(24.12420, 120.56790),
]
wps_lf, origin_lf = planner.plan_formation_mission(
wps_lf, origin_lf, _ = planner.plan_formation_mission(
drones, target, MissionType.LEADER_FOLLOWER,
params={'route_waypoints': route, 'lateral_offset': 3.0,
'longitudinal_spacing': 5.0, 'altitude': 10.0}
+155
View File
@@ -0,0 +1,155 @@
import asyncio
import websockets
import json
import sys
WS_URL = "ws://127.0.0.1:8765"
SYSTEM_ID = 2
GOTO_POSITION = [24.1234567, 120.1234567]
class WebSocketClient:
def __init__(self, ws_url=WS_URL, system_id=SYSTEM_ID):
self.ws_url = ws_url
if isinstance(system_id, bool) or not isinstance(system_id, int):
raise ValueError("system_id 必須為整數")
self.system_id = system_id
def process_websocket_message(self, data):
"""解析 server 的 Essential/Full UAV 狀態或命令 ACK。"""
if isinstance(data, str):
try:
data = json.loads(data)
except json.JSONDecodeError as e:
print(f"JSON 格式錯誤: {e}")
return
if not isinstance(data, dict):
print(f"未知訊息格式: {data}")
return
if all(key in data for key in ("result", "command", "message")):
print(
f"ACK | Command:{data.get('command', '-')} | "
f"Result:{data.get('result', '-')} | "
f"Message:{data.get('message', '-')}"
)
return
if "s" not in data:
print(f"未知 JSON 訊息: {data}")
return
position = data.get("p")
if isinstance(position, (list, tuple)) and len(position) >= 2:
position_text = f"{position[0]}, {position[1]}"
else:
position_text = "-"
ypr = data.get("ypr")
if isinstance(ypr, (list, tuple)) and len(ypr) >= 3:
yaw, pitch, roll = ypr[0], ypr[1], ypr[2]
else:
yaw, pitch, roll = data.get("y"), None, None
dop = data.get("d")
if isinstance(dop, (list, tuple)) and len(dop) >= 2:
dop_text = f"{dop[0]}/{dop[1]}"
else:
dop_text = "-"
armed = data.get("a")
if armed is None:
armed_text = "-"
else:
armed_text = "ARMED" if bool(armed) else "DISARMED"
def value_or_dash(value, suffix=""):
return f"{value}{suffix}" if value is not None else "-"
print(
f"UAV | ID:{data.get('s', '-')} | "
f"Mode:{value_or_dash(data.get('m'))} | "
f"Arm:{armed_text} | "
f"Battery:{value_or_dash(data.get('b'), '%')} | "
f"Position:{position_text} | "
f"Height:{value_or_dash(data.get('h'), 'm')} | "
f"Velocity:{value_or_dash(data.get('v'), 'm/s')} | "
f"Yaw:{value_or_dash(yaw, 'deg')} | "
f"Pitch:{value_or_dash(pitch, 'deg')} | "
f"Roll:{value_or_dash(roll, 'deg')} | "
f"GPS:{value_or_dash(data.get('g'))} | DOP(H/V):{dop_text}"
)
async def send_commands_loop(self, websocket):
"""從 stdin 讀取按鍵 (輸入後按 Enter) 並發送對應的 JSON 指令"""
loop = asyncio.get_event_loop()
print("輸入 1 = START, 2 = STOP, 3 = GOTO (然後按 Enter)")
while True:
# readline 會包含換行
try:
line = await loop.run_in_executor(None, sys.stdin.readline)
except Exception:
return
if not line:
# EOF
return
cmd = line.strip()
if cmd == '1':
msg = {"s": self.system_id, "c": "START"}
elif cmd == '2':
msg = {"s": self.system_id, "c": "STOP"}
elif cmd == '3':
msg = {
"s": self.system_id,
"c": "GOTO",
"p": list(GOTO_POSITION),
}
else:
print(f"未知指令: {cmd}")
continue
try:
await websocket.send(json.dumps(msg))
print(f"已發送: {msg}")
except Exception as e:
print(f"發送失敗: {e}")
return
async def ws_client_loop(self):
retry_count = 0
max_retries = 5
base_delay = 1.0
while retry_count < max_retries:
try:
async with websockets.connect(self.ws_url) as websocket:
print(f"WebSocket connected: {self.ws_url}")
retry_count = 0
# 啟動後台任務來接收 stdin 指令並發送
send_task = asyncio.create_task(self.send_commands_loop(websocket))
try:
async for message in websocket:
# message 已經是 string
self.process_websocket_message(message)
finally:
send_task.cancel()
await asyncio.gather(send_task, return_exceptions=True)
except websockets.exceptions.ConnectionClosedError:
print("WebSocket connection closed")
break
except Exception as e:
retry_count += 1
delay = base_delay * (2 ** min(retry_count, 4))
print(f"WebSocket connection error: {e}, retrying in {delay}s (attempt {retry_count}/{max_retries})")
await asyncio.sleep(delay)
print("WebSocket client stopped after maximum retries")
async def start(self):
"""啟動 WebSocket 客戶端"""
await self.ws_client_loop()
if __name__ == "__main__":
client = WebSocketClient()
asyncio.run(client.start())
+179
View File
@@ -0,0 +1,179 @@
import asyncio
import websockets
import json
from urllib.parse import urlparse
# 可切換為 "essential" 或 "full";預設廣播完整狀態。
STATUS_FORMAT = "full"
SYSTEM_ID = 2
# WebSocket 伺服器位址;修改此參數即可更換監聽 IP/port。
WS_URL = "ws://0.0.0.0:8765"
def parse_ws_url(ws_url):
"""將 ws://host:port 解析為 websockets.serve 所需參數。"""
parsed = urlparse(ws_url)
if parsed.scheme != "ws":
raise ValueError("WS_URL 必須使用 ws:// 格式")
if not parsed.hostname:
raise ValueError("WS_URL 缺少主機位址")
try:
port = parsed.port
except ValueError as e:
raise ValueError(f"WS_URL port 格式錯誤: {e}") from e
if port is None:
raise ValueError("WS_URL 缺少 port")
return parsed.hostname, port
# 啟動伺服器
async def main(ws_url=WS_URL):
host, port = parse_ws_url(ws_url)
async with websockets.serve(client_handler, host, port):
print(f"WebSocket server started at {ws_url}")
await asyncio.Future() # run forever
# 模擬資料
def get_uav_status(status_format=None):
status_format = status_format or STATUS_FORMAT
essential_status = {
"s": SYSTEM_ID, # system ID
"m": "GUIDED", # mode
"b": 78, # battery (%)
"p": [24.1199660, 120.677230], # [lat, lon] (deg)
"y": 178.5, # yaw (deg)
}
full_status = {
"s": SYSTEM_ID, # system ID
"m": "GUIDED", # mode
"a": 1, # 1=armed, 0=disarmed
"b": 78, # battery (%)
"h": 12.3, # height (m)
"v": 4.2, # velocity (m/s)
"p": [24.1199660, 120.677230], # [lat, lon] (deg)
"ypr": [178.5, -0.1, 0.0], # yaw, pitch, roll (deg)
"g": 3, # GPS fix type
"d": [0.8, 1.2], # HDOP, VDOP
}
if status_format == "essential":
return essential_status
if status_format == "full":
return full_status
raise ValueError(
f"不支援的 STATUS_FORMAT: {status_format},請使用 essential 或 full")
# 處理 client 傳來的訊息
async def handle_client_messages(websocket):
try:
async for message in websocket:
try:
data = json.loads(message)
print(f"Received from client: {data}")
system_id = data.get("s")
cmd = data.get("c")
# XBee 命令格式:
# {"s":2,"c":"START"}
# {"s":2,"c":"STOP"}
# {"s":2,"c":"GOTO","p":[lat,lon]}
if system_id is None:
ack = {
"result": "ERROR",
"command": cmd,
"message": "缺少 system_id 欄位 s",
}
await websocket.send(json.dumps(ack, ensure_ascii=False))
print(f"Sent ack: {ack}")
elif (isinstance(system_id, bool)
or not isinstance(system_id, int)
or system_id != SYSTEM_ID):
ack = {
"result": "ERROR",
"command": cmd,
"message": (
f"指令目標 system_id={system_id} 不符,"
f"本 UAV system_id={SYSTEM_ID}"
),
}
await websocket.send(json.dumps(ack, ensure_ascii=False))
print(f"Sent ack: {ack}")
elif isinstance(cmd, str) and cmd in {"START", "STOP", "GOTO"}:
if cmd == "START":
print(f"收到 START 指令: system_id={system_id}")
ack = {
"result": "SUCCESS",
"command": cmd,
"message": "已接收 START 指令",
}
elif cmd == "STOP":
print(f"收到 STOP 指令: system_id={system_id}")
ack = {
"result": "SUCCESS",
"command": cmd,
"message": "已接收 STOP 指令",
}
elif cmd == "GOTO":
position = data.get("p")
if isinstance(position, (list, tuple)) and len(position) >= 2:
lat, lon = position[0], position[1]
print(
f"收到 GOTO 指令: system_id={system_id}, "
f"lat={lat}, lon={lon}"
)
ack = {
"result": "SUCCESS",
"command": cmd,
"message": "已接收 GOTO 指令",
}
else:
print("GOTO 指令缺少位置 p=[lat,lon]")
ack = {
"result": "ERROR",
"command": cmd,
"message": "GOTO 指令缺少位置 p=[lat,lon]",
}
await websocket.send(json.dumps(ack, ensure_ascii=False))
print(f"Sent ack: {ack}")
else:
ack = {
"result": "ERROR",
"command": cmd,
"message": "指令 c 必須為大寫 START、STOP 或 GOTO",
}
await websocket.send(json.dumps(ack, ensure_ascii=False))
print(f"Sent ack: {ack}")
except json.JSONDecodeError:
print("❌ JSON 格式錯誤")
except websockets.ConnectionClosed:
print("❌ Client connection closed while receiving")
# 持續廣播 UAV 資訊
async def broadcast_uav_status(websocket):
try:
while True:
msg = get_uav_status()
await websocket.send(json.dumps(msg))
await asyncio.sleep(1) # 每秒發送一次
except websockets.ConnectionClosed:
print("❌ Client connection closed while sending")
# 每個 client 的處理流程
async def client_handler(websocket, path=None):
print(f"🔗 Client connected: {websocket.remote_address}")
receive_task = asyncio.create_task(handle_client_messages(websocket))
send_task = asyncio.create_task(broadcast_uav_status(websocket))
done, pending = await asyncio.wait(
[receive_task, send_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
print(f"🔌 Client disconnected: {websocket.remote_address}")
if __name__ == "__main__":
asyncio.run(main(WS_URL))
+127
View File
@@ -0,0 +1,127 @@
import serial
import json
import threading
import time
from pynput import keyboard
SYSTEM_ID = 2
try:
ser = serial.Serial('/dev/ttyUSB1', 115200, timeout=0.1)
print("XBee 地面站啟動")
print("[1] START [2] STOP [3] GOTO [Esc] 退出程式")
except Exception as e:
print(f"無法開啟序列埠: {e}")
exit()
GPS_TYPE_LABELS = {
0: "no GPS",
1: "no fix",
2: "2D",
3: "3D",
4: "DGPS",
5: "RTK float",
6: "RTK fixed",
}
def format_uav_data(uav_data):
system_id = uav_data.get("s", "?")
mode = uav_data.get("m", "?")
battery = uav_data.get("b", "?")
position = uav_data.get("p", "?")
parts = [
f"[UAV] ID:{system_id}",
f"Mode:{mode}",
f"Bat:{battery}%",
f"Pos:{position}",
]
if "a" in uav_data:
armed_text = "ARMED" if int(uav_data.get("a", 0)) == 1 else "DISARMED"
parts.append(f"Arm:{armed_text}")
ypr = uav_data.get("ypr")
if isinstance(ypr, list) and len(ypr) >= 3:
parts.append(f"Height:{uav_data.get('h', '?')}m")
parts.append(f"Yaw:{ypr[0]}deg")
parts.append(f"Pitch:{ypr[1]}deg")
parts.append(f"Roll:{ypr[2]}deg")
elif "y" in uav_data:
parts.append(f"Height:{uav_data.get('h', '?')}m")
parts.append(f"Yaw:{uav_data.get('y')}deg")
elif "h" in uav_data:
parts.append(f"Heading:{uav_data.get('h')}")
if "v" in uav_data:
parts.append(f"Vel:{uav_data.get('v')}m/s")
if "g" in uav_data:
gps_type = uav_data.get("g")
parts.append(f"GPS:{gps_type}({GPS_TYPE_LABELS.get(gps_type, 'unknown')})")
dop = uav_data.get("d")
if isinstance(dop, list) and len(dop) >= 2:
parts.append(f"DOP(H/V):{dop[0]}/{dop[1]}")
return " | ".join(parts)
def receive_thread():
while True:
try:
if ser.in_waiting > 0:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
try:
data = json.loads(line)
# 判斷是確認訊息(ack)還是 UAV 狀態
if "result" in data and "command" in data and "message" in data:
# 這是確認訊息
result = data.get("result")
cmd = data.get("command")
msg = data.get("message")
print(f"\n[確認] 命令:{cmd} 狀態:{result} 訊息:{msg}", flush=True)
else:
# 這是 UAV 狀態訊息
print(format_uav_data(data), flush=True)
except json.JSONDecodeError:
pass
except Exception as e:
print(f"\n[接收錯誤] {e}")
time.sleep(0.01)
t = threading.Thread(target=receive_thread, daemon=True)
t.start()
def send_command(command, position=None):
try:
if command not in {"START", "STOP", "GOTO"}:
raise ValueError("指令必須為大寫 START、STOP 或 GOTO")
cmd = {
"s": SYSTEM_ID,
"c": command
}
if position is not None:
cmd["p"] = position
payload = json.dumps(cmd, separators=(',', ':')) + "\n"
ser.write(payload.encode('utf-8'))
print(f"\n>> [指令已發送] {payload.strip()}")
except Exception as e:
print(f"\n發送失敗: {e}")
def on_press(key):
try:
if key.char == "1":
send_command("START")
elif key.char == "2":
send_command("STOP")
elif key.char == "3":
send_command("GOTO", [24.1199660, 120.677230])
except AttributeError:
if key == keyboard.Key.esc:
print("\n程式結束")
return False
with keyboard.Listener(on_press=on_press) as listener:
listener.join()
+141
View File
@@ -0,0 +1,141 @@
import serial
import json
import math
import time
PORT = '/dev/ttyUSB0' # 替換為實際的序列埠名稱
BAUD_RATE = 115200
SYSTEM_ID = 2
# 可切換為 "essential" 或 "full";預設發送完整狀態。
STATUS_FORMAT = "full"
try:
ser = serial.Serial(PORT, BAUD_RATE, timeout=0.1)
print("XBee 無人機啟動...")
except Exception as e:
print(f"無法開啟序列埠: {e}")
exit()
def get_uav_status(status_format=None):
status_format = status_format or STATUS_FORMAT
essential_status = {
"s": SYSTEM_ID, # system ID
"m": "GUIDED", # mode
"b": 78, # battery (%)
"p": [24.1199660, 120.677230], # [lat, lon] (deg)
"y": 178.5, # yaw (deg)
}
full_status = {
"s": SYSTEM_ID, # system ID
"m": "GUIDED", # mode
"a": 1, # 1=armed, 0=disarmed
"b": 78, # battery (%)
"h": 12.3, # height (m)
"v": 4.2, # velocity (m/s)
"p": [24.1199660, 120.677230], # position: [lat, lon] (deg)
"ypr": [178.5, -0.1, 0.0], # yaw, pitch, roll (deg)
"g": 3, # GPS type {0: no GPS, 1: no fix, 2: 2D, 3: 3D, 4: DGPS, 5: RTK float, 6: RTK fixed}
"d": [0.8, 1.2], # DOP: [hdop, vdop]
}
if status_format == "essential":
return essential_status
if status_format == "full":
return full_status
raise ValueError(
f"不支援的 STATUS_FORMAT: {status_format},請使用 essential 或 full")
def handle_command(cmd_obj):
# 統一指令格式: {"s": <int>, "c": "START|STOP|GOTO"}
target_s = cmd_obj.get("s")
command = cmd_obj.get("c")
ack = None
if (isinstance(target_s, bool)
or not isinstance(target_s, int)
or target_s != SYSTEM_ID):
ack = {
"result": "ERROR",
"command": command,
"message": (
f"指令目標 system_id={target_s} 不符,"
f"本 UAV system_id={SYSTEM_ID}"
),
}
elif not isinstance(command, str) or command not in {"START", "STOP", "GOTO"}:
print(f"\n[接收指令] 未知或格式錯誤的指令: {command}")
ack = {
"result": "ERROR",
"command": command,
"message": "指令 c 必須為大寫 START、STOP 或 GOTO",
}
elif command == "START":
print("\n[接收指令] 開始任務")
ack = {"result": "SUCCESS", "command": "START", "message": "成功"}
elif command == "STOP":
print("\n[接收指令] 停止任務")
ack = {"result": "SUCCESS", "command": "STOP", "message": "成功"}
elif command == "GOTO":
# 優先使用 XBee 格式 p=[lat, lon],並兼容舊版 lat/lon 欄位。
position = cmd_obj.get("p")
if isinstance(position, (list, tuple)) and len(position) >= 2:
lat, lon = position[0], position[1]
else:
lat, lon = cmd_obj.get("lat"), cmd_obj.get("lon")
try:
if isinstance(lat, bool) or isinstance(lon, bool):
raise ValueError
lat = float(lat)
lon = float(lon)
if not math.isfinite(lat) or not math.isfinite(lon):
raise ValueError
if not -90.0 <= lat <= 90.0 or not -180.0 <= lon <= 180.0:
raise ValueError
except (TypeError, ValueError):
print("\n[接收指令] GOTO 目標無效,需要有效的 p=[lat,lon]")
ack = {
"result": "ERROR",
"command": "GOTO",
"message": "GOTO 指令缺少或包含無效的 p=[lat,lon]",
}
else:
target = [lat, lon]
print(f"\n[接收指令] 導航任務 目標位置: {target}")
ack = {
"result": "SUCCESS",
"command": "GOTO",
"p": target,
"message": "成功",
}
# 發送回覆(ack)到 GCS via serial
if ack is not None:
try:
payload = json.dumps(ack, ensure_ascii=False) + "\n"
ser.write(payload.encode('utf-8'))
print(f"已發送回覆: {payload.strip()}")
except Exception as e:
print(f"發送回覆失敗: {e}")
try:
while True:
data = get_uav_status()
json_payload = json.dumps(data, separators=(',', ':')) + "\n"
ser.write(json_payload.encode('utf-8'))
print(f"發送狀態: {json_payload.strip()}")
for _ in range(10):
if ser.in_waiting > 0:
try:
cmd_line = ser.readline().decode('utf-8').strip()
if cmd_line:
cmd_obj = json.loads(cmd_line)
handle_command(cmd_obj)
except json.JSONDecodeError:
pass
time.sleep(0.1)
except KeyboardInterrupt:
print("發送停止")
finally:
ser.close()
+9
View File
@@ -8,10 +8,19 @@ endif()
# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)
find_package(builtin_interfaces REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/AttitudeRaw.msg"
"msg/GnssRaw.msg"
"msg/SystemDiagnosticsRaw.msg"
"msg/FcNetworkLog.msg"
"msg/ServiceAckResult.msg"
"srv/MavPing.srv"
"srv/MavCommandLong.srv"
"srv/MavPositionTargetGlobalInt.srv"
DEPENDENCIES std_msgs builtin_interfaces
)
ament_package()
+9
View File
@@ -0,0 +1,9 @@
uint32 seq
builtin_interfaces/Time stamp
string frame_id
float32 roll
float32 pitch
float32 yaw
float32 rollspeed
float32 pitchspeed
float32 yawspeed
+12
View File
@@ -0,0 +1,12 @@
uint8 DEBUG=10
uint8 INFO=20
uint8 WARN=30
uint8 ERROR=40
uint8 FATAL=50
builtin_interfaces/Time stamp
uint8 level
string source
string event_code
int32 sysid
string message
+8
View File
@@ -0,0 +1,8 @@
builtin_interfaces/Time stamp
float64 latitude
float64 longitude
float64 altitude
uint8 fix_type
uint8 satellites_visible
float32 eph
float32 epv
@@ -0,0 +1,43 @@
# ServiceAckResult.msg
# ACK code definition for command-like service responses.
# 這邊只是定義常數的檔案
#
# Design:
# - 0~10 : MAVLink MAV_RESULT compatible range (autopilot/firmware ACK) 大多不會用到 直接吃飛控回傳值
# - 50~89 : System-level ACK codes (local bridge/orchestrator status)
# - 90~99 : Unknown/reserved fallback
#
# Rule:
# - If FC ACK is received, return MAV_RESULT_* code.
# - If FC ACK is not reachable / local pipeline failed, return SYS_* code.
# ---- MAVLink MAV_RESULT (standard) ---- 詳細請自行去網路找 mavlink MAV_RESULT
uint8 MAV_RESULT_ACCEPTED=0 # 封包正常接受並執行
uint8 MAV_RESULT_TEMPORARILY_REJECTED=1 # 封包正常接受 但是飛控很忙 不執行 需要等待後重試
uint8 MAV_RESULT_DENIED=2 # 封包不正常 參數可能給錯了
uint8 MAV_RESULT_UNSUPPORTED=3 # 封包不正常 根本認不出是啥
uint8 MAV_RESULT_FAILED=4 # 封包正常接受 但是載具的狀態異常 不執行
uint8 MAV_RESULT_IN_PROGRESS=5
uint8 MAV_RESULT_CANCELLED=6
uint8 MAV_RESULT_COMMAND_LONG_ONLY=7
uint8 MAV_RESULT_COMMAND_INT_ONLY=8
uint8 MAV_RESULT_COMMAND_UNSUPPORTED_MAV_FRAME=9
uint8 MAV_RESULT_NOT_IN_CONTROL=10
# ---- System-level result codes (custom) ----
# 50 : 有接到預期的封包 但是訊息建議再確認
# 51 : some messages will not have obviously ack, so monitering feature message. however, we do NOT get the feature message. ex. 85/86 pair
#
# 52 : mavlink communication timeout 可能是通訊丟包了
# 53 : there already has a command being execution for this sysid, try later
# 54 : this sysid is not found
uint8 FCSYS_GET_EXPECTED_FEEDBACK=50
uint8 FCSYS_NO_EXPECTED_FEEDBACK=51
uint8 FCSYS_MAVLINK_TIMEOUT=52
uint8 FCSYS_BUSY=53
uint8 FCSYS_DEVICE_NOT_FOUND=54
# ---- Fallback ----
uint8 FCSYS_UNKNOWN=90
@@ -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
+5
View File
@@ -15,6 +15,11 @@
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>
<build_depend>std_msgs</build_depend>
<exec_depend>std_msgs</exec_depend>
<build_depend>builtin_interfaces</build_depend>
<exec_depend>builtin_interfaces</exec_depend>
<export>
<build_type>ament_cmake</build_type>
</export>
@@ -0,0 +1,36 @@
# Request
uint8 target_sysid
uint8 target_compid
uint32 time_boot_ms # ms since system boot, 一般用 0 讓飛控自己填
uint8 coordinate_frame # MAV_FRAME_*
uint16 type_mask # POSITION_TARGET_TYPEMASK bitmask
int32 lat_int # 1e7 * latitude (deg)
int32 lon_int # 1e7 * longitude (deg)
float32 alt # altitude (m)
float32 vx # X velocity (m/s)
float32 vy # Y velocity (m/s)
float32 vz # Z velocity (m/s)
float32 afx # X acceleration or force (m/s^2)
float32 afy # Y acceleration or force (m/s^2)
float32 afz # Z acceleration or force (m/s^2)
float32 yaw # yaw (rad)
float32 yaw_rate # yaw rate (rad/s)
float32 timeout_sec # 等待 POSITION_TARGET_GLOBAL_INT 回應的時間上限
---
# Response
string message
uint8 ack_result
uint32 r_time_boot_ms
uint16 r_type_mask
int32 r_lat_int
int32 r_lon_int
float32 r_alt
float32 r_vx
float32 r_vy
float32 r_vz
float32 r_afx
float32 r_afy
float32 r_afz
float32 r_yaw
float32 r_yaw_rate
@@ -6,6 +6,7 @@
# 開發此專案的注意事項
- 預設 autopilot 的 component id = 1
- 不允許 system id 重複
- 預設同一載具僅會使用相同的 socket
- 增加一個固定數值監控然後要到 ros2 topic
- mavlinkROS2Node.py 檔案內
- PublishRateController.topic_intervals 建立
@@ -36,6 +37,7 @@
- *self.bridge* 存放 mavlink_bridge 實例
- *self.plumber* 存放 serial_manager 實例
- *self.vehicle_registry* 存放 vehicle_registry 實例
- *self.fc_ros_manager* 存放 fc_ros_manager 實例
- *self.panel_thread* 面板的執行緒
- *self.panelState* 暫存面板與調配者互動的資料流動區
@@ -126,6 +128,7 @@
- *cls.mavlinkObjects* 資料結構 { socket_id(序號) : mavlink_object(物件實例) }
- *self.mavlink_socket* 從 pymavlink 繼承的socket物件
- *self.state* 描述這個 socket 物件的狀態
- *self.MAVLink* pymavlink 的一個裝置物件 模擬自己是某一個裝置 用以對於該 mavlink bus 負責封包發送
---
- *process_data()* [async method] 核心方法
- *remove_target_socket()* *add_target_socket()*
@@ -170,6 +173,61 @@
## mavlinkVehicleView.py
這個檔案是作為載具的資訊暫存庫使用 會搭配 ROS2 的功能 再做利用
## mavlinkROS2Nodes.py
這裡支持了所有 ROS2 框架的對應功能 包涵
1. 將載具的串流更新的狀態更新到對應的 topic
2. 將地面站與載具互動 mavlink microservice 包裝起來提供 ros2 service 互動介面
3. 訂閱外部 RTCM 資料並轉發為 MAVLink GPS_RTCM_DATA 給所有載具
### **[Class]** fc_ros_manager
MAVLink ROS2 節點管理器 管理三個獨立的 Node
會開一個執行緒 讓三個 Node 都跑在裡面
然後用 spin_once 保持 Node 的活性
- *self.status_publisher* 物件實例
- *self.command_service* 物件實例
- *self.rtcm_relay* 物件實例
- *self.spin_thread* 執行緒
---
- *start()* 啟動自己
- *stop()* 停下自己
- *shutdown()* 關閉自己並清理
### **[Class]** VehicleStatusPublisher
這整個組件都是自動的 目前沒有什麼需要 runtime 設定的地方
- *self.fc_publishers* 儲存所有 publisher 的資料結構
- *self.rate_controller* 儲存頻率參數的地方 也可以關掉某個 topic 的發佈
---
- *timer_callback()* 自動的抓出現有的 vehicle 中有的資訊 然後固定的將訊息丟到負責發佈的方法中
- *_publish_vehicle_status()* 接 timer_callback 後去分配到各項發佈中
- *_get_or_create_publisher()* 實際創建 topic 的地方
- *_publish_XXX()* 各項發佈的方法
### **[Class]** MavlinkCommandService
提供 ros2 service 讓 serial_object 去丟出 mavlink 封包
然後會從回應封包中挑出期待的回應 再傳給 ros2 request
其中 PendingEntry 是很關鍵的東西
它的每個物件 代表需要等待的封包種類與必需包含的內容
- *self._pending_by_sysid* 儲存 PendingEntry 的地方
---
- *return_router()* 檢查並消耗 return_packet_ring 然後將封包分配到 pending 去
- *__init__()* 這邊登入要創建的 service 到 ros2 系統
- *handle_XXX()* 這邊是實現 service 的具體方法
### **[Class]** RtcmRelay
訂閱 /fc_network/rtcm_in (mavros_msgs/msg/RTCM) 並轉發為 MAVLink GPS_RTCM_DATA
topic 的 QoS:BEST_EFFORT / KEEP_LAST depth=1 / VOLATILE(低延遲優先,丟舊包不排隊)
處理順序:過期丟棄(1s) → blake2s hash 去重 → 最短轉發間隔節流 → 分片轉發
分片:GPS_RTCM_DATA 每片 180 bytes,超過自動分片,最多 4 片 (720 bytes),超長直接丟棄
失敗語意:不做舊包重送,送失敗就放棄等下一包
- *self.counters* 觀測用計數器 (received/forwarded/dropped_stale/dropped_duplicate/dropped_throttle/dropped_oversize/no_route/send_fail)
- *self.clock_offset_sec* 與 RTCM 來源的時鐘偏移量(秒)
---
- *_rtcm_callback()* 收到 RTCM 封包時的處理流程
- *_send_rtcm()* 對單一載具編碼並送出 GPS_RTCM_DATA(含分片邏輯)
- *set_clock_offset()* 設定時鐘偏移量
- *get_counters()* 取得觀測計數器
# 開發記錄
## 已實現功能
@@ -182,9 +240,16 @@
7. 終端機介面控制
8. 基礎載具流量觀測
9. 載具狀態收集與彙整
10. a. ros2 topic 應用開發介面
10. a. ros2 topic 應用開發介面 (基礎框架)
- GNSS (alt lan fix_type epv eph)
- attitude position_ned (pitch yaw row local_position)
- battery
b. ros2 service 應用開發介面 (基礎框架)
c. RTCM 轉發 - 訂閱 RTCM topic 轉為 MAVLink GPS_RTCM_DATA 廣播給所有載具
11. ros2 topic service rtcm Node 重啟功能
12. 可以定義 ROS_DOMAIN_ID
### 待開發功能
5-1. 建立 serial 連線 並可以對接收器下達AT指令
5-2. 模組化 serial 連線機制 以利後期擴容其他模組
10. a. ros2 應用開發介面
5-2. 模組化 serial 連線機制 以利後期擴容其他模組
File diff suppressed because it is too large Load Diff
@@ -28,9 +28,6 @@ mavlink_bridge:
專門處理 stream_bridge_ring 裡面的訊息流
會把訊息流解開後 存放到 mavlinkVehicleView.py 定義的載具結構視圖
'''
# 基礎功能的 import
@@ -50,17 +47,19 @@ from pymavlink.dialects.v20 import ardupilotmega as mav_ardupilot
# 自定義的 import
from .mavlinkVehicleView import (
vehicle_registry,
VehicleView,
vehicle_registry, # 儲存全部物件的地方
VehicleView, # 代表每台載具的最外層
VehicleComponent,
ComponentType,
ConnectionType
StatusTextEntry,
)
from .utils import RingBuffer, setup_logger
# ====================== 分割線 =====================
logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "1.50"
stream_bridge_ring = RingBuffer(capacity=1024, buffer_id=255)
return_packet_ring = RingBuffer(capacity=256, buffer_id=254)
@@ -109,12 +108,15 @@ class mavlink_bridge:
def _init_message_handlers(self):
"""初始化訊息處理器映射表,提高處理效率"""
self.message_handlers = {
0: self._handle_heartbeat, # HEARTBEAT
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,30 @@ 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)"""
component.status.position.latitude = msg.lat / 1e7 # 轉換為度
@@ -239,6 +265,14 @@ class mavlink_bridge:
component.status.position.relative_altitude = msg.relative_alt / 1000.0 # 轉換為公尺
component.status.position.timestamp = timestamp
def _handle_gps_raw_int(self, vehicle, component, msg, timestamp):
"""處理 GPS_RAW_INT 訊息 (msg_id: 24)"""
component.status.gps.fix_type = msg.fix_type
component.status.gps.satellites_visible = msg.satellites_visible
component.status.gps.eph = None if msg.eph == 65535 else msg.eph
component.status.gps.epv = None if msg.epv == 65535 else msg.epv
component.status.gps.timestamp = timestamp
def _handle_vfr_hud(self, vehicle, component, msg, timestamp):
"""處理 VFR_HUD 訊息 (msg_id: 74)"""
component.status.vfr.airspeed = msg.airspeed
@@ -386,7 +420,9 @@ class mavlink_object:
self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191)
# 記錄訊息過濾類型 (可選)
self.bridge_msg_types = set([0, 30, 33, 74, 147]) # 0 HEARTBEAT, 30 ATTITUDE, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS
# 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 作為目標端口 的列表
@@ -823,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 訊息的處理機制
'''
File diff suppressed because it is too large Load Diff
@@ -5,14 +5,15 @@ VehicleView - Pure State Container
"""
import os
from typing import Dict, Optional, Any
from collections import deque
from typing import Dict, Optional, Any, Tuple
from dataclasses import dataclass, field
from enum import Enum
from .utils import setup_logger
logger = setup_logger(os.path.basename(__file__))
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
@@ -160,6 +187,7 @@ class RFStatus:
@dataclass
class SocketInfo:
"""Socket連接資訊"""
src64_addr: Optional[bytes] = None # 模組的物理定址
ip: Optional[str] = None # IP位址
port: Optional[int] = None # 埠號
local_ip: Optional[str] = None # 本地IP
@@ -283,6 +311,7 @@ class RFModule:
class VehicleView:
"""
最上層
載具視圖 - 純狀態容器
特點:
@@ -402,11 +431,18 @@ class VehicleViewRegistry:
def __init__(self):
self._vehicles: Dict[int, VehicleView] = {}
# 高頻讀取用 snapshot(弱一致性)
self._vehicles_snapshot_items: Tuple[Tuple[int, VehicleView], ...] = tuple()
def _refresh_snapshot(self) -> None:
"""重建快照(僅在 key 集合變動時觸發)"""
self._vehicles_snapshot_items = tuple(self._vehicles.items())
def register(self, sysid: int) -> VehicleView:
"""註冊新的載具視圖"""
if sysid not in self._vehicles:
self._vehicles[sysid] = VehicleView(sysid)
self._refresh_snapshot()
logger.info(f"Registered new VehicleView for system {sysid}")
return self._vehicles[sysid]
@@ -418,6 +454,7 @@ class VehicleViewRegistry:
"""註銷載具視圖"""
if sysid in self._vehicles:
del self._vehicles[sysid]
self._refresh_snapshot()
logger.info(f"Unregistered VehicleView for system {sysid}")
return True
return False
@@ -425,10 +462,22 @@ class VehicleViewRegistry:
def get_all(self) -> Dict[int, VehicleView]:
"""取得所有載具視圖"""
return self._vehicles.copy()
def read_all(self) -> Tuple[Tuple[int, VehicleView], ...]:
"""
高效讀取所有載具視圖快照(弱一致性)
注意:
- 回傳 immutable tuple 快照,適合高頻迭代
- 僅在 register/unregister/clear 後更新,可能不是最新資料
- 適合高頻讀取且可接受弱一致性的情境
"""
return self._vehicles_snapshot_items
def clear(self) -> None:
"""清空所有載具視圖"""
self._vehicles.clear()
self._refresh_snapshot()
logger.info("Cleared all VehicleViews")
def __len__(self) -> int:
@@ -449,5 +498,8 @@ vehicle_registry = VehicleViewRegistry()
2026.01.16
1. 新增 重置指定組件的封包統計 功能
2026.04.13
1. 新增 VehicleViewRegistry.read_all 方法 讓 ROS2 Node 組件更有效率的讀取其中的資料
'''
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,6 @@
共用工具模組
"""
from .ringBuffer import RingBuffer
from .theLogger import setup_logger
from .theLogger import get_ros_log_queue, setup_logger
__all__ = ['RingBuffer', 'setup_logger']
__all__ = ['RingBuffer', 'get_ros_log_queue', 'setup_logger']
@@ -0,0 +1,48 @@
"""
POLL 輪詢策略,供 XBeeFrameProcessor_ESPv1 使用。
不依賴 serialManager,僅接受裝置快照與排程狀態。
"""
from dataclasses import dataclass
from typing import List
@dataclass(frozen=True)
class PollDevice:
"""從 esp32AddrMapping 抽出的唯讀快照。"""
address_64: bytes
# system_id: int
remain_bytes: int
last_done_time: float
@dataclass
class PollSchedulerState:
"""每條 serial link 一份,保存 round-robin 索引。"""
round_robin_index: int = 0
def pick_next(
devices: List[PollDevice],
scheduler_state: PollSchedulerState
):
"""
選下一個 POLL 目標。
回傳 (target_address_64, grant_bytes);devices 為空時回傳 (None, 0)。
"""
if not devices:
return None, 0
device_count = len(devices)
selected_index = scheduler_state.round_robin_index % device_count
selected_device = devices[selected_index]
scheduler_state.round_robin_index += 1
grant_bytes = 0
if selected_device.remain_bytes > 0:
grant_bytes = min( 65535, max(0, selected_device.remain_bytes))
return selected_device.address_64, grant_bytes
@@ -1,9 +1,47 @@
import logging
import os
import queue
from logging.handlers import TimedRotatingFileHandler
# 全域 Logger 實例
_global_logger = None
_ros_log_queue = queue.Queue(maxsize=2000)
class RosTopicQueueHandler(logging.Handler):
"""將 FC Network log record 非阻塞地排入 ROS publisher 佇列。"""
def emit(self, record: logging.LogRecord) -> None:
try:
sysid = int(getattr(record, 'sysid', -1))
except (TypeError, ValueError):
sysid = -1
item = {
'created': float(record.created),
'level': int(record.levelno),
'source': str(record.name),
'event_code': str(getattr(record, 'event_code', '')),
'sysid': sysid,
'message': record.getMessage(),
}
try:
_ros_log_queue.put_nowait(item)
except queue.Full:
# GUI 不應反過來拖慢通訊;滿載時淘汰最舊一筆。
try:
_ros_log_queue.get_nowait()
except queue.Empty:
return
try:
_ros_log_queue.put_nowait(item)
except queue.Full:
pass
def get_ros_log_queue():
"""供 ROS publisher node 取得共用、thread-safe 的紀錄佇列。"""
return _ros_log_queue
def setup_logger(name: str, log_dir: str = "logs", level=logging.DEBUG) -> logging.Logger:
global _global_logger
@@ -37,6 +75,10 @@ def setup_logger(name: str, log_dir: str = "logs", level=logging.DEBUG) -> loggi
console_handler.setFormatter(formatter)
_global_logger.addHandler(console_handler)
ros_queue_handler = RosTopicQueueHandler()
ros_queue_handler.setLevel(logging.INFO)
_global_logger.addHandler(ros_queue_handler)
# 為每個模組建立子 Logger,並設定名稱
module_logger = _global_logger.getChild(name)
module_logger.name = name # 修改子 Logger 的名稱,僅保留子 Logger 名稱
+7
View File
@@ -7,6 +7,13 @@
<maintainer email="chiyu1468@hotmail.com">picars</maintainer>
<license>TODO: License declaration</license>
<exec_depend>fc_interfaces</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>nav_msgs</exec_depend>
<exec_depend>mavros_msgs</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
@@ -17,6 +17,7 @@ import std_msgs.msg
import sensor_msgs.msg
import geometry_msgs.msg
import mavros_msgs.msg
import fc_interfaces.msg
# 專案 imports
from ..fc_network_adapter.mavlinkROS2Nodes import (
@@ -39,7 +40,7 @@ class TestSubscriber(Node):
self.sysid = sysid
self.received_messages = {
'position': [],
'position_gnss': [],
'attitude': [],
'velocity': [],
'battery': [],
@@ -60,9 +61,9 @@ class TestSubscriber(Node):
# Position
self.create_subscription(
sensor_msgs.msg.NavSatFix,
f'{base_topic}/position',
lambda msg: self._on_message('position', msg),
fc_interfaces.msg.GnssRaw,
f'{base_topic}/position_gnss',
lambda msg: self._on_message('position_gnss', msg),
10
)
@@ -121,7 +122,7 @@ class TestSubscriber(Node):
def _format_msg(self, topic_name: str, msg) -> str:
"""格式化消息以便顯示"""
if topic_name == 'position':
if topic_name == 'position_gnss':
return f"lat={msg.latitude:.6f}, lon={msg.longitude:.6f}, alt={msg.altitude:.2f}m"
elif topic_name == 'attitude':
return f"quat=({msg.orientation.x:.3f}, {msg.orientation.y:.3f}, {msg.orientation.z:.3f}, {msg.orientation.w:.3f})"
@@ -264,7 +265,7 @@ def test_basic_publishing():
# 檢查收到的消息
print("\n--- 消息統計 ---")
total_messages = 0
for topic in ['position', 'attitude', 'velocity', 'battery', 'vfr_hud', 'mode', 'summary']:
for topic in ['position_gnss', 'attitude', 'velocity', 'battery', 'vfr_hud', 'mode', 'summary']:
count = test_sub.get_message_count(topic)
total_messages += count
print(f" {topic:15s}: {count:3d} 條消息")
@@ -304,7 +305,7 @@ def test_frequency_control():
# 修改頻率設定
publisher_node = ros2_manager.status_publisher
publisher_node.rate_controller.topic_intervals = {
'position': 1.5,
'position_gnss': 1.5,
'attitude': 1.0,
'velocity': 1.0,
'battery': 1.0,
@@ -329,7 +330,7 @@ def test_frequency_control():
print("預期:position 約 2 條 (0.67Hz),attitude/battery/velocity 約 3 條 (1Hz),vfr_hud 約 6 條 (2Hz) mode/summary 不發布")
print("2Hz Topics (預期 ~6 條):")
for topic in ['position', 'attitude', 'velocity', 'vfr_hud']:
for topic in ['position_gnss', 'attitude', 'velocity', 'vfr_hud']:
count = test_sub.get_message_count(topic)
print(f" {topic:15s}: {count:3d} 條")
for topic in ['battery', 'mode', 'summary']:
+6 -10
View File
@@ -1,14 +1,10 @@
from .longCommand import CommandLongClient, ChangeModeResult
from .changeMode import change_mode
from .arm_disarm import arm_disarm
from .takeoff import takeoff
from .land import land
from .longCommand import CommandLongClient, CommandLongResult
from .navigation import PositionTargetGlobalIntClient, PositionTargetGlobalIntResult
__all__ = [
"CommandLongClient",
"ChangeModeResult",
"change_mode",
"arm_disarm",
"takeoff",
"land",
"PositionTargetGlobalIntClient",
"CommandLongResult",
"PositionTargetGlobalIntResult",
]
-93
View File
@@ -1,93 +0,0 @@
"""Simple wrapper for arm/disarm via fc_network ROS2 service (MAV_CMD_COMPONENT_ARM_DISARM)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import rclpy
from rclpy.node import Node
from fc_interfaces.srv import MavCommandLong
COMMAND_COMPONENT_ARM_DISARM = 400
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
DEFAULT_TIMEOUT_SEC = 2.0
@dataclass
class ArmDisarmResult:
success: bool
message: str
ack_result: int
def arm_disarm(
*,
target_sysid: int,
arm: bool,
target_compid: int = 0,
confirmation: int = 0,
param2: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
service_name: str = DEFAULT_SERVICE_NAME,
) -> ArmDisarmResult:
"""One-shot MAV_CMD_COMPONENT_ARM_DISARM (400) wrapper.
param1: 1.0 to arm, 0.0 to disarm.
param2: usually 0. Some stacks use 21196 for force-arm (ArduPilot); pass via param2 if needed.
"""
rclpy.init(args=None)
node: Optional[Node] = None
try:
node = Node("fc_arm_disarm_client_once")
client = node.create_client(MavCommandLong, service_name)
if not client.wait_for_service(timeout_sec=timeout_sec):
return ArmDisarmResult(
success=False,
message=f"Service not available: {service_name}",
ack_result=-1,
)
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = COMMAND_COMPONENT_ARM_DISARM
req.confirmation = confirmation
req.param1 = 1.0 if arm else 0.0
req.param2 = float(param2)
req.param3 = 0.0
req.param4 = 0.0
req.param5 = 0.0
req.param6 = 0.0
req.param7 = 0.0
req.timeout_sec = float(timeout_sec)
future = client.call_async(req)
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
if not future.done() or future.result() is None:
return ArmDisarmResult(
success=False,
message="Service call timeout or no response.",
ack_result=-1,
)
response = future.result()
return ArmDisarmResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
finally:
if node is not None:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
r = arm_disarm(target_sysid=3, arm=True)
print(
f"arm_disarm success={r.success}, "
f"ack_result={r.ack_result}, message='{r.message}'"
)
-99
View File
@@ -1,99 +0,0 @@
"""Simple wrapper for mode change via fc_network ROS2 service.
Reference CLI command:
ros2 service call /fc_network/vehicle/send_command_long \
fc_interfaces/srv/MavCommandLong \
"{target_sysid: 3, target_compid: 0, command: 176, confirmation: 0, \
param1: 1, param2: 4, param3: 0, param4: 0, param5: 0, param6: 0, \
param7: 0, timeout_sec: 2}"
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import rclpy
from rclpy.node import Node
from fc_interfaces.srv import MavCommandLong
COMMAND_DO_SET_MODE = 176
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
DEFAULT_TIMEOUT_SEC = 2.0
@dataclass
class ChangeModeResult:
"""Return value for mode change requests."""
success: bool
message: str
ack_result: int
def change_mode(
*,
target_sysid: int,
custom_mode: float,
target_compid: int = 0,
base_mode: float = 1.0,
confirmation: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
service_name: str = DEFAULT_SERVICE_NAME,
) -> ChangeModeResult:
"""One-shot helper for collaborators who want minimal code."""
rclpy.init(args=None)
node: Optional[Node] = None
try:
node = Node("fc_change_mode_client_once")
client = node.create_client(MavCommandLong, service_name)
if not client.wait_for_service(timeout_sec=timeout_sec):
return ChangeModeResult(
success=False,
message=f"Service not available: {service_name}",
ack_result=-1,
)
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = COMMAND_DO_SET_MODE
req.confirmation = confirmation
req.param1 = float(base_mode)
req.param2 = float(custom_mode)
req.param3 = 0.0
req.param4 = 0.0
req.param5 = 0.0
req.param6 = 0.0
req.param7 = 0.0
req.timeout_sec = float(timeout_sec)
future = client.call_async(req)
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
if not future.done() or future.result() is None:
return ChangeModeResult(
success=False,
message="Service call timeout or no response.",
ack_result=-1,
)
response = future.result()
return ChangeModeResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
finally:
if node is not None:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
# Example values are aligned with your terminal command.
result = change_mode(target_sysid=3, custom_mode=4)
print(
f"change_mode success={result.success}, "
f"ack_result={result.ack_result}, message='{result.message}'"
)
-90
View File
@@ -1,90 +0,0 @@
"""Simple wrapper for land via fc_network ROS2 service."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import rclpy
from rclpy.node import Node
from fc_interfaces.srv import MavCommandLong
COMMAND_NAV_LAND = 21
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
DEFAULT_TIMEOUT_SEC = 2.0
@dataclass
class LandResult:
success: bool
message: str
ack_result: int
def land(
*,
target_sysid: int,
target_compid: int = 0,
yaw_deg: float = 0.0,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
altitude_m: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
service_name: str = DEFAULT_SERVICE_NAME,
) -> LandResult:
"""One-shot MAV_CMD_NAV_LAND wrapper."""
rclpy.init(args=None)
node: Optional[Node] = None
try:
node = Node("fc_land_client_once")
client = node.create_client(MavCommandLong, service_name)
if not client.wait_for_service(timeout_sec=timeout_sec):
return LandResult(
success=False,
message=f"Service not available: {service_name}",
ack_result=-1,
)
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = COMMAND_NAV_LAND
req.confirmation = 0
req.param1 = 0.0
req.param2 = 0.0
req.param3 = 0.0
req.param4 = float(yaw_deg)
req.param5 = float(latitude) if latitude is not None else 0.0
req.param6 = float(longitude) if longitude is not None else 0.0
req.param7 = float(altitude_m)
req.timeout_sec = float(timeout_sec)
future = client.call_async(req)
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
if not future.done() or future.result() is None:
return LandResult(
success=False,
message="Service call timeout or no response.",
ack_result=-1,
)
response = future.result()
return LandResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
finally:
if node is not None:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
result = land(target_sysid=3)
print(
f"land success={result.success}, "
f"ack_result={result.ack_result}, message='{result.message}'"
)
+382 -30
View File
@@ -1,8 +1,10 @@
"""ROS2 client for fc_network MavCommandLong service (COMMAND_LONG 系列指令)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import asyncio
import rclpy
from rclpy.node import Node
@@ -10,30 +12,83 @@ from rclpy.node import Node
from fc_interfaces.srv import MavCommandLong
COMMAND_DO_SET_MODE = 176
COMMAND_NAV_LAND = 21
COMMAND_NAV_TAKEOFF = 22
COMMAND_PREFLIGHT_REBOOT_SHUTDOWN = 246
COMMAND_COMPONENT_ARM_DISARM = 400
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
DEFAULT_TIMEOUT_SEC = 2.0
@dataclass
class ChangeModeResult:
"""Return value for mode change requests."""
class CommandLongResult:
success: bool
message: str
ack_result: int
class CommandLongClient(Node):
"""Small ROS2 client dedicated to change flight mode."""
"""ROS2 client : 對同一個 send_command_long service 發送各種 MAV_CMD_*。"""
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME) -> None:
rclpy.init()
super().__init__("fc_change_mode_client")
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME, node_name: str = None) -> None:
if not rclpy.ok():
rclpy.init(args=None)
# 使用提供的 node_name,或使用帶時間戳的預設名稱以避免名稱衝突
if node_name is None:
import time
node_name = f"fc_command_long_client_{int(time.time() * 1000) % 100000}"
super().__init__(node_name)
self._client = self.create_client(MavCommandLong, service_name)
def wait_for_service(self, timeout_sec: float = 3.0) -> bool:
return self._client.wait_for_service(timeout_sec=timeout_sec)
def _send_command_long(
self,
*,
target_sysid: int,
target_compid: int,
command: int,
confirmation: int,
param1: float,
param2: float,
param3: float,
param4: float,
param5: float,
param6: float,
param7: float,
timeout_sec: float,
) -> CommandLongResult:
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = command
req.confirmation = confirmation
req.param1 = param1
req.param2 = param2
req.param3 = param3
req.param4 = param4
req.param5 = param5
req.param6 = param6
req.param7 = param7
req.timeout_sec = float(timeout_sec)
future = self._client.call_async(req)
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec + 1.0)
if not future.done() or future.result() is None:
return CommandLongResult(
success=False,
message="Service timeout.",
ack_result=-1,
)
response = future.result()
return CommandLongResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
def change_mode(
self,
*,
@@ -42,34 +97,331 @@ class CommandLongClient(Node):
target_compid: int = 0,
base_mode: float = 1.0,
confirmation: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC ) -> ChangeModeResult:
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_DO_SET_MODE,
confirmation=confirmation,
param1=float(base_mode),
param2=float(custom_mode),
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
def takeoff(
self,
*,
target_sysid: int,
altitude_m: float,
target_compid: int = 0,
min_pitch_deg: float = 0.0,
yaw_deg: float = 0.0,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_NAV_TAKEOFF,
confirmation=0,
param1=float(min_pitch_deg),
param2=0.0,
param3=0.0,
param4=float(yaw_deg),
param5=float(latitude) if latitude is not None else 0.0,
param6=float(longitude) if longitude is not None else 0.0,
param7=float(altitude_m),
timeout_sec=timeout_sec,
)
def land(
self,
*,
target_sysid: int,
target_compid: int = 0,
yaw_deg: float = 0.0,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
altitude_m: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_NAV_LAND,
confirmation=0,
param1=0.0,
param2=0.0,
param3=0.0,
param4=float(yaw_deg),
param5=float(latitude) if latitude is not None else 0.0,
param6=float(longitude) if longitude is not None else 0.0,
param7=float(altitude_m),
timeout_sec=timeout_sec,
)
def arm_disarm(
self,
*,
target_sysid: int,
arm: bool,
target_compid: int = 0,
confirmation: int = 0,
param2: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_COMPONENT_ARM_DISARM,
confirmation=confirmation,
param1=1.0 if arm else 0.0,
param2=float(param2),
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
def reboot_autopilot(
self,
*,
target_sysid: int,
target_compid: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_PREFLIGHT_REBOOT_SHUTDOWN,
confirmation=0,
param1=1.0,
param2=0.0,
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
# ============================================================================
# 【新增】非阻塞 async 包裝方法(用於 GUI 的非阻塞調用)
# 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環
# ============================================================================
# ============================================================================
# 【重新實現】正確的非阻塞 async 方法(不使用 ThreadPoolExecutor)
# 使用 asyncio 的 polling 機制,避免在線程中調用 spin_until_future_complete
# ============================================================================
async def _send_command_long_async(
self,
*,
target_sysid: int,
target_compid: int,
command: int,
confirmation: int,
param1: float,
param2: float,
param3: float,
param4: float,
param5: float,
param6: float,
param7: float,
timeout_sec: float,
) -> CommandLongResult:
"""非阻塞的 async 版本 - 使用 asyncio polling 而非 spin"""
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = COMMAND_DO_SET_MODE
req.command = command
req.confirmation = confirmation
req.param1 = float(base_mode)
req.param2 = float(custom_mode)
req.param3 = 0.0
req.param4 = 0.0
req.param5 = 0.0
req.param6 = 0.0
req.param7 = 0.0
req.param1 = param1
req.param2 = param2
req.param3 = param3
req.param4 = param4
req.param5 = param5
req.param6 = param6
req.param7 = param7
req.timeout_sec = float(timeout_sec)
# 發送異步調用
future = self._client.call_async(req)
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec + 1.0)
if not future.done() or future.result() is None:
return ChangeModeResult(
success=False,
message="Service call timeout or no response.",
ack_result=-1,
)
# 使用 asyncio.sleep 進行 polling,而非 spin_until_future_complete
# 使用 10ms 輪詢間隔以匹配 GUI 執行器的 spin_once() 頻率
timeout = timeout_sec + 1.0
elapsed = 0.0
poll_interval = 0.01 # 10ms - 與 GUI timer 頻率一致
while elapsed < timeout:
if future.done():
try:
response = future.result()
if response is None:
return CommandLongResult(
success=False,
message="Service returned None.",
ack_result=-1,
)
return CommandLongResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
except Exception as e:
return CommandLongResult(
success=False,
message=f"Error getting result: {e}",
ack_result=-1,
)
# 讓出控制權給事件循環,允許 GUI executor 執行
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return CommandLongResult(
success=False,
message=f"Service timeout after {timeout}s.",
ack_result=-1,
)
response = future.result()
return ChangeModeResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
async def change_mode_async(
self,
*,
target_sysid: int,
custom_mode: float,
target_compid: int = 0,
base_mode: float = 1.0,
confirmation: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 change_mode"""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_DO_SET_MODE,
confirmation=confirmation,
param1=float(base_mode),
param2=float(custom_mode),
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
async def arm_disarm_async(
self,
*,
target_sysid: int,
arm: bool,
target_compid: int = 0,
confirmation: int = 0,
param2: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 arm_disarm"""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_COMPONENT_ARM_DISARM,
confirmation=confirmation,
param1=1.0 if arm else 0.0,
param2=float(param2),
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
async def takeoff_async(
self,
*,
target_sysid: int,
altitude_m: float,
target_compid: int = 0,
min_pitch_deg: float = 0.0,
yaw_deg: float = 0.0,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 takeoff"""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_NAV_TAKEOFF,
confirmation=0,
param1=float(min_pitch_deg),
param2=0.0,
param3=0.0,
param4=float(yaw_deg),
param5=float(latitude) if latitude is not None else 0.0,
param6=float(longitude) if longitude is not None else 0.0,
param7=float(altitude_m),
timeout_sec=timeout_sec,
)
async def reboot_autopilot_async(
self,
*,
target_sysid: int,
target_compid: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 autopilot reboot."""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_PREFLIGHT_REBOOT_SHUTDOWN,
confirmation=0,
param1=1.0,
param2=0.0,
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
async def land_async(
self,
*,
target_sysid: int,
target_compid: int = 0,
yaw_deg: float = 0.0,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
altitude_m: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 land"""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_NAV_LAND,
confirmation=0,
param1=0.0,
param2=0.0,
param3=0.0,
param4=float(yaw_deg),
param5=float(latitude) if latitude is not None else 0.0,
param6=float(longitude) if longitude is not None else 0.0,
param7=float(altitude_m),
timeout_sec=timeout_sec,
)
+418
View File
@@ -0,0 +1,418 @@
"""ROS2 client for fc_network MavPositionTargetGlobalInt service (SET_POSITION_TARGET_GLOBAL_INT / Offboard)."""
from __future__ import annotations
import asyncio
import math
from dataclasses import dataclass
import rclpy
from rclpy.node import Node
from fc_interfaces.srv import MavPositionTargetGlobalInt
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/pos_global_int"
DEFAULT_TIMEOUT_SEC = 2.0
# MAV_FRAME
MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6 # 使用 WGS84 GNSS 座標系 高度為相對於 Home 點高度
# POSITION_TARGET_TYPEMASK
# 1 1 Ignore position x
# 2 2 Ignore position y
# 3 4 Ignore position z
# 4 8 Ignore velocity x
# 5 16 Ignore velocity y
# 6 32 Ignore velocity z
# 7 64 Ignore acceleration x
# 8 128 Ignore acceleration y
# 9 256 Ignore acceleration z
# 10 512 Use force instead of acceleration
# 11 1024 Ignore yaw
# 12 2048 Ignore yaw rate
# 13+ Not Used
# 6543210987654321
POSITION_ONLY = 0b0000110111111000 # 忽略速度、加速度、偏航
# 回傳 echo 與送出值比對時,浮點欄位允許的絕對誤差(MAVLink float 轉傳常有小差)
_FLOAT_ECHO_ABS_TOL = 1e-5
def _float_echo_equal(a: float, b: float) -> bool:
return math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=_FLOAT_ECHO_ABS_TOL)
def _echo_position_target_matches(
*,
type_mask: int,
lat_int: int,
lon_int: int,
alt: float,
vx: float,
vy: float,
vz: float,
afx: float,
afy: float,
afz: float,
yaw: float,
yaw_rate: float,
time_boot_ms: int,
resp,
) -> tuple[bool, str]:
"""
比對本次送出的軌跡欄位與 server 填回的 r_* 是否一致。
若不符,回傳 (False, 簡短說明);相符則 (True, "")。
"""
_tm_sent = int(type_mask) & 0xFFF
_tm_recv = int(resp.r_type_mask) & 0xFFF
if _tm_recv != _tm_sent:
return False, f"type_mask echo: sent {_tm_sent:012b} != ack {_tm_recv:012b}"
coordinate_tolerance = 5 * 100
if abs(resp.r_lat_int - lat_int) >= coordinate_tolerance:
return False, f"lat_int echo: sent {lat_int} != ack {resp.r_lat_int}"
if abs(resp.r_lon_int - lon_int) >= coordinate_tolerance:
return False, f"lon_int echo: sent {lon_int} != ack {resp.r_lon_int}"
# TODO 我給飛控相對高度 它回給我絕對高度 看來這個比較方法 還有很多改良點 但是之後吧
# if not _float_echo_equal(resp.r_alt, alt):
# return False, f"alt echo: sent {alt} != ack {resp.r_alt}"
if not _float_echo_equal(resp.r_vx, vx):
return False, f"vx echo: sent {vx} != ack {resp.r_vx}"
if not _float_echo_equal(resp.r_vy, vy):
return False, f"vy echo: sent {vy} != ack {resp.r_vy}"
if not _float_echo_equal(resp.r_vz, vz):
return False, f"vz echo: sent {vz} != ack {resp.r_vz}"
if not _float_echo_equal(resp.r_afx, afx):
return False, f"afx echo: sent {afx} != ack {resp.r_afx}"
if not _float_echo_equal(resp.r_afy, afy):
return False, f"afy echo: sent {afy} != ack {resp.r_afy}"
if not _float_echo_equal(resp.r_afz, afz):
return False, f"afz echo: sent {afz} != ack {resp.r_afz}"
if not _float_echo_equal(resp.r_yaw, yaw):
return False, f"yaw echo: sent {yaw} != ack {resp.r_yaw}"
if not _float_echo_equal(resp.r_yaw_rate, yaw_rate):
return False, f"yaw_rate echo: sent {yaw_rate} != ack {resp.r_yaw_rate}"
# if int(resp.r_time_boot_ms) != int(time_boot_ms):
# return False, f"time_boot_ms echo: sent {time_boot_ms} != r {resp.r_time_boot_ms}"
return True, ""
@dataclass
class PositionTargetGlobalIntResult:
"""對應 MavPositionTargetGlobalInt.srv 的 Response;success 依「送出與 r_* echo 是否一致」。"""
success: bool
message: str
ack_result: int
r_time_boot_ms: int = 0
r_type_mask: int = 0
r_lat_int: int = 0
r_lon_int: int = 0
r_alt: float = 0.0
r_vx: float = 0.0
r_vy: float = 0.0
r_vz: float = 0.0
r_afx: float = 0.0
r_afy: float = 0.0
r_afz: float = 0.0
r_yaw: float = 0.0
r_yaw_rate: float = 0.0
class PositionTargetGlobalIntClient(Node):
"""
ROS2 client:對 pos_global_int service 發送 SET_POSITION_TARGET_GLOBAL_INT (msg 86),
並等待 POSITION_TARGET_GLOBAL_INT (msg 87) 回應(由 server 端處理)。
"""
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME, node_name: str = None) -> None:
if not rclpy.ok():
rclpy.init(args=None)
if node_name is None:
import time
node_name = f"fc_position_target_global_int_client_{int(time.time() * 1000) % 100000}"
super().__init__(node_name)
self._client = self.create_client(MavPositionTargetGlobalInt, service_name)
def wait_for_service(self, timeout_sec: float = 3.0) -> bool:
return self._client.wait_for_service(timeout_sec=timeout_sec)
def _send_position_target_global_int(
self,
*,
target_sysid: int,
target_compid: int,
time_boot_ms: int,
coordinate_frame: int,
type_mask: int,
lat_int: int,
lon_int: int,
alt: float,
vx: float,
vy: float,
vz: float,
afx: float,
afy: float,
afz: float,
yaw: float,
yaw_rate: float,
timeout_sec: float,
) -> PositionTargetGlobalIntResult:
req = MavPositionTargetGlobalInt.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.time_boot_ms = time_boot_ms
req.coordinate_frame = coordinate_frame
req.type_mask = type_mask
req.lat_int = lat_int
req.lon_int = lon_int
req.alt = float(alt)
req.vx = float(vx)
req.vy = float(vy)
req.vz = float(vz)
req.afx = float(afx)
req.afy = float(afy)
req.afz = float(afz)
req.yaw = float(yaw)
req.yaw_rate = float(yaw_rate)
req.timeout_sec = float(timeout_sec)
future = self._client.call_async(req)
rclpy.spin_until_future_complete(self, future, timeout_sec=float(timeout_sec) + 1.0)
if not future.done() or future.result() is None:
return PositionTargetGlobalIntResult(
success=False,
message="Service timeout.",
ack_result=-1,
)
resp = future.result()
if resp.ack_result != 0:
return PositionTargetGlobalIntResult(
success=False,
message=resp.message,
ack_result=resp.ack_result,
)
echo_ok, echo_detail = _echo_position_target_matches(
type_mask=type_mask,
lat_int=lat_int,
lon_int=lon_int,
alt=alt,
vx=vx,
vy=vy,
vz=vz,
afx=afx,
afy=afy,
afz=afz,
yaw=yaw,
yaw_rate=yaw_rate,
time_boot_ms=time_boot_ms,
resp=resp,
)
# out_message = resp.message
# if not echo_ok:
# out_message = echo_detail if not out_message else f"{echo_detail} | server: {out_message}"
return PositionTargetGlobalIntResult(
success=echo_ok,
message=echo_detail,
ack_result=resp.ack_result,
r_time_boot_ms=int(resp.r_time_boot_ms),
r_type_mask=int(resp.r_type_mask),
r_lat_int=int(resp.r_lat_int),
r_lon_int=int(resp.r_lon_int),
r_alt=float(resp.r_alt),
r_vx=float(resp.r_vx),
r_vy=float(resp.r_vy),
r_vz=float(resp.r_vz),
r_afx=float(resp.r_afx),
r_afy=float(resp.r_afy),
r_afz=float(resp.r_afz),
r_yaw=float(resp.r_yaw),
r_yaw_rate=float(resp.r_yaw_rate),
)
def goto_position_only(
self,
*,
target_sysid: int,
target_compid: int = 0,
latitude_deg: float,
longitude_deg: float,
alt: float = 0.0,
timeout_sec: float,
):
lat_int = int(round(latitude_deg * 1e7))
lon_int = int(round(longitude_deg * 1e7))
return self._send_position_target_global_int(
target_sysid=target_sysid,
target_compid=target_compid,
time_boot_ms= 0,
coordinate_frame=MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
type_mask=POSITION_ONLY,
lat_int=lat_int,
lon_int=lon_int,
alt=alt,
vx=0,
vy=0,
vz=0,
afx=0,
afy=0,
afz=0,
yaw=0,
yaw_rate=0,
timeout_sec=timeout_sec,
)
# ============================================================================
# 非阻塞 async 版本(asyncio polling,對齊 longCommand.py 的模板)
# 避免在 Qt 事件迴圈或主執行緒中呼叫 spin_until_future_complete
# ============================================================================
async def _send_position_target_global_int_async(
self,
*,
target_sysid: int,
target_compid: int,
time_boot_ms: int,
coordinate_frame: int,
type_mask: int,
lat_int: int,
lon_int: int,
alt: float,
vx: float,
vy: float,
vz: float,
afx: float,
afy: float,
afz: float,
yaw: float,
yaw_rate: float,
timeout_sec: float,
) -> PositionTargetGlobalIntResult:
"""非阻塞 async 版本 - 使用 asyncio polling 而非 spin"""
req = MavPositionTargetGlobalInt.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.time_boot_ms = time_boot_ms
req.coordinate_frame = coordinate_frame
req.type_mask = type_mask
req.lat_int = lat_int
req.lon_int = lon_int
req.alt = float(alt)
req.vx = float(vx)
req.vy = float(vy)
req.vz = float(vz)
req.afx = float(afx)
req.afy = float(afy)
req.afz = float(afz)
req.yaw = float(yaw)
req.yaw_rate = float(yaw_rate)
req.timeout_sec = float(timeout_sec)
future = self._client.call_async(req)
timeout = float(timeout_sec) + 1.0
elapsed = 0.0
poll_interval = 0.01 # 10ms - 與 GUI timer 頻率一致
while elapsed < timeout:
if future.done():
try:
resp = future.result()
if resp is None:
return PositionTargetGlobalIntResult(
success=False,
message="Service returned None.",
ack_result=-1,
)
if resp.ack_result != 0:
return PositionTargetGlobalIntResult(
success=False,
message=resp.message,
ack_result=resp.ack_result,
)
echo_ok, echo_detail = _echo_position_target_matches(
type_mask=type_mask,
lat_int=lat_int,
lon_int=lon_int,
alt=alt,
vx=vx,
vy=vy,
vz=vz,
afx=afx,
afy=afy,
afz=afz,
yaw=yaw,
yaw_rate=yaw_rate,
time_boot_ms=time_boot_ms,
resp=resp,
)
return PositionTargetGlobalIntResult(
success=echo_ok,
message=echo_detail,
ack_result=resp.ack_result,
r_time_boot_ms=int(resp.r_time_boot_ms),
r_type_mask=int(resp.r_type_mask),
r_lat_int=int(resp.r_lat_int),
r_lon_int=int(resp.r_lon_int),
r_alt=float(resp.r_alt),
r_vx=float(resp.r_vx),
r_vy=float(resp.r_vy),
r_vz=float(resp.r_vz),
r_afx=float(resp.r_afx),
r_afy=float(resp.r_afy),
r_afz=float(resp.r_afz),
r_yaw=float(resp.r_yaw),
r_yaw_rate=float(resp.r_yaw_rate),
)
except Exception as e:
return PositionTargetGlobalIntResult(
success=False,
message=f"Error getting result: {e}",
ack_result=-1,
)
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return PositionTargetGlobalIntResult(
success=False,
message=f"Service timeout after {timeout}s.",
ack_result=-1,
)
async def goto_position_only_async(
self,
*,
target_sysid: int,
target_compid: int = 0,
latitude_deg: float,
longitude_deg: float,
alt: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> PositionTargetGlobalIntResult:
"""非阻塞 async 版本的 goto_position_only"""
lat_int = int(round(latitude_deg * 1e7))
lon_int = int(round(longitude_deg * 1e7))
return await self._send_position_target_global_int_async(
target_sysid=target_sysid,
target_compid=target_compid,
time_boot_ms=0,
coordinate_frame=MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
type_mask=POSITION_ONLY,
lat_int=lat_int,
lon_int=lon_int,
alt=alt,
vx=0,
vy=0,
vz=0,
afx=0,
afy=0,
afz=0,
yaw=0,
yaw_rate=0,
timeout_sec=timeout_sec,
)
-91
View File
@@ -1,91 +0,0 @@
"""Simple wrapper for takeoff via fc_network ROS2 service."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import rclpy
from rclpy.node import Node
from fc_interfaces.srv import MavCommandLong
COMMAND_NAV_TAKEOFF = 22
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
DEFAULT_TIMEOUT_SEC = 2.0
@dataclass
class TakeoffResult:
success: bool
message: str
ack_result: int
def takeoff(
*,
target_sysid: int,
altitude_m: float,
target_compid: int = 0,
min_pitch_deg: float = 0.0,
yaw_deg: float = 0.0,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
service_name: str = DEFAULT_SERVICE_NAME,
) -> TakeoffResult:
"""One-shot MAV_CMD_NAV_TAKEOFF wrapper."""
rclpy.init(args=None)
node: Optional[Node] = None
try:
node = Node("fc_takeoff_client_once")
client = node.create_client(MavCommandLong, service_name)
if not client.wait_for_service(timeout_sec=timeout_sec):
return TakeoffResult(
success=False,
message=f"Service not available: {service_name}",
ack_result=-1,
)
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = COMMAND_NAV_TAKEOFF
req.confirmation = 0
req.param1 = float(min_pitch_deg)
req.param2 = 0.0
req.param3 = 0.0
req.param4 = float(yaw_deg)
req.param5 = float(latitude) if latitude is not None else 0.0
req.param6 = float(longitude) if longitude is not None else 0.0
req.param7 = float(altitude_m)
req.timeout_sec = float(timeout_sec)
future = client.call_async(req)
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
if not future.done() or future.result() is None:
return TakeoffResult(
success=False,
message="Service call timeout or no response.",
ack_result=-1,
)
response = future.result()
return TakeoffResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
finally:
if node is not None:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
result = takeoff(target_sysid=3, altitude_m=10.0)
print(
f"takeoff success={result.success}, "
f"ack_result={result.ack_result}, message='{result.message}'"
)
+544
View File
@@ -0,0 +1,544 @@
#!/usr/bin/env python3
"""Standalone curses TUI for fc_network vehicle diagnostics (sys_diags, status_text, summary)."""
from __future__ import annotations
import curses
import json
import re
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
from fc_interfaces.msg import SystemDiagnosticsRaw
VEHICLE_TOPIC_RE = re.compile(
r'^/fc_network/vehicle/(sys\d+)/(sys_diags|status_text|summary)$'
)
STATUS_TEXT_RE = re.compile(r'^\[([\d.]+)\]\s*\[(-?\d+)\]\s*(.*)$')
STATUS_TEXT_TTL_SEC = 30.0
STALE_SEC = 3.0
RESCAN_SEC = 2.0
OFFLINE_SEC = 10.0
DRAW_HZ = 5.0
# MAV_SYS_STATUS_SENSOR bit labels (MAVLink common)
SENSOR_BITS: Tuple[Tuple[int, str], ...] = (
(268435456, 'PreArm'),
(1, 'Gyro'),
(2, 'Accel'),
(4, 'Mag'),
(8, 'Baro'),
(16, 'DiffPress'),
(32, 'GPS'),
(64, 'OptFlow'),
(128, 'Vision'),
(256, 'Laser'),
(512, 'ExtGT'),
(1024, 'AngRate'),
(2048, 'AttStab'),
(4096, 'YawPos'),
(8192, 'AltCtrl'),
(16384, 'XYCtrl'),
(32768, 'Motor'),
(65536, 'RC'),
(131072, 'Gyro2'),
(262144, 'Accel2'),
(524288, 'Mag2'),
(1048576, 'Geofence'),
(2097152, 'AHRS'),
(4194304, 'Terrain'),
(8388608, 'RevMotor'),
(16777216, 'Log'),
(33554432, 'Batt'),
(67108864, 'Prox'),
(134217728, 'Satcom'),
(536870912, 'ObsAvoid'),
(1073741824, 'Propulsion'),
)
MAV_STATE_LABELS = {
0: 'UNINIT',
1: 'BOOT',
2: 'CAL',
3: 'STANDBY',
4: 'ACTIVE',
5: 'CRITICAL',
6: 'EMERGENCY',
7: 'OFF',
8: 'TERM',
}
SEVERITY_LABELS = {
0: 'EMERG',
1: 'ALERT',
2: 'CRIT',
3: 'ERROR',
4: 'WARN',
5: 'NOTICE',
6: 'INFO',
7: 'DEBUG',
}
@dataclass
class StatusTextEntry:
received_at: float
severity: int
text: str
raw: str
sys_key: str
@dataclass
class VehicleDiagState:
sys_key: str
sysid: int
summary: Optional[dict] = None
diag: Optional[SystemDiagnosticsRaw] = None
status_entries: List[StatusTextEntry] = field(default_factory=list)
last_summary_ts: float = 0.0
last_diag_ts: float = 0.0
last_topic_seen_ts: float = 0.0
offline: bool = False
def sys_key_to_id(sys_key: str) -> int:
return int(sys_key.replace('sys', '', 1))
def purge_status_entries(entries: List[StatusTextEntry], now: float, ttl: float = STATUS_TEXT_TTL_SEC) -> None:
cutoff = now - ttl
entries[:] = [e for e in entries if e.received_at >= cutoff]
def parse_status_text(raw: str, sys_key: str, received_at: float) -> StatusTextEntry:
match = STATUS_TEXT_RE.match(raw.strip())
if match:
severity = int(match.group(2))
text = match.group(3)
else:
severity = -1
text = raw
return StatusTextEntry(
received_at=received_at,
severity=severity,
text=text,
raw=raw,
sys_key=sys_key,
)
def decode_sensors(install: int, enabled: int, health: int) -> List[Tuple[str, str]]:
"""Return list of (label, status) where status is OK / FAIL / OFF."""
items: List[Tuple[str, str]] = []
for bit, label in SENSOR_BITS:
if not (install & bit):
continue
if not (enabled & bit):
items.append((label, 'OFF'))
elif health & bit:
items.append((label, 'OK'))
else:
items.append((label, 'FAIL'))
return items
def format_age(seconds: float) -> str:
if seconds < 0:
return 'never'
if seconds < 1.0:
return f'{seconds * 1000:.0f}ms'
if seconds < 60.0:
return f'{seconds:.1f}s'
return f'{seconds / 60:.1f}m'
def truncate(text: str, width: int) -> str:
if width <= 0:
return ''
if len(text) <= width:
return text
if width <= 3:
return text[:width]
return text[: width - 3] + '...'
class VehicleDiagNode(Node):
"""ROS2 node: discover vehicles and subscribe to diagnostic topics."""
def __init__(self) -> None:
super().__init__('vehicle_diag_show')
self._lock = threading.Lock()
self._vehicles: Dict[str, VehicleDiagState] = {}
self._subs: Dict[str, Dict[str, object]] = {}
self._scan_timer = self.create_timer(RESCAN_SEC, self._rescan_topics)
self._rescan_topics()
def snapshot(self) -> Dict[str, VehicleDiagState]:
now = time.monotonic()
with self._lock:
for state in self._vehicles.values():
purge_status_entries(state.status_entries, now)
if state.last_topic_seen_ts > 0:
state.offline = (now - state.last_topic_seen_ts) > OFFLINE_SEC
return {k: state for k, state in self._vehicles.items()}
def _get_or_create_state(self, sys_key: str) -> VehicleDiagState:
if sys_key not in self._vehicles:
self._vehicles[sys_key] = VehicleDiagState(
sys_key=sys_key,
sysid=sys_key_to_id(sys_key),
)
return self._vehicles[sys_key]
def _rescan_topics(self) -> None:
now = time.monotonic()
found: set = set()
for topic_name, _ in self.get_topic_names_and_types():
match = VEHICLE_TOPIC_RE.match(topic_name)
if not match:
continue
sys_key = match.group(1)
found.add(sys_key)
with self._lock:
state = self._get_or_create_state(sys_key)
state.last_topic_seen_ts = now
state.offline = False
self._ensure_subscriptions(sys_key)
with self._lock:
for sys_key, state in self._vehicles.items():
if sys_key not in found and state.last_topic_seen_ts > 0:
state.offline = (now - state.last_topic_seen_ts) > OFFLINE_SEC
def _ensure_subscriptions(self, sys_key: str) -> None:
if sys_key in self._subs:
return
base = f'/fc_network/vehicle/{sys_key}'
subs = {
'summary': self.create_subscription(
String,
f'{base}/summary',
lambda msg, sk=sys_key: self._on_summary(sk, msg),
10,
),
'sys_diags': self.create_subscription(
SystemDiagnosticsRaw,
f'{base}/sys_diags',
lambda msg, sk=sys_key: self._on_sys_diags(sk, msg),
10,
),
'status_text': self.create_subscription(
String,
f'{base}/status_text',
lambda msg, sk=sys_key: self._on_status_text(sk, msg),
10,
),
}
with self._lock:
self._subs[sys_key] = subs
def _on_summary(self, sys_key: str, msg: String) -> None:
now = time.monotonic()
try:
summary = json.loads(msg.data)
except json.JSONDecodeError:
summary = {'raw': msg.data}
with self._lock:
state = self._get_or_create_state(sys_key)
state.summary = summary
state.last_summary_ts = now
state.last_topic_seen_ts = now
def _on_sys_diags(self, sys_key: str, msg: SystemDiagnosticsRaw) -> None:
now = time.monotonic()
with self._lock:
state = self._get_or_create_state(sys_key)
state.diag = msg
state.last_diag_ts = now
state.last_topic_seen_ts = now
def _on_status_text(self, sys_key: str, msg: String) -> None:
now = time.monotonic()
entry = parse_status_text(msg.data, sys_key, now)
with self._lock:
state = self._get_or_create_state(sys_key)
state.status_entries.append(entry)
purge_status_entries(state.status_entries, now)
state.last_topic_seen_ts = now
class DiagCursesApp:
"""Curses front-end for vehicle diagnostics."""
def __init__(self, node: VehicleDiagNode) -> None:
self._node = node
self._running = True
def run(self) -> None:
curses.wrapper(self._main)
def _init_colors(self) -> None:
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1) # OK / DISARMED / INFO
curses.init_pair(2, curses.COLOR_RED, -1) # FAIL / ARMED / ERROR+
curses.init_pair(3, curses.COLOR_YELLOW, -1) # WARN / OFF
curses.init_pair(4, curses.COLOR_CYAN, -1) # header / INFO
curses.init_pair(5, curses.COLOR_WHITE, -1) # dim / offline
curses.init_pair(6, curses.COLOR_MAGENTA, -1) # NOTICE
def _main(self, stdscr) -> None:
curses.curs_set(0)
stdscr.nodelay(True)
stdscr.keypad(True)
self._init_colors()
while self._running and rclpy.ok():
try:
self._draw(stdscr)
except curses.error:
pass
key = stdscr.getch()
if key in (ord('q'), ord('Q'), 27):
self._running = False
time.sleep(1.0 / DRAW_HZ)
def _draw(self, stdscr) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
now = time.monotonic()
vehicles = self._node.snapshot()
sorted_keys = sorted(vehicles.keys(), key=sys_key_to_id)
header = f' Vehicle Diagnostics Monitor vehicles:{len(sorted_keys)} Q:quit '
stdscr.attron(curses.color_pair(4) | curses.A_BOLD)
stdscr.addstr(0, 0, truncate(header.ljust(width - 1), width - 1))
stdscr.attroff(curses.color_pair(4) | curses.A_BOLD)
row = 1
if not sorted_keys:
msg = ' Waiting for vehicles... (need /fc_network/vehicle/sysN/* topics) '
if row < height - 1:
stdscr.addstr(row, max(0, (width - len(msg)) // 2), truncate(msg, width - 1))
row += 2
vehicle_rows_end = max(row, height - 8)
for sys_key in sorted_keys:
if row >= vehicle_rows_end:
break
state = vehicles[sys_key]
row = self._draw_vehicle_block(stdscr, row, width, state, now)
if row < height - 1:
try:
stdscr.addstr(row, 0, truncate('-' * (width - 1), width - 1))
except curses.error:
pass
row += 1
log_title = ' Status Text (last 30s) '
if row < height - 1:
stdscr.attron(curses.color_pair(4))
stdscr.addstr(row, 0, truncate(log_title.ljust(width - 1), width - 1))
stdscr.attroff(curses.color_pair(4))
row += 1
all_entries: List[StatusTextEntry] = []
for sys_key in sorted_keys:
all_entries.extend(vehicles[sys_key].status_entries)
all_entries.sort(key=lambda e: e.received_at)
log_rows = height - row - 1
if log_rows <= 0:
stdscr.refresh()
return
if not all_entries:
empty = ' (no messages in last 30s) '
if row < height - 1:
stdscr.addstr(row, 0, truncate(empty, width - 1))
else:
visible = all_entries[-log_rows:]
for entry in visible:
if row >= height - 1:
break
self._draw_status_line(stdscr, row, width, entry)
row += 1
stdscr.refresh()
def _draw_vehicle_block(
self,
stdscr,
row: int,
width: int,
state: VehicleDiagState,
now: float,
) -> int:
summary = state.summary or {}
mode = summary.get('mode_name', 'UNKNOWN')
armed = summary.get('armed', False)
conn = summary.get('connection_type', '?')
socket_id = summary.get('socket_id', -1)
mav_status = summary.get('mav_status', 0)
mav_label = MAV_STATE_LABELS.get(int(mav_status), str(mav_status))
last_update = max(state.last_summary_ts, state.last_diag_ts)
age_sec = (now - last_update) if last_update > 0 else -1.0
stale = age_sec >= 0 and age_sec > STALE_SEC
armed_str = 'ARMED' if armed else 'DISARMED'
flags = []
if state.offline:
flags.append('OFFLINE')
if stale and not state.offline:
flags.append('STALE')
flag_str = f" [{' '.join(flags)}]" if flags else ''
line1 = (
f' {state.sys_key} (MAV {state.sysid}) {mode} {armed_str} '
f'{conn} socket:{socket_id} {mav_label} '
f'updated {format_age(age_sec)}{flag_str}'
)
if row < stdscr.getmaxyx()[0] - 1:
if state.offline:
stdscr.attron(curses.color_pair(5))
stdscr.addstr(row, 0, truncate(line1, width - 1))
stdscr.attroff(curses.color_pair(5))
else:
stdscr.addstr(row, 0, truncate(line1, width - 1))
armed_pos = line1.find(armed_str)
if armed_pos >= 0:
pair = curses.color_pair(2) if armed else curses.color_pair(1)
stdscr.attron(pair | curses.A_BOLD)
stdscr.addstr(row, armed_pos, armed_str)
stdscr.attroff(pair | curses.A_BOLD)
row += 1
if state.diag is not None and row < stdscr.getmaxyx()[0] - 1:
load_pct = state.diag.mcu_load / 10.0
drop_pct = state.diag.bus_error_rate / 10.0
line2 = f' MCU {load_pct:.1f}% CommDrop {drop_pct:.1f}%'
stdscr.addstr(row, 0, truncate(line2, width - 1))
row += 1
sensors = decode_sensors(
state.diag.sensors_install_mask,
state.diag.sensors_enabled_mask,
state.diag.sensors_health_mask,
)
row = self._draw_sensor_line(stdscr, row, width, sensors)
elif row < stdscr.getmaxyx()[0] - 1:
stdscr.addstr(row, 0, truncate(' (no sys_diags yet)', width - 1))
row += 1
return row
def _draw_sensor_line(
self,
stdscr,
row: int,
width: int,
sensors: List[Tuple[str, str]],
) -> int:
max_row, _ = stdscr.getmaxyx()
if not sensors:
if row < max_row - 1:
stdscr.addstr(row, 0, truncate(' Sensors: (none installed)', width - 1))
return row + 1
lines: List[List[Tuple[str, str]]] = [[]]
line_len = len(' Sensors: ')
for label, status in sensors:
token = f'{label}:{status} '
if lines[-1] and line_len + len(token) >= width - 1:
lines.append([])
line_len = 2
lines[-1].append((label, status))
line_len += len(token)
for idx, line_items in enumerate(lines):
if row >= max_row - 1:
break
prefix = ' Sensors: ' if idx == 0 else ' '
col = 0
stdscr.addstr(row, col, prefix)
col += len(prefix)
for label, status in line_items:
token = f'{label}:{status} '
if col + len(token) >= width - 1:
stdscr.addstr(row, col, truncate('...', width - col - 1))
return row + 1
pair = curses.A_NORMAL
if status == 'OK':
pair = curses.color_pair(1)
elif status == 'FAIL':
pair = curses.color_pair(2) | curses.A_BOLD
elif status == 'OFF':
pair = curses.color_pair(3)
stdscr.attron(pair)
stdscr.addstr(row, col, token)
stdscr.attroff(pair)
col += len(token)
row += 1
return row
def _draw_status_line(
self,
stdscr,
row: int,
width: int,
entry: StatusTextEntry,
) -> None:
sev_label = SEVERITY_LABELS.get(entry.severity, str(entry.severity))
prefix = f'[{entry.sys_key:5}] [{entry.severity} {sev_label:5}] '
body = truncate(entry.text, max(0, width - len(prefix) - 2))
line = prefix + body
pair = curses.A_NORMAL
if entry.severity <= 3:
pair = curses.color_pair(2)
elif entry.severity == 4:
pair = curses.color_pair(3)
elif entry.severity == 5:
pair = curses.color_pair(6)
elif entry.severity == 6:
pair = curses.color_pair(1)
elif entry.severity == 7:
pair = curses.color_pair(5)
stdscr.attron(pair)
stdscr.addstr(row, 0, truncate(line, width - 1))
stdscr.attroff(pair)
def main() -> int:
if not sys.stdout.isatty():
print('vehicleDiagShow requires an interactive terminal (TTY).', file=sys.stderr)
return 1
rclpy.init()
node = VehicleDiagNode()
spin_thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True)
spin_thread.start()
try:
DiagCursesApp(node).run()
finally:
node.destroy_node()
rclpy.shutdown()
spin_thread.join(timeout=2.0)
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,256 @@
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 """
RECONNECT_BASE_SEC = 10.0
RECONNECT_MAX_SEC = 60.0
def __init__(self):
super().__init__('ntrip_client')
self.declare_parameter('host', 'rtk2go.com')
self.declare_parameter('port', 2101)
self.declare_parameter('mountpoint', '')
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,
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
)
self.publisher_ = self.create_publisher(RTCM, '/fc_network/rtcm_input', rtcm_qos)
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()
self.get_logger().info('NtripClientNode 已啟動')
# ── 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
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 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}/{mountpoint} ...')
sock.connect((host, port))
auth = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
request = (
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'
)
sock.sendall(request.encode())
resp = sock.recv(4096)
if b'ICY 200 OK' not in resp:
self.get_logger().error(
f'NTRIP 握手失敗: {resp.decode(errors="ignore").strip()}'
)
raise ConnectionError('handshake failed')
self.get_logger().info(f'已成功連接至 {mountpoint}')
backoff = self.RECONNECT_BASE_SEC
self._read_stream(sock)
except Exception as e:
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:
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
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:
if self._stop_event.is_set():
break
raise ConnectionError("stream ended. Empty Chunk")
buf += chunk
while len(buf) >= 6:
if buf[0] != 0xD3:
buf = buf[1:]
continue
payload_len = ((buf[1] & 0x03) << 8) | buf[2]
frame_len = payload_len + 6 # header(3) + payload + CRC(3)
if len(buf) < frame_len:
break
packet = buf[:frame_len]
buf = buf[frame_len:]
self._publish_rtcm(packet)
def _publish_rtcm(self, raw_packet: bytes):
msg = RTCM()
msg.header.stamp = self.get_clock().now().to_msg()
msg.data = list(raw_packet)
self.publisher_.publish(msg)
def destroy_node(self):
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()
def main(args=None):
rclpy.init(args=args)
node = NtripClientNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
<?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>fc_network_module</name>
<version>0.0.0</version>
<description>TODO: Package description</description>
<maintainer email="chiyu1468@hotmail.com">picars</maintainer>
<license>TODO: License declaration</license>
<depend>rclpy</depend>
<depend>mavros_msgs</depend>
<depend>std_msgs</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>
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/fc_network_module
[install]
install_scripts=$base/lib/fc_network_module
+26
View File
@@ -0,0 +1,26 @@
from setuptools import find_packages, setup
package_name = 'fc_network_module'
setup(
name=package_name,
version='0.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='picars',
maintainer_email='chiyu1468@hotmail.com',
description='TODO: Package description',
license='TODO: License declaration',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'ntrip_client = fc_network_module.ntrip_client:main',
],
},
)
@@ -0,0 +1,25 @@
# Copyright 2015 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_copyright.main import main
import pytest
# Remove the `skip` decorator once the source file(s) have a copyright header
@pytest.mark.skip(reason='No copyright header has been placed in the generated source file.')
@pytest.mark.copyright
@pytest.mark.linter
def test_copyright():
rc = main(argv=['.', 'test'])
assert rc == 0, 'Found errors'
+25
View File
@@ -0,0 +1,25 @@
# Copyright 2017 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_flake8.main import main_with_errors
import pytest
@pytest.mark.flake8
@pytest.mark.linter
def test_flake8():
rc, errors = main_with_errors(argv=[])
assert rc == 0, \
'Found %d code style errors / warnings:\n' % len(errors) + \
'\n'.join(errors)
+23
View File
@@ -0,0 +1,23 @@
# Copyright 2015 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_pep257.main import main
import pytest
@pytest.mark.linter
@pytest.mark.pep257
def test_pep257():
rc = main(argv=['.', 'test'])
assert rc == 0, 'Found code style errors / warnings'
@@ -1,55 +0,0 @@
from fc_network_apps import CommandLongClient
import time
def main():
# Equivalent to:
# ros2 service call /fc_network/vehicle/send_command_long ... param1:1 param2:4
commandAPI = CommandLongClient()
result = commandAPI.change_mode(
target_sysid=3,
target_compid=0,
base_mode=1.0,
custom_mode=4.0,
timeout_sec=2.0,
)
print("=== change mode result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
time.sleep(1)
result = commandAPI.change_mode(
target_sysid=3,
target_compid=0,
base_mode=1.0,
custom_mode=3.0,
timeout_sec=2.0,
)
print("=== change mode result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
time.sleep(1)
result = commandAPI.change_mode(
target_sysid=3,
target_compid=0,
base_mode=1.0,
custom_mode=5.0,
timeout_sec=2.0,
)
print("=== change mode result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
if __name__ == "__main__":
main()
@@ -1,30 +0,0 @@
"""Usage example for arm/disarm helper.
Run from repo root with module mode:
python -m someotherpkg.src.example_arm_disarm
"""
from fc_network_apps import arm_disarm
def main() -> None:
# MAV_CMD_COMPONENT_ARM_DISARM (command=400)
# param1: 1 = arm, 0 = disarm
result = arm_disarm(
target_sysid=3,
target_compid=0,
arm=True,
timeout_sec=2.0,
)
print("=== arm result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
# To disarm instead:
# result = arm_disarm(target_sysid=3, target_compid=0, arm=False, timeout_sec=2.0)
if __name__ == "__main__":
main()
@@ -1,29 +0,0 @@
"""Usage example for collaborators.
Run after sourcing your ROS2 workspace:
source install/local_setup.bash
python src/fc_network_apps/example_change_mode.py
"""
from fc_network_apps import change_mode
def main() -> None:
# Equivalent to:
# ros2 service call /fc_network/vehicle/send_command_long ... param1:1 param2:4
result = change_mode(
target_sysid=3,
target_compid=0,
base_mode=1.0,
custom_mode=4.0,
timeout_sec=2.0,
)
print("=== change mode result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
if __name__ == "__main__":
main()
-27
View File
@@ -1,27 +0,0 @@
"""Usage example for land helper.
Run from repo root with module mode:
python -m someotherpkg.src.example_land
"""
from fc_network_apps import land
def main() -> None:
# MAV_CMD_NAV_LAND (command=21)
# This example asks vehicle sysid=3 to land.
result = land(
target_sysid=3,
target_compid=0,
yaw_deg=0.0,
timeout_sec=2.0,
)
print("=== land result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
if __name__ == "__main__":
main()
-28
View File
@@ -1,28 +0,0 @@
"""Usage example for takeoff helper.
Run from repo root with module mode:
python -m someotherpkg.src.example_takeoff
"""
from fc_network_apps import takeoff
def main() -> None:
# MAV_CMD_NAV_TAKEOFF (command=22)
# This example asks vehicle sysid=3 to take off to 10 meters.
result = takeoff(
target_sysid=3,
target_compid=0,
altitude_m=10.0,
yaw_deg=0.0,
timeout_sec=2.0,
)
print("=== takeoff result ===")
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
import fc_network_apps as fap
import time
def main():
commandAPI = fap.CommandLongClient()
navAPI = fap.PositionTargetGlobalIntClient()
target_sysid = 3
print("=== change mode ===")
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
while not result.success:
result.ack_result -= 1
result = commandAPI.change_mode(
target_sysid=target_sysid,
base_mode=1.0,
custom_mode=4.0,
timeout_sec=3.0,
)
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
time.sleep(1)
if result.ack_result < -3:
return
print("=== arm vehicle ===")
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
while not result.success:
result.ack_result -= 1
result = commandAPI.arm_disarm(
target_sysid=target_sysid,
arm = True,
)
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
time.sleep(1)
if result.ack_result < -3:
return
print("=== takeoff ===")
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
while not result.success:
result.ack_result -= 1
result = commandAPI.takeoff(
target_sysid=target_sysid,
altitude_m = 100.0,
)
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
time.sleep(1)
if result.ack_result < -3:
return
time.sleep(15)
print("=== set_position_target_global_int_deg ===")
result_deg = navAPI.goto_position_only(
target_sysid=target_sysid,
latitude_deg=-35.376655,
longitude_deg=149.157011,
alt=150.0,
timeout_sec=3.0,
)
print(f"success : {result_deg.success}")
print(f"ack_result: {result_deg.ack_result}")
print(f"message : {result_deg.message}")
print(f"r_lat_int : {result_deg.r_lat_int} r_lon_int: {result_deg.r_lon_int}")
time.sleep(180)
result_deg = navAPI.goto_position_only(
target_sysid=target_sysid,
latitude_deg=-35.3632621,
longitude_deg=149.1652375,
alt=100.0,
timeout_sec=3.0,
)
print("=== set_position_target_global_int_deg ===")
print(f"success : {result_deg.success}")
print(f"ack_result: {result_deg.ack_result}")
print(f"message : {result_deg.message}")
print(f"r_lat_int : {result_deg.r_lat_int} r_lon_int: {result_deg.r_lon_int}")
time.sleep(180)
print("=== land ===")
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
while not result.success:
result.ack_result -= 1
result = commandAPI.land(
target_sysid=3,
target_compid=0,
)
print(f"success : {result.success}")
print(f"ack_result: {result.ack_result}")
print(f"message : {result.message}")
time.sleep(1)
if result.ack_result < -3:
return
if __name__ == "__main__":
main()
+35 -12
View File
@@ -1,21 +1,25 @@
備選需要的功能
- serail 對於 telemetry 的支援
- serial_manager.serial_object.transport 這些變數可能不需要
不用動
- mavlink_object 的 send_message 確認一下 mavlink_bridge 的 _send_to_socket 是不是應該做成 async
- 不同 socket 上面有重複的 sysid 分開儲存 (不做 不允許sysid重複)
這一步
研究 ros2 service
下一步
下下一步
RTK
跟 RTK2go 抓取列表 Done
從特定 mount point 得到數據 Done
做一個 ros2 service 接到數據並包裝 Done
RTK 實機測試
下一步
重啟做好了 但是還沒自動化
下下一步
整合 xbee api-api 功能
後面
rssi 資訊提取s
@@ -24,25 +28,44 @@ python -m fc_network_adapter.tests.test_vehicleStatusPublisher
python -m fc_network_adapter.tests.test_ringBuffer
python -m fc_network_adapter.fc_network_adapter.mainOrchestrator
python -m someotherpkg.src.example_takeoff_land
python -m someotherpkg.src.example_change_mode
python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p mountpoint:=No1bio_02 -p username:=chiyu1468@hotmail.com
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
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
ros2 topic list
ros2 topic echo
ros2 topic echo /fc_network/vehicle/sys3/battery
ros2 topic echo hz /fc_network/vehicle/sys3/battery
ros2 topic bw /fc_network/vehicle/sys3/attitude
ros2 topic hz /fc_network/vehicle/sys3/attitude
/home/picars/ardupilot/build/sitl/bin/arducopter -S --model + --speedup 1 --slave 0 --defaults /home/picars/ardupilot/Tools/autotest/default_params/copter.parm --sim-address=127.0.0.1 -I3 --sysid 7
mavproxy.py --master=tcp:127.0.0.1:5790 --out=udp:127.0.0.1:14560
ros2 service call /mavlink/add_two_ints example_interfaces/srv/AddTwoInts "{a: 5,b: 8}"
ros2 service call /fc_network/vehicle/mav_ping fc_interfaces/srv/MavPing "{target_sysid: 3, target_compid: 0, ping_seq: 1}"
ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 3, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, param3: 0,param4: 0,param5: 0,param6: 0,param7: 0, timeout_sec: 2}"
ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 10, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, timeout_sec: 2}"
MAV_CMD_DO_SET_MODE (176)
ros2 service call /fc_network/vehicle/pos_global_int fc_interfaces/srv/MavPositionTargetGlobalInt "{target_sysid: 3, target_compid: 1, coordinate_frame: 6, type_mask: 3576, lat_int: -35376655, lon_int: 149157011, alt: 20.0, timeout_sec: 5.0}"
sudo tcpdump -i lo 'udp dst port 14561' -X
sudo tcpdump -i lo 'udp dst port 14550' -X -vv
sudo tcpdump -i lo -X udp port 14550
sudo tcpdump -i lo -X udp port 14550
-45
View File
@@ -1,45 +0,0 @@
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
import time
# import mavros_msgs.srv
class TalkerNode(Node):
def __init__(self):
start_time = time.time()
super().__init__('talker_node')
end_time = time.time()
print(f"Node initialization took {end_time - start_time:.2f} seconds")
self.publisher_ = self.create_publisher(String, 'hahatest/_1', 10)
self.timer = self.create_timer(1.0, self.timer_callback) # 每秒執行一次
self.get_logger().info('TalkerNode has been started.')
def timer_callback(self):
msg = String()
msg.data = 'Hello, ROS 2!'
self.publisher_.publish(msg)
self.get_logger().info(f'Published: "{msg.data}"')
def main(args=None):
rclpy.init(args=args)
node = TalkerNode()
print("Before sleep")
time.sleep(5) # 等待 5 秒鐘
print("After sleep")
try:
start_time = time.time()
while time.time() - start_time < 10: # 持續 10 秒鐘
rclpy.spin_once(node)
time.sleep(1) # 每秒執行一次
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
+188
View File
@@ -0,0 +1,188 @@
import time
from pymavlink import mavutil
# # 強制 source_system=255
# master = mavutil.mavlink_connection('udpout:localhost:14561', source_system=255)
# print("Starting Force-Ping Test...")
# while True:
# # 1. 發送 Heartbeat (證明連線活著)
# master.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
# mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
# # 2. 強制發送 PING (target 1, 1)
# # 我們加一個計數器,方便在 tcpdump 裡辨識
# ping_seq = int(time.time()) % 100
# master.mav.ping_send(int(time.time() * 1e6), ping_seq, 1, 1)
# print(f"Sent Heartbeat and PING (seq {ping_seq})")
# # 3. 非阻塞讀取,避免卡死
# msg = master.recv_match(blocking=False)
# if msg:
# print(f"--> Received something: {msg.get_type()}")
# time.sleep(1) # 每秒噴一次
# -------------------------------------------------------------------
# # 改成 udpin,這會讓你的程式守在 14561 等模擬器送資料進來
# # 這樣你就能收到模擬器的 Heartbeat,建立連線後再發 PING
# master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=255)
# print("Waiting for simulator heartbeat...")
# # 這一行很重要:先收到 Heartbeat,pymavlink 才會設定好 target 地址
# master.wait_heartbeat()
# print(f"Heartbeat from system {master.target_system} component {master.target_component}")
# while True:
# # 既然收到了,現在發 PING 就會順著原路回去
# master.mav.ping_send(int(time.time() * 1e6), 1, master.target_system, master.target_component)
# print("PING sent!")
# msg = master.recv_match(type='PING', blocking=True, timeout=2)
# if msg:
# print(f"Got PING response: {msg}")
# time.sleep(1)
# -------------------------------------------------------------------
# import time
# from pymavlink import mavutil
# # 核心修改:因為 MAVProxy 正在往 14561 噴資料,我們必須「接住」它
# # 使用 udpin 會讓你的程式在 14561 監聽
# master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=254)
# print("正在等待 MAVProxy 轉發的 Heartbeat...")
# # 只要這行跑過,代表你跟 MAVProxy 握手成功了
# master.wait_heartbeat()
# print(f"成功連線到系統 {master.target_system}!")
# while True:
# # 1. 務必發送 Heartbeat,讓 MAVProxy 知道要把回應送回這個 Port
# master.mav.heartbeat_send(
# mavutil.mavlink.MAV_TYPE_GCS,
# mavutil.mavlink.MAV_AUTOPILOT_INVALID,
# 0, 0, 0
# )
# # 2. 修改 target_component 為 1 (飛控核心)
# # 注意:這裡刻意將 seq 設為動態,方便你在 watch PING 裡觀察
# p_seq = int(time.time()) % 255
# master.mav.ping_send(
# int(time.time() * 1e6),
# p_seq,
# 1,
# 1 # <--- 改成 1 試試看!
# )
# print(f"Sent PING seq {p_seq} to 1/1")
# # 3. 讀取所有訊息,看看有沒有 PING 回來
# # 有時候回應的 target_system 可能是 255 (你的 ID)
# msg = master.recv_match(type='PING', blocking=True, timeout=1.0)
# if msg:
# print(f"🎉 成功!收到回應: {msg}")
# time.sleep(1)
# -------------------------------------------------------------------
# import time
# from pymavlink import mavutil
# # 建議一樣用 udpin 監聽 MAVProxy 的輸出
# master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=255)
# def get_time_ns():
# return int(time.time() * 1e9) # 轉為奈秒
# print("開始測試 TIMESYNC 響應...")
# while True:
# now_ns = get_time_ns()
# # 發送 TIMESYNC 請求
# # tc1=0 代表這是請求,ts1=當前時間
# master.mav.timesync_send(0, now_ns)
# # 等待回應
# msg = master.recv_match(type='TIMESYNC', blocking=True, timeout=1.0)
# if msg and msg.tc1 != 0:
# # 收到回應,msg.tc1 應該等於我們剛才送出的 now_ns
# rtt_ms = (get_time_ns() - msg.tc1) / 1e6
# print(f"🕒 TIMESYNC RTT: {rtt_ms:.3f} ms")
# break # 測試一次就好
# else:
# print("等待 TIMESYNC 回應中...")
# time.sleep(1)
# -------------------------------------------------------------------
import time
import statistics
from pymavlink import mavutil
# 連接到 MAVProxy 轉發的端口
master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=255)
# 測試設定
TARGET_SYS = 2 # 你想測試的載具 ID
SAMPLES = 20 # 取 20 次樣本來算平均和抖動
rtt_history = []
print(f"開始測試對 System {TARGET_SYS} 的通道品質...")
def get_time_ns():
return int(time.time() * 1e9)
try:
while len(rtt_history) < SAMPLES:
ts1_send = get_time_ns()
# 發送 TIMESYNC (V1/V2 格式相容)
# 針對特定系統發送
master.mav.timesync_send(0, ts1_send)
# 等待回應 (過濾目標系統的訊息)
msg = master.recv_match(type='TIMESYNC', blocking=True, timeout=1.0)
if msg and msg.get_srcSystem() == TARGET_SYS and msg.tc1 != 0:
ts1_recv = get_time_ns()
rtt_ns = ts1_recv - msg.tc1
rtt_ms = rtt_ns / 1e6
rtt_history.append(rtt_ms)
print(f"Sample {len(rtt_history)}: RTT = {rtt_ms:.3f} ms")
else:
print("Request timeout or mismatched ID...")
time.sleep(0.1) # 快速採樣
# 4. 計算統計數據
avg_latency = statistics.mean(rtt_history)
std_dev = statistics.stdev(rtt_history) # 這就是 Jitter 的一種表現
max_rtt = max(rtt_history)
min_rtt = min(rtt_history)
print("\n--- 物理通道品質分析報告 ---")
print(f"目標載具 : System {TARGET_SYS}")
print(f"平均延遲 (Avg) : {avg_latency:.3f} ms")
print(f"最小延遲 (Min) : {min_rtt:.3f} ms")
print(f"最大延遲 (Max) : {max_rtt:.3f} ms")
print(f"抖動幅度 (Jitter): {std_dev:.3f} ms (標準差)")
if std_dev > 10:
print("警告:網路抖動過高,可能影響控制穩定性!")
else:
print("通道品質:良好")
except KeyboardInterrupt:
print("測試終止。")
@@ -0,0 +1,46 @@
from pymavlink.dialects.v20 import common as mavlink
class NullBuffer:
def write(self, data): pass
def seek(self, pos): pass
def tell(self): return 0
class CapturingBuffer:
def __init__(self):
self.last_data = b''
def write(self, data):
self.last_data = bytes(data)
def seek(self, pos): pass
def tell(self): return 0
# 1. 初始化(僅作為編碼器)
# file 參數可以是任何擁有 write() 方法的物件,這裡用 BytesIO 模擬
# 初始化方法一:使用 BytesIO
# import io
# out_buf = io.BytesIO()
# mav = mavlink.MAVLink(out_buf, srcSystem=1, srcComponent=191)
# 初始化方法二:使用自定義的 NullBuffer
mav = mavlink.MAVLink(NullBuffer(), srcSystem=1, srcComponent=191)
mav.seq = 254
# 2. 建立心跳包並取得二進制數據
# 連續產出「不同」的訊息物件
for i in range(3):
msg = mav.heartbeat_encode(mavlink.MAV_TYPE_GCS, 0, 0, 0, 0)
data = msg.pack(mav)
mav.seq = (mav.seq + 1) & 0xFF
# MAVLink 2 的序列號在第 3 個 Byte (Index 2)
print(f"第 {i} 次發送, Seq: {data[4]}, Raw: {data.hex()}")
print("分隔線")
buf = CapturingBuffer()
mav_buf2 = mavlink.MAVLink(buf, srcSystem=1, srcComponent=191)
for i in range(3):
mav_buf2.heartbeat_send(mavlink.MAV_TYPE_GCS, 0, 0, 0, 0)
data = buf.last_data
print(f"第 {i} 次發送, Seq: {data[4]}, Raw: {data.hex()}")
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
import asyncio
import serial_asyncio
SERIAL_PORT = '/dev/ttyUSB0' # 修改為你的 serial port
SERIAL_BAUDRATE = 115200 # 修改為你的 baudrate
UDP_REMOTE_IP = '192.168.1.100' # 修改為目標 IP
UDP_REMOTE_PORT = 5005 # 修改為目標 port
UDP_LOCAL_PORT = 5006 # 本地 UDP 監聽 port
class SerialToUDP(asyncio.Protocol):
def __init__(self, udp_transport):
self.udp_transport = udp_transport
def connection_made(self, transport):
self.transport = transport
def data_received(self, data):
# Serial 收到資料,轉發到 UDP
self.udp_transport.sendto(data, (UDP_REMOTE_IP, UDP_REMOTE_PORT))
def write_to_serial(self, data):
self.transport.write(data)
class UDPToSerial(asyncio.DatagramProtocol):
def __init__(self, serial_proto):
self.serial_proto = serial_proto
def datagram_received(self, data, addr):
# UDP 收到資料,轉發到 Serial
self.serial_proto.write_to_serial(data)
async def main():
loop = asyncio.get_running_loop()
# 定義協議工廠函數
def create_empty_protocol():
return asyncio.DatagramProtocol()
# 建立 UDP1 傳輸
udp_transport, _ = await loop.create_datagram_endpoint(
create_empty_protocol,
local_addr=('0.0.0.0', UDP_LOCAL_PORT)
)
# 建立 Serial 傳輸
serial_proto = SerialToUDP(udp_transport)
def get_serial_protocol():
return serial_proto
_, serial_transport = await serial_asyncio.create_serial_connection(
loop, get_serial_protocol, SERIAL_PORT, baudrate=SERIAL_BAUDRATE
)
# 建立 UDP2 監聽
udp_proto = UDPToSerial(serial_proto)
def get_udp_protocol():
return udp_proto
udp_listen_transport, _ = await loop.create_datagram_endpoint(
get_udp_protocol,
local_addr=('0.0.0.0', UDP_LOCAL_PORT)
)
# 保持運行
try:
await asyncio.Future()
except asyncio.CancelledError:
pass
if __name__ == '__main__':
asyncio.run(main())
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# 網站清單
DOMAINS=("google.com" "smarter.nchu.edu.tw")
echo "網站 SSL 憑證剩餘天數:"
echo "---------------------------"
for domain in "${DOMAINS[@]}"; do
end_date=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null |
openssl x509 -noout -enddate | cut -d= -f2)
end_timestamp=$(date -d "$end_date" +%s)
now_timestamp=$(date +%s)
remaining_days=$(( (end_timestamp - now_timestamp) / 86400 ))
if [ $remaining_days -lt 0 ]; then
status="已過期 ❌"
elif [ $remaining_days -lt 15 ]; then
status="即將到期 ⚠️"
else
status="正常 ✅"
fi
printf "%-20s 到期日:%-25s 剩餘天數:%3d 天 %s\n" "$domain" "$end_date" "$remaining_days" "$status"
done
+40
View File
@@ -0,0 +1,40 @@
import socket
import sys
import select
# 設定來源 IP 與 Port
SRC_IP = '127.0.0.1' # 監聽所有介面
SRC_PORT = 16661 # 請自行修改
# 建立 UDP 監聽 socket
src_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
src_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
src_sock.bind((SRC_IP, SRC_PORT))
print(f"Listening for UDP on {SRC_IP}:{SRC_PORT}")
# 設定目標 Unix socket 路徑
UNIX_SOCKET_PATH = '/tmp/unix_socket_mavlink.sock' # 請自行修改
# 建立 Unix socket 連線
unix_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
unix_sock.connect(UNIX_SOCKET_PATH)
except Exception as e:
print(f"Failed to connect to unix socket: {e}")
sys.exit(1)
while True:
# 使用 select 監聽兩個 socket
readable, _, _ = select.select([src_sock, unix_sock], [], [])
for sock in readable:
if sock is src_sock:
data, addr = src_sock.recvfrom(4096)
if data:
unix_sock.sendall(data)
# print(f"Received UDP data from {addr}: {data}") # debug
# break # debug
elif sock is unix_sock:
data = unix_sock.recv(4096)
if data:
# 回送到最近收到資料的 UDP client
src_sock.sendto(data, addr)
+151
View File
@@ -0,0 +1,151 @@
from machine import UART
import time
import struct
import gc
# ================= 設定區 =================
FC_BAUDRATE = 115200
XB_BAUDRATE = 115200
DEST_64 = b'\x00\x13\xA2\x00\x42\x5B\x9D\xC8'
XBEE_MAX_PAYLOAD = 100
MY_SYSID = 15 # 第二台就改成 10,第三台 15
POLL_MAGIC = b'POLL'
tx_buf = bytearray()
MAX_BUF_SIZE = 6144 # 保留安全餘裕的緩衝區大小
# =========================================
uart_fc = UART(1, baudrate=FC_BAUDRATE, tx=32, rx=33, rxbuf=4096)
uart_xb = UART(2, baudrate=XB_BAUDRATE, tx=25, rx=26, rxbuf=4096)
rx_buf = bytearray()
def get_checksum(data):
return 0xFF - (sum(data) & 0xFF)
def send_to_xbee_chunked(payload):
total_len = len(payload)
sent_len = 0
while sent_len < total_len:
end_len = min(sent_len + XBEE_MAX_PAYLOAD, total_len)
chunk = payload[sent_len : end_len]
sent_len = end_len
frame_content = bytearray()
frame_content.append(0x10)
frame_content.append(0x00) # 0x00 代表不要求 XBee ACK,極大加快發送速度
frame_content.extend(DEST_64)
frame_content.extend(b'\xFF\xFE')
frame_content.append(0x00)
frame_content.append(0x00)
frame_content.extend(chunk)
length = len(frame_content)
checksum = get_checksum(frame_content)
packet = bytearray()
packet.append(0x7E)
packet.append((length >> 8) & 0xFF)
packet.append(length & 0xFF)
packet.extend(frame_content)
packet.append(checksum)
uart_xb.write(packet)
# 僅需極短暫延遲,讓硬體發送緩衝區消化即可
time.sleep_ms(1)
def process_xbee_buffer():
global rx_buf, tx_buf
while True:
start_pos = rx_buf.find(b'\x7E')
if start_pos == -1:
rx_buf = bytearray() # 避免用 clear()
return
if start_pos > 0:
rx_buf = rx_buf[start_pos:]
if len(rx_buf) < 3: return
pkt_len = (rx_buf[1] << 8) | rx_buf[2]
total_len = pkt_len + 4
if pkt_len > 300:
rx_buf = rx_buf[1:]
continue
if len(rx_buf) < total_len: return
packet = rx_buf[3 : 3+pkt_len]
checksum_recv = rx_buf[3+pkt_len]
if get_checksum(packet) == checksum_recv:
if packet[0] == 0x90:
real_data = packet[12:]
if len(real_data) == 5 and real_data.startswith(POLL_MAGIC):
if real_data[4] == MY_SYSID:
flush_tx_buffer()
else:
uart_fc.write(real_data)
rx_buf = rx_buf[total_len:]
else:
rx_buf = rx_buf[1:]
def flush_tx_buffer():
global tx_buf
if len(tx_buf) == 0:
return
# 單次最大發送量設為 1200 bytes,避免在空中飛行超過時槽導致碰撞
send_limit = min(len(tx_buf), 1200)
cut_idx = send_limit
if send_limit < len(tx_buf):
last_fd_pos = tx_buf[:send_limit].rfind(b'\xFD')
if last_fd_pos > 0:
cut_idx = last_fd_pos
data_to_send = tx_buf[:cut_idx]
tx_buf = tx_buf[cut_idx:]
send_to_xbee_chunked(data_to_send)
# 初始清理
gc.collect()
while True:
try:
# FC -> 緩衝區
if uart_fc.any():
data = uart_fc.read(250)
if data:
tx_buf.extend(data)
# 聰明的溫和防爆機制 (砍掉舊的 2/3,保留最新 1/3)
if len(tx_buf) > MAX_BUF_SIZE:
safe_cut = tx_buf.find(b'\xFD', (MAX_BUF_SIZE // 3) * 2)
if safe_cut != -1:
tx_buf = tx_buf[safe_cut:]
else:
tx_buf = tx_buf[(MAX_BUF_SIZE // 2):]
# XBee -> FC
if uart_xb.any():
chunk = uart_xb.read()
if chunk:
rx_buf.extend(chunk)
process_xbee_buffer()
time.sleep_ms(1)
except MemoryError:
# 發生 OOM 時的「斷尾求生」法,強制捨棄舊變數以釋放空間,防止死機
tx_buf = bytearray()
rx_buf = bytearray()
gc.collect()
time.sleep_ms(10)
except Exception as e:
time.sleep_ms(2)
+229
View File
@@ -0,0 +1,229 @@
import rclpy
from pymavlink import mavutil
from time import sleep
import time
import socket
import struct
# used at fdm_switch_example_two
import threading
import queue
def mavlink_analyzer_simple(count = 500):
# rclpy.init()
# node = rclpy.create_node('mavlink_analyzer_simple')
print('Inintializing connection')
connection_string="udp:127.0.0.1:14550"
connection = mavutil.mavlink_connection(connection_string)
print('Catch messages')
msg_count = {}
start_time = time.time()
for _ in range(count):
msg = connection.recv_msg()
# msg = connection.recv_match(blocking=True)
if msg:
msg_type = msg.get_type()
if msg_type in msg_count:
msg_count[msg_type] += 1
else:
msg_count[msg_type] = 1
if msg.get_type() == 'SERVO_OUTPUT_RAW':
print(msg)
pass
else:
print('No message yet, 1 second for data to fill')
sleep(1)
print("markA ------")
print(msg.get_type())
print(msg.ordered_fieldnames)
print("markA ------ END")
print('Messages Type received:')
print(msg_count)
end_time = time.time()
print('Time elapsed: ', end_time - start_time)
print('Closing connection')
connection.close()
def fdm_parser_example(data=None):
if data is None:
data = bytes.fromhex('1a48e903b1340500e803e803e803e803000000000000000000000000000000000000000000000000')
# 1a48e903b1340500e803e803e803e803000000000000000000000000000000000000000000000000
parse_format = 'HHI16H'
if len(data) != struct.calcsize(parse_format):
print("got packet of len %u, expected %u" % (len(data), struct.calcsize(parse_format)))
exit(1)
decoded = struct.unpack(parse_format,data)
magic = decoded[0]
frame_rate_hz = decoded[1]
frame_count = decoded[2]
pwm = decoded[3:]
print("magic: %u, frame_rate_hz: %u, frame_count: %u, pwm: %s" % (magic, frame_rate_hz, frame_count, pwm))
def fdm_switch_example_one():
'''
這個範例在 SITL 與 Gazebo 之間 做一個簡單的UDP轉發器
沒有轉換數據格式
'''
# make a link get fdm from SITL, as a server
sock_s2g = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_s2g.bind(('', 9002))
sock_s2g.settimeout(0.1)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19002)
packet_count = 0
start_time = time.time()
while True:
try:
# 接到SITL的數據 並轉發給Gazebo
data_s2g, addr_s2g = sock_s2g.recvfrom(1024)
sock_g2s.sendto(data_s2g, server_address)
# 得到Gazebo的回應 並轉發給SITL
data_g2s, addr_g2s = sock_g2s.recvfrom(1024)
sock_s2g.sendto(data_g2s, addr_s2g)
packet_count += 1
except socket.timeout:
pass
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
# print(f"Packets received in the last second: {packet_count}")
print(f'JSON Pack : {data_g2s}')
packet_count = 0
start_time = current_time
fdm_parser_example(data_s2g)
def fdm_switch_example_two():
'''
這個範例在 SITL 與 Gazebo 之間 做一個簡單的UDP轉發器
有轉換數據格式
time_usec
servo1_raw
servo16_raw
'''
# read info from SITL via MAVLink
connection_string="udp:127.0.0.1:14550" # uart mavlink # MATLAB
connection = mavutil.mavlink_connection(connection_string)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19002)
data_queue = queue.Queue()
servo_out = [0] * 16 # [0 0 0 0 0 0 0 0 0.....]
data_queue.put(servo_out)
def send_data_from_queue(sock, server_address, q, frame_rate_fdm=1, frame_count_fdm=1):
interval = 1.0 / frame_rate_fdm
while True:
start_time = time.time()
try:
data = q.get(timeout=0.1) # [0 0 0 0 0 0 0 0 0.....]
if data is None:
break
last_data = data
except queue.Empty:
data = last_data
data_fdm = struct.pack('HHI16H', 0x481a, int(frame_rate_fdm * 0.1), frame_count_fdm, *servo_out) # [ FMD ]
sock.sendto(data_fdm, server_address)
frame_count_fdm += 3
# fdm_parser_example(data_fdm) # debug
elapsed_time = time.time() - start_time
sleep_time = interval - elapsed_time
if sleep_time > 0:
sleep(sleep_time)
Running = True
thread = threading.Thread(target=send_data_from_queue, args=(sock_g2s, server_address, data_queue))
thread.start()
print('Start sending data to Gazebo')
while Running:
msgb1 = None
msg = connection.recv_msg()
while msg :
if msg.get_type() == 'SERVO_OUTPUT_RAW':
msgb1 = msg
# break # 這裡不需要break,因為我要最後一個 servo_output_raw 的值
msg = connection.recv_msg()
if msgb1:
for i in range(16):
servo_out[i] = getattr(msgb1, f'servo{i+1}_raw')
data_queue.put(servo_out)
msgb1 = None
# Running = False # debug
else:
# print('No message yet, 10 ms for data to fill')
sleep(0.01)
# Example of putting data into the queue
# data_queue.put(data)
# To stop the thread, you can put None into the queue
# data_queue.put(None)
from pymavlink.dialects.v20 import common # 使用 MAVLink 2.0
def mavlink_btye_generator_test():
# 創建一個 MAVLink 對象
mav = common.MAVLink(None) # 不透過 connection,直接使用 None
# 創建一個 HEARTBEAT 訊息
msg = mav.heartbeat_encode(
type=mavutil.mavlink.MAV_TYPE_QUADROTOR,
autopilot=mavutil.mavlink.MAV_AUTOPILOT_GENERIC,
base_mode=0,
custom_mode=0,
system_status=mavutil.mavlink.MAV_STATE_ACTIVE
)
# 取得 MAVLink 訊息的 bytes
mavlink_bytes = msg.pack(mav)
# 回傳 bytes
print(mavlink_bytes)
print(type(mavlink_bytes))
mav2 = common.MAVLink(None)
# my_msg = mav2.decode(mavlink_bytes)
print("========")
print(dir(mav2))
# print(my_msg)
print('Start')
# fdm_switch_example_two()
# fdm_parser_example()
# mavlink_analyzer_simple(8)
mavlink_btye_generator_test()
+193
View File
@@ -0,0 +1,193 @@
import asyncio
import serial_asyncio
import struct
import os
import sys
import serial
import signal
import traceback
from pymavlink import mavutil
# === 設定區 ===
SERIAL_PORT = 'COM15' # 手動指定
SERIAL_BAUDRATE = 57600
UDP_REMOTE_IP = '127.0.0.1'
UDP_REMOTE_PORT = 14550
DEBUG_MODE = False
TARGET_ADDR64 = b'\x00\x00\x00\x00\x00\x00\xFF\xFF' # 廣播
# === 工具函數 ===
def check_serial_port():
try:
ser = serial.Serial(SERIAL_PORT, SERIAL_BAUDRATE)
ser.close()
return True
except serial.SerialException as e:
print(f"錯誤:串口設備 {SERIAL_PORT} 被占用或無法訪問:{str(e)}")
return False
except Exception as e:
print(f"錯誤:檢查串口時發生未知錯誤:{str(e)}")
return False
def build_api_tx_frame(data: bytes, dest_addr64: bytes, frame_id=0x01) -> bytes:
frame_type = 0x10
dest_addr16 = b'\xFF\xFE'
broadcast_radius = 0x00
options = 0x00
frame = struct.pack(">B", frame_type) + struct.pack(">B", frame_id)
frame += dest_addr64 + dest_addr16
frame += struct.pack(">BB", broadcast_radius, options) + data
checksum = 0xFF - (sum(frame) & 0xFF)
return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
# === Serial Protocol 實作 ===
class SerialToUDP(asyncio.Protocol):
def __init__(self, udp_protocol):
self.udp_protocol = udp_protocol
self.buffer = bytearray()
def connection_made(self, transport):
self.transport = transport
if hasattr(self.udp_protocol, 'set_serial_transport'):
self.udp_protocol.set_serial_transport(self)
print(f"Serial connection established on {SERIAL_PORT}")
def data_received(self, data):
self.buffer.extend(data)
while True:
if len(self.buffer) < 3:
return
if self.buffer[0] != 0x7E:
self.buffer.pop(0)
continue
length = (self.buffer[1] << 8) | self.buffer[2]
full_length = 3 + length + 1
if len(self.buffer) < full_length:
return
frame = self.buffer[:full_length]
del self.buffer[:full_length]
if hasattr(self.udp_protocol, 'send_udp'):
self.udp_protocol.send_udp(bytes(frame))
def write_to_serial(self, data):
try:
api_frame = build_api_tx_frame(data, TARGET_ADDR64)
pass
self.transport.write(api_frame)
except Exception as e:
print(f"[TX Error] 無法封裝或傳送資料: {e}")
# === UDP Protocol 實作 ===
class UDPHandler(asyncio.DatagramProtocol):
def __init__(self):
self.serial_transport = None
self.transport = None
self.mav_decoder = mavutil.mavlink.MAVLink(None)
def connection_made(self, transport):
self.transport = transport
print("UDP transport ready.")
def set_serial_transport(self, serial_transport):
self.serial_transport = serial_transport
def datagram_received(self, data, addr):
if self.serial_transport:
self.serial_transport.write_to_serial(data)
def send_udp(self, data):
decoded_data = self.decapsulate_data(data)
if decoded_data is None:
pass
return
self.decode_mavlink_data(decoded_data)
if self.transport:
self.transport.sendto(decoded_data, (UDP_REMOTE_IP, UDP_REMOTE_PORT))
def decapsulate_data(self, data):
try:
if not data or data[0] != 0x7E:
return None
length = (data[1] << 8) | data[2]
if len(data) < length + 4:
return None
frame_type = data[3]
if frame_type == 0x90:
rf_data_start = 3 + 12
return data[rf_data_start:3 + length]
else:
return None
except Exception as e:
print(f"[XBee 解封錯誤] {e}")
return None
def decode_mavlink_data(self, data):
try:
msg = self.mav_decoder.parse_char(data)
if msg:
if msg.get_type() == "HEARTBEAT":
pass # 不輸出任何訊息
else:
pass
except Exception as e:
print(f"[MAVLink Decode Error] {e}")
# === 主流程 ===
async def main():
if not check_serial_port():
print("程式終止:串口檢查失敗")
return
loop = asyncio.get_running_loop()
if os.name != 'nt': # Windows 不支援 add_signal_handler
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda: asyncio.create_task(shutdown(loop)))
udp_handler = UDPHandler()
try:
udp_transport, _ = await loop.create_datagram_endpoint(
lambda: udp_handler,
local_addr=('0.0.0.0', 0)
)
except Exception as e:
print(f"無法創建 UDP 端點:{str(e)}")
return
sock = udp_transport.get_extra_info('socket')
print(f"UDP listening on {sock.getsockname()}")
try:
serial_proto = SerialToUDP(udp_handler)
await serial_asyncio.create_serial_connection(
loop, lambda: serial_proto, SERIAL_PORT, baudrate=SERIAL_BAUDRATE
)
except Exception as e:
print(f"無法建立串口連接:{str(e)}")
traceback.print_exc()
udp_transport.close()
return
print("等待串口資料...")
try:
await asyncio.Future()
except asyncio.CancelledError:
pass
async def shutdown(loop):
print("Shutting down...")
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
loop.stop()
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
print("程式被使用者中斷")
except Exception as e:
print("程式執行錯誤:")
traceback.print_exc()
+500
View File
@@ -0,0 +1,500 @@
import asyncio
import serial_asyncio
import struct
import serial
import time
import threading
import tkinter as tk
from tkinter import ttk
from collections import deque, defaultdict
from pymavlink import mavutil
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# === 多組設備設定 ===
CONFIGS = [
{"serial_port": "/dev/ttyUSB0", "udp_port": 14551},
{"serial_port": "COM15", "udp_port": 14570},
{"serial_port": "/dev/ttyUSB2", "udp_port": 14553},
{"serial_port": "/dev/ttyUSB3", "udp_port": 14554},
]
SERIAL_BAUDRATE = 115200
UDP_REMOTE_IP = '127.0.0.1'
TARGET_ADDR64 = b'\x00\x00\x00\x00\x00\x00\xFF\xFF'
ACTIVE_SYSIDS = [3, 10, 15]
POLL_MAGIC = b'POLL'
# === 全域變數與狀態追蹤 ===
current_base_slot_ms = 250 # 預設 TDMA 時槽
LOSS_TIME_WINDOW_SEC = 5.0 # 丟包率計算的時間視窗 (5秒)
uav_states = {
sysid: {
"mode": "NORMAL",
} for sysid in ACTIVE_SYSIDS
}
rssi_history = defaultdict(lambda: deque(maxlen=5000))
time_history = defaultdict(lambda: deque(maxlen=5000))
packet_loss_history = defaultdict(lambda: deque(maxlen=1000))
packet_loss_time_history = defaultdict(lambda: deque(maxlen=1000))
mavlink_sequence_tracker = defaultdict(dict)
packet_loss_stats = defaultdict(lambda: {'loss_rate': 0.0, 'total_received': 0, 'total_lost': 0})
serial_to_sysid = {}
serial_last_mavlink_time = {}
last_db_query_time = {}
# === 核心邏輯區 ===
def calculate_packet_loss(sysid, compid, current_seq):
global mavlink_sequence_tracker, packet_loss_stats
tracker = mavlink_sequence_tracker[sysid]
now = time.time()
if compid not in tracker:
tracker[compid] = {
'last_seq': current_seq,
'history': deque()
}
return 0.0
comp_tracker = tracker[compid]
last_seq = comp_tracker['last_seq']
if current_seq > last_seq:
expected = current_seq - last_seq
elif current_seq < last_seq:
expected = (255 - last_seq) + current_seq + 1
else:
return packet_loss_history[sysid][-1] if packet_loss_history[sysid] else 0.0
lost = max(0, expected - 1)
comp_tracker['history'].append((now, expected, lost))
comp_tracker['last_seq'] = current_seq
total_expected_all = 0
total_lost_all = 0
for c_id, c_data in tracker.items():
if 'history' in c_data:
while c_data['history'] and (now - c_data['history'][0][0]) > LOSS_TIME_WINDOW_SEC:
c_data['history'].popleft()
total_expected_all += sum(item[1] for item in c_data['history'])
total_lost_all += sum(item[2] for item in c_data['history'])
overall_loss_rate = (total_lost_all / total_expected_all) * 100.0 if total_expected_all > 0 else 0.0
packet_loss_stats[sysid] = {
'loss_rate': overall_loss_rate,
'total_received': total_expected_all - total_lost_all,
'total_lost': total_lost_all
}
packet_loss_history[sysid].append(overall_loss_rate)
packet_loss_time_history[sysid].append(now)
return overall_loss_rate
def build_api_tx_frame(data: bytes, dest_addr64: bytes, frame_id=0x00) -> bytes:
frame = b'\x10' + struct.pack(">B", frame_id) + dest_addr64 + b'\xFF\xFE\x00\x00' + data
return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", 0xFF - (sum(frame) & 0xFF))
class SerialToUDP(asyncio.Protocol):
def send_poll(self, target_sysid):
poll_payload = POLL_MAGIC + struct.pack("B", target_sysid)
api_frame = build_api_tx_frame(poll_payload, TARGET_ADDR64, 0x00)
self.transport.write(api_frame)
def __init__(self, udp_protocol, serial_port):
self.udp_protocol = udp_protocol
self.serial_port = serial_port
self.buffer = bytearray()
self.gcs_tx_queue = bytearray()
def connection_made(self, transport):
self.transport = transport
if hasattr(self.udp_protocol, 'set_serial_transport'):
self.udp_protocol.set_serial_transport(self)
print(f"[{self.serial_port}] Serial connection established.")
def data_received(self, data):
self.buffer.extend(data)
while True:
try:
start_idx = self.buffer.index(0x7E)
if start_idx > 0:
del self.buffer[:start_idx]
except ValueError:
self.buffer.clear()
return
if len(self.buffer) < 3: return
length = (self.buffer[1] << 8) | self.buffer[2]
full_length = 3 + length + 1
if length > 300:
self.buffer.pop(0)
continue
if len(self.buffer) < full_length: return
frame = self.buffer[:full_length]
checksum = 0xFF - (sum(frame[3:-1]) & 0xFF)
if checksum != frame[-1]:
self.buffer.pop(0)
continue
del self.buffer[:full_length]
if hasattr(self.udp_protocol, 'send_udp'):
self.udp_protocol.send_udp(bytes(frame))
if frame[3] == 0x88 and frame[5:7] == b'DB':
status = frame[7]
if status == 0x00 and len(frame) > 8:
rssi_value = frame[8]
now = time.time()
last_time = serial_last_mavlink_time.get(self.serial_port, 0)
if now - last_time <= 0.5:
sysid = serial_to_sysid.get(self.serial_port, None)
if sysid is not None:
rssi_history[sysid].append(-rssi_value)
time_history[sysid].append(now)
def write_to_serial(self, data):
self.gcs_tx_queue.extend(data)
def flush_gcs_queue(self):
if not self.gcs_tx_queue: return
send_limit = min(len(self.gcs_tx_queue), 150)
data_to_send = self.gcs_tx_queue[:send_limit]
self.gcs_tx_queue = self.gcs_tx_queue[send_limit:]
asyncio.create_task(self._async_send_chunks(data_to_send))
async def _async_send_chunks(self, data):
try:
MAX_PAYLOAD = 80
sent_len = 0
while sent_len < len(data):
end_len = min(sent_len + MAX_PAYLOAD, len(data))
chunk = data[sent_len:end_len]
sent_len = end_len
api_frame = build_api_tx_frame(chunk, TARGET_ADDR64, 0x00)
self.transport.write(api_frame)
await asyncio.sleep(0.01)
except Exception:
pass
def send_at_command_db(self):
try:
frame_type = 0x08
frame_id = 0x52
at_command = b'DB'
parameter = b''
frame = struct.pack(">B", frame_type) + struct.pack(">B", frame_id) + at_command + parameter
checksum = 0xFF - (sum(frame) & 0xFF)
api_frame = b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
self.transport.write(api_frame)
except Exception:
pass
class UDPHandler(asyncio.DatagramProtocol):
def __init__(self, udp_port):
self.udp_port = udp_port
self.serial_transport = None
self.transport = None
self.mav_decoder = mavutil.mavlink.MAVLink(None)
def connection_made(self, transport):
self.transport = transport
def set_serial_transport(self, serial_transport):
self.serial_transport = serial_transport
def datagram_received(self, data, addr):
if self.serial_transport:
self.serial_transport.write_to_serial(data)
def decapsulate_data(self, data):
try:
if not data or data[0] != 0x7E:
return None
length = (data[1] << 8) | data[2]
if len(data) < length + 4:
return None
frame_type = data[3]
if frame_type == 0x90:
rf_data_start = 3 + 12
return data[rf_data_start:3 + length]
else:
return None
except Exception:
return None
def send_udp(self, data):
decoded_data = self.decapsulate_data(data)
if decoded_data is None: return
try:
for byte in decoded_data:
msg = self.mav_decoder.parse_char(bytes([byte]))
if msg:
sysid = msg.get_srcSystem()
compid = msg.get_srcComponent()
seq = msg.get_seq()
if sysid == 0: continue
if self.serial_transport:
port_name = self.serial_transport.serial_port
serial_to_sysid[port_name] = sysid
serial_last_mavlink_time[port_name] = time.time()
now = time.time()
last_query = last_db_query_time.get(port_name, 0)
if now - last_query >= 0.5:
self.serial_transport.send_at_command_db()
last_db_query_time[port_name] = now
calculate_packet_loss(sysid, compid, seq)
except Exception:
pass
if self.transport:
self.transport.sendto(decoded_data, (UDP_REMOTE_IP, self.udp_port))
async def setup_bridge(config):
port, udp = config["serial_port"], config["udp_port"]
try:
ser = serial.Serial(port, SERIAL_BAUDRATE)
ser.close()
except: return None
loop = asyncio.get_running_loop()
udp_handler = UDPHandler(udp)
await loop.create_datagram_endpoint(lambda: udp_handler, local_addr=('0.0.0.0', 0))
serial_proto = SerialToUDP(udp_handler, port)
await serial_asyncio.create_serial_connection(loop, lambda: serial_proto, port, baudrate=SERIAL_BAUDRATE)
return serial_proto
async def tdma_scheduler(serial_protocols):
print(f"TDMA Scheduler Started... 找到 {len(serial_protocols)} 個可用端口")
while True:
# GCS 下行時槽
for sp in serial_protocols:
if hasattr(sp, 'flush_gcs_queue'):
sp.flush_gcs_queue()
await asyncio.sleep(0.1)
# UAV 上行時槽
for sysid in ACTIVE_SYSIDS:
state = uav_states[sysid]
if state["mode"] == "INITIALIZING":
# VIP 模式:0.8秒內連發4次點名,暴力獲取參數
for _ in range(4):
for sp in serial_protocols:
if hasattr(sp, 'send_poll'):
sp.send_poll(sysid)
await asyncio.sleep(0.2)
else:
# NORMAL 模式:聽從滑桿設定的時間
slot_time = current_base_slot_ms / 1000.0
for sp in serial_protocols:
if hasattr(sp, 'send_poll'):
sp.send_poll(sysid)
await asyncio.sleep(slot_time)
async def async_main():
protocols = await asyncio.gather(*(setup_bridge(cfg) for cfg in CONFIGS))
valid_protocols = [p for p in protocols if p is not None]
if valid_protocols:
asyncio.create_task(tdma_scheduler(valid_protocols))
await asyncio.Future()
# === GUI 介面區 ===
def start_gui():
root = tk.Tk()
root.title("UAV TDMA Control Station")
root.geometry("1200x800")
# --- 左側控制面板 ---
control_frame = tk.Frame(root, width=300, bg="#f0f0f0", padx=20, pady=20)
control_frame.pack(side=tk.LEFT, fill=tk.Y)
tk.Label(control_frame, text="TDMA 基礎時槽控制", font=("Arial", 14, "bold"), bg="#f0f0f0").pack(pady=10)
slider_val = tk.IntVar(value=current_base_slot_ms)
def on_slider_change(val):
global current_base_slot_ms
current_base_slot_ms = int(float(val))
val_label.config(text=f"當前設定: {current_base_slot_ms} ms")
slider = ttk.Scale(control_frame, from_=50, to_=1000, orient='horizontal', variable=slider_val, command=on_slider_change)
slider.pack(fill=tk.X, pady=10)
val_label = tk.Label(control_frame, text=f"當前設定: {current_base_slot_ms} ms", bg="#f0f0f0")
val_label.pack()
tk.Label(control_frame, text="群機初始化控制 (INIT)", font=("Arial", 14, "bold"), bg="#f0f0f0").pack(pady=(30, 10))
# === 1. 改為單選按鈕 (Radiobutton) ===
init_var = tk.IntVar(value=0) # 0 代表全體 NORMAL
radios = {}
status_labels = {}
def on_radio_change():
selected = init_var.get()
for sysid in ACTIVE_SYSIDS:
if sysid == selected:
uav_states[sysid]["mode"] = "INITIALIZING"
else:
uav_states[sysid]["mode"] = "NORMAL"
# 新增一個「取消特權」的選項
# tk.Radiobutton(control_frame, text="全體 NORMAL (無特權)", variable=init_var, value=0, command=on_radio_change, bg="#f0f0f0", font=("Arial", 11, "bold"), fg="blue").pack(anchor=tk.W, pady=(0, 10))
for sysid in ACTIVE_SYSIDS:
frame = tk.Frame(control_frame, bg="#f0f0f0")
frame.pack(fill=tk.X, pady=5)
rb = tk.Radiobutton(frame, text=f"SYSID {sysid} 專屬載入", variable=init_var, value=sysid,
command=on_radio_change, bg="#f0f0f0", font=("Arial", 11))
rb.pack(side=tk.LEFT)
radios[sysid] = rb
lbl = tk.Label(frame, text="NORMAL", font=("Arial", 10, "bold"), fg="green", bg="#f0f0f0")
lbl.pack(side=tk.RIGHT, padx=5)
status_labels[sysid] = lbl
# === 2. 改為開關式的鎖定按鈕 (Toggle) ===
is_locked = False
def toggle_tdma_mode():
nonlocal is_locked
is_locked = not is_locked
if is_locked:
# 鎖定:強制切回 0 (全體 NORMAL),並禁用單選按鈕
init_var.set(0)
on_radio_change()
for rb in radios.values():
rb.config(state=tk.DISABLED)
# 改變按鈕外觀為「解鎖」狀態
lock_btn.config(text="點擊解鎖 (允許重新下載參數)", bg="orange")
print("已鎖定進入純 TDMA 模式")
else:
# 解鎖:恢復單選按鈕功能
for rb in radios.values():
rb.config(state=tk.NORMAL)
# 改變按鈕外觀為「鎖定」狀態
lock_btn.config(text="參數載入完畢,鎖定進入 TDMA", bg="#4CAF50")
print("已解除鎖定,可重新分配特權")
lock_btn = tk.Button(control_frame, text="參數載入完畢,鎖定進入 TDMA",
font=("Arial", 12, "bold"), bg="#4CAF50", fg="white",
command=toggle_tdma_mode)
lock_btn.pack(pady=30, fill=tk.X)
# 動態更新狀態標籤
def update_status_gui():
for sysid, lbl in status_labels.items():
mode = uav_states[sysid]["mode"]
if mode == "INITIALIZING":
lbl.config(text="INIT (特權下載中)", fg="orange")
else:
lbl.config(text="NORMAL", fg="green")
root.after(500, update_status_gui)
update_status_gui()
# --- 右側圖表區 ---
plot_frame = tk.Frame(root)
plot_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 8), dpi=100)
canvas = FigureCanvasTkAgg(fig, master=plot_frame)
canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
def update_plot(frame):
ax1.clear()
ax2.clear()
ax1.set_title("RSSI", fontsize=12); ax1.set_xlim(10, 0); ax1.set_ylim(-100, -10); ax1.grid(True, alpha=0.3)
ax2.set_title("Packet Loss Rate (5s Window)", fontsize=12); ax2.set_xlim(10, 0); ax2.set_ylim(0, 100); ax2.grid(True, alpha=0.3)
now = time.time()
colors = ['blue', 'red', 'green', 'orange', 'purple']
try: sysids = sorted(list(rssi_history.keys()))
except RuntimeError: return
loss_labels = []
for i, sysid in enumerate(sysids):
color = colors[i % len(colors)]
try:
t_hist, r_hist = list(time_history[sysid]), list(rssi_history[sysid])
lt_hist, l_hist = list(packet_loss_time_history.get(sysid, [])), list(packet_loss_history.get(sysid, []))
except RuntimeError: continue
rssi_recent = [idx for idx, ts in enumerate(t_hist) if now - ts <= 10]
if rssi_recent:
ax1.plot([now - t_hist[idx] for idx in rssi_recent], [r_hist[idx] for idx in rssi_recent], label=f"SYSID:{sysid}", color=color)
loss_recent = [idx for idx, ts in enumerate(lt_hist) if now - ts <= 10]
if loss_recent:
loss_t = [now - lt_hist[idx] for idx in loss_recent]
loss_r = [l_hist[idx] for idx in loss_recent]
ax2.plot(loss_t, loss_r, label=f"SYSID:{sysid}", color=color, marker='o', markersize=3)
if loss_r:
loss_labels.append({
'sysid': sysid, 'y_real': loss_r[-1], 'x_real': loss_t[-1], 'color': color
})
if loss_labels:
loss_labels = sorted(loss_labels, key=lambda k: k['y_real'])
min_gap = 12.0
y_positions = [lbl['y_real'] for lbl in loss_labels]
for j in range(1, len(y_positions)):
if y_positions[j] - y_positions[j-1] < min_gap:
y_positions[j] = y_positions[j-1] + min_gap
if y_positions[-1] > 90:
shift = y_positions[-1] - 90
y_positions = [y - shift for y in y_positions]
for j, lbl in enumerate(loss_labels):
sysid = lbl['sysid']
color = lbl['color']
real_y = lbl['y_real']
text_y = y_positions[j]
ax2.text(0.5, text_y, f'ID:{sysid} ({real_y:.1f}%)',
bbox=dict(boxstyle="round,pad=0.3", facecolor=color, alpha=0.8),
fontsize=10, fontweight='bold', color='white',
horizontalalignment='right', verticalalignment='center')
if abs(real_y - text_y) > 1.0:
ax2.plot([lbl['x_real'], 0.5], [real_y, text_y], color=color, linestyle=':', alpha=0.6)
ax1.legend(loc="upper left")
ax2.legend(loc="upper left")
ani = animation.FuncAnimation(fig, update_plot, interval=1000)
def on_closing():
root.quit()
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_closing)
root.mainloop()
if __name__ == '__main__':
threading.Thread(target=lambda: asyncio.run(async_main()), daemon=True).start()
start_gui()
+384
View File
@@ -0,0 +1,384 @@
# === import 保持不變 ===
import asyncio
import serial_asyncio
import struct
import serial
import traceback
import time
from collections import deque, defaultdict
from pymavlink import mavutil
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import threading
# === 多組設備設定 ===
CONFIGS = [
{"serial_port": "/dev/ttyUSB2", "udp_port": 14551},
{"serial_port": "COM43", "udp_port": 14552},
{"serial_port": "COM15", "udp_port": 14553},
]
SERIAL_BAUDRATE = 115200
UDP_REMOTE_IP = '127.0.0.1'
TARGET_ADDR64 = b'\x00\x00\x00\x00\x00\x00\xFF\xFF'
# === 數據存儲 ===
rssi_history = defaultdict(lambda: deque(maxlen=5000))
time_history = defaultdict(lambda: deque(maxlen=5000))
# === 新增:丟包率追蹤 ===
packet_loss_history = defaultdict(lambda: deque(maxlen=1000))
packet_loss_time_history = defaultdict(lambda: deque(maxlen=1000))
mavlink_sequence_tracker = defaultdict(dict) # {sysid: {compid: {'last_seq': x, 'total_packets': y, 'lost_packets': z}}}
packet_loss_stats = defaultdict(lambda: {'loss_rate': 0.0, 'total_received': 0, 'total_lost': 0})
serial_to_sysid = {}
serial_last_mavlink_time = {} # 優化 1:追蹤各 serial port 最近的 MAVLink 時間
def calculate_packet_loss(sysid, compid, current_seq):
"""計算丟包率"""
global mavlink_sequence_tracker, packet_loss_stats
tracker = mavlink_sequence_tracker[sysid]
if compid not in tracker:
# 第一次收到這個component的消息
tracker[compid] = {
'last_seq': current_seq,
'total_packets': 1,
'lost_packets': 0,
'last_update': time.time()
}
return 0.0
comp_tracker = tracker[compid]
last_seq = comp_tracker['last_seq']
# 計算序列號差異(處理255溢出)
if current_seq > last_seq:
expected_packets = current_seq - last_seq
elif current_seq < last_seq:
# 序列號溢出(0-255循環)
expected_packets = (255 - last_seq) + current_seq + 1
else:
# 重複的序列號,忽略
return comp_tracker.get('loss_rate', 0.0)
# 更新統計
comp_tracker['total_packets'] += expected_packets
lost_packets = expected_packets - 1 # 實際收到1個,應該收到expected_packets個
comp_tracker['lost_packets'] += max(0, lost_packets)
comp_tracker['last_seq'] = current_seq
comp_tracker['last_update'] = time.time()
# 計算丟包率
total_expected = comp_tracker['total_packets']
total_lost = comp_tracker['lost_packets']
loss_rate = (total_lost / total_expected) * 100.0 if total_expected > 0 else 0.0
comp_tracker['loss_rate'] = loss_rate
# 更新全局統計(按sysid匯總所有component)
total_received = 0
total_lost_all = 0
total_expected_all = 0
for comp_id, comp_data in tracker.items():
total_expected_all += comp_data['total_packets']
total_lost_all += comp_data['lost_packets']
total_received += comp_data['total_packets'] - comp_data['lost_packets']
overall_loss_rate = (total_lost_all / total_expected_all) * 100.0 if total_expected_all > 0 else 0.0
packet_loss_stats[sysid] = {
'loss_rate': overall_loss_rate,
'total_received': total_received,
'total_lost': total_lost_all
}
# 記錄到歷史數據
now = time.time()
packet_loss_history[sysid].append(overall_loss_rate)
packet_loss_time_history[sysid].append(now)
return overall_loss_rate
def build_api_tx_frame(data: bytes, dest_addr64: bytes, frame_id=0x01) -> bytes:
frame_type = 0x10
dest_addr16 = b'\xFF\xFE'
broadcast_radius = 0x00
options = 0x00
frame = struct.pack(">B", frame_type) + struct.pack(">B", frame_id)
frame += dest_addr64 + dest_addr16
frame += struct.pack(">BB", broadcast_radius, options) + data
checksum = 0xFF - (sum(frame) & 0xFF)
return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
class SerialToUDP(asyncio.Protocol):
def __init__(self, udp_protocol, serial_port):
self.udp_protocol = udp_protocol
self.serial_port = serial_port
self.buffer = bytearray()
def connection_made(self, transport):
self.transport = transport
if hasattr(self.udp_protocol, 'set_serial_transport'):
self.udp_protocol.set_serial_transport(self)
print(f"[{self.serial_port}] Serial connection established.")
def data_received(self, data):
self.buffer.extend(data)
while True:
if len(self.buffer) < 3:
return
if self.buffer[0] != 0x7E:
self.buffer.pop(0)
continue
length = (self.buffer[1] << 8) | self.buffer[2]
full_length = 3 + length + 1
if len(self.buffer) < full_length:
return
frame = self.buffer[:full_length]
del self.buffer[:full_length]
if hasattr(self.udp_protocol, 'send_udp'):
self.udp_protocol.send_udp(bytes(frame))
if frame[3] == 0x88 and frame[5:7] == b'DB':
status = frame[7]
if status == 0x00 and len(frame) > 8:
rssi_value = frame[8]
now = time.time()
# === 優化 1:僅信任最近 0.5 秒內有接收 MAVLink 的 port
last_time = serial_last_mavlink_time.get(self.serial_port, 0)
if now - last_time <= 0.5:
sysid = serial_to_sysid.get(self.serial_port, None)
if sysid is not None:
rssi_history[sysid].append(-rssi_value)
time_history[sysid].append(now)
# print(f"[SYSID:{sysid}] RSSI = -{rssi_value} dBm")
else:
print(f"[{self.serial_port}] 找不到 sysid 對應,RSSI = -{rssi_value} dBm,已忽略")
else:
print(f"[{self.serial_port}] 超過 0.5 秒未接收 MAVLink,RSSI = -{rssi_value} dBm 已忽略")
else:
print(f"[{self.serial_port}] DB 指令失敗,狀態碼: {status}")
def write_to_serial(self, data):
try:
api_frame = build_api_tx_frame(data, TARGET_ADDR64)
self.transport.write(api_frame)
except Exception as e:
print(f"[{self.serial_port} TX Error] 無法封裝或傳送資料: {e}")
def send_at_command_db(self):
try:
frame_type = 0x08
frame_id = 0x52
at_command = b'DB'
parameter = b''
frame = struct.pack(">B", frame_type) + struct.pack(">B", frame_id) + at_command + parameter
checksum = 0xFF - (sum(frame) & 0xFF)
api_frame = b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
self.transport.write(api_frame)
except Exception as e:
print(f"[{self.serial_port}] 發送 DB 指令失敗: {e}")
class UDPHandler(asyncio.DatagramProtocol):
def __init__(self, udp_port):
self.udp_port = udp_port
self.serial_transport = None
self.transport = None
self.mav_decoder = mavutil.mavlink.MAVLink(None)
def connection_made(self, transport):
self.transport = transport
print(f"[UDP:{self.udp_port}] transport ready.")
def set_serial_transport(self, serial_transport):
self.serial_transport = serial_transport
def datagram_received(self, data, addr):
if self.serial_transport:
self.serial_transport.write_to_serial(data)
def send_udp(self, data):
decoded_data = self.decapsulate_data(data)
if decoded_data is None:
return
self.decode_mavlink_data(decoded_data)
if self.transport:
self.transport.sendto(decoded_data, (UDP_REMOTE_IP, self.udp_port))
def decapsulate_data(self, data):
try:
if not data or data[0] != 0x7E:
return None
length = (data[1] << 8) | data[2]
if len(data) < length + 4:
return None
frame_type = data[3]
if frame_type == 0x90:
rf_data_start = 3 + 12
return data[rf_data_start:3 + length]
else:
return None
except Exception as e:
print(f"[UDP:{self.udp_port} 解封錯誤] {e}")
return None
def decode_mavlink_data(self, data):
try:
# 逐字節解析MAVLink
for byte in data:
msg = self.mav_decoder.parse_char(bytes([byte]))
if msg:
sysid = msg.get_srcSystem()
compid = msg.get_srcComponent()
seq = msg.get_seq()
if sysid == 0:
continue
if self.serial_transport:
serial_to_sysid[self.serial_transport.serial_port] = sysid
serial_last_mavlink_time[self.serial_transport.serial_port] = time.time()
self.serial_transport.send_at_command_db()
# === 新增:計算丟包率 ===
try:
loss_rate = calculate_packet_loss(sysid, compid, seq)
# print(f"[SYSID:{sysid}] 丟包率: {loss_rate:.1f}%, SEQ: {seq}")
except Exception as e:
print(f"[UDP:{self.udp_port}] 丟包率計算錯誤: {e}")
except Exception as e:
print(f"[UDP:{self.udp_port} MAVLink Decode Error] {e}")
def start_plotting():
# 創建子圖:上方RSSI,下方丟包率
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
def update(frame):
ax1.clear()
ax2.clear()
# === RSSI 圖 ===
ax1.set_title("RSSI", fontsize=14)
ax1.set_xlabel("Time (s ago)")
ax1.set_ylabel("RSSI (dBm)")
ax1.set_xlim(30, 0) # 顯示最近10秒
ax1.set_ylim(-100, -10)
ax1.grid(True, alpha=0.3)
# === 丟包率圖 ===
ax2.set_title("Packet Loss Rate", fontsize=14)
ax2.set_xlabel("Time (s ago)")
ax2.set_ylabel("Loss Rate (%)")
ax2.set_xlim(10, 0)
ax2.set_ylim(0, 100)
ax2.grid(True, alpha=0.3)
now = time.time()
# 顏色列表
colors = ['blue', 'red', 'green', 'orange', 'purple', 'brown', 'pink', 'gray']
for i, sysid in enumerate(sorted(rssi_history.keys())):
color = colors[i % len(colors)]
# === 繪製 RSSI ===
rssi_recent_indices = [
idx for idx, ts in enumerate(time_history[sysid]) if now - ts <= 30
]
if rssi_recent_indices:
rssi_t = [now - time_history[sysid][idx] for idx in rssi_recent_indices]
rssi_r = [rssi_history[sysid][idx] for idx in rssi_recent_indices]
if rssi_t and rssi_r:
ax1.plot(rssi_t, rssi_r, label=f"SYSID:{sysid}", color=color, linewidth=2)
# 在RSSI圖上顯示當前數值
if rssi_r:
latest_rssi = rssi_r[-1]
ax1.text(2, latest_rssi, f'{latest_rssi:.0f}dBm',
bbox=dict(boxstyle="round,pad=0.3", facecolor=color, alpha=0.7),
fontsize=10, fontweight='bold', color='white')
# === 繪製丟包率 ===
if sysid in packet_loss_history:
loss_recent_indices = [
idx for idx, ts in enumerate(packet_loss_time_history[sysid]) if now - ts <= 10
]
if loss_recent_indices:
loss_t = [now - packet_loss_time_history[sysid][idx] for idx in loss_recent_indices]
loss_r = [packet_loss_history[sysid][idx] for idx in loss_recent_indices]
if loss_t and loss_r:
ax2.plot(loss_t, loss_r, label=f"SYSID:{sysid}", color=color, linewidth=2, marker='o', markersize=3)
# 在丟包率圖上顯示當前數值和統計
if loss_r:
latest_loss = loss_r[-1]
stats = packet_loss_stats[sysid]
# 顯示丟包率百分比
ax2.text(2, latest_loss + (i * 8), f'{latest_loss:.1f}%',
bbox=dict(boxstyle="round,pad=0.3", facecolor=color, alpha=0.7),
fontsize=10, fontweight='bold', color='white')
# 在圖表左側顯示詳細統計
stats_text = f"SYSID {sysid}:\nRecieved: {stats['total_received']}\nLoss: {stats['total_lost']}\nLoss Rate: {latest_loss:.1f}%"
ax2.text(28, 85 - (i * 20), stats_text,
bbox=dict(boxstyle="round,pad=0.5", facecolor=color, alpha=0.8),
fontsize=9, color='white', verticalalignment='top')
# 設置圖例
ax1.legend(loc="upper right", fontsize=10)
ax2.legend(loc="upper right", fontsize=10)
ani = animation.FuncAnimation(fig, update, interval=1000)
plt.tight_layout()
plt.show()
async def setup_bridge(config):
serial_port = config["serial_port"]
udp_port = config["udp_port"]
try:
ser = serial.Serial(serial_port, SERIAL_BAUDRATE)
ser.close()
except Exception as e:
print(f"[{serial_port}] 串口打開失敗: {e}")
return
loop = asyncio.get_running_loop()
udp_handler = UDPHandler(udp_port)
try:
await loop.create_datagram_endpoint(
lambda: udp_handler,
local_addr=('0.0.0.0', 0)
)
except Exception as e:
print(f"[{serial_port}] 無法創建 UDP: {e}")
return
try:
serial_proto = SerialToUDP(udp_handler, serial_port)
await serial_asyncio.create_serial_connection(
loop, lambda: serial_proto, serial_port, baudrate=SERIAL_BAUDRATE
)
except Exception as e:
print(f"[{serial_port}] 無法建立串口連接:{e}")
traceback.print_exc()
return
print(f"[{serial_port}] Serial <--> UDP:{udp_port} bridge ready.")
async def main():
tasks = [setup_bridge(cfg) for cfg in CONFIGS]
await asyncio.gather(*tasks)
await asyncio.Future()
if __name__ == '__main__':
try:
threading.Thread(target=lambda: asyncio.run(main()), daemon=True).start()
start_plotting()
except KeyboardInterrupt:
print("使用者中斷程式")
except Exception as e:
print("程式執行錯誤:")
traceback.print_exc()
Binary file not shown.