You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
MAI/autopilot_bridge.py

552 lines
19 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# ============================================================
# 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(
"<BBBhhbbB",
descriptor,
object_id,
target_state,
int(clamp(off_y_px, -32768, 32767)),
int(clamp(off_x_px, -32768, 32767)),
int(clamp(off_y_pct, -128, 127)),
int(clamp(off_x_pct, -128, 127)),
int(clamp(bbox_area_pct, 0, 255)),
)
class AutopilotCommand:
"""Унифицированная команда для любого бэкенда."""
__slots__ = [
"timestamp", "frame_id", "phase",
"roll", "pitch", "yaw", "throttle",
"steer_x", "steer_y", "cmd_x", "cmd_y",
"target_id", "confirmed", "on_target",
"target_state", "proto_descriptor",
"frame_w", "frame_h", "aim_x", "aim_y", "bbox_w", "bbox_h",
"range_m", "tau", "closing_vel", "confidence",
"failsafe", "failsafe_action",
]
def __init__(self):
self.timestamp = 0.0
self.frame_id = -1
self.phase = "SEARCH"
self.roll = 1500
self.pitch = 1500
self.yaw = 1500
self.throttle = 1500
self.steer_x = 0.0
self.steer_y = 0.0
self.cmd_x = 0.0
self.cmd_y = 0.0
self.target_id = None
self.confirmed = False
self.on_target = False
self.target_state = 0
self.proto_descriptor = int(PROTO_UDP_DESCRIPTOR)
self.frame_w = None
self.frame_h = None
self.aim_x = None
self.aim_y = None
self.bbox_w = None
self.bbox_h = None
self.range_m = None
self.tau = float('inf')
self.closing_vel = 0.0
self.confidence = 0.0
self.failsafe = False
self.failsafe_action = "hover"
def to_dict(self):
return {k: getattr(self, k) for k in self.__slots__}
class AutopilotBridge:
"""
Главный класс моста к автопилоту.
Принимает guidance_state + intercept_params + range_state,
формирует AutopilotCommand и отправляет в выбранный бэкенд.
"""
def __init__(self):
self.enabled = bool(AUTOPILOT_ENABLE)
self.backend_name = str(AUTOPILOT_BACKEND).lower().strip()
# Бэкенд
self._backend = None
if self.enabled:
if self.backend_name == "mavlink":
self._backend = _MAVLinkBackend()
elif self.backend_name == "msp":
self._backend = _MSPBackend()
elif self.backend_name == "proto_udp":
self._backend = _ProtoUDPBackend()
else:
self._backend = _JSONBackend()
# Failsafe
self._failsafe_miss_count = 0
self._failsafe_active = False
# Rate limiting
self._last_send_time = 0.0
self._send_interval = 0.0
if self.backend_name == "mavlink":
self._send_interval = 1.0 / max(1.0, float(MAVLINK_CMD_RATE_HZ))
elif self.backend_name == "msp":
self._send_interval = 1.0 / max(1.0, float(MSP_CMD_RATE_HZ))
self.last_command = AutopilotCommand()
def start(self):
if self.enabled and self._backend is not None:
self._backend.start()
def stop(self):
if self._backend is not None:
self._backend.stop()
def status_line(self):
if not self.enabled:
return "Autopilot bridge disabled"
backend_status = self._backend.status_line() if self._backend else "no backend"
return f"Autopilot bridge: {self.backend_name}{backend_status}"
def update(self, frame_id, timestamp, guidance_state, intercept_params,
range_state, pn_state, confirmed, miss_streak):
"""
Формирует и отправляет команду автопилоту.
Args:
frame_id: int
timestamp: float
guidance_state: dict из guidance.py
intercept_params: dict из intercept_fsm.py
range_state: dict из range_estimation.py
pn_state: dict из proportional_navigation.py
confirmed: bool
miss_streak: int
Returns:
AutopilotCommand
"""
if not self.enabled:
return self.last_command
cmd = AutopilotCommand()
cmd.timestamp = timestamp
cmd.frame_id = frame_id
cmd.confirmed = confirmed
cmd.target_state = select_proto_udp_target_state(confirmed, miss_streak)
# ─── Фаза и параметры ────────────────────────────────────
phase = "SEARCH"
throttle_bias = 0.5
use_pn = False
if intercept_params is not None:
phase = intercept_params.get("phase", "SEARCH")
throttle_bias = intercept_params.get("throttle_bias", 0.5)
use_pn = intercept_params.get("use_pn", False)
# Заморозка команд в TERMINAL
if intercept_params.get("lock_commands") and intercept_params.get("frozen_cmd"):
frozen = intercept_params["frozen_cmd"]
cmd.steer_x = float(frozen.get("steer_x", 0.0))
cmd.steer_y = float(frozen.get("steer_y", 0.0))
cmd.cmd_x = float(frozen.get("cmd_x", 0.0))
cmd.cmd_y = float(frozen.get("cmd_y", 0.0))
use_pn = False # используем замороженные
cmd.phase = phase
# ─── Наведение ───────────────────────────────────────────
if use_pn and pn_state is not None and pn_state.get("active"):
# Используем PN-команды
cmd.steer_x = float(pn_state.get("accel_x", 0.0))
cmd.steer_y = float(pn_state.get("accel_y", 0.0))
cmd.cmd_x = cmd.steer_x
cmd.cmd_y = cmd.steer_y
elif not intercept_params or not intercept_params.get("lock_commands"):
# Screen guidance
if guidance_state is not None:
cmd.steer_x = float(guidance_state.get("steer_x", 0.0))
cmd.steer_y = float(guidance_state.get("steer_y", 0.0))
cmd.cmd_x = float(guidance_state.get("cmd_x", 0.0))
cmd.cmd_y = float(guidance_state.get("cmd_y", 0.0))
cmd.on_target = bool(guidance_state.get("on_target", False))
cmd.confidence = float(guidance_state.get("confidence", 0.0))
cmd.target_id = guidance_state.get("target_id")
cmd.frame_w = guidance_state.get("frame_w")
cmd.frame_h = guidance_state.get("frame_h")
cmd.aim_x = guidance_state.get("aim_x")
cmd.aim_y = guidance_state.get("aim_y")
cmd.bbox_w = guidance_state.get("box_w")
cmd.bbox_h = guidance_state.get("box_h")
# ─── Range info ─────────────────────────────────────────
if range_state is not None:
cmd.range_m = range_state.get("range_m")
cmd.tau = range_state.get("tau", float('inf'))
cmd.closing_vel = range_state.get("closing_vel", 0.0)
# ─── RC channels ────────────────────────────────────────
cmd.roll = _rc_value(cmd.steer_x, AUTOPILOT_ROLL_SCALE,
1500, AUTOPILOT_RC_MIN, AUTOPILOT_RC_MAX)
cmd.pitch = _rc_value(-cmd.steer_y, AUTOPILOT_PITCH_SCALE,
1500, AUTOPILOT_RC_MIN, AUTOPILOT_RC_MAX)
cmd.yaw = 1500 # yaw управляется отдельно или через steer_x
cmd.throttle = _rc_value(
throttle_bias * 2.0 - 1.0, # [0..1] → [-1..+1]
float(AUTOPILOT_RC_MAX - AUTOPILOT_THROTTLE_CENTER),
AUTOPILOT_THROTTLE_CENTER,
AUTOPILOT_RC_MIN,
AUTOPILOT_RC_MAX,
)
# ─── Failsafe ───────────────────────────────────────────
if AUTOPILOT_FAILSAFE_ENABLE:
if not confirmed:
self._failsafe_miss_count += 1
else:
self._failsafe_miss_count = 0
if self._failsafe_miss_count >= 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