# ============================================================ # autopilot_bridge.py # Модуль 4: Мост к автопилоту (MAVLink / MSP / JSON) # # Конвертирует команды наведения в управляющие сигналы # для полётного контроллера. Поддерживает: # - JSON export (для симулятора / внешнего потребителя) # - MAVLink (ArduPilot / PX4) # - MSP (Betaflight / INAV) # ============================================================ import json import socket import struct import time import threading from pathlib import Path import numpy as np from config import * from config_intercept import * from helpers import clamp def _rc_value(normalized, scale, center=1500, rc_min=1000, rc_max=2000): """ Конвертирует нормализованную команду [-1..+1] в RC PWM (мкс). Args: normalized: float в диапазоне [-1, +1] scale: масштаб (макс. отклонение от center) center: центр (обычно 1500) rc_min, rc_max: лимиты Returns: int — PWM в микросекундах """ val = float(center) + float(normalized) * float(scale) return int(clamp(val, float(rc_min), float(rc_max))) def select_proto_udp_target_state(confirmed, miss_streak): if not confirmed: return 0 if int(miss_streak) > 0: return 3 return 1 def build_proto_udp_payload(cmd): descriptor = int(clamp(getattr(cmd, "proto_descriptor", PROTO_UDP_DESCRIPTOR), 0, 255)) target_state = int(clamp(getattr(cmd, "target_state", 0), 0, 255)) if target_state == 0: object_id = 0 off_y_px = 0 off_x_px = 0 off_y_pct = 0 off_x_pct = 0 bbox_area_pct = 0 else: frame_w = max(1.0, float(getattr(cmd, "frame_w", 0) or 0)) frame_h = max(1.0, float(getattr(cmd, "frame_h", 0) or 0)) aim_x = float(getattr(cmd, "aim_x", 0.0) or 0.0) aim_y = float(getattr(cmd, "aim_y", 0.0) or 0.0) bbox_w = max(0.0, float(getattr(cmd, "bbox_w", 0.0) or 0.0)) bbox_h = max(0.0, float(getattr(cmd, "bbox_h", 0.0) or 0.0)) center_x = 0.5 * frame_w center_y = 0.5 * frame_h off_x_px = int(round(aim_x - center_x)) off_y_px = int(round(center_y - aim_y)) off_x_pct = int(round(clamp((off_x_px / max(1.0, center_x)) * 100.0, -100.0, 100.0))) off_y_pct = int(round(clamp((off_y_px / max(1.0, center_y)) * 100.0, -100.0, 100.0))) bbox_area_pct = int(round(clamp((bbox_w * bbox_h * 100.0) / max(1.0, frame_w * frame_h), 0.0, 100.0))) object_id = int(clamp(getattr(cmd, "target_id", 0) or 0, 0, 255)) return struct.pack( "= int(AUTOPILOT_FAILSAFE_MISS_FRAMES): self._failsafe_active = True cmd.failsafe = True cmd.failsafe_action = str(AUTOPILOT_FAILSAFE_ACTION) # В failsafe: центрируем стики, throttle на hover cmd.roll = 1500 cmd.pitch = 1500 cmd.yaw = 1500 cmd.throttle = 1500 cmd.steer_x = 0.0 cmd.steer_y = 0.0 else: self._failsafe_active = False # ─── Отправка ─────────────────────────────────────────── now = time.perf_counter() if (now - self._last_send_time) >= self._send_interval: if self._backend is not None: self._backend.send(cmd) self._last_send_time = now self.last_command = cmd return cmd # ───────────────────────────────────────────────────────────── # BACKENDS # ───────────────────────────────────────────────────────────── class _JSONBackend: """ Экспорт команд в JSON-файл. Расширенная версия текущего guidance_state.json. """ def __init__(self): self.path = Path(AUTOPILOT_JSON_PATH) self.path.parent.mkdir(parents=True, exist_ok=True) self.every = int(max(1, AUTOPILOT_JSON_EVERY)) self._frame_count = 0 def start(self): pass def stop(self): pass def status_line(self): return f"JSON export → {self.path}" def send(self, cmd): self._frame_count += 1 if self._frame_count % self.every != 0: return payload = cmd.to_dict() # Сериализация: inf → null для JSON for k, v in payload.items(): if isinstance(v, float) and (v == float('inf') or v == float('-inf')): payload[k] = None tmp = self.path.with_suffix(self.path.suffix + ".tmp") try: with tmp.open("w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=True, indent=2) tmp.replace(self.path) except OSError: pass class _MAVLinkBackend: """ MAVLink backend для ArduPilot / PX4. Отправляет RC_CHANNELS_OVERRIDE или MANUAL_CONTROL. Требует: pip install pymavlink """ def __init__(self): self._conn = None self._connected = False def start(self): try: from pymavlink import mavutil self._conn = mavutil.mavlink_connection( str(MAVLINK_CONNECTION), source_system=int(MAVLINK_SYSTEM_ID), source_component=int(MAVLINK_COMPONENT_ID), ) self._connected = True except Exception as e: self._connected = False print(f"[autopilot] MAVLink connection failed: {e}") def stop(self): if self._conn is not None: try: self._conn.close() except Exception: pass def status_line(self): if self._connected: return f"MAVLink connected: {MAVLINK_CONNECTION}" return "MAVLink not connected" def send(self, cmd): if not self._connected or self._conn is None: return try: if cmd.failsafe: # Failsafe: отправляем hover/RTL if cmd.failsafe_action == "rtl": self._conn.set_mode_rtl() elif cmd.failsafe_action == "land": self._conn.set_mode("LAND") else: # hover — отпускаем стики self._send_rc_override(cmd) else: self._send_rc_override(cmd) except Exception: pass def _send_rc_override(self, cmd): """Отправка RC_CHANNELS_OVERRIDE.""" self._conn.mav.rc_channels_override_send( self._conn.target_system, self._conn.target_component, cmd.roll, # chan1 — roll cmd.pitch, # chan2 — pitch cmd.throttle, # chan3 — throttle cmd.yaw, # chan4 — yaw 0, 0, 0, 0, # chan5-8 ) class _MSPBackend: """ MSP (MultiWii Serial Protocol) backend для Betaflight / INAV. Отправляет MSP_SET_RAW_RC (200) через serial. Требует: pip install pyserial """ def __init__(self): self._ser = None self._connected = False def start(self): try: import serial self._ser = serial.Serial( str(MSP_PORT), int(MSP_BAUD), timeout=0.05, ) self._connected = True except Exception as e: self._connected = False print(f"[autopilot] MSP serial open failed: {e}") def stop(self): if self._ser is not None: try: self._ser.close() except Exception: pass def status_line(self): if self._connected: return f"MSP connected: {MSP_PORT}@{MSP_BAUD}" return "MSP not connected" def send(self, cmd): if not self._connected or self._ser is None: return try: channels = [ cmd.roll, # roll cmd.pitch, # pitch cmd.throttle, # throttle cmd.yaw, # yaw 1500, 1500, 1500, 1500, # aux 1-4 ] self._send_msp_set_raw_rc(channels) except Exception: pass def _send_msp_set_raw_rc(self, channels): """ Формирует и отправляет MSP пакет SET_RAW_RC (cmd=200). Формат MSP v1: $M< [size] [cmd] [data...] [checksum] """ data = bytearray() for ch in channels[:8]: val = int(clamp(ch, 1000, 2000)) data.append(val & 0xFF) data.append((val >> 8) & 0xFF) size = len(data) cmd_id = 200 # MSP_SET_RAW_RC # Checksum = XOR of size, cmd, data checksum = size ^ cmd_id for b in data: checksum ^= b packet = bytearray([ ord('$'), ord('M'), ord('<'), size, cmd_id, ]) packet.extend(data) packet.append(checksum & 0xFF) self._ser.write(packet) class _ProtoUDPBackend: def __init__(self): self._sock = None self._enabled = bool(PROTO_UDP_ENABLE) self._host = str(PROTO_UDP_HOST) self._port = int(PROTO_UDP_PORT) def start(self): if not self._enabled: return try: self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) except OSError as e: self._sock = None print(f"[autopilot] proto_udp socket failed: {e}") def stop(self): if self._sock is not None: try: self._sock.close() except OSError: pass self._sock = None def status_line(self): if not self._enabled: return "PROTO_UDP disabled" return f"PROTO_UDP ready: {self._host}:{self._port}" def send(self, cmd): if (not self._enabled) or (self._sock is None): return try: payload = build_proto_udp_payload(cmd) self._sock.sendto(payload, (self._host, self._port)) except OSError: pass