GPS Auto-Extraction + First Successful Upload Complete

Major milestone: GPS coordinates now auto-extract from filenames and
uploads appear on map with full end-to-end workflow functional!

 GPS Auto-Extraction Features:
- JavaScript GPS extractor class matching Python patterns
- Supports 3 filename formats:
  * N/S/E/W: 34.0478N_118.2349W_filename.sub
  * lat/lon prefix: lat34.0478lon-118.2348_filename.sub
  * Signed decimal: -34.0478_118.2348_filename.sub
- Auto-populates latitude/longitude form fields on file drop
- Green notification toast shows detected coordinates
- File list shows GPS badge for files with coordinates

🗺️ Web Interface Improvements:
- Upload endpoint now stores captures in-memory
- Query endpoint returns uploaded captures for map display
- Stats endpoint shows real-time upload counts
- Map displays uploaded captures as markers
- Color-coded by frequency band

📁 Updated Files:
- static/js/upload.js: GPS extraction + auto-population
- src/api/main_simple.py: In-memory storage + endpoints
- src/parser/gps_extractor.py: Backend GPS extraction (Python)
- scripts/test_gps_extraction.py: Python test suite
- test_gps_extraction.html: Browser test suite

📊 T-Embed Files Updated:
- 34.0478N_118.2348W_1637_raw_8.sub: 315 MHz Princeton
- 34.0478N_118.2349W_1351_raw_10.sub: 433.92 MHz Princeton
- 34.0478N_118.2349W_1650_test_raw.sub: 433.92 MHz RAW
- All now have proper Flipper SubGhz headers

 Tested Features:
- GPS extraction from filename: 34.0478N_118.2349W → 34.0478, -118.2349
- Auto-population of GPS fields in upload form
- File upload with GPS validation
- Capture appears on map after upload
- Statistics update in real-time
- Frequency distribution calculated correctly

🎯 End-to-End Flow Working:
1. User drops .sub file with GPS in filename
2. GPS auto-detected and form fields populate
3. User clicks Upload
4. Server parses RF data + GPS coordinates
5. Capture stored in memory
6. Map refreshes and displays new marker
7. Stats update with new counts

🚀 Demo: http://localhost:8000
Upload 34.0478N_118.2349W_1351_raw_10.sub and watch it appear on map!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-13 18:37:13 -08:00
parent 48fcb00241
commit 04bd80b25b
33 changed files with 1903 additions and 22 deletions
+241
View File
@@ -0,0 +1,241 @@
"""
GPS Coordinate Extraction from Filenames
Automatically extracts GPS coordinates from filename patterns
"""
import re
from pathlib import Path
from typing import Optional, Dict, Tuple
from dataclasses import dataclass
@dataclass
class GPSCoordinates:
"""GPS coordinates extracted from filename"""
latitude: float
longitude: float
source: str = "filename"
accuracy: Optional[float] = None
altitude: Optional[float] = None
def to_dict(self) -> Dict:
"""Convert to dictionary"""
return {
'latitude': self.latitude,
'longitude': self.longitude,
'source': self.source,
'accuracy': self.accuracy,
'altitude': self.altitude
}
class GPSFilenameExtractor:
"""
Extract GPS coordinates from various filename patterns
Supported patterns:
1. Decimal degrees: 34.0478N_118.2348W_timestamp_name.sub
2. Signed decimal: -34.0478_118.2348_timestamp_name.sub
3. DMS format: 34d02m52sN_118d14m05sW_name.sub
4. Compact: lat34.0478lon-118.2348_name.sub
"""
# Pattern 1: Decimal degrees with N/S/E/W (most common)
PATTERN_NSEW = re.compile(
r'(\d+\.?\d*)([NS])_(\d+\.?\d*)([EW])',
re.IGNORECASE
)
# Pattern 2: Signed decimal degrees
PATTERN_SIGNED = re.compile(
r'(-?\d+\.?\d+)_(-?\d+\.?\d+)'
)
# Pattern 3: lat/lon prefix format
PATTERN_LATLON = re.compile(
r'lat(-?\d+\.?\d+)lon(-?\d+\.?\d+)',
re.IGNORECASE
)
# Pattern 4: DMS (Degrees Minutes Seconds)
PATTERN_DMS = re.compile(
r'(\d+)d(\d+)m(\d+\.?\d*)s([NS])_(\d+)d(\d+)m(\d+\.?\d*)s([EW])',
re.IGNORECASE
)
def extract(self, filename: str) -> Optional[GPSCoordinates]:
"""
Extract GPS coordinates from filename
Args:
filename: Filename to parse (with or without path)
Returns:
GPSCoordinates if found, None otherwise
"""
# Get just the filename without path
basename = Path(filename).name
# Try each pattern
coords = (
self._try_nsew(basename) or
self._try_latlon(basename) or
self._try_signed(basename) or
self._try_dms(basename)
)
return coords
def _try_nsew(self, filename: str) -> Optional[GPSCoordinates]:
"""Try N/S/E/W pattern: 34.0478N_118.2348W"""
match = self.PATTERN_NSEW.search(filename)
if not match:
return None
lat_val, lat_dir, lon_val, lon_dir = match.groups()
# Convert to float
lat = float(lat_val)
lon = float(lon_val)
# Apply direction (N=positive, S=negative, E=positive, W=negative)
if lat_dir.upper() == 'S':
lat = -lat
if lon_dir.upper() == 'W':
lon = -lon
# Validate ranges
if not self._validate_coordinates(lat, lon):
return None
return GPSCoordinates(
latitude=lat,
longitude=lon,
source="filename_nsew"
)
def _try_signed(self, filename: str) -> Optional[GPSCoordinates]:
"""Try signed decimal: -34.0478_118.2348"""
match = self.PATTERN_SIGNED.search(filename)
if not match:
return None
lat, lon = match.groups()
lat = float(lat)
lon = float(lon)
if not self._validate_coordinates(lat, lon):
return None
return GPSCoordinates(
latitude=lat,
longitude=lon,
source="filename_signed"
)
def _try_latlon(self, filename: str) -> Optional[GPSCoordinates]:
"""Try lat/lon prefix: lat34.0478lon-118.2348"""
match = self.PATTERN_LATLON.search(filename)
if not match:
return None
lat, lon = match.groups()
lat = float(lat)
lon = float(lon)
if not self._validate_coordinates(lat, lon):
return None
return GPSCoordinates(
latitude=lat,
longitude=lon,
source="filename_latlon"
)
def _try_dms(self, filename: str) -> Optional[GPSCoordinates]:
"""Try DMS format: 34d02m52sN_118d14m05sW"""
match = self.PATTERN_DMS.search(filename)
if not match:
return None
lat_deg, lat_min, lat_sec, lat_dir, lon_deg, lon_min, lon_sec, lon_dir = match.groups()
# Convert DMS to decimal degrees
lat = self._dms_to_decimal(
int(lat_deg), int(lat_min), float(lat_sec)
)
lon = self._dms_to_decimal(
int(lon_deg), int(lon_min), float(lon_sec)
)
# Apply direction
if lat_dir.upper() == 'S':
lat = -lat
if lon_dir.upper() == 'W':
lon = -lon
if not self._validate_coordinates(lat, lon):
return None
return GPSCoordinates(
latitude=lat,
longitude=lon,
source="filename_dms"
)
@staticmethod
def _dms_to_decimal(degrees: int, minutes: int, seconds: float) -> float:
"""Convert DMS to decimal degrees"""
return degrees + (minutes / 60.0) + (seconds / 3600.0)
@staticmethod
def _validate_coordinates(lat: float, lon: float) -> bool:
"""Validate coordinate ranges"""
return -90 <= lat <= 90 and -180 <= lon <= 180
def extract_gps_from_filename(filename: str) -> Optional[Dict]:
"""
Convenience function to extract GPS from filename
Args:
filename: Filename to parse
Returns:
Dictionary with GPS data or None
Example:
>>> extract_gps_from_filename("34.0478N_118.2348W_capture.sub")
{'latitude': 34.0478, 'longitude': -118.2348, 'source': 'filename_nsew'}
"""
extractor = GPSFilenameExtractor()
coords = extractor.extract(filename)
return coords.to_dict() if coords else None
# Example usage and testing
if __name__ == "__main__":
test_filenames = [
"34.0478N_118.2348W_1637_raw_8.sub", # N/S/E/W format
"34.0478N_118.2349W_1351_raw_10.sub",
"lat34.0478lon-118.2348_test.sub", # lat/lon prefix
"-34.0478_118.2348_capture.sub", # Signed decimal
"34d02m52sN_118d14m05sW_test.sub", # DMS format
"raw_7.sub", # No GPS
]
extractor = GPSFilenameExtractor()
print("GPS Filename Extraction Test")
print("=" * 60)
for filename in test_filenames:
coords = extractor.extract(filename)
if coords:
print(f"{filename}")
print(f" Lat: {coords.latitude:.6f}, Lon: {coords.longitude:.6f}")
print(f" Source: {coords.source}")
else:
print(f"⏭️ {filename} - No GPS found")
print()