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:
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# Create giglez repository on Gitea and push
|
||||
# Based on gitea-command-center pattern
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}🛰️ Creating GigLez Repository on Gitea${NC}"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Get token from gopass
|
||||
echo "Retrieving API token from gopass..."
|
||||
TOKEN=$(gopass show -o gitea/api-tokens/tea-cli 2>/dev/null)
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}❌ Token not found in gopass${NC}"
|
||||
echo "Please setup gopass token first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Token retrieved${NC}"
|
||||
|
||||
# Get username from API
|
||||
echo "Getting username..."
|
||||
USER_INFO=$(curl -sk -H "Authorization: token $TOKEN" https://localhost:3030/api/v1/user)
|
||||
USERNAME=$(echo "$USER_INFO" | jq -r '.login')
|
||||
|
||||
if [ -z "$USERNAME" ] || [ "$USERNAME" = "null" ]; then
|
||||
echo -e "${RED}❌ Could not get username from API${NC}"
|
||||
echo "Is Gitea running on localhost:3030?"
|
||||
echo "Response: $USER_INFO"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Logged in as: $USERNAME${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if repository exists
|
||||
echo "Checking if giglez repository exists..."
|
||||
REPO_CHECK=$(curl -sk -H "Authorization: token $TOKEN" \
|
||||
https://localhost:3030/api/v1/repos/$USERNAME/giglez)
|
||||
|
||||
if echo "$REPO_CHECK" | jq -e '.id' > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠️ Repository already exists${NC}"
|
||||
REPO_EXISTS=true
|
||||
else
|
||||
echo -e "${GREEN}✅ Repository doesn't exist, will create${NC}"
|
||||
REPO_EXISTS=false
|
||||
fi
|
||||
|
||||
# Create repository if it doesn't exist
|
||||
if [ "$REPO_EXISTS" = false ]; then
|
||||
echo ""
|
||||
echo "Creating giglez repository..."
|
||||
|
||||
CREATE_RESPONSE=$(curl -sk -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "giglez",
|
||||
"description": "IoT RF Device Mapping Platform - Wigle for Sub-GHz Signals",
|
||||
"private": false,
|
||||
"default_branch": "main"
|
||||
}' \
|
||||
https://localhost:3030/api/v1/user/repos)
|
||||
|
||||
if echo "$CREATE_RESPONSE" | jq -e '.id' > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ Repository created successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Failed to create repository${NC}"
|
||||
echo "$CREATE_RESPONSE" | jq .
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}📡 Setting up Git remote${NC}"
|
||||
echo "================================"
|
||||
|
||||
# Get Gitea URL (check for Tailscale or local)
|
||||
GITEA_URL="https://localhost:3030"
|
||||
REMOTE_URL="${GITEA_URL}/${USERNAME}/giglez.git"
|
||||
|
||||
# Check if origin already exists
|
||||
if git remote get-url origin > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠️ Remote 'origin' already exists${NC}"
|
||||
CURRENT_ORIGIN=$(git remote get-url origin)
|
||||
echo " Current: $CURRENT_ORIGIN"
|
||||
echo " New: $REMOTE_URL"
|
||||
|
||||
read -p "Replace origin? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
git remote remove origin
|
||||
git remote add origin "$REMOTE_URL"
|
||||
echo -e "${GREEN}✅ Remote 'origin' updated${NC}"
|
||||
fi
|
||||
else
|
||||
git remote add origin "$REMOTE_URL"
|
||||
echo -e "${GREEN}✅ Remote 'origin' added${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🚀 Pushing to Gitea${NC}"
|
||||
echo "================================"
|
||||
|
||||
# Get current branch
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
# Push
|
||||
echo "Pushing $CURRENT_BRANCH to origin..."
|
||||
if git push -u origin "$CURRENT_BRANCH"; then
|
||||
echo -e "${GREEN}✅ Successfully pushed to Gitea!${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Push failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo -e "${GREEN}🎉 Repository Setup Complete!${NC}"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "Repository URL: ${GITEA_URL}/${USERNAME}/giglez"
|
||||
echo "Git Remote: $REMOTE_URL"
|
||||
echo "Branch: $CURRENT_BRANCH"
|
||||
echo ""
|
||||
echo "View your repository:"
|
||||
echo " ${GITEA_URL}/${USERNAME}/giglez"
|
||||
echo ""
|
||||
@@ -0,0 +1,43 @@
|
||||
# Gitea Repository Setup
|
||||
|
||||
## Creating the Repository
|
||||
|
||||
### On Gitea Web Interface
|
||||
|
||||
1. **Login to your Gitea instance**
|
||||
2. **Click "+" → "New Repository"**
|
||||
3. **Fill in details**:
|
||||
- Repository Name: `giglez`
|
||||
- Description: `IoT RF Device Mapping Platform - Wigle for Sub-GHz Signals`
|
||||
- Visibility: Private (or Public)
|
||||
- Initialize: ❌ **DO NOT** initialize with README (we already have code)
|
||||
|
||||
4. **Click "Create Repository"**
|
||||
|
||||
### Add Remote
|
||||
|
||||
```bash
|
||||
# Replace with your Gitea URL
|
||||
git remote add origin https://your-gitea-server.com/your-username/giglez.git
|
||||
|
||||
# Or with SSH
|
||||
git remote add origin git@your-gitea-server.com:your-username/giglez.git
|
||||
```
|
||||
|
||||
### Push to Gitea
|
||||
|
||||
```bash
|
||||
# Push current branch
|
||||
git push -u origin p1-p2-validation
|
||||
|
||||
# Or push all branches
|
||||
git push -u origin --all
|
||||
```
|
||||
|
||||
## Current Commit
|
||||
|
||||
**Branch**: `p1-p2-validation`
|
||||
**Commit**: Phase 3 Complete: Web Interface MVP
|
||||
**Files**: 39 files changed, 10,138 insertions(+)
|
||||
|
||||
**Ready to push** when you provide Gitea URL!
|
||||
@@ -0,0 +1,226 @@
|
||||
# GPS Auto-Extraction from Filenames
|
||||
|
||||
## Feature Overview
|
||||
|
||||
The web interface now automatically detects and extracts GPS coordinates from filenames, eliminating the need for manual coordinate entry when filenames contain location data.
|
||||
|
||||
## Supported Filename Patterns
|
||||
|
||||
### 1. N/S/E/W Format (Primary)
|
||||
**Pattern**: `LAT[NS]_LON[EW]`
|
||||
|
||||
**Examples**:
|
||||
- `34.0478N_118.2348W_1637_raw_8.sub` → 34.0478, -118.2348
|
||||
- `34.0478N_118.2349W_1351_raw_10.sub` → 34.0478, -118.2349
|
||||
- `40.7128N_74.0060W_capture.sub` → 40.7128, -74.0060
|
||||
|
||||
**Notes**:
|
||||
- Most common format from T-Embed RF captures
|
||||
- N = positive latitude, S = negative latitude
|
||||
- E = positive longitude, W = negative longitude
|
||||
|
||||
### 2. lat/lon Prefix Format
|
||||
**Pattern**: `latLATlonLON`
|
||||
|
||||
**Examples**:
|
||||
- `lat34.0478lon-118.2348_test.sub` → 34.0478, -118.2348
|
||||
- `lat40.7128lon-74.0060_capture.sub` → 40.7128, -74.0060
|
||||
|
||||
**Notes**:
|
||||
- Allows signed decimal notation
|
||||
- Case-insensitive (LAT/LON also works)
|
||||
|
||||
### 3. Signed Decimal Format
|
||||
**Pattern**: `LAT_LON` (with optional negative signs)
|
||||
|
||||
**Examples**:
|
||||
- `-34.0478_118.2348_capture.sub` → -34.0478, 118.2348
|
||||
- `40.7128_-74.0060_test.sub` → 40.7128, -74.0060
|
||||
|
||||
**Notes**:
|
||||
- Simple signed decimal degrees
|
||||
- Less common but supported
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. File Upload Detection
|
||||
When user selects/drops .sub files:
|
||||
```javascript
|
||||
1. Files are added to upload queue
|
||||
2. First file with GPS coordinates is automatically detected
|
||||
3. GPS fields are auto-populated
|
||||
4. User sees notification toast
|
||||
```
|
||||
|
||||
### 2. UI Indicators
|
||||
- **GPS Detection Notification**: Green toast shows which file provided coordinates
|
||||
- **File List Badge**: Files with GPS show 📍 GPS badge
|
||||
- **Coordinate Display**: Extracted coordinates appear next to filename
|
||||
|
||||
### 3. Validation
|
||||
- Latitude range: -90 to 90
|
||||
- Longitude range: -180 to 180
|
||||
- Invalid coordinates are rejected
|
||||
- Malformed patterns are skipped
|
||||
|
||||
## User Experience
|
||||
|
||||
### Upload Flow with GPS in Filename
|
||||
|
||||
1. **Drop/Select File**: User uploads `34.0478N_118.2348W_capture.sub`
|
||||
2. **Auto-Detection**: System extracts GPS (34.0478, -118.2348)
|
||||
3. **Notification**: Green toast appears:
|
||||
```
|
||||
📍 GPS Auto-Detected!
|
||||
From: 34.0478N_118.2348W_capture.sub
|
||||
Coordinates: 34.047800, -118.234800
|
||||
```
|
||||
4. **Form Population**: Latitude and longitude fields auto-fill
|
||||
5. **Upload**: User clicks "Upload Files" (no manual GPS entry needed)
|
||||
|
||||
### Upload Flow WITHOUT GPS in Filename
|
||||
|
||||
1. **Drop/Select File**: User uploads `raw_7.sub`
|
||||
2. **No Detection**: No GPS found in filename
|
||||
3. **Manual Entry**: User must manually enter GPS or use "Use Current Location"
|
||||
4. **Validation**: System checks for valid coordinates before upload
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### JavaScript GPS Extractor Class
|
||||
Location: `static/js/upload.js`
|
||||
|
||||
```javascript
|
||||
class GPSFilenameExtractor {
|
||||
extract(filename) {
|
||||
// Try each pattern in order
|
||||
return this.tryNSEW(filename) ||
|
||||
this.tryLatLon(filename) ||
|
||||
this.trySigned(filename);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern Matching
|
||||
- **PATTERN_NSEW**: `/(\d+\.?\d*)([NS])_(\d+\.?\d*)([EW])/i`
|
||||
- **PATTERN_LATLON**: `/lat(-?\d+\.?\d+)lon(-?\d+\.?\d+)/i`
|
||||
- **PATTERN_SIGNED**: `/(-?\d+\.?\d+)_(-?\d+\.?\d+)/`
|
||||
|
||||
### Auto-Population Logic
|
||||
```javascript
|
||||
function addFiles(files) {
|
||||
// Try to extract GPS from first file with coordinates
|
||||
if (!autoDetectedGPS) {
|
||||
for (const file of files) {
|
||||
const coords = gpsExtractor.extract(file.name);
|
||||
if (coords) {
|
||||
autoDetectedGPS = coords;
|
||||
document.getElementById('default-lat').value = coords.latitude.toFixed(6);
|
||||
document.getElementById('default-lon').value = coords.longitude.toFixed(6);
|
||||
showGPSDetectionNotification(file.name, coords);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Suite
|
||||
Location: `test_gps_extraction.html`
|
||||
|
||||
Open in browser to verify all patterns:
|
||||
```bash
|
||||
firefox test_gps_extraction.html
|
||||
# or
|
||||
chromium test_gps_extraction.html
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
- ✅ N/S/E/W format (3 test cases)
|
||||
- ✅ lat/lon prefix format
|
||||
- ✅ Signed decimal format
|
||||
- ✅ Negative detection (files without GPS)
|
||||
|
||||
### Python Compatibility
|
||||
The JavaScript implementation matches the Python `GPSFilenameExtractor` class:
|
||||
- Same pattern support
|
||||
- Same validation rules
|
||||
- Same coordinate transformation logic
|
||||
|
||||
## T-Embed RF Compatibility
|
||||
|
||||
### Tested Files
|
||||
From `signatures/t-embed-rf/`:
|
||||
- ✅ `34.0478N_118.2348W_1637_raw_8.sub` → Auto-detected
|
||||
- ✅ `34.0478N_118.2349W_1351_raw_10.sub` → Auto-detected
|
||||
- ✅ `34.0478N_118.2349W_1650_test_raw.sub` → Auto-detected
|
||||
- ⏭️ `raw_4.sub` → Manual entry required
|
||||
- ⏭️ `raw_5.sub` → Manual entry required
|
||||
|
||||
**Success Rate**: 37.5% (3 of 8 files) auto-detected
|
||||
|
||||
## Error Messages
|
||||
|
||||
### GPS Required
|
||||
If no GPS provided (manual or filename):
|
||||
```
|
||||
Please provide GPS coordinates (manually or via filename like: 34.0478N_118.2349W_filename.sub)
|
||||
```
|
||||
|
||||
### GPS Detected but Not Populated
|
||||
If GPS exists in filename but form is empty:
|
||||
```
|
||||
GPS detected in filename but not populated. Please refresh and try again.
|
||||
```
|
||||
|
||||
### Out of Range
|
||||
If coordinates exceed valid ranges:
|
||||
```
|
||||
GPS coordinates out of range
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Faster Uploads**: No manual GPS entry for T-Embed captures
|
||||
2. **Fewer Errors**: Eliminates coordinate typos
|
||||
3. **Better UX**: Clear feedback when GPS is detected
|
||||
4. **Flexible**: Still allows manual entry for files without GPS
|
||||
5. **Compatible**: Matches Python backend extraction logic
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Additional Patterns
|
||||
- DMS format: `34d02m52sN_118d14m05sW`
|
||||
- Compact format: `N34.0478W118.2348`
|
||||
- Plus codes: `8762+MXP_capture.sub`
|
||||
|
||||
### Multi-File GPS
|
||||
Currently uses first file's GPS for all uploads. Future enhancement:
|
||||
- Per-file GPS extraction
|
||||
- Mixed batch uploads (some with GPS, some without)
|
||||
- GPS override UI per file
|
||||
|
||||
### Companion JSON
|
||||
Check for `.json` files alongside `.sub` files:
|
||||
```json
|
||||
{
|
||||
"capture.sub": {
|
||||
"latitude": 34.0478,
|
||||
"longitude": -118.2348,
|
||||
"accuracy": 5.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- `static/js/upload.js` - Frontend GPS extraction
|
||||
- `src/parser/gps_extractor.py` - Backend GPS extraction
|
||||
- `scripts/test_gps_extraction.py` - Python test suite
|
||||
- `test_gps_extraction.html` - JavaScript test suite
|
||||
|
||||
## Summary
|
||||
|
||||
GPS auto-extraction is **LIVE** and working with the format `34.0478N_118.2349W_filename.sub`. Users can now simply drag and drop T-Embed captures without manually entering coordinates!
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test GPS extraction from T-Embed files
|
||||
|
||||
Demonstrates automatic GPS population from filenames
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.parser.gps_extractor import GPSFilenameExtractor
|
||||
|
||||
|
||||
def main():
|
||||
"""Test GPS extraction on T-Embed files"""
|
||||
|
||||
print("=" * 80)
|
||||
print("GPS EXTRACTION TEST - T-Embed Files")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Find T-Embed files
|
||||
tembed_dir = Path(__file__).parent.parent / 'signatures' / 't-embed-rf'
|
||||
|
||||
if not tembed_dir.exists():
|
||||
print(f"❌ T-Embed directory not found: {tembed_dir}")
|
||||
return 1
|
||||
|
||||
sub_files = list(tembed_dir.glob('*.sub'))
|
||||
print(f"Found {len(sub_files)} .sub files")
|
||||
print()
|
||||
|
||||
# Test extraction
|
||||
extractor = GPSFilenameExtractor()
|
||||
|
||||
with_gps = []
|
||||
without_gps = []
|
||||
|
||||
for sub_file in sorted(sub_files):
|
||||
coords = extractor.extract(sub_file.name)
|
||||
|
||||
if coords:
|
||||
with_gps.append((sub_file.name, coords))
|
||||
print(f"✅ {sub_file.name}")
|
||||
print(f" 📍 Latitude: {coords.latitude:.6f}")
|
||||
print(f" 📍 Longitude: {coords.longitude:.6f}")
|
||||
print(f" 📋 Source: {coords.source}")
|
||||
else:
|
||||
without_gps.append(sub_file.name)
|
||||
print(f"⏭️ {sub_file.name} - No GPS in filename")
|
||||
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Total files: {len(sub_files)}")
|
||||
print(f"With GPS coords: {len(with_gps)} ({len(with_gps)/len(sub_files)*100:.1f}%)")
|
||||
print(f"Without GPS coords: {len(without_gps)} ({len(without_gps)/len(sub_files)*100:.1f}%)")
|
||||
print()
|
||||
|
||||
if with_gps:
|
||||
print("Files with GPS coordinates:")
|
||||
for filename, coords in with_gps:
|
||||
print(f" - {filename}")
|
||||
print(f" {coords.latitude:.6f}, {coords.longitude:.6f}")
|
||||
|
||||
print()
|
||||
|
||||
if without_gps:
|
||||
print("Files without GPS (would need manual entry or JSON companion):")
|
||||
for filename in without_gps:
|
||||
print(f" - {filename}")
|
||||
|
||||
print()
|
||||
print("=" * 80)
|
||||
print("NEXT STEPS")
|
||||
print("=" * 80)
|
||||
print("1. Files with GPS in filename → Auto-populate coordinates")
|
||||
print("2. Files without GPS → Look for companion JSON files")
|
||||
print("3. If no JSON found → Require manual GPS entry during upload")
|
||||
print("=" * 80)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
# T-Embed RF SubGHz Wardriving Export
|
||||
|
||||
**Export Date:** 2026-01-13
|
||||
**Device:** Bruce T-Embed CC1101 (ESP32-S3)
|
||||
**GPS Source:** Phone (termux-location)
|
||||
**Format:** Wigle-compatible CSV
|
||||
|
||||
## Files in This Export
|
||||
|
||||
### `test_export_wigle.csv`
|
||||
Wigle-compatible CSV format with GPS+RF signal pairs.
|
||||
|
||||
**Format:**
|
||||
- **SignalHash:** SHA256 hash of frequency+protocol (16 chars)
|
||||
- **Frequency(MHz):** RF frequency in MHz
|
||||
- **Protocol:** SubGHz protocol (RAW, Princeton, etc.)
|
||||
- **Latitude/Longitude:** GPS coordinates (decimal degrees)
|
||||
- **FirstSeen/LastSeen:** ISO 8601 timestamps
|
||||
- **CaptureCount:** Number of times signal was observed
|
||||
- **BruceFile:** Original .sub filename from Bruce device
|
||||
|
||||
### `raw10.sub`
|
||||
Flipper Zero format SubGHz capture file from Bruce T-Embed.
|
||||
|
||||
**Format:**
|
||||
```
|
||||
Filetype: Flipper SubGhz RAW File
|
||||
Version: 1
|
||||
Frequency: 433920000 (433.92 MHz)
|
||||
Preset: FCC_433
|
||||
Protocol: RAW
|
||||
RAW_Data: <timing data>
|
||||
```
|
||||
|
||||
### `quick_capture.py`
|
||||
Python script to capture GPS+RF pairs and store in database.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 quick_capture.py <bruce_file.sub> [session_id]
|
||||
```
|
||||
|
||||
## Current GPS Location
|
||||
|
||||
**Latest Capture:**
|
||||
- **Coordinates:** 34.073625°N, -118.359557°W
|
||||
- **Accuracy:** 24.5 meters
|
||||
- **Timestamp:** 2026-01-13T19:17:51Z
|
||||
- **Provider:** GPS
|
||||
|
||||
**Location:** Greater Los Angeles area, California, USA
|
||||
|
||||
## Database Schema
|
||||
|
||||
Stored in: `~/logs/subghz_wardriving.db` (on phone)
|
||||
|
||||
**Tables:**
|
||||
- `wardriving_sessions` - Session metadata
|
||||
- `gps_track` - GPS breadcrumb trail
|
||||
- `subghz_signals` - RF captures with GPS correlation
|
||||
|
||||
## Wigle CSV Sample
|
||||
|
||||
```csv
|
||||
WigleWifi-1.4,appRelease=SubGHz-Wardrive-1.0,model=BruceT-Embed...
|
||||
SignalHash,Frequency(MHz),Protocol,RSSI(dBm),Latitude,Longitude...
|
||||
8a3f2d1c4e5b6a7f,433.920,RAW,,34.073625,-118.359557,24.5...
|
||||
```
|
||||
|
||||
## Hardware
|
||||
|
||||
**Bruce T-Embed CC1101:**
|
||||
- **RF Module:** Texas Instruments CC1101
|
||||
- **Frequency Range:** 300-928 MHz
|
||||
- **Modulations:** ASK, FSK, GFSK, MSK, 2-FSK, 4-FSK
|
||||
- **Data Rate:** 0.6 - 500 kBaud
|
||||
- **RX Sensitivity:** -112 dBm @ 1.2 kBaud
|
||||
- **TX Power:** -30 to +10 dBm
|
||||
|
||||
**Phone GPS:**
|
||||
- **Providers:** GPS, Network, Passive
|
||||
- **Typical Accuracy:** 10-50 meters
|
||||
- **Update Interval:** 158.4 seconds (2.64 minutes) for wardriving
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Analyze Signals:** Plot frequency distribution
|
||||
2. **Map Visualization:** Create heatmap of signal locations
|
||||
3. **Protocol Decode:** Identify known protocols (garage doors, car keys, weather stations)
|
||||
4. **Expand Collection:** Add more capture sessions
|
||||
5. **Cross-reference:** Compare with FCC database for legitimate transmitters
|
||||
|
||||
## Resources
|
||||
|
||||
- **Bruce Firmware:** https://github.com/pr3y/Bruce
|
||||
- **Flipper SubGHz:** https://docs.flipperzero.one/sub-ghz
|
||||
- **CC1101 Datasheet:** https://www.ti.com/product/CC1101
|
||||
- **Wigle.net:** https://wigle.net/ (WiFi wardriving reference)
|
||||
|
||||
## Security Notes
|
||||
|
||||
This system is designed for:
|
||||
- ✅ Security research and education
|
||||
- ✅ RF spectrum analysis
|
||||
- ✅ Authorized penetration testing
|
||||
- ✅ Personal network security assessment
|
||||
- ❌ **NOT for:** Unauthorized access, signal replay attacks, or malicious use
|
||||
|
||||
---
|
||||
|
||||
**System:** SubGHz Wigle-Like Wardriving v1.0
|
||||
**Documentation:** ~/docs/SUBGHZ_WIGLE_WARDRIVING.md
|
||||
**Database:** ~/logs/subghz_wardriving.db
|
||||
**Export Tool:** ~/python/subghz_wardriving/exporter.py
|
||||
@@ -0,0 +1,391 @@
|
||||
# SubGHz Wigle-Like Wardriving System
|
||||
**Complete GPS+RF Capture and Export System**
|
||||
|
||||
## Overview
|
||||
|
||||
This system creates a Wigle-like wardriving platform for sub-GHz RF signals, pairing GPS coordinates with Bruce T-Embed RF captures in a standardized format similar to Wigle WiFi wardriving.
|
||||
|
||||
**Architecture:**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ GPS Sources (termux-location) │
|
||||
└─────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Bruce T-Embed RF Captures (.sub files) │
|
||||
│ - Frequency, Protocol, Preset │
|
||||
│ - RAW data, Key data │
|
||||
└─────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ SubGHz Wardriving Database (~/logs/subghz_wardriving.db) │
|
||||
│ ├─ wardriving_sessions │
|
||||
│ ├─ gps_track (GPS breadcrumbs) │
|
||||
│ └─ subghz_signals (RF+GPS pairs) │
|
||||
└─────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Export Formats │
|
||||
│ ├─ JSON (detailed) │
|
||||
│ ├─ CSV (standard wardriving) │
|
||||
│ ├─ Wigle CSV (standardized for sharing) │
|
||||
│ └─ SQLite (database backup) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Capture GPS + Associate with RF File
|
||||
|
||||
```bash
|
||||
# Capture current GPS and pair with Bruce .sub file
|
||||
python3 ~/python/subghz_wardriving/quick_capture.py \
|
||||
~/projects/device-integration/bruce/data/captures/raw10.sub
|
||||
|
||||
# With custom session name
|
||||
python3 ~/python/subghz_wardriving/quick_capture.py \
|
||||
raw10.sub downtown_survey
|
||||
```
|
||||
|
||||
**Latest GPS:**
|
||||
- **Coordinates:** 34.07362505, -118.35955673
|
||||
- **Accuracy:** 24.5m
|
||||
- **Timestamp:** 2026-01-13T11:17:51-08:00
|
||||
- **Provider:** GPS
|
||||
|
||||
### 2. Export to Wigle Format
|
||||
|
||||
```bash
|
||||
# Export all data to Wigle CSV
|
||||
gps wigle-export
|
||||
|
||||
# Export specific session
|
||||
gps wigle-export --session downtown_survey
|
||||
|
||||
# Direct export
|
||||
python3 -c "
|
||||
from subghz_wardriving.exporter import SubGHzExporter, ExportConfig
|
||||
exporter = SubGHzExporter()
|
||||
config = ExportConfig(format='wigle', compress=False)
|
||||
result = exporter.export_session(config)
|
||||
print(f'Exported to: {result[\"output_file\"]}')
|
||||
"
|
||||
```
|
||||
|
||||
**Output Location:** `~/exports/subghz_wardriving/`
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: `subghz_signals`
|
||||
|
||||
Stores RF captures paired with GPS coordinates:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | INTEGER | Primary key |
|
||||
| `session_id` | TEXT | Wardriving session ID |
|
||||
| `capture_id` | TEXT | Unique capture identifier |
|
||||
| `timestamp` | TEXT | ISO 8601 timestamp |
|
||||
| `bruce_filename` | TEXT | Original .sub filename |
|
||||
| `bruce_file_path` | TEXT | Full path to .sub file |
|
||||
| `file_size_bytes` | INTEGER | File size |
|
||||
| `frequency_hz` | INTEGER | RF frequency in Hz |
|
||||
| `protocol` | TEXT | Protocol (RAW, Princeton, etc.) |
|
||||
| `gps_latitude` | REAL | Latitude (decimal degrees) |
|
||||
| `gps_longitude` | REAL | Longitude (decimal degrees) |
|
||||
| `gps_accuracy_meters` | REAL | GPS accuracy |
|
||||
| `interpolated` | BOOLEAN | GPS interpolated? |
|
||||
| `interpolation_confidence` | REAL | Confidence score (0-1) |
|
||||
| `time_gap_seconds` | REAL | Time since last capture |
|
||||
| `distance_traveled_meters` | REAL | Distance from last capture |
|
||||
| `estimated_speed_mps` | REAL | Speed in m/s |
|
||||
| `file_hash_md5` | TEXT | MD5 hash of file |
|
||||
| `notes` | TEXT | Additional notes |
|
||||
|
||||
### Table: `gps_track`
|
||||
|
||||
GPS breadcrumb trail:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | INTEGER | Primary key |
|
||||
| `session_id` | TEXT | Session identifier |
|
||||
| `timestamp` | TEXT | ISO 8601 timestamp |
|
||||
| `latitude` | REAL | Latitude |
|
||||
| `longitude` | REAL | Longitude |
|
||||
| `altitude` | REAL | Altitude (meters) |
|
||||
| `accuracy` | REAL | Horizontal accuracy (meters) |
|
||||
| `speed` | REAL | Speed (m/s) |
|
||||
| `provider` | TEXT | GPS provider (gps/network/passive) |
|
||||
|
||||
### Table: `wardriving_sessions`
|
||||
|
||||
Session metadata:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `session_id` | TEXT | Primary key |
|
||||
| `start_time` | TEXT | Session start time |
|
||||
| `end_time` | TEXT | Session end time |
|
||||
| `total_signals` | INTEGER | Number of signals captured |
|
||||
| `total_distance_km` | REAL | Total distance traveled |
|
||||
| `device_info` | TEXT | Device information |
|
||||
| `notes` | TEXT | Session notes |
|
||||
|
||||
## Wigle CSV Format
|
||||
|
||||
### Header Format
|
||||
|
||||
```csv
|
||||
WigleWifi-1.4,appRelease=SubGHz-Wardrive-1.0,model=BruceT-Embed,release=Android,device=Phone,display=SubGHzWardrive,board=termux,brand=custom
|
||||
SignalHash,Frequency(MHz),Protocol,RSSI(dBm),Latitude,Longitude,Altitude,Accuracy,FirstSeen,LastSeen,CaptureCount,Interpolated,Confidence,BruceFile,SessionID
|
||||
```
|
||||
|
||||
### Columns
|
||||
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| `SignalHash` | SHA256 hash of frequency+protocol (16 chars) |
|
||||
| `Frequency(MHz)` | Frequency in MHz (e.g., 433.92) |
|
||||
| `Protocol` | Protocol name (RAW, Princeton, etc.) |
|
||||
| `RSSI(dBm)` | Signal strength (not available from .sub files) |
|
||||
| `Latitude` | Decimal degrees |
|
||||
| `Longitude` | Decimal degrees |
|
||||
| `Altitude` | Meters above sea level |
|
||||
| `Accuracy` | GPS accuracy in meters |
|
||||
| `FirstSeen` | ISO 8601 timestamp of first capture |
|
||||
| `LastSeen` | ISO 8601 timestamp of last capture |
|
||||
| `CaptureCount` | Number of times signal was seen |
|
||||
| `Interpolated` | true/false - was GPS interpolated? |
|
||||
| `Confidence` | Interpolation confidence (0-1) |
|
||||
| `BruceFile` | Original .sub filename |
|
||||
| `SessionID` | Wardriving session ID |
|
||||
|
||||
### Signal Aggregation
|
||||
|
||||
Signals are grouped by hash (frequency + protocol), similar to how Wigle groups WiFi by BSSID. Multiple captures of the same signal show:
|
||||
- First and last seen timestamps
|
||||
- Total capture count
|
||||
- Location from first capture
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Quick Capture
|
||||
|
||||
```bash
|
||||
# Capture GPS now with Bruce file
|
||||
python3 ~/python/subghz_wardriving/quick_capture.py raw10.sub
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
📡 SubGHz GPS Capture
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Bruce File: raw10.sub
|
||||
Session: quick_20260113_112045
|
||||
|
||||
🛰️ Getting GPS location...
|
||||
✓ GPS Location: 34.073625, -118.359557
|
||||
Accuracy: 24.5m
|
||||
|
||||
📻 Parsing RF file...
|
||||
✓ Frequency: 433920000 Hz (433.920 MHz)
|
||||
Protocol: RAW
|
||||
Preset: FCC_433
|
||||
|
||||
💾 Storing to database...
|
||||
✓ Stored signal: quick_20260113_112045_raw10_1736789645
|
||||
|
||||
✅ Capture paired successfully!
|
||||
Database: ~/logs/subghz_wardriving.db
|
||||
```
|
||||
|
||||
### Example 2: Export Session
|
||||
|
||||
```bash
|
||||
# Export to Wigle CSV
|
||||
gps wigle-export --session downtown_survey
|
||||
```
|
||||
|
||||
**Output File:** `~/exports/subghz_wardriving/downtown_survey_20260113_112130_wigle.csv`
|
||||
|
||||
**Content:**
|
||||
```csv
|
||||
WigleWifi-1.4,appRelease=SubGHz-Wardrive-1.0,model=BruceT-Embed,release=Android,device=Phone,display=SubGHzWardrive,board=termux,brand=custom
|
||||
SignalHash,Frequency(MHz),Protocol,RSSI(dBm),Latitude,Longitude,Altitude,Accuracy,FirstSeen,LastSeen,CaptureCount,Interpolated,Confidence,BruceFile,SessionID
|
||||
8a3f2d1c4e5b6a7f,433.920,RAW,,34.073625,-118.359557,,24.5,2026-01-13T19:17:51Z,2026-01-13T19:17:51Z,1,false,1.0,raw10.sub,downtown_survey
|
||||
```
|
||||
|
||||
### Example 3: Wardriving Session
|
||||
|
||||
```bash
|
||||
# Start GPS logging
|
||||
gps on --session wardrive_session1 --interval 158.4
|
||||
|
||||
# Bruce captures RF signals to /BruceRF/*.sub files
|
||||
# (Manually on Bruce device)
|
||||
|
||||
# After session, run batch import
|
||||
for subfile in ~/projects/device-integration/bruce/data/captures/*.sub; do
|
||||
python3 ~/python/subghz_wardriving/quick_capture.py "$subfile" wardrive_session1
|
||||
done
|
||||
|
||||
# Stop GPS logging
|
||||
gps off
|
||||
|
||||
# Export to Wigle format
|
||||
gps wigle-export --session wardrive_session1
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
| Component | Path |
|
||||
|-----------|------|
|
||||
| **Database** | `~/logs/subghz_wardriving.db` |
|
||||
| **GPS Quick Log** | `~/logs/gps_quick.jsonl` |
|
||||
| **GPS Correlations** | `~/logs/gps_correlations.db` (legacy) |
|
||||
| **Exports** | `~/exports/subghz_wardriving/` |
|
||||
| **Bruce Captures** | `~/projects/device-integration/bruce/data/captures/` |
|
||||
| **Quick Capture Script** | `~/python/subghz_wardriving/quick_capture.py` |
|
||||
| **Exporter Module** | `~/python/subghz_wardriving/exporter.py` |
|
||||
| **Database Module** | `~/python/subghz_wardriving/database.py` |
|
||||
|
||||
## Integration with Existing GPS Infrastructure
|
||||
|
||||
This system integrates with your existing GPS wardriving infrastructure:
|
||||
|
||||
### Existing Systems
|
||||
1. **`gps` command** - CLI tool for GPS operations
|
||||
2. **`gps_service.py`** - Background GPS logging (158.4s interval)
|
||||
3. **`smart_gps_detector.py`** - Auto-detection and file renaming
|
||||
4. **`realtime_gps_attribution.py`** - Real-time GPS attribution
|
||||
|
||||
### New Additions
|
||||
1. **`quick_capture.py`** - Quick GPS+RF pairing
|
||||
2. **Wigle export format** - Added to `exporter.py`
|
||||
3. **`gps wigle-export`** - New command in `gps` script
|
||||
4. **Standardized database** - Uses existing `subghz_wardriving.db`
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### GPS Interpolation
|
||||
|
||||
When RF captures occur between GPS readings, the system interpolates GPS coordinates based on:
|
||||
- Time gap between captures
|
||||
- Distance traveled
|
||||
- Estimated speed
|
||||
- Interpolation confidence score
|
||||
|
||||
### Session Management
|
||||
|
||||
```python
|
||||
from subghz_wardriving.database import SubGHzWardrivingDB
|
||||
|
||||
db = SubGHzWardrivingDB()
|
||||
|
||||
# Create session
|
||||
db.create_session("my_session", device_info="Bruce T-Embed", notes="Downtown survey")
|
||||
|
||||
# End session (calculates statistics)
|
||||
db.end_session("my_session")
|
||||
|
||||
# Get summary
|
||||
summary = db.get_session_summary("my_session")
|
||||
print(f"Signals: {summary['session']['total_signals']}")
|
||||
print(f"Distance: {summary['session']['total_distance_km']} km")
|
||||
```
|
||||
|
||||
### Filtering Exports
|
||||
|
||||
```python
|
||||
from subghz_wardriving.exporter import SubGHzExporter, ExportConfig
|
||||
|
||||
exporter = SubGHzExporter()
|
||||
|
||||
# Export only 433 MHz signals
|
||||
config = ExportConfig(
|
||||
format='wigle',
|
||||
frequency_filter=(433_000_000, 434_000_000), # 433-434 MHz
|
||||
confidence_threshold=0.8, # High confidence only
|
||||
protocol_filter='RAW'
|
||||
)
|
||||
|
||||
result = exporter.export_session(config)
|
||||
```
|
||||
|
||||
## Comparison to WiFi Wigle
|
||||
|
||||
| Feature | WiFi Wigle | SubGHz Wigle |
|
||||
|---------|------------|--------------|
|
||||
| **Signal ID** | BSSID (MAC address) | SignalHash (freq+protocol) |
|
||||
| **Signal Name** | SSID | Protocol name |
|
||||
| **Frequency** | WiFi channels (2.4/5 GHz) | SubGHz (300-928 MHz) |
|
||||
| **Strength** | RSSI (dBm) | Not available from .sub |
|
||||
| **Device** | WiFi adapter | Bruce T-Embed CC1101 |
|
||||
| **Format** | CSV | CSV (Wigle-compatible) |
|
||||
| **Location** | GPS | GPS |
|
||||
| **Aggregation** | By BSSID | By SignalHash |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **RSSI Integration** - If Bruce firmware provides signal strength
|
||||
2. **Real-time Export** - Live streaming to Wigle format
|
||||
3. **Map Visualization** - Plot signals on map
|
||||
4. **Protocol Decoding** - Decode known protocols (garage doors, car keys, etc.)
|
||||
5. **Community Database** - Share SubGHz signal maps
|
||||
6. **Cross-device Correlation** - Combine with Pineapple WiFi wardriving
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No GPS Fix
|
||||
|
||||
```bash
|
||||
# Check GPS status
|
||||
gps status
|
||||
|
||||
# Try network provider
|
||||
termux-location -p network
|
||||
|
||||
# Use test coordinates
|
||||
# Edit gps script to add test coordinate option
|
||||
```
|
||||
|
||||
### Database Locked
|
||||
|
||||
```bash
|
||||
# Check for running processes
|
||||
ps aux | grep python | grep subghz
|
||||
|
||||
# Close database connections
|
||||
pkill -f subghz_wardriving
|
||||
```
|
||||
|
||||
### Export Fails
|
||||
|
||||
```bash
|
||||
# Check database exists
|
||||
ls -lh ~/logs/subghz_wardriving.db
|
||||
|
||||
# Check export directory
|
||||
mkdir -p ~/exports/subghz_wardriving
|
||||
|
||||
# Test export directly
|
||||
python3 -m subghz_wardriving.exporter
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **Bruce Firmware:** https://github.com/pr3y/Bruce
|
||||
- **Flipper SubGHz Format:** https://docs.flipperzero.one/sub-ghz
|
||||
- **Wigle.net:** https://wigle.net/
|
||||
- **CC1101 Datasheet:** https://www.ti.com/product/CC1101
|
||||
|
||||
---
|
||||
|
||||
**Created:** 2026-01-13
|
||||
**System Version:** 1.0
|
||||
**Database:** ~/logs/subghz_wardriving.db
|
||||
**Latest GPS:** 34.07362505, -118.35955673 (24.5m accuracy)
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/data/data/com.termux/files/usr/bin/python3
|
||||
"""
|
||||
Quick GPS+RF Capture - Uses existing wardriving infrastructure
|
||||
Captures current GPS and associates it with a Bruce .sub file
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Import existing infrastructure
|
||||
from database import SubGHzWardrivingDB
|
||||
|
||||
|
||||
def get_current_gps(timeout=15):
|
||||
"""Get current GPS using termux-location."""
|
||||
try:
|
||||
cmd = ["termux-location", "-p", "gps"]
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
gps_data = json.loads(result.stdout.strip())
|
||||
if 'latitude' in gps_data and 'longitude' in gps_data:
|
||||
print(f"✓ GPS Location: {gps_data['latitude']:.6f}, {gps_data['longitude']:.6f}")
|
||||
print(f" Accuracy: {gps_data.get('accuracy', 'N/A')}m")
|
||||
return gps_data
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"✗ GPS timeout after {timeout}s")
|
||||
except Exception as e:
|
||||
print(f"✗ GPS error: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_sub_file(sub_file):
|
||||
"""Parse Bruce .sub file to extract RF metadata."""
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
with open(sub_file, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or not ':' in line:
|
||||
continue
|
||||
|
||||
key, value = line.split(':', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
if key == 'Frequency':
|
||||
metadata['frequency_hz'] = int(value)
|
||||
elif key == 'Protocol':
|
||||
metadata['protocol'] = value
|
||||
elif key == 'Preset':
|
||||
metadata['preset'] = value
|
||||
|
||||
return metadata if metadata else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error parsing {sub_file}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def calculate_file_hash(file_path):
|
||||
"""Calculate MD5 hash of file."""
|
||||
md5 = hashlib.md5()
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
md5.update(chunk)
|
||||
return md5.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: quick_capture.py <bruce_file.sub> [session_id]")
|
||||
print("\nExample:")
|
||||
print(" python3 quick_capture.py raw10.sub")
|
||||
print(" python3 quick_capture.py ~/projects/device-integration/bruce/data/captures/raw10.sub mysession")
|
||||
return 1
|
||||
|
||||
sub_file = Path(sys.argv[1])
|
||||
session_id = sys.argv[2] if len(sys.argv) > 2 else f"quick_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
if not sub_file.exists():
|
||||
print(f"✗ File not found: {sub_file}")
|
||||
return 1
|
||||
|
||||
print(f"\n📡 SubGHz GPS Capture")
|
||||
print(f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
print(f"Bruce File: {sub_file.name}")
|
||||
print(f"Session: {session_id}")
|
||||
print()
|
||||
|
||||
# Get GPS
|
||||
print("🛰️ Getting GPS location...")
|
||||
gps_data = get_current_gps()
|
||||
if not gps_data:
|
||||
print("\n✗ Failed to get GPS - trying to use latest from log...")
|
||||
gps_log = Path.home() / "logs" / "gps_quick.jsonl"
|
||||
if gps_log.exists():
|
||||
with open(gps_log, 'r') as f:
|
||||
last_line = f.readlines()[-1]
|
||||
gps_data = json.loads(last_line.strip())
|
||||
print(f"✓ Using latest GPS: {gps_data['latitude']:.6f}, {gps_data['longitude']:.6f}")
|
||||
else:
|
||||
print("✗ No GPS data available")
|
||||
return 1
|
||||
|
||||
# Parse RF file
|
||||
print("\n📻 Parsing RF file...")
|
||||
rf_metadata = parse_sub_file(sub_file)
|
||||
if not rf_metadata:
|
||||
print("✗ Failed to parse RF file")
|
||||
return 1
|
||||
|
||||
print(f"✓ Frequency: {rf_metadata.get('frequency_hz', 'N/A')} Hz ({rf_metadata.get('frequency_hz', 0) / 1e6:.3f} MHz)")
|
||||
print(f" Protocol: {rf_metadata.get('protocol', 'N/A')}")
|
||||
print(f" Preset: {rf_metadata.get('preset', 'N/A')}")
|
||||
|
||||
# Initialize database
|
||||
print("\n💾 Storing to database...")
|
||||
db = SubGHzWardrivingDB()
|
||||
|
||||
# Create session if it doesn't exist
|
||||
db.create_session(session_id, device_info="Bruce T-Embed CC1101", notes="Quick GPS capture")
|
||||
|
||||
# Add GPS track point
|
||||
db.add_gps_point(
|
||||
session_id=session_id,
|
||||
latitude=gps_data['latitude'],
|
||||
longitude=gps_data['longitude'],
|
||||
altitude=gps_data.get('altitude'),
|
||||
accuracy=gps_data.get('accuracy'),
|
||||
speed=gps_data.get('speed'),
|
||||
provider=gps_data.get('provider', 'gps')
|
||||
)
|
||||
|
||||
# Add signal capture
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
capture_id = f"{session_id}_{sub_file.stem}_{int(datetime.now().timestamp())}"
|
||||
|
||||
db.add_signal(
|
||||
session_id=session_id,
|
||||
capture_id=capture_id,
|
||||
timestamp=timestamp,
|
||||
bruce_filename=sub_file.name,
|
||||
bruce_file_path=str(sub_file),
|
||||
file_size_bytes=sub_file.stat().st_size,
|
||||
frequency_hz=rf_metadata.get('frequency_hz'),
|
||||
protocol=rf_metadata.get('protocol'),
|
||||
gps_latitude=gps_data['latitude'],
|
||||
gps_longitude=gps_data['longitude'],
|
||||
gps_accuracy_meters=gps_data.get('accuracy'),
|
||||
interpolated=False,
|
||||
interpolation_confidence=1.0,
|
||||
file_hash_md5=calculate_file_hash(sub_file)
|
||||
)
|
||||
|
||||
print(f"✓ Stored signal: {capture_id}")
|
||||
print(f"\n✅ Capture paired successfully!")
|
||||
print(f" Database: ~/logs/subghz_wardriving.db")
|
||||
print(f" Export with: python3 -m subghz_wardriving.exporter")
|
||||
print()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
WigleWifi-1.4,appRelease=SubGHz-Wardrive-1.0,model=BruceT-Embed,release=Android,device=Phone,display=SubGHzWardrive,board=termux,brand=custom
|
||||
SignalHash,Frequency(MHz),Protocol,RSSI(dBm),Latitude,Longitude,Altitude,Accuracy,FirstSeen,LastSeen,CaptureCount,Interpolated,Confidence,BruceFile,SessionID
|
||||
8a3f2d1c4e5b6a7f,433.920,RAW,,34.073625,-118.359557,,24.5,2026-01-13T19:17:51Z,2026-01-13T19:17:51Z,1,false,1.0,raw10.sub,test_session
|
||||
|
+126
-10
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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()
|
||||
+171
-6
@@ -1,6 +1,7 @@
|
||||
// GigLez - Upload Functionality
|
||||
|
||||
let selectedFiles = [];
|
||||
let autoDetectedGPS = null; // Store GPS detected from filenames
|
||||
|
||||
// Initialize upload functionality
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -8,6 +9,90 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setupFileInput();
|
||||
});
|
||||
|
||||
// GPS Extraction from Filenames
|
||||
// Matches patterns like: 34.0478N_118.2349W_1650_test_raw.sub
|
||||
class GPSFilenameExtractor {
|
||||
constructor() {
|
||||
// Pattern 1: Decimal degrees with N/S/E/W (most common)
|
||||
this.PATTERN_NSEW = /(\d+\.?\d*)([NS])_(\d+\.?\d*)([EW])/i;
|
||||
|
||||
// Pattern 2: lat/lon prefix format
|
||||
this.PATTERN_LATLON = /lat(-?\d+\.?\d+)lon(-?\d+\.?\d+)/i;
|
||||
|
||||
// Pattern 3: Signed decimal degrees
|
||||
this.PATTERN_SIGNED = /(-?\d+\.?\d+)_(-?\d+\.?\d+)/;
|
||||
}
|
||||
|
||||
extract(filename) {
|
||||
// Try each pattern
|
||||
let coords = this.tryNSEW(filename) ||
|
||||
this.tryLatLon(filename) ||
|
||||
this.trySigned(filename);
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
tryNSEW(filename) {
|
||||
const match = filename.match(this.PATTERN_NSEW);
|
||||
if (!match) return null;
|
||||
|
||||
let lat = parseFloat(match[1]);
|
||||
let lon = parseFloat(match[3]);
|
||||
const latDir = match[2].toUpperCase();
|
||||
const lonDir = match[4].toUpperCase();
|
||||
|
||||
// Apply direction (N=positive, S=negative, E=positive, W=negative)
|
||||
if (latDir === 'S') lat = -lat;
|
||||
if (lonDir === 'W') lon = -lon;
|
||||
|
||||
if (!this.validateCoordinates(lat, lon)) return null;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: 'filename_nsew'
|
||||
};
|
||||
}
|
||||
|
||||
tryLatLon(filename) {
|
||||
const match = filename.match(this.PATTERN_LATLON);
|
||||
if (!match) return null;
|
||||
|
||||
const lat = parseFloat(match[1]);
|
||||
const lon = parseFloat(match[2]);
|
||||
|
||||
if (!this.validateCoordinates(lat, lon)) return null;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: 'filename_latlon'
|
||||
};
|
||||
}
|
||||
|
||||
trySigned(filename) {
|
||||
const match = filename.match(this.PATTERN_SIGNED);
|
||||
if (!match) return null;
|
||||
|
||||
const lat = parseFloat(match[1]);
|
||||
const lon = parseFloat(match[2]);
|
||||
|
||||
if (!this.validateCoordinates(lat, lon)) return null;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: 'filename_signed'
|
||||
};
|
||||
}
|
||||
|
||||
validateCoordinates(lat, lon) {
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
}
|
||||
|
||||
const gpsExtractor = new GPSFilenameExtractor();
|
||||
|
||||
function setupDropZone() {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
|
||||
@@ -51,6 +136,23 @@ function addFiles(files) {
|
||||
// Add new files
|
||||
selectedFiles.push(...files);
|
||||
|
||||
// Try to extract GPS from first file with coordinates
|
||||
if (!autoDetectedGPS) {
|
||||
for (const file of files) {
|
||||
const coords = gpsExtractor.extract(file.name);
|
||||
if (coords) {
|
||||
autoDetectedGPS = coords;
|
||||
// Auto-populate GPS fields
|
||||
document.getElementById('default-lat').value = coords.latitude.toFixed(6);
|
||||
document.getElementById('default-lon').value = coords.longitude.toFixed(6);
|
||||
|
||||
// Show notification
|
||||
showGPSDetectionNotification(file.name, coords);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show file list
|
||||
document.getElementById('file-list').style.display = 'block';
|
||||
|
||||
@@ -58,20 +160,72 @@ function addFiles(files) {
|
||||
renderFileList();
|
||||
}
|
||||
|
||||
function showGPSDetectionNotification(filename, coords) {
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
notification.innerHTML = `
|
||||
<strong>📍 GPS Auto-Detected!</strong><br>
|
||||
<small>From: ${filename}</small><br>
|
||||
<small>Coordinates: ${coords.latitude.toFixed(6)}, ${coords.longitude.toFixed(6)}</small>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Add animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(400px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideIn 0.3s ease-out reverse';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function renderFileList() {
|
||||
const container = document.getElementById('files-container');
|
||||
|
||||
container.innerHTML = selectedFiles.map((file, index) => `
|
||||
container.innerHTML = selectedFiles.map((file, index) => {
|
||||
const coords = gpsExtractor.extract(file.name);
|
||||
const hasGPS = coords !== null;
|
||||
|
||||
return `
|
||||
<div class="file-item" data-index="${index}">
|
||||
<div class="file-info">
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-meta">${formatFileSize(file.size)}</div>
|
||||
<div class="file-name">
|
||||
${file.name}
|
||||
${hasGPS ? '<span style="color: #10b981; margin-left: 0.5rem;">📍 GPS</span>' : ''}
|
||||
</div>
|
||||
<div class="file-meta">
|
||||
${formatFileSize(file.size)}
|
||||
${hasGPS ? ` | ${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="removeFile(${index})">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
@@ -86,6 +240,7 @@ function removeFile(index) {
|
||||
|
||||
function clearFiles() {
|
||||
selectedFiles = [];
|
||||
autoDetectedGPS = null; // Reset auto-detected GPS
|
||||
document.getElementById('file-list').style.display = 'none';
|
||||
document.getElementById('file-input').value = '';
|
||||
}
|
||||
@@ -96,12 +251,19 @@ async function uploadFiles() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate GPS coordinates
|
||||
// Validate GPS coordinates (allow auto-detected or manual entry)
|
||||
const lat = parseFloat(document.getElementById('default-lat').value);
|
||||
const lon = parseFloat(document.getElementById('default-lon').value);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
alert('Please provide valid GPS coordinates');
|
||||
// Check if any file has GPS in filename
|
||||
const hasGPSInFilename = selectedFiles.some(file => gpsExtractor.extract(file.name) !== null);
|
||||
|
||||
if (hasGPSInFilename) {
|
||||
alert('GPS detected in filename but not populated. Please refresh and try again.');
|
||||
} else {
|
||||
alert('Please provide GPS coordinates (manually or via filename like: 34.0478N_118.2349W_filename.sub)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,6 +398,9 @@ function resetUpload() {
|
||||
document.getElementById('default-lat').value = '';
|
||||
document.getElementById('default-lon').value = '';
|
||||
document.getElementById('session-uuid').value = '';
|
||||
|
||||
// Reset auto-detected GPS
|
||||
autoDetectedGPS = null;
|
||||
}
|
||||
|
||||
function useCurrentLocation() {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test GPS Extraction</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.test-case {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2563eb;
|
||||
}
|
||||
.filename {
|
||||
font-family: monospace;
|
||||
background: #f0f0f0;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.result {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f0fdf4;
|
||||
border-left: 3px solid #10b981;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.result.fail {
|
||||
background: #fef2f2;
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>GPS Filename Extraction Test</h1>
|
||||
<p>Testing the same patterns as Python implementation</p>
|
||||
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
// Copy the GPS extraction code from upload.js
|
||||
class GPSFilenameExtractor {
|
||||
constructor() {
|
||||
this.PATTERN_NSEW = /(\d+\.?\d*)([NS])_(\d+\.?\d*)([EW])/i;
|
||||
this.PATTERN_LATLON = /lat(-?\d+\.?\d+)lon(-?\d+\.?\d+)/i;
|
||||
this.PATTERN_SIGNED = /(-?\d+\.?\d+)_(-?\d+\.?\d+)/;
|
||||
}
|
||||
|
||||
extract(filename) {
|
||||
let coords = this.tryNSEW(filename) ||
|
||||
this.tryLatLon(filename) ||
|
||||
this.trySigned(filename);
|
||||
return coords;
|
||||
}
|
||||
|
||||
tryNSEW(filename) {
|
||||
const match = filename.match(this.PATTERN_NSEW);
|
||||
if (!match) return null;
|
||||
|
||||
let lat = parseFloat(match[1]);
|
||||
let lon = parseFloat(match[3]);
|
||||
const latDir = match[2].toUpperCase();
|
||||
const lonDir = match[4].toUpperCase();
|
||||
|
||||
if (latDir === 'S') lat = -lat;
|
||||
if (lonDir === 'W') lon = -lon;
|
||||
|
||||
if (!this.validateCoordinates(lat, lon)) return null;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: 'filename_nsew'
|
||||
};
|
||||
}
|
||||
|
||||
tryLatLon(filename) {
|
||||
const match = filename.match(this.PATTERN_LATLON);
|
||||
if (!match) return null;
|
||||
|
||||
const lat = parseFloat(match[1]);
|
||||
const lon = parseFloat(match[2]);
|
||||
|
||||
if (!this.validateCoordinates(lat, lon)) return null;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: 'filename_latlon'
|
||||
};
|
||||
}
|
||||
|
||||
trySigned(filename) {
|
||||
const match = filename.match(this.PATTERN_SIGNED);
|
||||
if (!match) return null;
|
||||
|
||||
const lat = parseFloat(match[1]);
|
||||
const lon = parseFloat(match[2]);
|
||||
|
||||
if (!this.validateCoordinates(lat, lon)) return null;
|
||||
|
||||
return {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: 'filename_signed'
|
||||
};
|
||||
}
|
||||
|
||||
validateCoordinates(lat, lon) {
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
}
|
||||
|
||||
// Test cases
|
||||
const testCases = [
|
||||
{ filename: "34.0478N_118.2348W_1637_raw_8.sub", expected: { lat: 34.0478, lon: -118.2348 } },
|
||||
{ filename: "34.0478N_118.2349W_1351_raw_10.sub", expected: { lat: 34.0478, lon: -118.2349 } },
|
||||
{ filename: "34.0478N_118.2349W_1650_test_raw.sub", expected: { lat: 34.0478, lon: -118.2349 } },
|
||||
{ filename: "lat34.0478lon-118.2348_test.sub", expected: { lat: 34.0478, lon: -118.2348 } },
|
||||
{ filename: "-34.0478_118.2348_capture.sub", expected: { lat: -34.0478, lon: 118.2348 } },
|
||||
{ filename: "raw_7.sub", expected: null },
|
||||
{ filename: "raw_8.sub", expected: null }
|
||||
];
|
||||
|
||||
const extractor = new GPSFilenameExtractor();
|
||||
const resultsDiv = document.getElementById('results');
|
||||
|
||||
testCases.forEach((test, index) => {
|
||||
const coords = extractor.extract(test.filename);
|
||||
|
||||
const testDiv = document.createElement('div');
|
||||
testDiv.className = 'test-case';
|
||||
|
||||
let success = false;
|
||||
let resultHTML = '';
|
||||
|
||||
if (test.expected === null) {
|
||||
// Expect no GPS
|
||||
success = coords === null;
|
||||
resultHTML = coords === null ?
|
||||
`<div class="result">✅ Correctly detected: No GPS in filename</div>` :
|
||||
`<div class="result fail">❌ False positive: ${coords.latitude}, ${coords.longitude}</div>`;
|
||||
} else {
|
||||
// Expect GPS
|
||||
if (coords) {
|
||||
const latMatch = Math.abs(coords.latitude - test.expected.lat) < 0.0001;
|
||||
const lonMatch = Math.abs(coords.longitude - test.expected.lon) < 0.0001;
|
||||
success = latMatch && lonMatch;
|
||||
|
||||
resultHTML = success ?
|
||||
`<div class="result">✅ GPS Detected: ${coords.latitude.toFixed(6)}, ${coords.longitude.toFixed(6)} (${coords.source})</div>` :
|
||||
`<div class="result fail">❌ Wrong coordinates: Got ${coords.latitude}, ${coords.longitude} | Expected ${test.expected.lat}, ${test.expected.lon}</div>`;
|
||||
} else {
|
||||
resultHTML = `<div class="result fail">❌ Failed to extract GPS (expected ${test.expected.lat}, ${test.expected.lon})</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
testDiv.innerHTML = `
|
||||
<div><strong>Test ${index + 1}</strong></div>
|
||||
<div class="filename">${test.filename}</div>
|
||||
${resultHTML}
|
||||
`;
|
||||
|
||||
resultsDiv.appendChild(testDiv);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user