/ 9_Firmware / 9_3_GUI / GUI_V5_Demo.py
GUI_V5_Demo.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 matplotlib.patches as patches
  12  import logging
  13  from dataclasses import dataclass
  14  from typing import Dict, List, Tuple, Optional
  15  from scipy import signal
  16  from sklearn.cluster import DBSCAN
  17  from filterpy.kalman import KalmanFilter
  18  import crcmod
  19  import math
  20  import webbrowser
  21  import tempfile
  22  import os
  23  import random
  24  import json
  25  
  26  # Try to import tkinterweb for embedded browser
  27  try:
  28      import tkinterweb
  29      TKINTERWEB_AVAILABLE = True
  30      logging.info("tkinterweb available - Embedded browser enabled")
  31  except ImportError:
  32      TKINTERWEB_AVAILABLE = False
  33      logging.warning("tkinterweb not available. Please install: pip install tkinterweb")
  34  
  35  try:
  36      import usb.core
  37      import usb.util
  38      USB_AVAILABLE = True
  39  except ImportError:
  40      USB_AVAILABLE = False
  41      logging.warning("pyusb not available. USB CDC functionality will be disabled.")
  42  
  43  try:
  44      from pyftdi.ftdi import Ftdi
  45      from pyftdi.usbtools import UsbTools
  46      FTDI_AVAILABLE = True
  47  except ImportError:
  48      FTDI_AVAILABLE = False
  49      logging.warning("pyftdi not available. FTDI functionality will be disabled.")
  50  
  51  # Configure logging
  52  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
  53  
  54  # Dark theme colors
  55  DARK_BG = "#2b2b2b"
  56  DARK_FG = "#e0e0e0"
  57  DARK_ACCENT = "#3c3f41"
  58  DARK_HIGHLIGHT = "#4e5254"
  59  DARK_BORDER = "#555555"
  60  DARK_TEXT = "#cccccc"
  61  DARK_BUTTON = "#3c3f41"
  62  DARK_BUTTON_HOVER = "#4e5254"
  63  DARK_TREEVIEW = "#3c3f41"
  64  DARK_TREEVIEW_ALT = "#404040"
  65  
  66  @dataclass
  67  class RadarTarget:
  68      id: int
  69      range: float
  70      velocity: float
  71      azimuth: int
  72      elevation: int
  73      latitude: float = 0.0
  74      longitude: float = 0.0
  75      snr: float = 0.0
  76      timestamp: float = 0.0
  77      track_id: int = -1
  78  
  79  @dataclass
  80  class RadarSettings:
  81      system_frequency: float = 10e9
  82      chirp_duration_1: float = 30e-6  # Long chirp duration
  83      chirp_duration_2: float = 0.5e-6  # Short chirp duration
  84      chirps_per_position: int = 32
  85      freq_min: float = 10e6
  86      freq_max: float = 30e6
  87      prf1: float = 1000
  88      prf2: float = 2000
  89      max_distance: float = 50000
  90      map_size: float = 50000  # Map size in meters
  91  
  92  @dataclass
  93  class GPSData:
  94      latitude: float
  95      longitude: float
  96      altitude: float
  97      pitch: float  # Pitch angle in degrees
  98      timestamp: float
  99  
 100  class RadarProcessor:
 101      def __init__(self):
 102          self.range_doppler_map = np.zeros((1024, 32))
 103          self.detected_targets = []
 104          self.track_id_counter = 0
 105          self.tracks = {}
 106          self.frame_count = 0
 107          
 108      def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
 109          """Dual-CPI fusion for better detection"""
 110          fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
 111          return fused_profile
 112      
 113      def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
 114          """Multi-PRF velocity unwrapping"""
 115          lambda_wavelength = 3e8 / 10e9
 116          v_max1 = prf1 * lambda_wavelength / 2
 117          v_max2 = prf2 * lambda_wavelength / 2
 118          
 119          unwrapped_velocities = []
 120          for doppler in doppler_measurements:
 121              v1 = doppler * lambda_wavelength / 2
 122              v2 = doppler * lambda_wavelength / 2
 123              
 124              velocity = self._solve_chinese_remainder(v1, v2, v_max1, v_max2)
 125              unwrapped_velocities.append(velocity)
 126              
 127          return unwrapped_velocities
 128      
 129      def _solve_chinese_remainder(self, v1, v2, max1, max2):
 130          for k in range(-5, 6):
 131              candidate = v1 + k * max1
 132              if abs(candidate - v2) < max2 / 2:
 133                  return candidate
 134          return v1
 135      
 136      def clustering(self, detections, eps=100, min_samples=2):
 137          """DBSCAN clustering of detections"""
 138          if len(detections) == 0:
 139              return []
 140              
 141          points = np.array([[d.range, d.velocity] for d in detections])
 142          clustering = DBSCAN(eps=eps, min_samples=min_samples).fit(points)
 143          
 144          clusters = []
 145          for label in set(clustering.labels_):
 146              if label != -1:
 147                  cluster_points = points[clustering.labels_ == label]
 148                  clusters.append({
 149                      'center': np.mean(cluster_points, axis=0),
 150                      'points': cluster_points,
 151                      'size': len(cluster_points)
 152                  })
 153                  
 154          return clusters
 155      
 156      def association(self, detections, clusters):
 157          """Association of detections to tracks"""
 158          associated_detections = []
 159          
 160          for detection in detections:
 161              best_track = None
 162              min_distance = float('inf')
 163              
 164              for track_id, track in self.tracks.items():
 165                  distance = np.sqrt(
 166                      (detection.range - track['state'][0])**2 +
 167                      (detection.velocity - track['state'][2])**2
 168                  )
 169                  
 170                  if distance < min_distance and distance < 500:
 171                      min_distance = distance
 172                      best_track = track_id
 173              
 174              if best_track is not None:
 175                  detection.track_id = best_track
 176                  associated_detections.append(detection)
 177              else:
 178                  detection.track_id = self.track_id_counter
 179                  self.track_id_counter += 1
 180                  associated_detections.append(detection)
 181                  
 182          return associated_detections
 183      
 184      def tracking(self, associated_detections):
 185          """Kalman filter tracking"""
 186          current_time = time.time()
 187          
 188          for detection in associated_detections:
 189              if detection.track_id not in self.tracks:
 190                  kf = KalmanFilter(dim_x=4, dim_z=2)
 191                  kf.x = np.array([detection.range, 0, detection.velocity, 0])
 192                  kf.F = np.array([[1, 1, 0, 0],
 193                                 [0, 1, 0, 0],
 194                                 [0, 0, 1, 1],
 195                                 [0, 0, 0, 1]])
 196                  kf.H = np.array([[1, 0, 0, 0],
 197                                 [0, 0, 1, 0]])
 198                  kf.P *= 1000
 199                  kf.R = np.diag([10, 1])
 200                  kf.Q = np.eye(4) * 0.1
 201                  
 202                  self.tracks[detection.track_id] = {
 203                      'filter': kf,
 204                      'state': kf.x,
 205                      'last_update': current_time,
 206                      'hits': 1
 207                  }
 208              else:
 209                  track = self.tracks[detection.track_id]
 210                  track['filter'].predict()
 211                  track['filter'].update([detection.range, detection.velocity])
 212                  track['state'] = track['filter'].x
 213                  track['last_update'] = current_time
 214                  track['hits'] += 1
 215          
 216          stale_tracks = [tid for tid, track in self.tracks.items() 
 217                         if current_time - track['last_update'] > 5.0]
 218          for tid in stale_tracks:
 219              del self.tracks[tid]
 220  
 221  class USBPacketParser:
 222      def __init__(self):
 223          self.crc16_func = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000)
 224          
 225      def parse_gps_data(self, data):
 226          """Parse GPS data from STM32 USB CDC with pitch angle"""
 227          if not data:
 228              return None
 229              
 230          try:
 231              # Try text format first: "GPS:lat,lon,alt,pitch\r\n"
 232              text_data = data.decode('utf-8', errors='ignore').strip()
 233              if text_data.startswith('GPS:'):
 234                  parts = text_data.split(':')[1].split(',')
 235                  if len(parts) == 4:  # Now expecting 4 values
 236                      lat = float(parts[0])
 237                      lon = float(parts[1])
 238                      alt = float(parts[2])
 239                      pitch = float(parts[3])  # Pitch angle in degrees
 240                      return GPSData(latitude=lat, longitude=lon, altitude=alt, pitch=pitch, timestamp=time.time())
 241              
 242              # Try binary format (30 bytes with pitch)
 243              if len(data) >= 30 and data[0:4] == b'GPSB':
 244                  return self._parse_binary_gps_with_pitch(data)
 245                  
 246          except Exception as e:
 247              logging.error(f"Error parsing GPS data: {e}")
 248              
 249          return None
 250      
 251      def _parse_binary_gps_with_pitch(self, data):
 252          """Parse binary GPS format with pitch angle (30 bytes)"""
 253          try:
 254              # Binary format: [Header 4][Latitude 8][Longitude 8][Altitude 4][Pitch 4][CRC 2]
 255              if len(data) < 30:
 256                  return None
 257                  
 258              # Verify CRC (simple checksum)
 259              crc_received = (data[28] << 8) | data[29]
 260              crc_calculated = sum(data[0:28]) & 0xFFFF
 261              
 262              if crc_received != crc_calculated:
 263                  logging.warning("GPS CRC mismatch")
 264                  return None
 265              
 266              # Parse latitude (double, big-endian)
 267              lat_bits = 0
 268              for i in range(8):
 269                  lat_bits = (lat_bits << 8) | data[4 + i]
 270              latitude = struct.unpack('>d', struct.pack('>Q', lat_bits))[0]
 271              
 272              # Parse longitude (double, big-endian)
 273              lon_bits = 0
 274              for i in range(8):
 275                  lon_bits = (lon_bits << 8) | data[12 + i]
 276              longitude = struct.unpack('>d', struct.pack('>Q', lon_bits))[0]
 277              
 278              # Parse altitude (float, big-endian)
 279              alt_bits = 0
 280              for i in range(4):
 281                  alt_bits = (alt_bits << 8) | data[20 + i]
 282              altitude = struct.unpack('>f', struct.pack('>I', alt_bits))[0]
 283              
 284              # Parse pitch angle (float, big-endian)
 285              pitch_bits = 0
 286              for i in range(4):
 287                  pitch_bits = (pitch_bits << 8) | data[24 + i]
 288              pitch = struct.unpack('>f', struct.pack('>I', pitch_bits))[0]
 289              
 290              return GPSData(
 291                  latitude=latitude, 
 292                  longitude=longitude, 
 293                  altitude=altitude, 
 294                  pitch=pitch, 
 295                  timestamp=time.time()
 296              )
 297              
 298          except Exception as e:
 299              logging.error(f"Error parsing binary GPS with pitch: {e}")
 300              return None
 301  
 302  class RadarPacketParser:
 303      def __init__(self):
 304          self.sync_pattern = b'\xA5\xC3'
 305          self.crc16_func = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000)
 306          
 307      def parse_packet(self, data):
 308          if len(data) < 6:
 309              return None
 310              
 311          sync_index = data.find(self.sync_pattern)
 312          if sync_index == -1:
 313              return None
 314              
 315          packet = data[sync_index:]
 316          
 317          if len(packet) < 6:
 318              return None
 319              
 320          sync = packet[0:2]
 321          packet_type = packet[2]
 322          length = packet[3]
 323          
 324          if len(packet) < (4 + length + 2):
 325              return None
 326              
 327          payload = packet[4:4+length]
 328          crc_received = struct.unpack('<H', packet[4+length:4+length+2])[0]
 329          
 330          crc_calculated = self.calculate_crc(packet[0:4+length])
 331          if crc_calculated != crc_received:
 332              logging.warning(f"CRC mismatch: got {crc_received:04X}, calculated {crc_calculated:04X}")
 333              return None
 334          
 335          if packet_type == 0x01:
 336              return self.parse_range_packet(payload)
 337          elif packet_type == 0x02:
 338              return self.parse_doppler_packet(payload)
 339          elif packet_type == 0x03:
 340              return self.parse_detection_packet(payload)
 341          else:
 342              logging.warning(f"Unknown packet type: {packet_type:02X}")
 343              return None
 344      
 345      def calculate_crc(self, data):
 346          return self.crc16_func(data)
 347      
 348      def parse_range_packet(self, payload):
 349          if len(payload) < 12:
 350              return None
 351              
 352          try:
 353              range_value = struct.unpack('>I', payload[0:4])[0]
 354              elevation = payload[4] & 0x1F
 355              azimuth = payload[5] & 0x3F
 356              chirp_counter = payload[6] & 0x1F
 357              
 358              return {
 359                  'type': 'range',
 360                  'range': range_value,
 361                  'elevation': elevation,
 362                  'azimuth': azimuth,
 363                  'chirp': chirp_counter,
 364                  'timestamp': time.time()
 365              }
 366          except Exception as e:
 367              logging.error(f"Error parsing range packet: {e}")
 368              return None
 369      
 370      def parse_doppler_packet(self, payload):
 371          if len(payload) < 12:
 372              return None
 373              
 374          try:
 375              doppler_real = struct.unpack('>h', payload[0:2])[0]
 376              doppler_imag = struct.unpack('>h', payload[2:4])[0]
 377              elevation = payload[4] & 0x1F
 378              azimuth = payload[5] & 0x3F
 379              chirp_counter = payload[6] & 0x1F
 380              
 381              return {
 382                  'type': 'doppler',
 383                  'doppler_real': doppler_real,
 384                  'doppler_imag': doppler_imag,
 385                  'elevation': elevation,
 386                  'azimuth': azimuth,
 387                  'chirp': chirp_counter,
 388                  'timestamp': time.time()
 389              }
 390          except Exception as e:
 391              logging.error(f"Error parsing Doppler packet: {e}")
 392              return None
 393      
 394      def parse_detection_packet(self, payload):
 395          if len(payload) < 8:
 396              return None
 397              
 398          try:
 399              detection_flag = (payload[0] & 0x01) != 0
 400              elevation = payload[1] & 0x1F
 401              azimuth = payload[2] & 0x3F
 402              chirp_counter = payload[3] & 0x1F
 403              
 404              return {
 405                  'type': 'detection',
 406                  'detected': detection_flag,
 407                  'elevation': elevation,
 408                  'azimuth': azimuth,
 409                  'chirp': chirp_counter,
 410                  'timestamp': time.time()
 411              }
 412          except Exception as e:
 413              logging.error(f"Error parsing detection packet: {e}")
 414              return None
 415  
 416  class MapGenerator:
 417      def __init__(self):
 418          self.map_file_path = None
 419          self.map_html_template = """<!DOCTYPE html>
 420  <html>
 421  <head>
 422      <title>Radar Live Map - OpenStreetMap</title>
 423      <meta charset="utf-8">
 424      <meta name="viewport" content="width=device-width, initial-scale=1.0">
 425      <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
 426          integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
 427          crossorigin=""/>
 428      <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
 429          integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
 430          crossorigin=""></script>
 431      <style>
 432          body { 
 433              margin: 0; 
 434              padding: 0; 
 435              font-family: Arial, sans-serif;
 436              background-color: #2b2b2b;
 437              color: #e0e0e0;
 438          }
 439          #map { 
 440              height: 100vh; 
 441              width: 100%; 
 442          }
 443          #status-bar {
 444              position: absolute;
 445              top: 10px;
 446              left: 50%;
 447              transform: translateX(-50%);
 448              background: rgba(0, 0, 0, 0.7);
 449              color: white;
 450              padding: 8px 15px;
 451              border-radius: 5px;
 452              z-index: 1000;
 453              font-size: 14px;
 454              font-weight: bold;
 455          }
 456          .info-window {
 457              font-family: Arial, sans-serif;
 458              font-size: 14px;
 459              padding: 10px;
 460              min-width: 200px;
 461              background-color: #3c3f41;
 462              color: #e0e0e0;
 463              border-radius: 5px;
 464          }
 465          .info-window h3 {
 466              margin-top: 0;
 467              color: #4e9eff;
 468          }
 469          .info-window p {
 470              margin: 5px 0;
 471          }
 472          .leaflet-container {
 473              background-color: #2b2b2b !important;
 474          }
 475      </style>
 476  </head>
 477  <body>
 478      <div id="status-bar">Loading radar map...</div>
 479      <div id="map"></div>
 480      
 481      <script>
 482          var map;
 483          var radarMarker;
 484          var coverageCircle;
 485          var targetMarkers = [];
 486          
 487          function initMap() {
 488              console.log('Initializing OpenStreetMap...');
 489              
 490              var radarLat = {lat};
 491              var radarLng = {lon};
 492              var radarPosition = [radarLat, radarLng];
 493              
 494              // Initialize map with OpenStreetMap tiles
 495              map = L.map('map', {
 496                  preferCanvas: true // Better performance
 497              }).setView(radarPosition, 12);
 498              
 499              // Add OpenStreetMap tile layer
 500              L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
 501                  attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
 502                  maxZoom: 19
 503              }).addTo(map);
 504              
 505              // Radar position marker
 506              radarMarker = L.marker(radarPosition, {
 507                  title: 'Radar System',
 508                  icon: L.divIcon({
 509                      className: 'radar-icon',
 510                      html: '<div style="background-color:red;border-radius:50%;border:2px solid white;width:20px;height:20px;"></div>',
 511                      iconSize: [20, 20]
 512                  })
 513              }).addTo(map);
 514              
 515              // Radar coverage area
 516              coverageCircle = L.circle(radarPosition, {
 517                  color: '#FF0000',
 518                  fillColor: '#FF0000',
 519                  fillOpacity: 0.1,
 520                  radius: {coverage_radius}
 521              }).addTo(map);
 522              
 523              // Info window for radar
 524              var radarPopup = L.popup().setContent(
 525                  '<div class="info-window">' +
 526                  '<h3>Radar System</h3>' +
 527                  '<p>Latitude: ' + radarLat.toFixed(6) + '</p>' +
 528                  '<p>Longitude: ' + radarLng.toFixed(6) + '</p>' +
 529                  '<p>Altitude: {alt:.1f}m</p>' +
 530                  '<p>Pitch: {pitch:+.1f}°</p>' +
 531                  '<p>Coverage: {coverage_radius_km:.1f} km</p>' +
 532                  '<p>Status: <span style="color:green">Active</span></p>' +
 533                  '</div>'
 534              );
 535              
 536              radarMarker.bindPopup(radarPopup);
 537              
 538              // Auto-open radar popup
 539              setTimeout(function() { radarMarker.openPopup(); }, 1000);
 540              
 541              // Display initial targets if any
 542              if (window.initialTargets && window.initialTargets.length > 0) {
 543                  updateTargets(window.initialTargets);
 544              }
 545              
 546              updateStatus('Map initialized with ' + (window.initialTargets ? window.initialTargets.length : 0) + ' targets');
 547          }
 548          
 549          function updateTargets(targets) {
 550              console.log('Updating targets:', targets.length);
 551              
 552              // Clear existing targets
 553              targetMarkers.forEach(function(marker) { 
 554                  map.removeLayer(marker); 
 555              });
 556              targetMarkers = [];
 557              
 558              // Add new targets
 559              targets.forEach(function(target) {
 560                  var targetColor = getTargetColor(target.velocity);
 561                  var markerSize = 12 + (target.snr / 5); // Size based on SNR
 562                  
 563                  var targetMarker = L.marker([target.lat, target.lng], {
 564                      title: 'Target #' + target.id + ' - Range: ' + target.range.toFixed(1) + 'm, Vel: ' + target.velocity.toFixed(1) + 'm/s',
 565                      icon: L.divIcon({
 566                          className: 'target-icon',
 567                          html: '<div style="background-color:' + targetColor + ';border-radius:50%;border:1px solid white;width:' + markerSize + 'px;height:' + markerSize + 'px;"></div>',
 568                          iconSize: [markerSize, markerSize]
 569                      })
 570                  }).addTo(map);
 571                  
 572                  var targetPopup = L.popup().setContent(
 573                      '<div class="info-window">' +
 574                      '<h3>Target #' + target.id + '</h3>' +
 575                      '<p><b>Range:</b> ' + target.range.toFixed(1) + ' m</p>' +
 576                      '<p><b>Velocity:</b> ' + target.velocity.toFixed(1) + ' m/s</p>' +
 577                      '<p><b>Azimuth:</b> ' + target.azimuth + '°</p>' +
 578                      '<p><b>Elevation:</b> ' + target.elevation.toFixed(1) + '°</p>' +
 579                      '<p><b>SNR:</b> ' + target.snr.toFixed(1) + ' dB</p>' +
 580                      '<p><b>Track ID:</b> ' + target.track_id + '</p>' +
 581                      '<p><b>Status:</b> ' + (target.velocity > 0 ? '<span style="color:red">Approaching</span>' : '<span style="color:blue">Receding</span>') + '</p>' +
 582                      '</div>'
 583                  );
 584                  
 585                  targetMarker.bindPopup(targetPopup);
 586                  targetMarkers.push(targetMarker);
 587              });
 588              
 589              updateStatus(targets.length + ' targets displayed');
 590          }
 591          
 592          function getTargetColor(velocity) {
 593              // Color code based on velocity
 594              if (velocity > 100) return '#FF0000'; // Red for fast approaching
 595              if (velocity > 50) return '#FF6600';  // Orange for medium
 596              if (velocity > 0) return '#00FF00';   // Green for slow approaching
 597              if (velocity < -100) return '#0000FF'; // Blue for fast receding
 598              if (velocity < 0) return '#0066FF';    // Light blue for slow receding
 599              return '#888888'; // Gray for stationary
 600          }
 601          
 602          function updateRadarPosition(lat, lng, alt, pitch) {
 603              var newPosition = [lat, lng];
 604              radarMarker.setLatLng(newPosition);
 605              coverageCircle.setLatLng(newPosition);
 606              map.setView(newPosition);
 607              
 608              updateStatus('Radar moved to: ' + lat.toFixed(6) + ', ' + lng.toFixed(6));
 609          }
 610          
 611          function updateStatus(message) {
 612              var statusBar = document.getElementById('status-bar');
 613              if (statusBar) {
 614                  statusBar.textContent = message;
 615              }
 616              console.log('Status:', message);
 617          }
 618          
 619          // Function to be called from Python updates
 620          window.updateMapData = function(lat, lng, alt, pitch, targets) {
 621              console.log('Received update from Python');
 622              updateRadarPosition(lat, lng, alt, pitch);
 623              updateTargets(targets);
 624          };
 625          
 626          // Initialize map when page loads
 627          document.addEventListener('DOMContentLoaded', function() {
 628              initMap();
 629          });
 630      </script>
 631  </body>
 632  </html>"""
 633      
 634      def generate_map_html_content(self, gps_data, targets, coverage_radius):
 635          """Generate map HTML as string (for embedded browser)"""
 636          # Convert targets for JavaScript
 637          map_targets = []
 638          for target in targets:
 639              target_lat, target_lon = self.polar_to_geographic(
 640                  gps_data.latitude, gps_data.longitude, 
 641                  target.range, target.azimuth
 642              )
 643              map_targets.append({
 644                  'id': target.id,
 645                  'lat': target_lat,
 646                  'lng': target_lon,
 647                  'range': target.range,
 648                  'velocity': target.velocity,
 649                  'azimuth': target.azimuth,
 650                  'elevation': target.elevation,
 651                  'snr': target.snr,
 652                  'track_id': target.track_id
 653              })
 654          
 655          # Calculate coverage radius in km
 656          coverage_radius_km = coverage_radius / 1000.0
 657          
 658          # Generate HTML content
 659          map_html = self.map_html_template.replace('{lat}', str(gps_data.latitude))
 660          map_html = map_html.replace('{lon}', str(gps_data.longitude))
 661          map_html = map_html.replace('{alt:.1f}', f"{gps_data.altitude:.1f}")
 662          map_html = map_html.replace('{pitch:+.1f}', f"{gps_data.pitch:+.1f}")
 663          map_html = map_html.replace('{coverage_radius}', str(coverage_radius))
 664          map_html = map_html.replace('{coverage_radius_km:.1f}', f"{coverage_radius_km:.1f}")
 665          map_html = map_html.replace('{target_count}', str(len(map_targets)))
 666          
 667          # Inject initial targets as JavaScript variable
 668          targets_json = json.dumps(map_targets)
 669          map_html = map_html.replace(
 670              '// Display initial targets if any',
 671              f'window.initialTargets = {targets_json};\n        // Display initial targets if any'
 672          )
 673          
 674          return map_html
 675      
 676      def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
 677          """
 678          Convert polar coordinates (range, azimuth) to geographic coordinates
 679          using simple flat-earth approximation (good for small distances)
 680          """
 681          # Earth radius in meters
 682          earth_radius = 6371000
 683          
 684          # Convert azimuth to radians (0° = North, 90° = East)
 685          azimuth_rad = math.radians(90 - azimuth_deg)  # Convert to math convention
 686          
 687          # Convert range to angular distance
 688          angular_distance = range_m / earth_radius
 689          
 690          # Convert to geographic coordinates
 691          target_lat = radar_lat + math.cos(azimuth_rad) * angular_distance * (180 / math.pi)
 692          target_lon = radar_lon + math.sin(azimuth_rad) * angular_distance * (180 / math.pi) / math.cos(math.radians(radar_lat))
 693          
 694          return target_lat, target_lon
 695  
 696  # ... [Other classes remain the same: STM32USBInterface, FTDIInterface, RadarProcessor, USBPacketParser, RadarPacketParser] ...
 697  class STM32USBInterface:
 698      def __init__(self):
 699          self.device = None
 700          self.is_open = False
 701          self.ep_in = None
 702          self.ep_out = None
 703          
 704      def list_devices(self):
 705          """List available STM32 USB CDC devices"""
 706          if not USB_AVAILABLE:
 707              logging.warning("USB not available - please install pyusb")
 708              return []
 709              
 710          try:
 711              devices = []
 712              # STM32 USB CDC devices typically use these vendor/product IDs
 713              stm32_vid_pids = [
 714                  (0x0483, 0x5740),  # STM32 Virtual COM Port
 715                  (0x0483, 0x3748),  # STM32 Discovery
 716                  (0x0483, 0x374B),  # STM32 CDC
 717                  (0x0483, 0x374D),  # STM32 CDC
 718                  (0x0483, 0x374E),  # STM32 CDC
 719                  (0x0483, 0x3752),  # STM32 CDC
 720              ]
 721              
 722              for vid, pid in stm32_vid_pids:
 723                  found_devices = usb.core.find(find_all=True, idVendor=vid, idProduct=pid)
 724                  for dev in found_devices:
 725                      try:
 726                          product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else "STM32 CDC"
 727                          serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else "Unknown"
 728                          devices.append({
 729                              'description': f"{product} ({serial})",
 730                              'vendor_id': vid,
 731                              'product_id': pid,
 732                              'device': dev
 733                          })
 734                      except:
 735                          devices.append({
 736                              'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
 737                              'vendor_id': vid,
 738                              'product_id': pid,
 739                              'device': dev
 740                          })
 741              
 742              return devices
 743          except Exception as e:
 744              logging.error(f"Error listing USB devices: {e}")
 745              # Return mock devices for testing
 746              return [{'description': 'STM32 Virtual COM Port', 'vendor_id': 0x0483, 'product_id': 0x5740}]
 747      
 748      def open_device(self, device_info):
 749          """Open STM32 USB CDC device"""
 750          if not USB_AVAILABLE:
 751              logging.error("USB not available - cannot open device")
 752              return False
 753              
 754          try:
 755              self.device = device_info['device']
 756              
 757              # Detach kernel driver if active
 758              if self.device.is_kernel_driver_active(0):
 759                  self.device.detach_kernel_driver(0)
 760              
 761              # Set configuration
 762              self.device.set_configuration()
 763              
 764              # Get CDC endpoints
 765              cfg = self.device.get_active_configuration()
 766              intf = cfg[(0,0)]
 767              
 768              # Find bulk endpoints (CDC data interface)
 769              self.ep_out = usb.util.find_descriptor(
 770                  intf,
 771                  custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
 772              )
 773              
 774              self.ep_in = usb.util.find_descriptor(
 775                  intf,
 776                  custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
 777              )
 778              
 779              if self.ep_out is None or self.ep_in is None:
 780                  logging.error("Could not find CDC endpoints")
 781                  return False
 782              
 783              self.is_open = True
 784              logging.info(f"STM32 USB device opened: {device_info['description']}")
 785              return True
 786              
 787          except Exception as e:
 788              logging.error(f"Error opening USB device: {e}")
 789              return False
 790      
 791      def send_start_flag(self):
 792          """Step 12: Send start flag to STM32 via USB"""
 793          start_packet = bytes([23, 46, 158, 237])
 794          logging.info("Sending start flag to STM32 via USB...")
 795          return self._send_data(start_packet)
 796      
 797      def send_settings(self, settings):
 798          """Step 13: Send radar settings to STM32 via USB"""
 799          try:
 800              packet = self._create_settings_packet(settings)
 801              logging.info("Sending radar settings to STM32 via USB...")
 802              return self._send_data(packet)
 803          except Exception as e:
 804              logging.error(f"Error sending settings via USB: {e}")
 805              return False
 806      
 807      def read_data(self, size=64, timeout=1000):
 808          """Read data from STM32 via USB"""
 809          if not self.is_open or self.ep_in is None:
 810              return None
 811              
 812          try:
 813              data = self.ep_in.read(size, timeout=timeout)
 814              return bytes(data)
 815          except usb.core.USBError as e:
 816              if e.errno == 110:  # Timeout
 817                  return None
 818              logging.error(f"USB read error: {e}")
 819              return None
 820          except Exception as e:
 821              logging.error(f"Error reading from USB: {e}")
 822              return None
 823      
 824      def _send_data(self, data):
 825          """Send data to STM32 via USB"""
 826          if not self.is_open or self.ep_out is None:
 827              return False
 828              
 829          try:
 830              # USB CDC typically uses 64-byte packets
 831              packet_size = 64
 832              for i in range(0, len(data), packet_size):
 833                  chunk = data[i:i + packet_size]
 834                  # Pad to packet size if needed
 835                  if len(chunk) < packet_size:
 836                      chunk += b'\x00' * (packet_size - len(chunk))
 837                  self.ep_out.write(chunk)
 838              
 839              return True
 840          except Exception as e:
 841              logging.error(f"Error sending data via USB: {e}")
 842              return False
 843      
 844      def _create_settings_packet(self, settings):
 845          """Create binary settings packet for USB transmission"""
 846          packet = b'SET'
 847          packet += struct.pack('>d', settings.system_frequency)
 848          packet += struct.pack('>d', settings.chirp_duration_1)
 849          packet += struct.pack('>d', settings.chirp_duration_2)
 850          packet += struct.pack('>I', settings.chirps_per_position)
 851          packet += struct.pack('>d', settings.freq_min)
 852          packet += struct.pack('>d', settings.freq_max)
 853          packet += struct.pack('>d', settings.prf1)
 854          packet += struct.pack('>d', settings.prf2)
 855          packet += struct.pack('>d', settings.max_distance)
 856          packet += struct.pack('>d', settings.map_size)
 857          packet += b'END'
 858          return packet
 859      
 860      def close(self):
 861          """Close USB device"""
 862          if self.device and self.is_open:
 863              try:
 864                  usb.util.dispose_resources(self.device)
 865                  self.is_open = False
 866              except Exception as e:
 867                  logging.error(f"Error closing USB device: {e}")
 868  
 869  class FTDIInterface:
 870      def __init__(self):
 871          self.ftdi = None
 872          self.is_open = False
 873          
 874      def list_devices(self):
 875          """List available FTDI devices using pyftdi"""
 876          if not FTDI_AVAILABLE:
 877              logging.warning("FTDI not available - please install pyftdi")
 878              return []
 879              
 880          try:
 881              devices = []
 882              # Get list of all FTDI devices
 883              for device in UsbTools.find_all([(0x0403, 0x6010)]):  # FT2232H vendor/product ID
 884                  devices.append({
 885                      'description': f"FTDI Device {device}",
 886                      'url': f"ftdi://{device}/1"
 887                  })
 888              return devices
 889          except Exception as e:
 890              logging.error(f"Error listing FTDI devices: {e}")
 891              # Return mock devices for testing
 892              return [{'description': 'FT2232H Device A', 'url': 'ftdi://device/1'}]
 893      
 894      def open_device(self, device_url):
 895          """Open FTDI device using pyftdi"""
 896          if not FTDI_AVAILABLE:
 897              logging.error("FTDI not available - cannot open device")
 898              return False
 899              
 900          try:
 901              self.ftdi = Ftdi()
 902              self.ftdi.open_from_url(device_url)
 903              
 904              # Configure for synchronous FIFO mode
 905              self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.SYNCFF)
 906              
 907              # Set latency timer
 908              self.ftdi.set_latency_timer(2)
 909              
 910              # Purge buffers
 911              self.ftdi.purge_buffers()
 912              
 913              self.is_open = True
 914              logging.info(f"FTDI device opened: {device_url}")
 915              return True
 916              
 917          except Exception as e:
 918              logging.error(f"Error opening FTDI device: {e}")
 919              return False
 920      
 921      def read_data(self, bytes_to_read):
 922          """Read data from FTDI"""
 923          if not self.is_open or self.ftdi is None:
 924              return None
 925              
 926          try:
 927              data = self.ftdi.read_data(bytes_to_read)
 928              if data:
 929                  return bytes(data)
 930              return None
 931          except Exception as e:
 932              logging.error(f"Error reading from FTDI: {e}")
 933              return None
 934      
 935      def close(self):
 936          """Close FTDI device"""
 937          if self.ftdi and self.is_open:
 938              self.ftdi.close()
 939              self.is_open = False
 940  
 941  class RadarGUI:
 942      def __init__(self, root):
 943          self.root = root
 944          self.root.title("Advanced Radar System GUI - USB CDC with Embedded Map")
 945          self.root.geometry("1400x900")
 946          
 947          # Apply dark theme to root window
 948          self.root.configure(bg=DARK_BG)
 949          
 950          # Configure ttk style for dark theme
 951          self.style = ttk.Style()
 952          self.style.theme_use('clam')  # Use 'clam' as base for better customization
 953          
 954          # Configure dark theme colors
 955          self.configure_dark_theme()
 956          
 957          # Initialize interfaces
 958          self.stm32_usb_interface = STM32USBInterface()
 959          self.ftdi_interface = FTDIInterface()
 960          self.radar_processor = RadarProcessor()
 961          self.usb_packet_parser = USBPacketParser()
 962          self.radar_packet_parser = RadarPacketParser()
 963          self.map_generator = MapGenerator()
 964          self.last_map_update = 0
 965          self.map_update_interval = 5  # Update map every 5 seconds
 966          self.settings = RadarSettings()
 967          
 968          # Embedded browser
 969          self.browser_frame = None
 970          self.browser = None
 971          self.current_map_html = ""
 972          
 973          # Data queues
 974          self.radar_data_queue = queue.Queue()
 975          self.gps_data_queue = queue.Queue()
 976          
 977          # Thread control
 978          self.running = False
 979          self.radar_thread = None
 980          self.gps_thread = None
 981          
 982          # Counters
 983          self.received_packets = 0
 984          self.current_gps = GPSData(latitude=41.9028, longitude=12.4964, altitude=0, pitch=0.0, timestamp=0)
 985          self.corrected_elevations = []  # Store corrected elevation values
 986          
 987          self.create_gui()
 988          self.start_background_threads()
 989  
 990          
 991          # Demo mode variables
 992          self.demo_mode_active = False
 993          self.demo_thread = None
 994          self.demo_targets = []
 995          
 996      def create_gui(self):
 997          """Create the main GUI with tabs"""
 998          self.notebook = ttk.Notebook(self.root)
 999          self.notebook.pack(fill='both', expand=True, padx=10, pady=10)
1000          
1001          self.tab_main = ttk.Frame(self.notebook)
1002          self.tab_map = ttk.Frame(self.notebook)
1003          self.tab_diagnostics = ttk.Frame(self.notebook)
1004          self.tab_settings = ttk.Frame(self.notebook)
1005          
1006          self.notebook.add(self.tab_main, text='Main View')
1007          self.notebook.add(self.tab_map, text='Map View')
1008          self.notebook.add(self.tab_diagnostics, text='Diagnostics')
1009          self.notebook.add(self.tab_settings, text='Settings')
1010          
1011          self.setup_main_tab()
1012          self.setup_map_tab()
1013          self.setup_settings_tab()
1014      
1015      def setup_main_tab(self):
1016          """Setup the main radar display tab"""
1017          # Control frame
1018          control_frame = ttk.Frame(self.tab_main)
1019          control_frame.pack(fill='x', padx=10, pady=5)
1020          
1021          # USB Device selection
1022          ttk.Label(control_frame, text="STM32 USB Device:").grid(row=0, column=0, padx=5)
1023          self.stm32_usb_combo = ttk.Combobox(control_frame, state="readonly", width=40)
1024          self.stm32_usb_combo.grid(row=0, column=1, padx=5)
1025          
1026          ttk.Label(control_frame, text="FTDI Device:").grid(row=0, column=2, padx=5)
1027          self.ftdi_combo = ttk.Combobox(control_frame, state="readonly", width=30)
1028          self.ftdi_combo.grid(row=0, column=3, padx=5)
1029          
1030          ttk.Button(control_frame, text="Refresh Devices", 
1031                    command=self.refresh_devices).grid(row=0, column=4, padx=5)
1032          
1033          self.start_button = ttk.Button(control_frame, text="Start Radar", 
1034                                        command=self.start_radar)
1035          self.start_button.grid(row=0, column=5, padx=5)
1036          
1037          self.stop_button = ttk.Button(control_frame, text="Stop Radar", 
1038                                       command=self.stop_radar, state="disabled")
1039          self.stop_button.grid(row=0, column=6, padx=5)
1040          
1041          # DEMO BUTTONS
1042          self.demo_button = ttk.Button(control_frame, text="Start Demo", 
1043                                       command=self.start_demo_mode)
1044          self.demo_button.grid(row=0, column=7, padx=5)
1045          
1046          self.stop_demo_button = ttk.Button(control_frame, text="Stop Demo", 
1047                                            command=self.stop_demo_mode, state="disabled")
1048          self.stop_demo_button.grid(row=0, column=8, padx=5)
1049          
1050          # GPS and Pitch info
1051          self.gps_label = ttk.Label(control_frame, text="GPS: Waiting for data...")
1052          self.gps_label.grid(row=1, column=0, columnspan=4, sticky='w', padx=5, pady=2)
1053          
1054          # Pitch display
1055          self.pitch_label = ttk.Label(control_frame, text="Pitch: --.--°")
1056          self.pitch_label.grid(row=1, column=4, columnspan=2, padx=5, pady=2)
1057          
1058          # Status info
1059          self.status_label = ttk.Label(control_frame, text="Status: Ready")
1060          self.status_label.grid(row=1, column=6, sticky='e', padx=5, pady=2)
1061          
1062          # Main display area
1063          display_frame = ttk.Frame(self.tab_main)
1064          display_frame.pack(fill='both', expand=True, padx=10, pady=5)
1065          
1066          # Range-Doppler Map with dark theme
1067          plt.style.use('dark_background')
1068          fig = Figure(figsize=(10, 6), facecolor=DARK_BG)
1069          self.range_doppler_ax = fig.add_subplot(111, facecolor=DARK_ACCENT)
1070          self.range_doppler_plot = self.range_doppler_ax.imshow(
1071              np.random.rand(1024, 32), aspect='auto', cmap='hot', 
1072              extent=[0, 32, 0, 1024])
1073          self.range_doppler_ax.set_title('Range-Doppler Map (Pitch Corrected)', color=DARK_FG)
1074          self.range_doppler_ax.set_xlabel('Doppler Bin', color=DARK_FG)
1075          self.range_doppler_ax.set_ylabel('Range Bin', color=DARK_FG)
1076          self.range_doppler_ax.tick_params(colors=DARK_FG)
1077          self.range_doppler_ax.spines['bottom'].set_color(DARK_FG)
1078          self.range_doppler_ax.spines['top'].set_color(DARK_FG)
1079          self.range_doppler_ax.spines['left'].set_color(DARK_FG)
1080          self.range_doppler_ax.spines['right'].set_color(DARK_FG)
1081          
1082          self.canvas = FigureCanvasTkAgg(fig, display_frame)
1083          self.canvas.draw()
1084          self.canvas.get_tk_widget().pack(side='left', fill='both', expand=True)
1085          
1086          # Targets list
1087          targets_frame = ttk.LabelFrame(display_frame, text="Detected Targets")
1088          targets_frame.pack(side='right', fill='y', padx=5)
1089          
1090          self.targets_tree = ttk.Treeview(targets_frame, 
1091                                         columns=('ID', 'Range', 'Velocity', 'Azimuth', 'Elevation', 'SNR'), 
1092                                         show='headings', height=20)
1093          self.targets_tree.heading('ID', text='Track ID')
1094          self.targets_tree.heading('Range', text='Range (m)')
1095          self.targets_tree.heading('Velocity', text='Velocity (m/s)')
1096          self.targets_tree.heading('Azimuth', text='Azimuth')
1097          self.targets_tree.heading('Elevation', text='Elevation')
1098          self.targets_tree.heading('SNR', text='SNR (dB)')
1099          
1100          self.targets_tree.column('ID', width=70)
1101          self.targets_tree.column('Range', width=90)
1102          self.targets_tree.column('Velocity', width=90)
1103          self.targets_tree.column('Azimuth', width=70)
1104          self.targets_tree.column('Elevation', width=70)
1105          self.targets_tree.column('SNR', width=70)
1106          
1107          # Add scrollbar to targets tree
1108          tree_scroll = ttk.Scrollbar(targets_frame, orient="vertical", command=self.targets_tree.yview)
1109          self.targets_tree.configure(yscrollcommand=tree_scroll.set)
1110          self.targets_tree.pack(side='left', fill='both', expand=True, padx=5, pady=5)
1111          tree_scroll.pack(side='right', fill='y', padx=(0, 5), pady=5)
1112  
1113      def setup_map_tab(self):
1114          """Setup the map display tab with embedded browser"""
1115          # Main container
1116          main_container = ttk.Frame(self.tab_map)
1117          main_container.pack(fill='both', expand=True, padx=10, pady=10)
1118          
1119          # Top frame for controls
1120          controls_frame = ttk.Frame(main_container)
1121          controls_frame.pack(fill='x', pady=(0, 10))
1122          
1123          # Map controls
1124          ttk.Button(controls_frame, text="Generate/Refresh Map", 
1125                    command=self.generate_map).pack(side='left', padx=5)
1126          
1127          if TKINTERWEB_AVAILABLE:
1128              ttk.Button(controls_frame, text="Open in External Browser", 
1129                        command=self.open_external_browser).pack(side='left', padx=5)
1130          else:
1131              ttk.Label(controls_frame, text="Install tkinterweb: pip install tkinterweb", 
1132                       foreground='orange', font=('Arial', 9)).pack(side='left', padx=5)
1133              ttk.Button(controls_frame, text="Open in Browser", 
1134                        command=self.open_external_browser).pack(side='left', padx=5)
1135          
1136          self.map_status_label = ttk.Label(controls_frame, text="Map: Ready to generate")
1137          self.map_status_label.pack(side='left', padx=20)
1138          
1139          # Map info display
1140          info_frame = ttk.Frame(main_container)
1141          info_frame.pack(fill='x', pady=(0, 10))
1142          
1143          self.map_info_label = ttk.Label(info_frame, text="No GPS data received yet", font=('Arial', 10))
1144          self.map_info_label.pack()
1145          
1146          # Embedded browser area - This is where the map will appear
1147          self.browser_container = ttk.Frame(main_container)
1148          self.browser_container.pack(fill='both', expand=True)
1149          
1150          # Create browser widget if tkinterweb is available
1151          if TKINTERWEB_AVAILABLE:
1152              try:
1153                  self.browser = tkinterweb.HtmlFrame(self.browser_container)
1154                  self.browser.pack(fill='both', expand=True)
1155                  
1156                  # Initial placeholder HTML
1157                  placeholder_html = """
1158                  <html>
1159                  <body style="background-color:#2b2b2b; color:#e0e0e0; padding:20px;">
1160                      <h2>Radar Map Display</h2>
1161                      <p>Click "Generate/Refresh Map" button to load the interactive map.</p>
1162                      <p>The map will display:</p>
1163                      <ul>
1164                          <li>Radar position (red marker)</li>
1165                          <li>Coverage area (red circle)</li>
1166                          <li>Detected targets (colored markers)</li>
1167                      </ul>
1168                      <p>Map updates automatically every 5 seconds when new data is available.</p>
1169                  </body>
1170                  </html>
1171                  """
1172                  self.browser.load_html(placeholder_html)
1173                  
1174              except Exception as e:
1175                  logging.error(f"Failed to create embedded browser: {e}")
1176                  self.create_browser_fallback()
1177          else:
1178              self.create_browser_fallback()
1179  
1180      def setup_settings_tab(self):
1181          """Setup the settings tab"""
1182          settings_frame = ttk.Frame(self.tab_settings)
1183          settings_frame.pack(fill='both', expand=True, padx=10, pady=10)
1184          
1185          entries = [
1186              ('System Frequency (Hz):', 'system_frequency', 10e9),
1187              ('Chirp Duration 1 - Long (s):', 'chirp_duration_1', 30e-6),
1188              ('Chirp Duration 2 - Short (s):', 'chirp_duration_2', 0.5e-6),
1189              ('Chirps per Position:', 'chirps_per_position', 32),
1190              ('Frequency Min (Hz):', 'freq_min', 10e6),
1191              ('Frequency Max (Hz):', 'freq_max', 30e6),
1192              ('PRF1 (Hz):', 'prf1', 1000),
1193              ('PRF2 (Hz):', 'prf2', 2000),
1194              ('Max Distance (m):', 'max_distance', 50000),
1195              ('Map Size (m):', 'map_size', 50000)
1196          ]
1197          
1198          self.settings_vars = {}
1199          
1200          for i, (label, attr, default) in enumerate(entries):
1201              ttk.Label(settings_frame, text=label).grid(row=i, column=0, sticky='w', padx=5, pady=5)
1202              var = tk.StringVar(value=str(default))
1203              entry = ttk.Entry(settings_frame, textvariable=var, width=25)
1204              entry.grid(row=i, column=1, padx=5, pady=5)
1205              self.settings_vars[attr] = var
1206          
1207          # Map type info
1208          ttk.Label(settings_frame, text="Map Type:", font=('Arial', 10, 'bold')).grid(
1209              row=len(entries), column=0, sticky='w', padx=5, pady=10)
1210          ttk.Label(settings_frame, text="OpenStreetMap (Free)", foreground='green').grid(
1211              row=len(entries), column=1, sticky='w', padx=5, pady=10)
1212          
1213          ttk.Button(settings_frame, text="Apply Settings", 
1214                    command=self.apply_settings).grid(row=len(entries)+1, column=0, columnspan=2, pady=10)
1215          
1216      def create_browser_fallback(self):
1217          """Create a fallback display when tkinterweb is not available"""
1218          for widget in self.browser_container.winfo_children():
1219              widget.destroy()
1220          
1221          # Create text widget as fallback
1222          text_frame = ttk.Frame(self.browser_container)
1223          text_frame.pack(fill='both', expand=True)
1224          
1225          text_widget = tk.Text(text_frame, wrap='word', bg=DARK_ACCENT, fg=DARK_FG, 
1226                              font=('Courier', 10))
1227          scrollbar = ttk.Scrollbar(text_frame, orient='vertical', command=text_widget.yview)
1228          text_widget.configure(yscrollcommand=scrollbar.set)
1229          
1230          text_widget.pack(side='left', fill='both', expand=True)
1231          scrollbar.pack(side='right', fill='y')
1232          
1233          # Insert placeholder text
1234          placeholder = """EMBEDDED BROWSER NOT AVAILABLE
1235  
1236  To enable the interactive map viewer, please install tkinterweb:
1237  
1238      pip install tkinterweb
1239  
1240  Without tkinterweb, you can still:
1241  1. Generate maps using the button above
1242  2. View them in your external browser
1243  3. See map data in the text display below
1244  
1245  Map HTML will appear here when generated.
1246  """
1247          text_widget.insert('1.0', placeholder)
1248          text_widget.configure(state='disabled')
1249          
1250          # Store reference for later updates
1251          self.fallback_text = text_widget
1252      
1253      def update_embedded_browser(self, html_content):
1254          """Update the embedded browser with new HTML content"""
1255          try:
1256              if TKINTERWEB_AVAILABLE and hasattr(self, 'browser') and self.browser:
1257                  # Update existing browser
1258                  self.browser.load_html(html_content)
1259                  logging.info("Embedded browser updated with new map")
1260              elif hasattr(self, 'fallback_text'):
1261                  # Update fallback text widget
1262                  self.fallback_text.configure(state='normal')
1263                  self.fallback_text.delete('1.0', tk.END)
1264                  self.fallback_text.insert('1.0', html_content)
1265                  self.fallback_text.configure(state='disabled')
1266                  self.fallback_text.see('1.0')  # Scroll to top
1267                  logging.info("Fallback text widget updated with map HTML")
1268          except Exception as e:
1269              logging.error(f"Error updating embedded browser: {e}")
1270  
1271      def generate_map(self):
1272          """Generate or update the map display"""
1273          if self.current_gps.latitude == 0 and self.current_gps.longitude == 0:
1274              messagebox.showinfo("Info", "No GPS data available yet. Start demo mode or wait for GPS data.")
1275              return
1276          
1277          current_time = time.time()
1278          
1279          # Only update map at specified intervals
1280          if current_time - self.last_map_update < 1.0:  # 1 second minimum between updates
1281              return
1282          
1283          try:
1284              # Get current targets (demo + real)
1285              targets = self.get_combined_targets()
1286              
1287              # Generate map HTML
1288              map_html = self.map_generator.generate_map_html_content(
1289                  self.current_gps,
1290                  targets,
1291                  self.settings.map_size
1292              )
1293              
1294              self.current_map_html = map_html
1295              
1296              # Update embedded browser
1297              self.update_embedded_browser(map_html)
1298              
1299              # Update map status
1300              self.map_status_label.config(text=f"Map: Generated with {len(targets)} targets")
1301              
1302              # Update map info display
1303              self.map_info_label.config(
1304                  text=f"Radar: {self.current_gps.latitude:.6f}, {self.current_gps.longitude:.6f} | "
1305                       f"Targets: {len(targets)} | "
1306                       f"Pitch: {self.current_gps.pitch:+.1f}° | "
1307                       f"Coverage: {self.settings.map_size/1000:.1f}km"
1308              )
1309              
1310              self.last_map_update = current_time
1311              
1312              logging.info(f"Map generated with {len(targets)} targets")
1313              
1314          except Exception as e:
1315              logging.error(f"Error generating map: {e}")
1316              self.map_status_label.config(text=f"Map: Error - {str(e)[:50]}")
1317  
1318      def open_external_browser(self):
1319          """Open map in external browser"""
1320          if not self.current_map_html:
1321              messagebox.showinfo("Info", "Generate a map first using 'Generate/Refresh Map' button.")
1322              return
1323          
1324          try:
1325              # Create temporary HTML file
1326              import tempfile
1327              temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8')
1328              temp_file.write(self.current_map_html)
1329              temp_file.close()
1330              
1331              # Open in default browser
1332              webbrowser.open('file://' + os.path.abspath(temp_file.name))
1333              logging.info(f"Map opened in external browser: {temp_file.name}")
1334              
1335          except Exception as e:
1336              logging.error(f"Error opening external browser: {e}")
1337              messagebox.showerror("Error", f"Failed to open browser: {e}")
1338  
1339      # ... [Rest of the methods remain the same - demo mode, radar processing, etc.] ...
1340  
1341  # IMPORTANT: You need to install tkinterweb first!
1342  # Run: pip install tkinterweb
1343  
1344  def main():
1345      """Main application entry point"""
1346      try:
1347          root = tk.Tk()
1348          app = RadarGUI(root)
1349          root.mainloop()
1350      except Exception as e:
1351          logging.error(f"Application error: {e}")
1352          messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
1353  
1354  if __name__ == "__main__":
1355      main()
1356