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
+126 -10
View File
@@ -4,13 +4,22 @@ GigLez FastAPI Application - Simplified Version
Runs without database requirement for testing web interface
"""
from fastapi import FastAPI, Request
import json
import sys
from pathlib import Path
from typing import List
from fastapi import FastAPI, Request, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pathlib import Path
# Add project root to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.parser.sub_parser import SubFileParser
from src.parser.gps_extractor import GPSFilenameExtractor
# =============================================================================
# APPLICATION INSTANCE
@@ -29,6 +38,10 @@ BASE_DIR = Path(__file__).parent.parent.parent
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# In-memory storage for uploaded captures (simplified mode)
captures_storage = []
upload_counter = 0
# =============================================================================
# MIDDLEWARE
@@ -85,10 +98,10 @@ async def api_root():
@app.get("/api/v1/query/captures")
async def get_captures():
"""Mock endpoint - return empty captures for now"""
"""Return stored captures from in-memory storage"""
return {
"captures": [],
"total": 0,
"captures": captures_storage,
"total": len(captures_storage),
"page": 1,
"page_size": 100
}
@@ -96,17 +109,120 @@ async def get_captures():
@app.get("/api/v1/stats/summary")
async def get_stats():
"""Mock endpoint - return zero stats for now"""
"""Return stats from in-memory storage"""
# Calculate frequency distribution
freq_dist = {}
for capture in captures_storage:
freq = capture.get("frequency", 0)
if freq > 0:
freq_dist[freq] = freq_dist.get(freq, 0) + 1
# Count unique protocols as proxy for unique devices
unique_protocols = len(set(c.get("protocol", "Unknown") for c in captures_storage))
return {
"total_captures": 0,
"unique_devices": 0,
"total_captures": len(captures_storage),
"unique_devices": unique_protocols,
"coverage_area_km2": 0,
"total_contributors": 0,
"frequency_distribution": {},
"total_contributors": 1 if len(captures_storage) > 0 else 0,
"frequency_distribution": freq_dist,
"captures_timeline": []
}
@app.post("/api/v1/captures/upload")
async def upload_captures(
files: List[UploadFile] = File(...),
manifest: str = Form(...)
):
"""
Mock upload endpoint - parses files and returns success
Does not save to database (simplified version)
"""
import tempfile
import os
try:
# Parse manifest
manifest_data = json.loads(manifest)
# Initialize parsers
sub_parser = SubFileParser()
gps_extractor = GPSFilenameExtractor()
successful = []
failed = []
# Process each file
for file in files:
temp_path = None
try:
# Read file content
content = await file.read()
# Save to temporary file for parsing
with tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.sub') as temp_file:
temp_path = temp_file.name
temp_file.write(content)
# Parse .sub file
metadata = sub_parser.parse(temp_path)
# Try to extract GPS from filename
gps_coords = gps_extractor.extract(file.filename)
# Use GPS from manifest or filename
global upload_counter
upload_counter += 1
capture_info = {
"id": upload_counter,
"filename": file.filename,
"frequency": metadata.frequency if hasattr(metadata, 'frequency') else 0,
"protocol": metadata.protocol if hasattr(metadata, 'protocol') else "RAW",
"preset": metadata.preset if hasattr(metadata, 'preset') else "Unknown",
"latitude": gps_coords.latitude if gps_coords else manifest_data.get("captures", [{}])[0].get("latitude"),
"longitude": gps_coords.longitude if gps_coords else manifest_data.get("captures", [{}])[0].get("longitude"),
"gps_source": gps_coords.source if gps_coords else "manual",
"timestamp": manifest_data.get("captures", [{}])[0].get("timestamp", ""),
}
# Store in memory
captures_storage.append(capture_info)
successful.append(capture_info)
except Exception as e:
failed.append({
"filename": file.filename,
"error": str(e)
})
finally:
# Clean up temp file
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
return {
"success": True,
"message": f"Processed {len(successful)} files successfully",
"successful": successful,
"failed": failed,
"total_uploaded": len(files),
"total_successful": len(successful),
"total_failed": len(failed)
}
except Exception as e:
return {
"success": False,
"message": f"Upload failed: {str(e)}",
"successful": [],
"failed": [],
"total_uploaded": 0,
"total_successful": 0,
"total_failed": 0
}
# =============================================================================
# RUN WITH UVICORN (for development)
# =============================================================================
+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()