/ 9_Firmware / 9_3_GUI / GUI_V3.py
GUI_V3.py
   1  import tkinter as tk
   2  from tkinter import ttk, messagebox
   3  import threading
   4  import queue
   5  import time
   6  import struct
   7  import numpy as np
   8  import matplotlib.pyplot as plt
   9  from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
  10  from matplotlib.figure import Figure
  11  import logging
  12  from dataclasses import dataclass
  13  from typing import Dict, List, Tuple, Optional
  14  from scipy import signal
  15  from sklearn.cluster import DBSCAN
  16  from filterpy.kalman import KalmanFilter
  17  import crcmod
  18  import math
  19  
  20  try:
  21      import usb.core
  22      import usb.util
  23      USB_AVAILABLE = True
  24  except ImportError:
  25      USB_AVAILABLE = False
  26      logging.warning("pyusb not available. USB CDC functionality will be disabled.")
  27  
  28  try:
  29      from pyftdi.ftdi import Ftdi
  30      from pyftdi.usbtools import UsbTools
  31      FTDI_AVAILABLE = True
  32  except ImportError:
  33      FTDI_AVAILABLE = False
  34      logging.warning("pyftdi not available. FTDI functionality will be disabled.")
  35  
  36  # Configure logging
  37  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
  38  
  39  @dataclass
  40  class RadarTarget:
  41      id: int
  42      range: float
  43      velocity: float
  44      azimuth: int
  45      elevation: int
  46      snr: float
  47      timestamp: float
  48      track_id: int = -1
  49  
  50  @dataclass
  51  class RadarSettings:
  52      system_frequency: float = 10e9
  53      chirp_duration: float = 30e-6
  54      chirps_per_position: int = 32
  55      freq_min: float = 10e6
  56      freq_max: float = 30e6
  57      prf1: float = 1000
  58      prf2: float = 2000
  59      max_distance: float = 50000
  60  
  61  @dataclass
  62  class GPSData:
  63      latitude: float
  64      longitude: float
  65      altitude: float
  66      pitch: float  # Pitch angle in degrees
  67      timestamp: float
  68  
  69  class STM32USBInterface:
  70      def __init__(self):
  71          self.device = None
  72          self.is_open = False
  73          self.ep_in = None
  74          self.ep_out = None
  75          
  76      def list_devices(self):
  77          """List available STM32 USB CDC devices"""
  78          if not USB_AVAILABLE:
  79              logging.warning("USB not available - please install pyusb")
  80              return []
  81              
  82          try:
  83              devices = []
  84              # STM32 USB CDC devices typically use these vendor/product IDs
  85              stm32_vid_pids = [
  86                  (0x0483, 0x5740),  # STM32 Virtual COM Port
  87                  (0x0483, 0x3748),  # STM32 Discovery
  88                  (0x0483, 0x374B),  # STM32 CDC
  89                  (0x0483, 0x374D),  # STM32 CDC
  90                  (0x0483, 0x374E),  # STM32 CDC
  91                  (0x0483, 0x3752),  # STM32 CDC
  92              ]
  93              
  94              for vid, pid in stm32_vid_pids:
  95                  found_devices = usb.core.find(find_all=True, idVendor=vid, idProduct=pid)
  96                  for dev in found_devices:
  97                      try:
  98                          product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else "STM32 CDC"
  99                          serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else "Unknown"
 100                          devices.append({
 101                              'description': f"{product} ({serial})",
 102                              'vendor_id': vid,
 103                              'product_id': pid,
 104                              'device': dev
 105                          })
 106                      except:
 107                          devices.append({
 108                              'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
 109                              'vendor_id': vid,
 110                              'product_id': pid,
 111                              'device': dev
 112                          })
 113              
 114              return devices
 115          except Exception as e:
 116              logging.error(f"Error listing USB devices: {e}")
 117              # Return mock devices for testing
 118              return [{'description': 'STM32 Virtual COM Port', 'vendor_id': 0x0483, 'product_id': 0x5740}]
 119      
 120      def open_device(self, device_info):
 121          """Open STM32 USB CDC device"""
 122          if not USB_AVAILABLE:
 123              logging.error("USB not available - cannot open device")
 124              return False
 125              
 126          try:
 127              self.device = device_info['device']
 128              
 129              # Detach kernel driver if active
 130              if self.device.is_kernel_driver_active(0):
 131                  self.device.detach_kernel_driver(0)
 132              
 133              # Set configuration
 134              self.device.set_configuration()
 135              
 136              # Get CDC endpoints
 137              cfg = self.device.get_active_configuration()
 138              intf = cfg[(0,0)]
 139              
 140              # Find bulk endpoints (CDC data interface)
 141              self.ep_out = usb.util.find_descriptor(
 142                  intf,
 143                  custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
 144              )
 145              
 146              self.ep_in = usb.util.find_descriptor(
 147                  intf,
 148                  custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
 149              )
 150              
 151              if self.ep_out is None or self.ep_in is None:
 152                  logging.error("Could not find CDC endpoints")
 153                  return False
 154              
 155              self.is_open = True
 156              logging.info(f"STM32 USB device opened: {device_info['description']}")
 157              return True
 158              
 159          except Exception as e:
 160              logging.error(f"Error opening USB device: {e}")
 161              return False
 162      
 163      def send_start_flag(self):
 164          """Step 12: Send start flag to STM32 via USB"""
 165          start_packet = bytes([23, 46, 158, 237])
 166          logging.info("Sending start flag to STM32 via USB...")
 167          return self._send_data(start_packet)
 168      
 169      def send_settings(self, settings):
 170          """Step 13: Send radar settings to STM32 via USB"""
 171          try:
 172              packet = self._create_settings_packet(settings)
 173              logging.info("Sending radar settings to STM32 via USB...")
 174              return self._send_data(packet)
 175          except Exception as e:
 176              logging.error(f"Error sending settings via USB: {e}")
 177              return False
 178      
 179      def read_data(self, size=64, timeout=1000):
 180          """Read data from STM32 via USB"""
 181          if not self.is_open or self.ep_in is None:
 182              return None
 183              
 184          try:
 185              data = self.ep_in.read(size, timeout=timeout)
 186              return bytes(data)
 187          except usb.core.USBError as e:
 188              if e.errno == 110:  # Timeout
 189                  return None
 190              logging.error(f"USB read error: {e}")
 191              return None
 192          except Exception as e:
 193              logging.error(f"Error reading from USB: {e}")
 194              return None
 195      
 196      def _send_data(self, data):
 197          """Send data to STM32 via USB"""
 198          if not self.is_open or self.ep_out is None:
 199              return False
 200              
 201          try:
 202              # USB CDC typically uses 64-byte packets
 203              packet_size = 64
 204              for i in range(0, len(data), packet_size):
 205                  chunk = data[i:i + packet_size]
 206                  # Pad to packet size if needed
 207                  if len(chunk) < packet_size:
 208                      chunk += b'\x00' * (packet_size - len(chunk))
 209                  self.ep_out.write(chunk)
 210              
 211              return True
 212          except Exception as e:
 213              logging.error(f"Error sending data via USB: {e}")
 214              return False
 215      
 216      def _create_settings_packet(self, settings):
 217          """Create binary settings packet for USB transmission"""
 218          packet = b'SET'
 219          packet += struct.pack('>d', settings.system_frequency)
 220          packet += struct.pack('>d', settings.chirp_duration)
 221          packet += struct.pack('>I', settings.chirps_per_position)
 222          packet += struct.pack('>d', settings.freq_min)
 223          packet += struct.pack('>d', settings.freq_max)
 224          packet += struct.pack('>d', settings.prf1)
 225          packet += struct.pack('>d', settings.prf2)
 226          packet += struct.pack('>d', settings.max_distance)
 227          packet += b'END'
 228          return packet
 229      
 230      def close(self):
 231          """Close USB device"""
 232          if self.device and self.is_open:
 233              try:
 234                  usb.util.dispose_resources(self.device)
 235                  self.is_open = False
 236              except Exception as e:
 237                  logging.error(f"Error closing USB device: {e}")
 238  
 239  class FTDIInterface:
 240      def __init__(self):
 241          self.ftdi = None
 242          self.is_open = False
 243          
 244      def list_devices(self):
 245          """List available FTDI devices using pyftdi"""
 246          if not FTDI_AVAILABLE:
 247              logging.warning("FTDI not available - please install pyftdi")
 248              return []
 249              
 250          try:
 251              devices = []
 252              # Get list of all FTDI devices
 253              for device in UsbTools.find_all([(0x0403, 0x6010)]):  # FT2232H vendor/product ID
 254                  devices.append({
 255                      'description': f"FTDI Device {device}",
 256                      'url': f"ftdi://{device}/1"
 257                  })
 258              return devices
 259          except Exception as e:
 260              logging.error(f"Error listing FTDI devices: {e}")
 261              # Return mock devices for testing
 262              return [{'description': 'FT2232H Device A', 'url': 'ftdi://device/1'}]
 263      
 264      def open_device(self, device_url):
 265          """Open FTDI device using pyftdi"""
 266          if not FTDI_AVAILABLE:
 267              logging.error("FTDI not available - cannot open device")
 268              return False
 269              
 270          try:
 271              self.ftdi = Ftdi()
 272              self.ftdi.open_from_url(device_url)
 273              
 274              # Configure for synchronous FIFO mode
 275              self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.SYNCFF)
 276              
 277              # Set latency timer
 278              self.ftdi.set_latency_timer(2)
 279              
 280              # Purge buffers
 281              self.ftdi.purge_buffers()
 282              
 283              self.is_open = True
 284              logging.info(f"FTDI device opened: {device_url}")
 285              return True
 286              
 287          except Exception as e:
 288              logging.error(f"Error opening FTDI device: {e}")
 289              return False
 290      
 291      def read_data(self, bytes_to_read):
 292          """Read data from FTDI"""
 293          if not self.is_open or self.ftdi is None:
 294              return None
 295              
 296          try:
 297              data = self.ftdi.read_data(bytes_to_read)
 298              if data:
 299                  return bytes(data)
 300              return None
 301          except Exception as e:
 302              logging.error(f"Error reading from FTDI: {e}")
 303              return None
 304      
 305      def close(self):
 306          """Close FTDI device"""
 307          if self.ftdi and self.is_open:
 308              self.ftdi.close()
 309              self.is_open = False
 310  
 311  class RadarProcessor:
 312      def __init__(self):
 313          self.range_doppler_map = np.zeros((1024, 32))
 314          self.detected_targets = []
 315          self.track_id_counter = 0
 316          self.tracks = {}
 317          self.frame_count = 0
 318          
 319      def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
 320          """Dual-CPI fusion for better detection"""
 321          fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
 322          return fused_profile
 323      
 324      def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
 325          """Multi-PRF velocity unwrapping"""
 326          lambda_wavelength = 3e8 / 10e9
 327          v_max1 = prf1 * lambda_wavelength / 2
 328          v_max2 = prf2 * lambda_wavelength / 2
 329          
 330          unwrapped_velocities = []
 331          for doppler in doppler_measurements:
 332              v1 = doppler * lambda_wavelength / 2
 333              v2 = doppler * lambda_wavelength / 2
 334              
 335              velocity = self._solve_chinese_remainder(v1, v2, v_max1, v_max2)
 336              unwrapped_velocities.append(velocity)
 337              
 338          return unwrapped_velocities
 339      
 340      def _solve_chinese_remainder(self, v1, v2, max1, max2):
 341          for k in range(-5, 6):
 342              candidate = v1 + k * max1
 343              if abs(candidate - v2) < max2 / 2:
 344                  return candidate
 345          return v1
 346      
 347      def clustering(self, detections, eps=100, min_samples=2):
 348          """DBSCAN clustering of detections"""
 349          if len(detections) == 0:
 350              return []
 351              
 352          points = np.array([[d.range, d.velocity] for d in detections])
 353          clustering = DBSCAN(eps=eps, min_samples=min_samples).fit(points)
 354          
 355          clusters = []
 356          for label in set(clustering.labels_):
 357              if label != -1:
 358                  cluster_points = points[clustering.labels_ == label]
 359                  clusters.append({
 360                      'center': np.mean(cluster_points, axis=0),
 361                      'points': cluster_points,
 362                      'size': len(cluster_points)
 363                  })
 364                  
 365          return clusters
 366      
 367      def association(self, detections, clusters):
 368          """Association of detections to tracks"""
 369          associated_detections = []
 370          
 371          for detection in detections:
 372              best_track = None
 373              min_distance = float('inf')
 374              
 375              for track_id, track in self.tracks.items():
 376                  distance = np.sqrt(
 377                      (detection.range - track['state'][0])**2 +
 378                      (detection.velocity - track['state'][2])**2
 379                  )
 380                  
 381                  if distance < min_distance and distance < 500:
 382                      min_distance = distance
 383                      best_track = track_id
 384              
 385              if best_track is not None:
 386                  detection.track_id = best_track
 387                  associated_detections.append(detection)
 388              else:
 389                  detection.track_id = self.track_id_counter
 390                  self.track_id_counter += 1
 391                  associated_detections.append(detection)
 392                  
 393          return associated_detections
 394      
 395      def tracking(self, associated_detections):
 396          """Kalman filter tracking"""
 397          current_time = time.time()
 398          
 399          for detection in associated_detections:
 400              if detection.track_id not in self.tracks:
 401                  kf = KalmanFilter(dim_x=4, dim_z=2)
 402                  kf.x = np.array([detection.range, 0, detection.velocity, 0])
 403                  kf.F = np.array([[1, 1, 0, 0],
 404                                 [0, 1, 0, 0],
 405                                 [0, 0, 1, 1],
 406                                 [0, 0, 0, 1]])
 407                  kf.H = np.array([[1, 0, 0, 0],
 408                                 [0, 0, 1, 0]])
 409                  kf.P *= 1000
 410                  kf.R = np.diag([10, 1])
 411                  kf.Q = np.eye(4) * 0.1
 412                  
 413                  self.tracks[detection.track_id] = {
 414                      'filter': kf,
 415                      'state': kf.x,
 416                      'last_update': current_time,
 417                      'hits': 1
 418                  }
 419              else:
 420                  track = self.tracks[detection.track_id]
 421                  track['filter'].predict()
 422                  track['filter'].update([detection.range, detection.velocity])
 423                  track['state'] = track['filter'].x
 424                  track['last_update'] = current_time
 425                  track['hits'] += 1
 426          
 427          stale_tracks = [tid for tid, track in self.tracks.items() 
 428                         if current_time - track['last_update'] > 5.0]
 429          for tid in stale_tracks:
 430              del self.tracks[tid]
 431  
 432  class USBPacketParser:
 433      def __init__(self):
 434          self.crc16_func = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000)
 435          
 436      def parse_gps_data(self, data):
 437          """Parse GPS data from STM32 USB CDC with pitch angle"""
 438          if not data:
 439              return None
 440              
 441          try:
 442              # Try text format first: "GPS:lat,lon,alt,pitch\r\n"
 443              text_data = data.decode('utf-8', errors='ignore').strip()
 444              if text_data.startswith('GPS:'):
 445                  parts = text_data.split(':')[1].split(',')
 446                  if len(parts) == 4:  # Now expecting 4 values
 447                      lat = float(parts[0])
 448                      lon = float(parts[1])
 449                      alt = float(parts[2])
 450                      pitch = float(parts[3])  # Pitch angle in degrees
 451                      return GPSData(latitude=lat, longitude=lon, altitude=alt, pitch=pitch, timestamp=time.time())
 452              
 453              # Try binary format (30 bytes with pitch)
 454              if len(data) >= 30 and data[0:4] == b'GPSB':
 455                  return self._parse_binary_gps_with_pitch(data)
 456                  
 457          except Exception as e:
 458              logging.error(f"Error parsing GPS data: {e}")
 459              
 460          return None
 461      
 462      def _parse_binary_gps_with_pitch(self, data):
 463          """Parse binary GPS format with pitch angle (30 bytes)"""
 464          try:
 465              # Binary format: [Header 4][Latitude 8][Longitude 8][Altitude 4][Pitch 4][CRC 2]
 466              if len(data) < 30:
 467                  return None
 468                  
 469              # Verify CRC (simple checksum)
 470              crc_received = (data[28] << 8) | data[29]
 471              crc_calculated = sum(data[0:28]) & 0xFFFF
 472              
 473              if crc_received != crc_calculated:
 474                  logging.warning("GPS CRC mismatch")
 475                  return None
 476              
 477              # Parse latitude (double, big-endian)
 478              lat_bits = 0
 479              for i in range(8):
 480                  lat_bits = (lat_bits << 8) | data[4 + i]
 481              latitude = struct.unpack('>d', struct.pack('>Q', lat_bits))[0]
 482              
 483              # Parse longitude (double, big-endian)
 484              lon_bits = 0
 485              for i in range(8):
 486                  lon_bits = (lon_bits << 8) | data[12 + i]
 487              longitude = struct.unpack('>d', struct.pack('>Q', lon_bits))[0]
 488              
 489              # Parse altitude (float, big-endian)
 490              alt_bits = 0
 491              for i in range(4):
 492                  alt_bits = (alt_bits << 8) | data[20 + i]
 493              altitude = struct.unpack('>f', struct.pack('>I', alt_bits))[0]
 494              
 495              # Parse pitch angle (float, big-endian)
 496              pitch_bits = 0
 497              for i in range(4):
 498                  pitch_bits = (pitch_bits << 8) | data[24 + i]
 499              pitch = struct.unpack('>f', struct.pack('>I', pitch_bits))[0]
 500              
 501              return GPSData(
 502                  latitude=latitude, 
 503                  longitude=longitude, 
 504                  altitude=altitude, 
 505                  pitch=pitch, 
 506                  timestamp=time.time()
 507              )
 508              
 509          except Exception as e:
 510              logging.error(f"Error parsing binary GPS with pitch: {e}")
 511              return None
 512  
 513  class RadarPacketParser:
 514      def __init__(self):
 515          self.sync_pattern = b'\xA5\xC3'
 516          self.crc16_func = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000)
 517          
 518      def parse_packet(self, data):
 519          if len(data) < 6:
 520              return None
 521              
 522          sync_index = data.find(self.sync_pattern)
 523          if sync_index == -1:
 524              return None
 525              
 526          packet = data[sync_index:]
 527          
 528          if len(packet) < 6:
 529              return None
 530              
 531          sync = packet[0:2]
 532          packet_type = packet[2]
 533          length = packet[3]
 534          
 535          if len(packet) < (4 + length + 2):
 536              return None
 537              
 538          payload = packet[4:4+length]
 539          crc_received = struct.unpack('<H', packet[4+length:4+length+2])[0]
 540          
 541          crc_calculated = self.calculate_crc(packet[0:4+length])
 542          if crc_calculated != crc_received:
 543              logging.warning(f"CRC mismatch: got {crc_received:04X}, calculated {crc_calculated:04X}")
 544              return None
 545          
 546          if packet_type == 0x01:
 547              return self.parse_range_packet(payload)
 548          elif packet_type == 0x02:
 549              return self.parse_doppler_packet(payload)
 550          elif packet_type == 0x03:
 551              return self.parse_detection_packet(payload)
 552          else:
 553              logging.warning(f"Unknown packet type: {packet_type:02X}")
 554              return None
 555      
 556      def calculate_crc(self, data):
 557          return self.crc16_func(data)
 558      
 559      def parse_range_packet(self, payload):
 560          if len(payload) < 12:
 561              return None
 562              
 563          try:
 564              range_value = struct.unpack('>I', payload[0:4])[0]
 565              elevation = payload[4] & 0x1F
 566              azimuth = payload[5] & 0x3F
 567              chirp_counter = payload[6] & 0x1F
 568              
 569              return {
 570                  'type': 'range',
 571                  'range': range_value,
 572                  'elevation': elevation,
 573                  'azimuth': azimuth,
 574                  'chirp': chirp_counter,
 575                  'timestamp': time.time()
 576              }
 577          except Exception as e:
 578              logging.error(f"Error parsing range packet: {e}")
 579              return None
 580      
 581      def parse_doppler_packet(self, payload):
 582          if len(payload) < 12:
 583              return None
 584              
 585          try:
 586              doppler_real = struct.unpack('>h', payload[0:2])[0]
 587              doppler_imag = struct.unpack('>h', payload[2:4])[0]
 588              elevation = payload[4] & 0x1F
 589              azimuth = payload[5] & 0x3F
 590              chirp_counter = payload[6] & 0x1F
 591              
 592              return {
 593                  'type': 'doppler',
 594                  'doppler_real': doppler_real,
 595                  'doppler_imag': doppler_imag,
 596                  'elevation': elevation,
 597                  'azimuth': azimuth,
 598                  'chirp': chirp_counter,
 599                  'timestamp': time.time()
 600              }
 601          except Exception as e:
 602              logging.error(f"Error parsing Doppler packet: {e}")
 603              return None
 604      
 605      def parse_detection_packet(self, payload):
 606          if len(payload) < 8:
 607              return None
 608              
 609          try:
 610              detection_flag = (payload[0] & 0x01) != 0
 611              elevation = payload[1] & 0x1F
 612              azimuth = payload[2] & 0x3F
 613              chirp_counter = payload[3] & 0x1F
 614              
 615              return {
 616                  'type': 'detection',
 617                  'detected': detection_flag,
 618                  'elevation': elevation,
 619                  'azimuth': azimuth,
 620                  'chirp': chirp_counter,
 621                  'timestamp': time.time()
 622              }
 623          except Exception as e:
 624              logging.error(f"Error parsing detection packet: {e}")
 625              return None
 626  
 627  class RadarGUI:
 628      def __init__(self, root):
 629          self.root = root
 630          self.root.title("Advanced Radar System GUI - USB CDC with Pitch Correction")
 631          self.root.geometry("1400x900")
 632          
 633          # Initialize interfaces
 634          self.stm32_usb_interface = STM32USBInterface()
 635          self.ftdi_interface = FTDIInterface()
 636          self.radar_processor = RadarProcessor()
 637          self.usb_packet_parser = USBPacketParser()
 638          self.radar_packet_parser = RadarPacketParser()
 639          self.settings = RadarSettings()
 640          
 641          # Data queues
 642          self.radar_data_queue = queue.Queue()
 643          self.gps_data_queue = queue.Queue()
 644          
 645          # Thread control
 646          self.running = False
 647          self.radar_thread = None
 648          self.gps_thread = None
 649          
 650          # Counters
 651          self.received_packets = 0
 652          self.current_gps = GPSData(latitude=41.9028, longitude=12.4964, altitude=0, pitch=0.0, timestamp=0)
 653          self.corrected_elevations = []  # Store corrected elevation values
 654          
 655          self.create_gui()
 656          self.start_background_threads()
 657      
 658      def create_gui(self):
 659          """Create the main GUI with tabs"""
 660          self.notebook = ttk.Notebook(self.root)
 661          self.notebook.pack(fill='both', expand=True, padx=10, pady=10)
 662          
 663          self.tab_main = ttk.Frame(self.notebook)
 664          self.tab_map = ttk.Frame(self.notebook)
 665          self.tab_diagnostics = ttk.Frame(self.notebook)
 666          self.tab_settings = ttk.Frame(self.notebook)
 667          
 668          self.notebook.add(self.tab_main, text='Main View')
 669          self.notebook.add(self.tab_map, text='Map View')
 670          self.notebook.add(self.tab_diagnostics, text='Diagnostics')
 671          self.notebook.add(self.tab_settings, text='Settings')
 672          
 673          self.setup_main_tab()
 674          self.setup_map_tab()
 675          self.setup_settings_tab()
 676      
 677      def setup_main_tab(self):
 678          """Setup the main radar display tab"""
 679          # Control frame
 680          control_frame = ttk.Frame(self.tab_main)
 681          control_frame.pack(fill='x', padx=10, pady=5)
 682          
 683          # USB Device selection
 684          ttk.Label(control_frame, text="STM32 USB Device:").grid(row=0, column=0, padx=5)
 685          self.stm32_usb_combo = ttk.Combobox(control_frame, state="readonly", width=40)
 686          self.stm32_usb_combo.grid(row=0, column=1, padx=5)
 687          
 688          ttk.Label(control_frame, text="FTDI Device:").grid(row=0, column=2, padx=5)
 689          self.ftdi_combo = ttk.Combobox(control_frame, state="readonly", width=30)
 690          self.ftdi_combo.grid(row=0, column=3, padx=5)
 691          
 692          ttk.Button(control_frame, text="Refresh Devices", 
 693                    command=self.refresh_devices).grid(row=0, column=4, padx=5)
 694          
 695          self.start_button = ttk.Button(control_frame, text="Start Radar", 
 696                                        command=self.start_radar)
 697          self.start_button.grid(row=0, column=5, padx=5)
 698          
 699          self.stop_button = ttk.Button(control_frame, text="Stop Radar", 
 700                                       command=self.stop_radar, state="disabled")
 701          self.stop_button.grid(row=0, column=6, padx=5)
 702          
 703          # GPS and Pitch info
 704          self.gps_label = ttk.Label(control_frame, text="GPS: Waiting for data...")
 705          self.gps_label.grid(row=1, column=0, columnspan=4, sticky='w', padx=5, pady=2)
 706          
 707          # Pitch display
 708          self.pitch_label = ttk.Label(control_frame, text="Pitch: --.--°")
 709          self.pitch_label.grid(row=1, column=4, columnspan=2, padx=5, pady=2)
 710          
 711          # Status info
 712          self.status_label = ttk.Label(control_frame, text="Status: Ready")
 713          self.status_label.grid(row=1, column=6, sticky='e', padx=5, pady=2)
 714          
 715          # Main display area
 716          display_frame = ttk.Frame(self.tab_main)
 717          display_frame.pack(fill='both', expand=True, padx=10, pady=5)
 718          
 719          # Range-Doppler Map
 720          fig = Figure(figsize=(10, 6))
 721          self.range_doppler_ax = fig.add_subplot(111)
 722          self.range_doppler_plot = self.range_doppler_ax.imshow(
 723              np.random.rand(1024, 32), aspect='auto', cmap='hot', 
 724              extent=[0, 32, 0, 1024])
 725          self.range_doppler_ax.set_title('Range-Doppler Map (Pitch Corrected)')
 726          self.range_doppler_ax.set_xlabel('Doppler Bin')
 727          self.range_doppler_ax.set_ylabel('Range Bin')
 728          
 729          self.canvas = FigureCanvasTkAgg(fig, display_frame)
 730          self.canvas.draw()
 731          self.canvas.get_tk_widget().pack(side='left', fill='both', expand=True)
 732          
 733          # Targets list with corrected elevation
 734          targets_frame = ttk.LabelFrame(display_frame, text="Detected Targets (Pitch Corrected)")
 735          targets_frame.pack(side='right', fill='y', padx=5)
 736          
 737          self.targets_tree = ttk.Treeview(targets_frame, 
 738                                         columns=('ID', 'Range', 'Velocity', 'Azimuth', 'Elevation', 'Corrected Elev', 'SNR'), 
 739                                         show='headings', height=20)
 740          self.targets_tree.heading('ID', text='Track ID')
 741          self.targets_tree.heading('Range', text='Range (m)')
 742          self.targets_tree.heading('Velocity', text='Velocity (m/s)')
 743          self.targets_tree.heading('Azimuth', text='Azimuth')
 744          self.targets_tree.heading('Elevation', text='Raw Elev')
 745          self.targets_tree.heading('Corrected Elev', text='Corr Elev')
 746          self.targets_tree.heading('SNR', text='SNR (dB)')
 747          
 748          self.targets_tree.column('ID', width=70)
 749          self.targets_tree.column('Range', width=90)
 750          self.targets_tree.column('Velocity', width=90)
 751          self.targets_tree.column('Azimuth', width=70)
 752          self.targets_tree.column('Elevation', width=70)
 753          self.targets_tree.column('Corrected Elev', width=70)
 754          self.targets_tree.column('SNR', width=70)
 755          
 756          self.targets_tree.pack(fill='both', expand=True, padx=5, pady=5)
 757      
 758      def setup_map_tab(self):
 759          """Setup the map display tab"""
 760          self.map_frame = ttk.Frame(self.tab_map)
 761          self.map_frame.pack(fill='both', expand=True, padx=10, pady=10)
 762          
 763          # Map placeholder
 764          self.map_label = ttk.Label(self.map_frame, text="Map will be displayed here after GPS data is received", 
 765                                   font=('Arial', 12))
 766          self.map_label.pack(expand=True)
 767      
 768      def setup_settings_tab(self):
 769          """Setup the settings tab"""
 770          settings_frame = ttk.Frame(self.tab_settings)
 771          settings_frame.pack(fill='both', expand=True, padx=10, pady=10)
 772          
 773          entries = [
 774              ('System Frequency (Hz):', 'system_frequency', 10e9),
 775              ('Chirp Duration (s):', 'chirp_duration', 30e-6),
 776              ('Chirps per Position:', 'chirps_per_position', 32),
 777              ('Frequency Min (Hz):', 'freq_min', 10e6),
 778              ('Frequency Max (Hz):', 'freq_max', 30e6),
 779              ('PRF1 (Hz):', 'prf1', 1000),
 780              ('PRF2 (Hz):', 'prf2', 2000),
 781              ('Max Distance (m):', 'max_distance', 50000)
 782          ]
 783          
 784          self.settings_vars = {}
 785          
 786          for i, (label, attr, default) in enumerate(entries):
 787              ttk.Label(settings_frame, text=label).grid(row=i, column=0, sticky='w', padx=5, pady=5)
 788              var = tk.StringVar(value=str(default))
 789              entry = ttk.Entry(settings_frame, textvariable=var, width=20)
 790              entry.grid(row=i, column=1, padx=5, pady=5)
 791              self.settings_vars[attr] = var
 792          
 793          ttk.Button(settings_frame, text="Apply Settings", 
 794                    command=self.apply_settings).grid(row=len(entries), column=0, columnspan=2, pady=10)
 795      
 796      def apply_pitch_correction(self, raw_elevation, pitch_angle):
 797          """
 798          Apply pitch correction to elevation angle
 799          raw_elevation: measured elevation from radar (degrees)
 800          pitch_angle: antenna pitch angle from IMU (degrees)
 801          Returns: corrected elevation angle (degrees)
 802          """
 803          # Convert to radians for trigonometric functions
 804          raw_elev_rad = math.radians(raw_elevation)
 805          pitch_rad = math.radians(pitch_angle)
 806          
 807          # Apply pitch correction: corrected_elev = raw_elev - pitch
 808          # This assumes the pitch angle is positive when antenna is tilted up
 809          corrected_elev_rad = raw_elev_rad - pitch_rad
 810          
 811          # Convert back to degrees and ensure it's within valid range
 812          corrected_elev_deg = math.degrees(corrected_elev_rad)
 813          
 814          # Normalize to 0-180 degree range
 815          corrected_elev_deg = corrected_elev_deg % 180
 816          if corrected_elev_deg < 0:
 817              corrected_elev_deg += 180
 818              
 819          return corrected_elev_deg
 820      
 821      def refresh_devices(self):
 822          """Refresh available USB devices"""
 823          # STM32 USB devices
 824          stm32_devices = self.stm32_usb_interface.list_devices()
 825          stm32_names = [dev['description'] for dev in stm32_devices]
 826          self.stm32_usb_combo['values'] = stm32_names
 827          
 828          # FTDI devices
 829          ftdi_devices = self.ftdi_interface.list_devices()
 830          ftdi_names = [dev['description'] for dev in ftdi_devices]
 831          self.ftdi_combo['values'] = ftdi_names
 832          
 833          if stm32_names:
 834              self.stm32_usb_combo.current(0)
 835          if ftdi_names:
 836              self.ftdi_combo.current(0)
 837      
 838      def start_radar(self):
 839          """Step 11: Start button pressed - Begin radar operation"""
 840          try:
 841              # Open STM32 USB device
 842              stm32_index = self.stm32_usb_combo.current()
 843              if stm32_index == -1:
 844                  messagebox.showerror("Error", "Please select an STM32 USB device")
 845                  return
 846                  
 847              stm32_devices = self.stm32_usb_interface.list_devices()
 848              if stm32_index >= len(stm32_devices):
 849                  messagebox.showerror("Error", "Invalid STM32 device selection")
 850                  return
 851                  
 852              if not self.stm32_usb_interface.open_device(stm32_devices[stm32_index]):
 853                  messagebox.showerror("Error", "Failed to open STM32 USB device")
 854                  return
 855              
 856              # Open FTDI device
 857              if FTDI_AVAILABLE:
 858                  ftdi_index = self.ftdi_combo.current()
 859                  if ftdi_index != -1:
 860                      ftdi_devices = self.ftdi_interface.list_devices()
 861                      if ftdi_index < len(ftdi_devices):
 862                          device_url = ftdi_devices[ftdi_index]['url']
 863                          if not self.ftdi_interface.open_device(device_url):
 864                              logging.warning("Failed to open FTDI device, continuing without radar data")
 865                  else:
 866                      logging.warning("No FTDI device selected, continuing without radar data")
 867              else:
 868                  logging.warning("FTDI not available, continuing without radar data")
 869              
 870              # Step 12: Send start flag to STM32 via USB
 871              if not self.stm32_usb_interface.send_start_flag():
 872                  messagebox.showerror("Error", "Failed to send start flag to STM32")
 873                  return
 874              
 875              # Step 13: Send settings to STM32 via USB
 876              self.apply_settings()
 877              
 878              # Start radar operation
 879              self.running = True
 880              self.start_button.config(state="disabled")
 881              self.stop_button.config(state="normal")
 882              self.status_label.config(text="Status: Radar running - Waiting for GPS data...")
 883              
 884              logging.info("Radar system started successfully via USB CDC")
 885              
 886          except Exception as e:
 887              messagebox.showerror("Error", f"Failed to start radar: {e}")
 888              logging.error(f"Start radar error: {e}")
 889      
 890      def stop_radar(self):
 891          """Stop radar operation"""
 892          self.running = False
 893          self.start_button.config(state="normal")
 894          self.stop_button.config(state="disabled")
 895          self.status_label.config(text="Status: Radar stopped")
 896          
 897          self.stm32_usb_interface.close()
 898          self.ftdi_interface.close()
 899          
 900          logging.info("Radar system stopped")
 901      
 902      def apply_settings(self):
 903          """Step 13: Apply and send radar settings via USB"""
 904          try:
 905              self.settings.system_frequency = float(self.settings_vars['system_frequency'].get())
 906              self.settings.chirp_duration = float(self.settings_vars['chirp_duration'].get())
 907              self.settings.chirps_per_position = int(self.settings_vars['chirps_per_position'].get())
 908              self.settings.freq_min = float(self.settings_vars['freq_min'].get())
 909              self.settings.freq_max = float(self.settings_vars['freq_max'].get())
 910              self.settings.prf1 = float(self.settings_vars['prf1'].get())
 911              self.settings.prf2 = float(self.settings_vars['prf2'].get())
 912              self.settings.max_distance = float(self.settings_vars['max_distance'].get())
 913              
 914              if self.stm32_usb_interface.is_open:
 915                  self.stm32_usb_interface.send_settings(self.settings)
 916              
 917              messagebox.showinfo("Success", "Settings applied and sent to STM32 via USB")
 918              logging.info("Radar settings applied via USB")
 919              
 920          except ValueError as e:
 921              messagebox.showerror("Error", f"Invalid setting value: {e}")
 922      
 923      def start_background_threads(self):
 924          """Start background data processing threads"""
 925          self.radar_thread = threading.Thread(target=self.process_radar_data, daemon=True)
 926          self.radar_thread.start()
 927          
 928          self.gps_thread = threading.Thread(target=self.process_gps_data, daemon=True)
 929          self.gps_thread.start()
 930          
 931          self.root.after(100, self.update_gui)
 932      
 933      def process_radar_data(self):
 934          """Step 39: Process incoming radar data from FTDI"""
 935          buffer = b''
 936          while True:
 937              if self.running and self.ftdi_interface.is_open:
 938                  try:
 939                      data = self.ftdi_interface.read_data(4096)
 940                      if data:
 941                          buffer += data
 942                          
 943                          while len(buffer) >= 6:
 944                              packet = self.radar_packet_parser.parse_packet(buffer)
 945                              if packet:
 946                                  self.process_radar_packet(packet)
 947                                  packet_length = 4 + len(packet.get('payload', b'')) + 2
 948                                  buffer = buffer[packet_length:]
 949                                  self.received_packets += 1
 950                              else:
 951                                  break
 952                                  
 953                  except Exception as e:
 954                      logging.error(f"Error processing radar data: {e}")
 955                      time.sleep(0.1)
 956              else:
 957                  time.sleep(0.1)
 958      
 959      def process_gps_data(self):
 960          """Step 16/17: Process GPS data from STM32 via USB CDC"""
 961          while True:
 962              if self.running and self.stm32_usb_interface.is_open:
 963                  try:
 964                      # Read data from STM32 USB
 965                      data = self.stm32_usb_interface.read_data(64, timeout=100)
 966                      if data:
 967                          gps_data = self.usb_packet_parser.parse_gps_data(data)
 968                          if gps_data:
 969                              self.gps_data_queue.put(gps_data)
 970                              logging.info(f"GPS Data received via USB: Lat {gps_data.latitude:.6f}, Lon {gps_data.longitude:.6f}, Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°")
 971                  except Exception as e:
 972                      logging.error(f"Error processing GPS data via USB: {e}")
 973              time.sleep(0.1)
 974      
 975      def process_radar_packet(self, packet):
 976          """Step 40: Process radar data and apply pitch correction"""
 977          try:
 978              if packet['type'] == 'range':
 979                  range_meters = packet['range'] * 0.1
 980                  
 981                  # Apply pitch correction to elevation
 982                  raw_elevation = packet['elevation']
 983                  corrected_elevation = self.apply_pitch_correction(raw_elevation, self.current_gps.pitch)
 984                  
 985                  # Store correction for display
 986                  self.corrected_elevations.append({
 987                      'raw': raw_elevation,
 988                      'corrected': corrected_elevation,
 989                      'pitch': self.current_gps.pitch,
 990                      'timestamp': packet['timestamp']
 991                  })
 992                  
 993                  # Keep only recent corrections
 994                  if len(self.corrected_elevations) > 100:
 995                      self.corrected_elevations = self.corrected_elevations[-100:]
 996                  
 997                  target = RadarTarget(
 998                      id=packet['chirp'],
 999                      range=range_meters,
1000                      velocity=0,
1001                      azimuth=packet['azimuth'],
1002                      elevation=corrected_elevation,  # Use corrected elevation
1003                      snr=20.0,
1004                      timestamp=packet['timestamp']
1005                  )
1006                  
1007                  self.update_range_doppler_map(target)
1008                  
1009              elif packet['type'] == 'doppler':
1010                  lambda_wavelength = 3e8 / self.settings.system_frequency
1011                  velocity = (packet['doppler_real'] / 32767.0) * (self.settings.prf1 * lambda_wavelength / 2)
1012                  self.update_target_velocity(packet, velocity)
1013                  
1014              elif packet['type'] == 'detection':
1015                  if packet['detected']:
1016                      # Apply pitch correction to detection elevation
1017                      raw_elevation = packet['elevation']
1018                      corrected_elevation = self.apply_pitch_correction(raw_elevation, self.current_gps.pitch)
1019                      
1020                      logging.info(f"CFAR Detection: Raw Elev {raw_elevation}°, Corrected Elev {corrected_elevation:.1f}°, Pitch {self.current_gps.pitch:.1f}°")
1021                      
1022          except Exception as e:
1023              logging.error(f"Error processing radar packet: {e}")
1024      
1025      def update_range_doppler_map(self, target):
1026          """Update range-Doppler map with new target"""
1027          range_bin = min(int(target.range / 50), 1023)
1028          doppler_bin = min(abs(int(target.velocity)), 31)
1029          
1030          self.radar_processor.range_doppler_map[range_bin, doppler_bin] += 1
1031          
1032          self.radar_processor.detected_targets.append(target)
1033          
1034          if len(self.radar_processor.detected_targets) > 100:
1035              self.radar_processor.detected_targets = self.radar_processor.detected_targets[-100:]
1036      
1037      def update_target_velocity(self, packet, velocity):
1038          """Update target velocity information"""
1039          for target in self.radar_processor.detected_targets:
1040              if (target.azimuth == packet['azimuth'] and 
1041                  target.elevation == packet['elevation'] and
1042                  target.id == packet['chirp']):
1043                  target.velocity = velocity
1044                  break
1045      
1046      def update_gps_display(self):
1047          """Step 18: Update GPS and pitch display"""
1048          try:
1049              while not self.gps_data_queue.empty():
1050                  gps_data = self.gps_data_queue.get_nowait()
1051                  self.current_gps = gps_data
1052                  
1053                  # Update GPS label
1054                  self.gps_label.config(
1055                      text=f"GPS: Lat {gps_data.latitude:.6f}, Lon {gps_data.longitude:.6f}, Alt {gps_data.altitude:.1f}m")
1056                  
1057                  # Update pitch label with color coding
1058                  pitch_text = f"Pitch: {gps_data.pitch:+.1f}°"
1059                  self.pitch_label.config(text=pitch_text)
1060                  
1061                  # Color code based on pitch magnitude
1062                  if abs(gps_data.pitch) > 10:
1063                      self.pitch_label.config(foreground='red')  # High pitch warning
1064                  elif abs(gps_data.pitch) > 5:
1065                      self.pitch_label.config(foreground='orange')  # Medium pitch
1066                  else:
1067                      self.pitch_label.config(foreground='green')  # Normal pitch
1068                  
1069                  # Update map
1070                  self.update_map_display(gps_data)
1071                  
1072          except queue.Empty:
1073              pass
1074      
1075      def update_targets_list(self):
1076          """Update the targets list display with corrected elevations"""
1077          for item in self.targets_tree.get_children():
1078              self.targets_tree.delete(item)
1079          
1080          for target in self.radar_processor.detected_targets[-20:]:
1081              # Find the corresponding raw elevation if available
1082              raw_elevation = "N/A"
1083              for correction in self.corrected_elevations[-20:]:
1084                  if abs(correction['corrected'] - target.elevation) < 0.1:  # Fuzzy match
1085                      raw_elevation = f"{correction['raw']}"
1086                      break
1087              
1088              self.targets_tree.insert('', 'end', values=(
1089                  target.track_id, 
1090                  f"{target.range:.1f}", 
1091                  f"{target.velocity:.1f}",
1092                  target.azimuth,
1093                  raw_elevation,  # Show raw elevation
1094                  f"{target.elevation:.1f}",  # Show corrected elevation
1095                  f"{target.snr:.1f}"
1096              ))
1097      
1098      def update_gui(self):
1099          """Step 40: Update all GUI displays"""
1100          try:
1101              # Update status with pitch information
1102              if self.running:
1103                  self.status_label.config(
1104                      text=f"Status: Running - Packets: {self.received_packets} - Pitch: {self.current_gps.pitch:+.1f}°")
1105              
1106              # Update range-Doppler map
1107              if hasattr(self, 'range_doppler_plot'):
1108                  display_data = np.log10(self.radar_processor.range_doppler_map + 1)
1109                  self.range_doppler_plot.set_array(display_data)
1110                  self.canvas.draw_idle()
1111              
1112              # Update targets list
1113              self.update_targets_list()
1114              
1115              # Update GPS and pitch display
1116              self.update_gps_display()
1117              
1118          except Exception as e:
1119              logging.error(f"Error updating GUI: {e}")
1120          
1121          self.root.after(100, self.update_gui)
1122      
1123      def update_map_display(self, gps_data):
1124          """Step 18: Update map display with current GPS position"""
1125          try:
1126              self.map_label.config(text=f"Radar Position: {gps_data.latitude:.6f}, {gps_data.longitude:.6f}\n"
1127                                       f"Altitude: {gps_data.altitude:.1f}m\n"
1128                                       f"Pitch: {gps_data.pitch:+.1f}°\n"
1129                                       f"Coverage: 50km radius\n"
1130                                       f"Map centered on GPS coordinates")
1131              
1132          except Exception as e:
1133              logging.error(f"Error updating map display: {e}")
1134  
1135  def main():
1136      """Main application entry point"""
1137      try:
1138          root = tk.Tk()
1139          app = RadarGUI(root)
1140          root.mainloop()
1141      except Exception as e:
1142          logging.error(f"Application error: {e}")
1143          messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
1144  
1145  if __name__ == "__main__":
1146      main()