feat: RTL_433 protocol database import - iteration 1/5

- Expanded protocol database from 18 → 299 signatures (16.6x increase)
- Imported 281 protocols from RTL_433 open-source database (286 total devices)
- Created automated import script: scripts/import_rtl433_protocols.py
- Generated rtl433_protocols_imported.py with timing/frequency/modulation data
- Updated protocol_database.py to include RTL433_PROTOCOLS
- All 26 tests passing

Breakdown by category:
  - Weather: 116 protocols
  - Sensors: 36 protocols
  - TPMS: 25 protocols
  - Security: 23 protocols
  - Home Automation: 18 protocols
  - Other: 50+ protocols

Frequency coverage:
  - 433.92 MHz: 248 protocols
  - 315.00 MHz: 32 protocols
  - 915.00 MHz: 1 protocol

This provides comprehensive coverage of Sub-GHz IoT devices for accurate
identification from raw RF captures.
This commit is contained in:
leetcrypt
2026-02-14 18:55:55 -08:00
parent 01b06fadc6
commit 9f73595b20
32 changed files with 15365 additions and 50 deletions
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Upload Log Analyzer
Analyzes upload logs from beta testing to identify patterns, common errors,
and performance bottlenecks.
Usage:
python3 scripts/analyze_upload_logs.py --date 2026-01-15
python3 scripts/analyze_upload_logs.py --last 7 # Last 7 days
python3 scripts/analyze_upload_logs.py --upload-id upload_20260115_123456
"""
import argparse
import json
import re
from pathlib import Path
from datetime import datetime, timedelta
from collections import Counter, defaultdict
from typing import List, Dict, Any
import statistics
class UploadLogAnalyzer:
"""Analyze upload logs and generate reports"""
def __init__(self, log_dir: str = "logs/uploads"):
self.log_dir = Path(log_dir)
def analyze_date(self, date: str) -> Dict[str, Any]:
"""Analyze logs for a specific date (YYYY-MM-DD)"""
# Read main upload log
log_file = self.log_dir / f"uploads_{date}.log"
error_log = self.log_dir / f"upload_errors_{date}.log"
structured_log = self.log_dir / f"uploads_structured_{date}.jsonl"
if not log_file.exists():
return {"error": f"Log file not found: {log_file}"}
stats = {
"date": date,
"total_uploads": 0,
"total_files": 0,
"successful_files": 0,
"failed_files": 0,
"duplicate_files": 0,
"errors_by_type": Counter(),
"errors_by_stage": Counter(),
"parse_errors": [],
"gps_errors": [],
"performance": {
"parse_times": [],
"matching_times": [],
"total_times": []
},
"top_failures": [],
"user_stats": defaultdict(int)
}
# Parse structured JSON log
if structured_log.exists():
with open(structured_log, 'r') as f:
for line in f:
try:
entry = json.loads(line)
self._process_log_entry(entry, stats)
except json.JSONDecodeError:
continue
# Parse error log for details
if error_log.exists():
with open(error_log, 'r') as f:
for line in f:
self._process_error_line(line, stats)
# Calculate summary statistics
self._calculate_summary(stats)
return stats
def _process_log_entry(self, entry: Dict[str, Any], stats: Dict[str, Any]):
"""Process a single structured log entry"""
event = entry.get("event")
if event == "upload_attempt":
stats["total_uploads"] += 1
stats["total_files"] += entry.get("file_count", 0)
if entry.get("user_id"):
stats["user_stats"]["authenticated"] += 1
else:
stats["user_stats"]["anonymous"] += 1
elif event == "file_processing":
stage = entry.get("stage")
success = entry.get("success")
if stage == "complete" and success:
stats["successful_files"] += 1
elif not success:
if stage == "validation" and entry.get("metadata", {}).get("status") == "duplicate":
stats["duplicate_files"] += 1
else:
stats["failed_files"] += 1
stats["errors_by_stage"][stage] += 1
# Track performance
duration = entry.get("duration_ms")
if duration and success:
if stage == "parsing":
stats["performance"]["parse_times"].append(duration)
elif stage == "complete":
stats["performance"]["total_times"].append(duration)
elif event == "parse_error":
stats["parse_errors"].append({
"filename": entry.get("filename"),
"error_type": entry.get("error_type"),
"error_message": entry.get("error_message"),
"upload_id": entry.get("upload_id")
})
stats["errors_by_type"][entry.get("error_type", "Unknown")] += 1
elif event == "gps_validation_error":
stats["gps_errors"].append({
"filename": entry.get("filename"),
"latitude": entry.get("latitude"),
"longitude": entry.get("longitude"),
"reason": entry.get("reason")
})
stats["errors_by_type"]["GPS Validation"] += 1
elif event == "matching_results":
duration = entry.get("duration_ms")
if duration:
stats["performance"]["matching_times"].append(duration)
elif event == "performance_metrics":
# Aggregate performance metrics
for key, value in entry.items():
if key.endswith("_ms") and isinstance(value, (int, float)):
perf_key = key.replace("_ms", "_times")
if perf_key not in stats["performance"]:
stats["performance"][perf_key] = []
stats["performance"][perf_key].append(value)
def _process_error_line(self, line: str, stats: Dict[str, Any]):
"""Process error log line"""
# Extract error type from log line
error_match = re.search(r'(Exception|Error): (.+)', line)
if error_match:
error_type = error_match.group(1)
stats["errors_by_type"][error_type] += 1
def _calculate_summary(self, stats: Dict[str, Any]):
"""Calculate summary statistics"""
# Success rate
total = stats["successful_files"] + stats["failed_files"]
if total > 0:
stats["success_rate"] = round(stats["successful_files"] / total * 100, 1)
else:
stats["success_rate"] = 0.0
# Performance statistics
for key, times in stats["performance"].items():
if times:
stats["performance"][f"{key}_avg"] = round(statistics.mean(times), 2)
stats["performance"][f"{key}_median"] = round(statistics.median(times), 2)
stats["performance"][f"{key}_p95"] = round(statistics.quantiles(times, n=20)[18], 2) if len(times) > 20 else None
# Top failure reasons
stats["top_failures"] = stats["errors_by_type"].most_common(10)
def print_report(self, stats: Dict[str, Any]):
"""Print formatted report"""
print("=" * 80)
print(f"Upload Log Analysis - {stats['date']}")
print("=" * 80)
# Overview
print("\n📊 OVERVIEW")
print(f" Total Uploads: {stats['total_uploads']}")
print(f" Total Files: {stats['total_files']}")
print(f" Successful: {stats['successful_files']} ({stats['success_rate']}%)")
print(f" Failed: {stats['failed_files']}")
print(f" Duplicates: {stats['duplicate_files']}")
# User statistics
print("\n👥 USERS")
print(f" Authenticated: {stats['user_stats']['authenticated']}")
print(f" Anonymous: {stats['user_stats']['anonymous']}")
# Performance
print("\n⚡ PERFORMANCE")
perf = stats["performance"]
if perf.get("parse_times_avg"):
print(f" Parse Time: {perf['parse_times_avg']:.0f}ms avg, {perf['parse_times_median']:.0f}ms median")
if perf.get("matching_times_avg"):
print(f" Matching Time: {perf['matching_times_avg']:.0f}ms avg, {perf['matching_times_median']:.0f}ms median")
if perf.get("total_times_avg"):
print(f" Total Time: {perf['total_times_avg']:.0f}ms avg, {perf['total_times_median']:.0f}ms median")
# Errors
if stats["top_failures"]:
print("\n❌ TOP ERRORS")
for error_type, count in stats["top_failures"]:
print(f" {error_type:30} {count:5} occurrences")
# Parse errors detail
if stats["parse_errors"]:
print(f"\n🔍 PARSE ERRORS ({len(stats['parse_errors'])})")
for error in stats["parse_errors"][:5]: # Show first 5
print(f" {error['filename']:30} {error['error_type']}: {error['error_message'][:50]}")
# GPS errors detail
if stats["gps_errors"]:
print(f"\n📍 GPS ERRORS ({len(stats['gps_errors'])})")
for error in stats["gps_errors"][:5]: # Show first 5
print(f" {error['filename']:30} ({error['latitude']}, {error['longitude']}) - {error['reason']}")
print("\n" + "=" * 80)
def analyze_upload_id(self, upload_id: str) -> Dict[str, Any]:
"""Analyze a specific upload"""
# Search all structured logs
results = {
"upload_id": upload_id,
"events": [],
"files": [],
"errors": [],
"timeline": []
}
for log_file in self.log_dir.glob("uploads_structured_*.jsonl"):
with open(log_file, 'r') as f:
for line in f:
try:
entry = json.loads(line)
if entry.get("upload_id") == upload_id:
results["events"].append(entry)
results["timeline"].append({
"timestamp": entry.get("timestamp"),
"event": entry.get("event"),
"details": entry
})
except json.JSONDecodeError:
continue
# Sort timeline
results["timeline"].sort(key=lambda x: x["timestamp"])
return results
def print_upload_details(self, details: Dict[str, Any]):
"""Print detailed upload report"""
print("=" * 80)
print(f"Upload Details: {details['upload_id']}")
print("=" * 80)
print(f"\nTotal Events: {len(details['events'])}")
print("\n📅 TIMELINE:")
for event in details["timeline"]:
timestamp = event["timestamp"].split('T')[1][:12] # HH:MM:SS.mmm
print(f" {timestamp} | {event['event']:20} | {self._format_event_details(event['details'])}")
def _format_event_details(self, event: Dict[str, Any]) -> str:
"""Format event details for display"""
event_type = event.get("event")
if event_type == "upload_attempt":
return f"{event.get('file_count')} files, {event.get('total_size_mb')} MB"
elif event_type == "file_processing":
success = "" if event.get("success") else ""
return f"{success} {event.get('filename')} - {event.get('stage')} ({event.get('duration_ms', 0):.0f}ms)"
elif event_type == "parse_error":
return f"{event.get('filename')} - {event.get('error_type')}"
elif event_type == "matching_results":
return f"🔍 {event.get('match_count')} matches ({event.get('duration_ms', 0):.0f}ms)"
elif event_type == "upload_complete":
return f"{event.get('successful_count')} success, {event.get('failed_count')} failed"
else:
return json.dumps(event, indent=2)[:100]
def main():
parser = argparse.ArgumentParser(description="Analyze upload logs from beta testing")
parser.add_argument("--date", help="Date to analyze (YYYY-MM-DD)")
parser.add_argument("--last", type=int, help="Analyze last N days")
parser.add_argument("--upload-id", help="Analyze specific upload ID")
parser.add_argument("--log-dir", default="logs/uploads", help="Log directory")
args = parser.parse_args()
analyzer = UploadLogAnalyzer(log_dir=args.log_dir)
if args.upload_id:
# Analyze specific upload
details = analyzer.analyze_upload_id(args.upload_id)
analyzer.print_upload_details(details)
elif args.date:
# Analyze specific date
stats = analyzer.analyze_date(args.date)
analyzer.print_report(stats)
elif args.last:
# Analyze last N days
for i in range(args.last):
date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
print(f"\n{'='*80}")
print(f"Analyzing {date}")
print('='*80)
stats = analyzer.analyze_date(date)
analyzer.print_report(stats)
else:
# Default: analyze today
today = datetime.now().strftime("%Y-%m-%d")
stats = analyzer.analyze_date(today)
analyzer.print_report(stats)
if __name__ == "__main__":
main()
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""
Import RTL_433 Protocol Database
Expands GigLez protocol database from 18 → 200+ by importing RTL_433's open-source
protocol definitions.
Source: data/rtl_433_protocols.json (286 devices)
Output: src/matcher/rtl433_protocols_imported.py
"""
import json
from pathlib import Path
from typing import Dict, List
from collections import defaultdict
def load_rtl433_json() -> Dict:
"""Load RTL_433 protocol database JSON"""
json_path = Path(__file__).parent.parent / "data" / "rtl_433_protocols.json"
with open(json_path) as f:
data = json.load(f)
print(f"✓ Loaded {data['total_devices']} devices from RTL_433 database")
return data
def estimate_frequency(device: Dict) -> int:
"""
Estimate frequency for devices with null frequency field
Based on:
- Category (weather = 433MHz, automotive = 315MHz)
- Manufacturer patterns
- Default ISM bands
"""
freq = device.get('frequency')
if freq:
return freq
# Heuristics based on category and manufacturer
category = (device.get('category') or '').lower()
manufacturer = (device.get('manufacturer') or '').lower()
name = (device.get('name') or '').lower()
# Automotive TPMS → 315 MHz (North America) or 433 MHz (Europe)
if category == 'automotive' or 'tpms' in name:
return 315000000 # Default to NA frequency
# Security systems → 433 MHz (most common)
if category == 'security' or 'alarm' in name or 'security' in name:
return 433920000
# Doorbells → 433 MHz
if 'doorbell' in name or 'bell' in name:
return 433920000
# Garage door openers → 315 MHz (NA) or 433 MHz
if 'garage' in name or 'door' in name:
# Chamberlain/LiftMaster = 315 MHz
if 'chamberlain' in manufacturer or 'liftmaster' in manufacturer:
return 315000000
return 433920000
# Weather sensors → 433 MHz (most common globally)
if category == 'weather' or any(kw in name for kw in ['temperature', 'humidity', 'rain', 'wind', 'sensor']):
# Some exceptions: Acurite (915 MHz for 5n1)
if 'acurite' in manufacturer and '5n1' in name:
return 915000000
return 433920000
# Default: 433.92 MHz (most popular ISM band)
return 433920000
def estimate_encoding(device: Dict) -> str:
"""
Estimate encoding scheme from modulation and name patterns
Common mappings:
- OOK → PWM (most common)
- FSK → Manchester or custom
- ASK → PWM
"""
modulation = (device.get('modulation') or 'OOK').upper()
name = (device.get('name') or '').lower()
# Manchester encoding indicators
if 'manchester' in name or 'oregon' in name:
return 'Encoding.MANCHESTER'
# Differential Manchester (rare)
if 'diff' in name and 'manchester' in name:
return 'Encoding.DIFFERENTIAL_MANCHESTER'
# Default based on modulation
if modulation in ['OOK', 'ASK']:
return 'Encoding.PWM'
elif modulation == 'FSK':
return 'Encoding.MANCHESTER' # FSK often uses Manchester
return 'Encoding.PWM' # Safe default
def estimate_bit_counts(device: Dict) -> tuple:
"""
Estimate min/max bit counts from device characteristics
Based on:
- Typical payload sizes for device categories
- Manufacturer patterns
"""
category = (device.get('category') or '').lower()
name = (device.get('name') or '').lower()
# Weather sensors: typically 32-72 bits
if category == 'weather':
if 'oregon' in name:
return (64, 128) # Oregon Scientific uses longer messages
return (32, 72)
# Remotes/garage openers: typically 24-40 bits
if 'remote' in name or 'garage' in name:
return (24, 40)
# TPMS: typically 64-80 bits
if 'tpms' in name:
return (64, 80)
# Security sensors: 32-64 bits
if category == 'security':
return (32, 64)
# Default
return (24, 64)
def estimate_pulse_count(short_width: int, long_width: int, avg_bits: int) -> int:
"""
Estimate typical pulse count for a transmission
Formula: pulses ≈ 2 * bits (for PWM: each bit = HIGH + LOW pulse)
+ preamble overhead (~20-40 pulses)
"""
base_pulses = avg_bits * 2 # Each bit = 2 transitions
preamble_overhead = 30 # Typical preamble length
return base_pulses + preamble_overhead
def convert_to_protocol_signature(device: Dict) -> Dict:
"""
Convert RTL_433 device format to GigLez ProtocolSignature
Input (RTL_433):
{
"device_id": "acurite_th",
"name": "Acurite 609TXC Temperature and Humidity Sensor",
"modulation": "OOK",
"short_width": 1000,
"long_width": 2000,
"gap_limit": 3000,
"reset_limit": 10000,
"frequency": null,
"category": "weather",
"manufacturer": "Acurite"
}
Output (GigLez):
ProtocolSignature(
name="Acurite 609TXC Temperature and Humidity Sensor",
category="Weather Sensor",
manufacturer="Acurite",
modulation=Modulation.OOK,
encoding=Encoding.PWM,
short_pulse_us=1000,
long_pulse_us=2000,
frequency=433920000,
...
)
"""
# Extract basic fields
name = device.get('name', 'Unknown Device')
manufacturer = device.get('manufacturer', 'Unknown')
category = device.get('category', 'unknown').title()
# Map modulation
modulation_map = {
'OOK': 'Modulation.OOK',
'FSK': 'Modulation.FSK',
'ASK': 'Modulation.ASK'
}
modulation = modulation_map.get(device.get('modulation', 'OOK'), 'Modulation.OOK')
# Timing parameters
short_width = device.get('short_width') or 500
long_width = device.get('long_width') or 1000
# Frequency estimation
frequency = estimate_frequency(device)
# Encoding estimation
encoding = estimate_encoding(device)
# Bit count estimation
min_bits, max_bits = estimate_bit_counts(device)
avg_bits = (min_bits + max_bits) // 2
# Pulse count estimation
typical_pulse_count = estimate_pulse_count(short_width, long_width, avg_bits)
# Preamble/sync patterns (if detectable from name)
preamble_pattern = None
sync_pattern = None
if 'oregon' in name.lower():
preamble_pattern = '"1010" * 12' # Oregon Scientific preamble
sync_pattern = '"1000"'
elif 'princeton' in name.lower() or 'pt2262' in name.lower():
preamble_pattern = '"1" * 4'
sync_pattern = '"10"'
return {
'name': name,
'category': category,
'manufacturer': manufacturer,
'modulation': modulation,
'encoding': encoding,
'short_pulse_us': short_width,
'long_pulse_us': long_width,
'frequency': frequency,
'frequency_tolerance': 100000, # ±100 kHz
'min_bits': min_bits,
'max_bits': max_bits,
'typical_pulse_count': typical_pulse_count,
'preamble_pattern': preamble_pattern,
'sync_pattern': sync_pattern,
'timing_tolerance': 0.25, # ±25% (more lenient than hand-curated)
'min_confidence': 0.5, # Lower threshold for imported protocols
'source': f"RTL_433 (device_id: {device.get('device_id', 'unknown')})"
}
def filter_valid_protocols(protocols: List[Dict]) -> List[Dict]:
"""
Filter out invalid/incomplete protocol definitions
Requirements:
- Must have valid timing (short_width and long_width > 0)
- Must have reasonable values (not extreme outliers)
- Prefer unique entries (deduplicate by name)
"""
valid = []
seen_names = set()
for proto in protocols:
# Check timing validity
if proto['short_pulse_us'] <= 0 or proto['long_pulse_us'] <= 0:
continue
# Check reasonable ranges (10μs to 10ms)
if not (10 <= proto['short_pulse_us'] <= 10000):
continue
if not (10 <= proto['long_pulse_us'] <= 10000):
continue
# Check long > short (PWM assumption)
if proto['long_pulse_us'] <= proto['short_pulse_us']:
# Swap if reversed
proto['short_pulse_us'], proto['long_pulse_us'] = \
proto['long_pulse_us'], proto['short_pulse_us']
# Deduplicate by name
if proto['name'] in seen_names:
continue
seen_names.add(proto['name'])
valid.append(proto)
return valid
def generate_python_code(protocols: List[Dict]) -> str:
"""
Generate Python code for rtl433_protocols_imported.py
Creates a list of ProtocolSignature objects that can be imported
into protocol_database.py
"""
# Group by category for organization
by_category = defaultdict(list)
for proto in protocols:
by_category[proto['category']].append(proto)
code = '''#!/usr/bin/env python3
"""
RTL_433 Imported Protocol Signatures
Auto-generated from RTL_433 open-source protocol database.
Source: data/rtl_433_protocols.json ({total} devices)
DO NOT EDIT MANUALLY - run scripts/import_rtl433_protocols.py to regenerate.
Generated: {date}
"""
from src.matcher.protocol_database import ProtocolSignature, Modulation, Encoding
# Imported RTL_433 Protocols ({count} total)
RTL433_PROTOCOLS = [
'''.format(
total=len(protocols),
count=len(protocols),
date=__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')
)
# Generate entries by category
for category in sorted(by_category.keys()):
protos = by_category[category]
code += f'\n # {category} ({len(protos)} devices)\n'
for proto in protos:
code += f''' ProtocolSignature(
name="{proto['name']}",
category="{proto['category']}",
manufacturer="{proto['manufacturer']}",
modulation={proto['modulation']},
encoding={proto['encoding']},
short_pulse_us={proto['short_pulse_us']},
long_pulse_us={proto['long_pulse_us']},
frequency={proto['frequency']},
frequency_tolerance={proto['frequency_tolerance']},
min_bits={proto['min_bits']},
max_bits={proto['max_bits']},
typical_pulse_count={proto['typical_pulse_count']},
'''
if proto['preamble_pattern']:
code += f" preamble_pattern={proto['preamble_pattern']},\n"
if proto['sync_pattern']:
code += f" sync_pattern={proto['sync_pattern']},\n"
code += f''' timing_tolerance={proto['timing_tolerance']},
min_confidence={proto['min_confidence']},
),
'''
code += ''']
# Export for use in protocol_database.py
__all__ = ['RTL433_PROTOCOLS']
'''
return code
def update_protocol_database():
"""
Update protocol_database.py to include RTL433_PROTOCOLS
"""
db_path = Path(__file__).parent.parent / "src" / "matcher" / "protocol_database.py"
with open(db_path, 'r') as f:
original_code = f.read()
# Check if already updated
if 'RTL433_PROTOCOLS' in original_code:
print("⚠ protocol_database.py already includes RTL433_PROTOCOLS")
return
# Find the ALL_PROTOCOLS definition and update it
old_all_protocols = '''# Compile all protocols into single list
ALL_PROTOCOLS = (
WEATHER_SENSORS +
GARAGE_DOOR_OPENERS +
DOORBELLS +
TIRE_PRESSURE +
SECURITY_SENSORS +
REMOTE_CONTROLS
)'''
new_all_protocols = '''# Import RTL_433 protocols
try:
from src.matcher.rtl433_protocols_imported import RTL433_PROTOCOLS
except ImportError:
print("Warning: RTL_433 protocols not imported yet. Run scripts/import_rtl433_protocols.py")
RTL433_PROTOCOLS = []
# Compile all protocols into single list
ALL_PROTOCOLS = (
WEATHER_SENSORS +
GARAGE_DOOR_OPENERS +
DOORBELLS +
TIRE_PRESSURE +
SECURITY_SENSORS +
REMOTE_CONTROLS +
RTL433_PROTOCOLS # Imported from RTL_433 database
)'''
updated_code = original_code.replace(old_all_protocols, new_all_protocols)
with open(db_path, 'w') as f:
f.write(updated_code)
print(f"✓ Updated {db_path} to include RTL433_PROTOCOLS")
def main():
"""Main import workflow"""
print("=== RTL_433 Protocol Database Import ===\n")
# Step 1: Load RTL_433 JSON
data = load_rtl433_json()
devices = data['devices']
# Step 2: Convert to GigLez format
print(f"\n📝 Converting {len(devices)} devices to ProtocolSignature format...")
protocols = [convert_to_protocol_signature(dev) for dev in devices]
# Step 3: Filter valid entries
print(f"🔍 Filtering valid protocols...")
valid_protocols = filter_valid_protocols(protocols)
print(f"✓ Kept {len(valid_protocols)} valid protocols (removed {len(protocols) - len(valid_protocols)} invalid)")
# Step 4: Generate Python code
print(f"\n📄 Generating Python code...")
code = generate_python_code(valid_protocols)
# Step 5: Write to file
output_path = Path(__file__).parent.parent / "src" / "matcher" / "rtl433_protocols_imported.py"
with open(output_path, 'w') as f:
f.write(code)
print(f"✓ Written to {output_path}")
# Step 6: Update protocol_database.py
print(f"\n🔧 Updating protocol_database.py...")
update_protocol_database()
# Step 7: Summary statistics
print(f"\n=== Import Summary ===")
print(f"Total RTL_433 devices: {len(devices)}")
print(f"Valid protocols imported: {len(valid_protocols)}")
# By category
from collections import Counter
categories = Counter(p['category'] for p in valid_protocols)
print(f"\nProtocols by category:")
for cat, count in categories.most_common():
print(f" {cat}: {count}")
# By frequency
frequencies = Counter(p['frequency'] for p in valid_protocols)
print(f"\nProtocols by frequency:")
for freq, count in sorted(frequencies.items()):
freq_mhz = freq / 1_000_000
print(f" {freq_mhz:.2f} MHz: {count}")
print(f"\n✅ Import complete!")
print(f" Original protocols: 18")
print(f" Imported protocols: {len(valid_protocols)}")
print(f" Total protocols: {18 + len(valid_protocols)}")
# Verify import
print(f"\n🧪 Verifying import...")
try:
from src.matcher.protocol_database import get_protocol_database
db = get_protocol_database()
stats = db.get_statistics()
print(f"✓ Database loaded successfully")
print(f" Total protocols in database: {stats['total_protocols']}")
except Exception as e:
print(f"❌ Error loading database: {e}")
if __name__ == '__main__':
main()
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
Import RF Device Signatures into Database
Imports device signatures from downloaded datasets:
1. Flipper Zero .sub file collections (with automatic labeling from file paths)
2. RTL_433 protocol definitions
3. Creates devices and signatures records for matching engine
Usage:
python3 scripts/import_signatures_to_db.py --flipper --rtl433 --limit 10000
"""
import sys
import os
import json
import re
import hashlib
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from loguru import logger
from src.database.connection import get_session
from src.database.models import Device, Signature, FlipperSignature
from src.parser.sub_parser import parse_sub_file
from src.parser.metadata import SignalMetadata
# Compatibility alias
SessionLocal = get_session
# =============================================================================
# CONFIGURATION
# =============================================================================
DATA_DIR = Path(__file__).parent.parent / "data"
FLIPPER_DATASETS = [
DATA_DIR / "rf_test_datasets" / "UberGuidoZ_Flipper",
DATA_DIR / "test_known_devices",
]
RTL433_REPO = DATA_DIR / "test_rtl433_real"
# Device type mapping from file paths
DEVICE_TYPE_PATTERNS = {
r"weather[_\s]?station": "Weather Sensor",
r"garage[_\s]?door": "Garage Door Opener",
r"gate[_\s]?opener": "Gate Opener",
r"doorbell": "Doorbell",
r"door[_\s]?sensor": "Door Sensor",
r"motion[_\s]?sensor": "Motion Sensor",
r"smoke[_\s]?detector": "Smoke Detector",
r"remote[_\s]?control": "Remote Control",
r"car[_\s]?key": "Car Key Fob",
r"tpms": "TPMS",
r"tire[_\s]?pressure": "TPMS",
r"security": "Security System",
r"alarm": "Alarm System",
}
MANUFACTURER_PATTERNS = {
r"chamberlain": "Chamberlain",
r"liftmaster": "LiftMaster",
r"genie": "Genie",
r"linear": "Linear",
r"oregon[_\s]?scientific": "Oregon Scientific",
r"acurite": "AcuRite",
r"lacrosse": "LaCrosse",
r"ambient[_\s]?weather": "Ambient Weather",
r"honeywell": "Honeywell",
r"nest": "Nest",
r"ring": "Ring",
r"yale": "Yale",
r"toyota": "Toyota",
r"schrader": "Schrader",
r"nice": "NICE",
r"came": "CAME",
r"bft": "BFT",
}
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class ExtractedDeviceInfo:
"""Device information extracted from file path and content"""
device_type: Optional[str] = None
manufacturer: Optional[str] = None
model: Optional[str] = None
confidence: float = 0.5 # Confidence in automatic extraction
# =============================================================================
# FILE PATH ANALYSIS
# =============================================================================
def extract_device_info_from_path(file_path: Path) -> ExtractedDeviceInfo:
"""
Extract device information from file path structure
Example paths:
- Weather_stations/Oregon_Scientific/THGN123N.sub
- Garage_Door_Openers/Chamberlain/model_891LM.sub
- UberGuidoZ/Sub-GHz/Vehicles/Tesla/Model_3_Charge_Port.sub
"""
info = ExtractedDeviceInfo()
path_str = str(file_path).lower()
parts = file_path.parts
# Extract device type
for pattern, device_type in DEVICE_TYPE_PATTERNS.items():
if re.search(pattern, path_str, re.IGNORECASE):
info.device_type = device_type
info.confidence = 0.7
break
# Extract manufacturer
for pattern, manufacturer in MANUFACTURER_PATTERNS.items():
if re.search(pattern, path_str, re.IGNORECASE):
info.manufacturer = manufacturer
info.confidence = 0.8
break
# Extract model from filename (heuristic: uppercase letters + numbers)
filename = file_path.stem # Without extension
model_match = re.search(r'([A-Z0-9]{3,}[-_]?[A-Z0-9]*)', filename)
if model_match:
info.model = model_match.group(1).replace('_', ' ')
info.confidence = max(info.confidence, 0.6)
return info
# =============================================================================
# FLIPPER ZERO SIGNATURE IMPORT
# =============================================================================
def import_flipper_signatures(
db: Session,
limit: Optional[int] = None,
skip_existing: bool = True
) -> Tuple[int, int, int]:
"""
Import Flipper Zero .sub files as device signatures
Returns:
(devices_created, signatures_created, files_skipped)
"""
devices_created = 0
signatures_created = 0
files_skipped = 0
# Collect all .sub files
sub_files: List[Path] = []
for dataset_dir in FLIPPER_DATASETS:
if not dataset_dir.exists():
logger.warning(f"Dataset not found: {dataset_dir}")
continue
logger.info(f"Scanning: {dataset_dir}")
sub_files.extend(dataset_dir.rglob("*.sub"))
logger.info(f"Found {len(sub_files)} .sub files across all datasets")
if limit:
sub_files = sub_files[:limit]
logger.info(f"Limited to {limit} files")
# Process each file
for idx, sub_file in enumerate(sub_files, 1):
if idx % 100 == 0:
logger.info(f"Progress: {idx}/{len(sub_files)} ({idx/len(sub_files)*100:.1f}%)")
try:
# Compute file hash for deduplication
file_hash = hashlib.sha256(sub_file.read_bytes()).hexdigest()
# Check if already imported
if skip_existing:
existing = db.query(FlipperSignature).filter_by(
file_hash=file_hash
).first()
if existing:
files_skipped += 1
continue
# Parse .sub file
try:
metadata = parse_sub_file(str(sub_file))
except Exception as e:
logger.debug(f"Parse failed: {sub_file.name} - {e}")
files_skipped += 1
continue
# Extract device info from path
device_info = extract_device_info_from_path(sub_file)
# Skip if no useful information extracted
if not device_info.device_type and not metadata.protocol:
files_skipped += 1
continue
# Create or find device
device_query = db.query(Device)
if device_info.manufacturer and device_info.model:
device_query = device_query.filter_by(
manufacturer=device_info.manufacturer,
model=device_info.model
)
elif metadata.protocol:
# Use protocol as fallback identifier
device_query = device_query.filter_by(
protocol=metadata.protocol,
typical_frequency=metadata.frequency
)
else:
files_skipped += 1
continue
device = device_query.first()
if not device:
# Create new device
device = Device(
manufacturer=device_info.manufacturer or "Unknown",
model=device_info.model or metadata.protocol or "Unknown",
device_type=device_info.device_type or "Unknown",
typical_frequency=metadata.frequency,
protocol=metadata.protocol,
bit_length=metadata.bit_length,
encoding=metadata.file_format if metadata.file_format else None,
source="flipper",
verified=False # Needs community verification
)
db.add(device)
db.flush() # Get device ID
devices_created += 1
# Extract timing statistics for signature matching
timing_min = None
timing_max = None
if metadata.timing_element:
# KEY format: use timing_element with tolerance
timing_min = int(metadata.timing_element * 0.8)
timing_max = int(metadata.timing_element * 1.2)
elif metadata.raw_data and len(metadata.raw_data) > 0:
# RAW format: calculate from pulse statistics
pulse_widths = [abs(p) for p in metadata.raw_data]
if pulse_widths:
timing_min = int(min(pulse_widths) * 0.9) # 10% tolerance
timing_max = int(max(pulse_widths) * 1.1)
# Generate bit_mask for KEY format (all bits significant by default)
bit_mask = None
if metadata.key_data:
# Create mask with all bits set to 1 (all bits are significant)
bit_mask = bytes([0xFF] * len(metadata.key_data))
# Create signature with enhanced metadata
signature = Signature(
device_id=device.id,
protocol=metadata.protocol,
frequency=metadata.frequency,
modulation=str(metadata.modulation).replace("Modulation.", "") if metadata.modulation else None,
bit_pattern=metadata.key_data if metadata.key_data else None,
bit_mask=bit_mask,
timing_min=timing_min,
timing_max=timing_max,
weight=device_info.confidence,
source="flipper"
)
db.add(signature)
signatures_created += 1
# Create Flipper-specific record
flipper_sig = FlipperSignature(
signature_id=None, # Will be set after signature is committed
file_hash=file_hash,
file_path=str(sub_file.relative_to(DATA_DIR)),
preset=metadata.preset if hasattr(metadata, 'preset') else None,
raw_format=metadata.file_format
)
db.add(flipper_sig)
# Commit in batches
if idx % 50 == 0:
try:
db.commit()
except Exception as e:
logger.debug(f"Batch commit error: {e}")
db.rollback()
except Exception as e:
logger.error(f"Error processing {sub_file}: {e}")
db.rollback() # Rollback after error to continue with next file
files_skipped += 1
continue
# Final commit
try:
db.commit()
except Exception as e:
logger.debug(f"Final commit error: {e}")
db.rollback()
logger.info(f"Import complete:")
logger.info(f" - Devices created: {devices_created}")
logger.info(f" - Signatures created: {signatures_created}")
logger.info(f" - Files skipped: {files_skipped}")
return devices_created, signatures_created, files_skipped
# =============================================================================
# RTL_433 PROTOCOL IMPORT
# =============================================================================
def import_rtl433_protocols(db: Session) -> Tuple[int, int]:
"""
Import RTL_433 protocol definitions from test files
RTL_433 test files include .json metadata with:
- Protocol name and number
- Expected output fields
- Device information
Returns:
(devices_created, signatures_created)
"""
devices_created = 0
signatures_created = 0
if not RTL433_REPO.exists():
logger.warning(f"RTL_433 test repo not found: {RTL433_REPO}")
return 0, 0
# Find all .json files (contain protocol metadata)
json_files = list(RTL433_REPO.rglob("*.json"))
logger.info(f"Found {len(json_files)} RTL_433 test .json files")
protocol_map = {} # protocol_name -> metadata
for json_file in json_files:
try:
with open(json_file, 'r') as f:
data = json.load(f)
# RTL_433 .json files contain decoded device information
if isinstance(data, dict) and 'model' in data:
model = data.get('model')
protocol_id = data.get('protocol', None)
# Extract device info
manufacturer = None
if '-' in model:
parts = model.split('-', 1)
manufacturer = parts[0].strip()
model_name = parts[1].strip()
else:
model_name = model
# Frequency from file path or metadata
freq_match = re.search(r'(\d+)M', str(json_file))
frequency = int(freq_match.group(1)) * 1000000 if freq_match else 433920000
# Store protocol info
key = (manufacturer or "Unknown", model_name)
if key not in protocol_map:
protocol_map[key] = {
'manufacturer': manufacturer or "Unknown",
'model': model_name,
'frequency': frequency,
'protocol': model,
'protocol_id': protocol_id,
'device_type': 'Weather Sensor', # Most RTL_433 devices
'example_data': data
}
except Exception as e:
logger.debug(f"Error parsing {json_file}: {e}")
continue
logger.info(f"Extracted {len(protocol_map)} unique RTL_433 protocols")
# Create devices and signatures
for (manufacturer, model_name), proto_info in protocol_map.items():
# Check if device exists
device = db.query(Device).filter_by(
manufacturer=manufacturer,
model=model_name
).first()
if not device:
device = Device(
manufacturer=manufacturer,
model=model_name,
device_type=proto_info['device_type'],
typical_frequency=proto_info['frequency'],
protocol=proto_info['protocol'],
source="rtl433",
verified=True # RTL_433 protocols are trusted
)
db.add(device)
db.flush()
devices_created += 1
# Create signature
signature = Signature(
device_id=device.id,
protocol=proto_info['protocol'],
frequency=proto_info['frequency'],
weight=0.95 # High confidence for RTL_433
)
db.add(signature)
signatures_created += 1
db.commit()
logger.info(f"RTL_433 import complete:")
logger.info(f" - Devices created: {devices_created}")
logger.info(f" - Signatures created: {signatures_created}")
return devices_created, signatures_created
# =============================================================================
# MAIN
# =============================================================================
def main():
import argparse
parser = argparse.ArgumentParser(description="Import RF device signatures into database")
parser.add_argument("--flipper", action="store_true", help="Import Flipper Zero signatures")
parser.add_argument("--rtl433", action="store_true", help="Import RTL_433 protocols")
parser.add_argument("--limit", type=int, default=None, help="Limit number of Flipper files to process")
parser.add_argument("--skip-existing", action="store_true", default=True, help="Skip already imported files")
parser.add_argument("--all", action="store_true", help="Import all sources")
args = parser.parse_args()
if not any([args.flipper, args.rtl433, args.all]):
parser.print_help()
print("\nError: Must specify at least one import source (--flipper, --rtl433, or --all)")
sys.exit(1)
# Setup logging
logger.remove()
logger.add(sys.stderr, level="INFO")
logger.add("logs/import_signatures.log", rotation="10 MB", level="DEBUG")
logger.info("=" * 80)
logger.info("RF Device Signature Import")
logger.info("=" * 80)
# Connect to database
db = SessionLocal()
try:
# Import Flipper signatures
if args.flipper or args.all:
logger.info("\n[1/2] Importing Flipper Zero signatures...")
flipper_stats = import_flipper_signatures(
db,
limit=args.limit,
skip_existing=args.skip_existing
)
logger.info(f"Flipper import: {flipper_stats[0]} devices, {flipper_stats[1]} signatures")
# Import RTL_433 protocols
if args.rtl433 or args.all:
logger.info("\n[2/2] Importing RTL_433 protocols...")
rtl_stats = import_rtl433_protocols(db)
logger.info(f"RTL_433 import: {rtl_stats[0]} devices, {rtl_stats[1]} signatures")
logger.info("\n" + "=" * 80)
logger.info("Import complete!")
logger.info("=" * 80)
# Summary
total_devices = db.query(Device).count()
total_signatures = db.query(Signature).count()
logger.info(f"\nDatabase totals:")
logger.info(f" - Total devices: {total_devices}")
logger.info(f" - Total signatures: {total_signatures}")
finally:
db.close()
if __name__ == "__main__":
main()
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""
Import Flipper Zero Captures with Random African GPS Coordinates
Imports all downloaded .sub files with random GPS coordinates across Africa
to visualize device distribution on the map.
Africa Coverage:
- Latitude: -35° to 37° (South Africa to Mediterranean)
- Longitude: -17° to 51° (West coast to East coast)
Cities included as hotspots:
- Cairo, Egypt
- Lagos, Nigeria
- Nairobi, Kenya
- Johannesburg, South Africa
- Casablanca, Morocco
- Addis Ababa, Ethiopia
- Dar es Salaam, Tanzania
- Khartoum, Sudan
- Accra, Ghana
- Kampala, Uganda
"""
import sys
import os
import random
import hashlib
from pathlib import Path
from typing import Tuple, List
from datetime import datetime, timedelta
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from loguru import logger
from src.database.connection import get_session
from src.database.models import Capture, Device, CaptureMatch
from src.parser.sub_parser import parse_sub_file
from src.matcher.engine import SignatureMatcher
# =============================================================================
# CONFIGURATION
# =============================================================================
DATA_DIR = Path(__file__).parent.parent / "data" / "rf_test_datasets"
FLIPPER_DATASETS = [
DATA_DIR / "FlipperZero-Subghz-DB",
DATA_DIR / "UberGuidoZ_Flipper",
DATA_DIR / "Full_Flipper_Database",
]
# Africa bounding box
AFRICA_LAT_MIN = -35.0 # South Africa
AFRICA_LAT_MAX = 37.0 # Mediterranean coast
AFRICA_LON_MIN = -17.0 # West coast (Senegal)
AFRICA_LON_MAX = 51.0 # East coast (Somalia)
# Major African cities (hotspots with higher concentration)
AFRICAN_CITIES = [
{"name": "Cairo", "lat": 30.0444, "lon": 31.2357, "weight": 10},
{"name": "Lagos", "lat": 6.5244, "lon": 3.3792, "weight": 8},
{"name": "Nairobi", "lat": -1.2921, "lon": 36.8219, "weight": 7},
{"name": "Johannesburg", "lat": -26.2041, "lon": 28.0473, "weight": 8},
{"name": "Casablanca", "lat": 33.5731, "lon": -7.5898, "weight": 6},
{"name": "Addis Ababa", "lat": 9.0320, "lon": 38.7469, "weight": 6},
{"name": "Dar es Salaam", "lat": -6.7924, "lon": 39.2083, "weight": 5},
{"name": "Khartoum", "lat": 15.5007, "lon": 32.5599, "weight": 5},
{"name": "Accra", "lat": 5.6037, "lon": -0.1870, "weight": 5},
{"name": "Kampala", "lat": 0.3476, "lon": 32.5825, "weight": 5},
{"name": "Kinshasa", "lat": -4.4419, "lon": 15.2663, "weight": 6},
{"name": "Luanda", "lat": -8.8368, "lon": 13.2343, "weight": 5},
{"name": "Dakar", "lat": 14.7167, "lon": -17.4677, "weight": 4},
{"name": "Cape Town", "lat": -33.9249, "lon": 18.4241, "weight": 6},
{"name": "Abidjan", "lat": 5.3600, "lon": -4.0083, "weight": 5},
]
# =============================================================================
# GPS GENERATION
# =============================================================================
def generate_random_african_gps(use_hotspots: bool = True) -> Tuple[float, float, float]:
"""
Generate random GPS coordinates in Africa
Returns:
(latitude, longitude, accuracy_meters)
"""
if use_hotspots and random.random() < 0.6: # 60% chance of city hotspot
# Weight by city population/importance
weights = [city["weight"] for city in AFRICAN_CITIES]
city = random.choices(AFRICAN_CITIES, weights=weights, k=1)[0]
# Add random offset within ~10km of city center
lat_offset = random.gauss(0, 0.05) # ~5.5 km std dev
lon_offset = random.gauss(0, 0.05)
latitude = city["lat"] + lat_offset
longitude = city["lon"] + lon_offset
accuracy = random.uniform(5.0, 15.0) # Urban GPS accuracy
else:
# Random location anywhere in Africa
latitude = random.uniform(AFRICA_LAT_MIN, AFRICA_LAT_MAX)
longitude = random.uniform(AFRICA_LON_MIN, AFRICA_LON_MAX)
accuracy = random.uniform(10.0, 50.0) # Rural GPS accuracy
return latitude, longitude, accuracy
def generate_random_timestamp(days_back: int = 90) -> datetime:
"""Generate random timestamp within last N days"""
now = datetime.utcnow()
delta = timedelta(days=random.randint(0, days_back))
return now - delta
# =============================================================================
# IMPORT WITH GPS
# =============================================================================
def import_captures_with_african_gps(
db: Session,
limit: int = None,
skip_existing: bool = True,
run_matching: bool = True
) -> dict:
"""
Import all .sub files with random African GPS coordinates
Returns:
Statistics dict
"""
stats = {
"files_processed": 0,
"captures_created": 0,
"duplicates_skipped": 0,
"parse_errors": 0,
"matches_created": 0,
"cities_covered": set()
}
# Collect all .sub files
sub_files: List[Path] = []
for dataset_dir in FLIPPER_DATASETS:
if not dataset_dir.exists():
logger.warning(f"Dataset not found: {dataset_dir}")
continue
logger.info(f"Scanning: {dataset_dir}")
sub_files.extend(dataset_dir.rglob("*.sub"))
logger.info(f"Found {len(sub_files)} .sub files total")
if limit:
sub_files = sub_files[:limit]
logger.info(f"Limited to {limit} files")
# Shuffle for geographic randomness
random.shuffle(sub_files)
# Initialize matcher if enabled
matcher = SignatureMatcher(db) if run_matching else None
# Process files
for idx, sub_file in enumerate(sub_files, 1):
try:
if idx % 100 == 0:
logger.info(f"Progress: {idx}/{len(sub_files)} ({idx/len(sub_files)*100:.1f}%)")
db.commit() # Commit in batches
stats["files_processed"] += 1
# Read file
content = sub_file.read_bytes()
file_hash = hashlib.sha256(content).hexdigest()
# Check for duplicates
if skip_existing:
existing = db.query(Capture).filter_by(file_hash=file_hash).first()
if existing:
stats["duplicates_skipped"] += 1
continue
# Parse .sub file
try:
metadata = parse_sub_file(str(sub_file))
except Exception as e:
logger.debug(f"Parse failed: {sub_file.name} - {e}")
stats["parse_errors"] += 1
continue
# Generate random African GPS
latitude, longitude, accuracy = generate_random_african_gps()
# Track which city region this is near
for city in AFRICAN_CITIES:
dist_lat = abs(latitude - city["lat"])
dist_lon = abs(longitude - city["lon"])
if dist_lat < 1.0 and dist_lon < 1.0: # Within ~100km
stats["cities_covered"].add(city["name"])
break
# Generate random timestamp (last 90 days)
captured_at = generate_random_timestamp(days_back=90)
# Store file (simplified - just use relative path)
storage_path = f"data/uploads/{file_hash[:2]}/{file_hash}.sub"
# Ensure directory exists
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
# Write file
with open(storage_path, 'wb') as f:
f.write(content)
# Create Capture record
capture = Capture(
file_hash=file_hash,
session_id=None,
user_id=None,
latitude=latitude,
longitude=longitude,
altitude=random.uniform(0, 1500), # 0-1500m elevation
gps_accuracy=accuracy,
captured_at=captured_at,
frequency=metadata.frequency,
modulation=metadata.modulation,
preset=metadata.preset,
protocol=metadata.protocol,
bit_length=metadata.bit_length,
key_data=metadata.key_data,
timing_element=metadata.timing_element,
raw_data=str(metadata.raw_data) if metadata.raw_data else None,
raw_format=metadata.file_format,
file_path=storage_path,
file_size=len(content)
)
db.add(capture)
db.flush() # Get capture ID
stats["captures_created"] += 1
# Run matching engine
if matcher and run_matching:
try:
match_results = matcher.match(metadata)
if match_results:
# Store top 5 matches
for match_result in match_results[:5]:
device = db.query(Device).filter_by(id=match_result.device_id).first()
if not device:
continue
capture_match = CaptureMatch(
capture_id=capture.id,
device_id=device.id,
confidence=match_result.confidence,
match_method=match_result.method,
match_details=match_result.details or {}
)
db.add(capture_match)
stats["matches_created"] += 1
# Set best match
best_match = match_results[0]
capture.device_id = best_match.device_id
capture.match_confidence = best_match.confidence
capture.match_method = best_match.method
except Exception as e:
logger.debug(f"Matching failed for {sub_file.name}: {e}")
except Exception as e:
logger.error(f"Failed to process {sub_file}: {e}")
continue
# Final commit
db.commit()
return stats
# =============================================================================
# MAIN
# =============================================================================
def main():
import argparse
parser = argparse.ArgumentParser(
description="Import .sub files with random African GPS coordinates"
)
parser.add_argument("--limit", type=int, default=None, help="Limit number of files")
parser.add_argument("--no-matching", action="store_true", help="Skip device matching")
parser.add_argument("--skip-existing", action="store_true", default=True)
args = parser.parse_args()
# Setup logging
logger.remove()
logger.add(sys.stderr, level="INFO")
logger.add("logs/import_african_gps.log", rotation="10 MB", level="DEBUG")
logger.info("=" * 80)
logger.info("Importing Captures with African GPS Coordinates")
logger.info("=" * 80)
# Setup
db = get_session()
# Set random seed for reproducibility (optional)
random.seed(42)
try:
stats = import_captures_with_african_gps(
db=db,
limit=args.limit,
skip_existing=args.skip_existing,
run_matching=not args.no_matching
)
logger.info("\n" + "=" * 80)
logger.info("Import Complete!")
logger.info("=" * 80)
logger.info(f"Files processed: {stats['files_processed']}")
logger.info(f"Captures created: {stats['captures_created']}")
logger.info(f"Duplicates skipped: {stats['duplicates_skipped']}")
logger.info(f"Parse errors: {stats['parse_errors']}")
logger.info(f"Matches created: {stats['matches_created']}")
logger.info(f"Cities covered: {len(stats['cities_covered'])}")
logger.info(f"{', '.join(sorted(stats['cities_covered']))}")
# Database totals
try:
total_captures = db.query(Capture).count()
total_matches = db.query(CaptureMatch).count()
logger.info(f"\nDatabase totals:")
logger.info(f" Total captures: {total_captures}")
logger.info(f" Total matches: {total_matches}")
except Exception as e:
logger.warning(f"Could not query database totals: {e}")
# Geographic distribution
logger.info(f"\nGeographic distribution:")
result = db.execute("""
SELECT
COUNT(*) as count,
AVG(latitude) as avg_lat,
AVG(longitude) as avg_lon
FROM captures
WHERE latitude BETWEEN -35 AND 37
AND longitude BETWEEN -17 AND 51
""").fetchone()
if result:
logger.info(f" Captures in Africa: {result[0]}")
logger.info(f" Center point: {result[1]:.2f}°, {result[2]:.2f}°")
finally:
db.close()
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Add missing columns to flipper_signatures table
"""
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.database.connection import get_engine
from sqlalchemy import text
def main():
engine = get_engine()
with engine.connect() as conn:
print("Adding missing columns to flipper_signatures table...")
try:
# Add file_hash column
conn.execute(text("""
ALTER TABLE flipper_signatures
ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64) UNIQUE
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_flipper_signatures_file_hash
ON flipper_signatures(file_hash)
"""))
print("✓ Added file_hash column")
except Exception as e:
print(f"file_hash: {e}")
try:
# Add file_path column
conn.execute(text("""
ALTER TABLE flipper_signatures
ADD COLUMN IF NOT EXISTS file_path VARCHAR(500)
"""))
print("✓ Added file_path column")
except Exception as e:
print(f"file_path: {e}")
try:
# Add raw_format column
conn.execute(text("""
ALTER TABLE flipper_signatures
ADD COLUMN IF NOT EXISTS raw_format VARCHAR(20)
"""))
print("✓ Added raw_format column")
except Exception as e:
print(f"raw_format: {e}")
conn.commit()
print("\n✓ Migration complete!")
if __name__ == "__main__":
main()
+468
View File
@@ -0,0 +1,468 @@
#!/bin/bash
###############################################################################
# Safe Package Installation Script
#
# Provides safe python -m pip installation with:
# - Virtual environment detection/creation
# - Dependency resolution
# - Version compatibility checking
# - Rollback on failure
# - Security scanning
#
# Usage:
# ./scripts/safe_install.sh [package_name] [version]
# ./scripts/safe_install.sh -r requirements.txt
# ./scripts/safe_install.sh --all # Install all project dependencies
###############################################################################
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Configuration
VENV_DIR="$PROJECT_ROOT/venv"
REQUIREMENTS_FILE="$PROJECT_ROOT/requirements.txt"
BACKUP_DIR="$PROJECT_ROOT/.pip_backups"
###############################################################################
# Helper Functions
###############################################################################
print_header() {
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}========================================${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
###############################################################################
# Virtual Environment Management
###############################################################################
check_venv() {
if [[ "$VIRTUAL_ENV" != "" ]]; then
print_success "Virtual environment active: $VIRTUAL_ENV"
return 0
else
print_warning "No virtual environment active"
return 1
fi
}
create_venv() {
print_header "Creating Virtual Environment"
if [ -d "$VENV_DIR" ]; then
print_info "Virtual environment already exists at: $VENV_DIR"
read -p "Recreate it? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "Removing old virtual environment..."
rm -rf "$VENV_DIR"
else
return 0
fi
fi
print_info "Creating new virtual environment..."
python3 -m venv "$VENV_DIR"
if [ $? -eq 0 ]; then
print_success "Virtual environment created at: $VENV_DIR"
print_info "Activate it with: source $VENV_DIR/bin/activate"
return 0
else
print_error "Failed to create virtual environment"
return 1
fi
}
activate_venv() {
if [ ! -d "$VENV_DIR" ]; then
print_warning "Virtual environment not found at: $VENV_DIR"
read -p "Create it now? (Y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
create_venv || return 1
else
return 1
fi
fi
# Check if already activated
if check_venv; then
return 0
fi
print_info "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
if check_venv; then
print_success "Virtual environment activated"
return 0
else
print_error "Failed to activate virtual environment"
return 1
fi
}
###############################################################################
# Pip Safety Checks
###############################################################################
check_pip() {
print_header "Checking pip"
# Use python -m pip to bypass any aliases
if ! python -m pip --version &> /dev/null; then
print_error "pip not found"
print_info "Installing pip..."
# Try to install pip
if command -v python3 &> /dev/null; then
python3 -m ensurepip --upgrade
else
print_error "Python 3 not found. Please install Python 3 first."
return 1
fi
fi
# Check pip version
pip_version=$(python -m pip --version | awk '{print $2}')
print_success "pip version: $pip_version"
# Upgrade pip if needed
print_info "Upgrading pip to latest version..."
python -m pip install --upgrade pip
return 0
}
create_backup() {
print_header "Creating Backup"
mkdir -p "$BACKUP_DIR"
timestamp=$(date +%Y%m%d_%H%M%S)
backup_file="$BACKUP_DIR/installed_packages_$timestamp.txt"
print_info "Saving current package list to: $backup_file"
python -m pip freeze > "$backup_file"
print_success "Backup created"
echo "$backup_file"
}
check_conflicts() {
local package=$1
print_header "Checking for Conflicts"
# Use pip-conflict-checker if available
if command -v pip-conflict-checker &> /dev/null; then
pip-conflict-checker "$package"
else
# Basic check using pip
print_info "Running dependency check..."
python -m pip install --dry-run "$package" 2>&1 | grep -i "conflict\|incompatible" || true
fi
}
###############################################################################
# Installation Functions
###############################################################################
safe_install_package() {
local package=$1
local version=$2
# Construct package spec
if [ -n "$version" ]; then
package_spec="$package==$version"
else
package_spec="$package"
fi
print_header "Installing: $package_spec"
# Create backup
backup_file=$(create_backup)
# Check for conflicts
check_conflicts "$package_spec"
# Ask for confirmation
read -p "Proceed with installation? (Y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
print_warning "Installation cancelled"
return 1
fi
# Install
print_info "Installing $package_spec..."
if python -m pip install "$package_spec"; then
print_success "Successfully installed $package_spec"
# Verify installation
print_info "Verifying installation..."
python -m pip show "$package" &> /dev/null
if [ $? -eq 0 ]; then
print_success "Package verified"
return 0
else
print_error "Package installation verification failed"
rollback "$backup_file"
return 1
fi
else
print_error "Installation failed"
rollback "$backup_file"
return 1
fi
}
install_from_requirements() {
local req_file=$1
if [ ! -f "$req_file" ]; then
print_error "Requirements file not found: $req_file"
return 1
fi
print_header "Installing from: $req_file"
# Create backup
backup_file=$(create_backup)
# Count packages
package_count=$(grep -c -v '^#' "$req_file" | grep -c -v '^$' || echo 0)
print_info "Found $package_count packages to install"
# Show packages
print_info "Packages to install:"
grep -v '^#' "$req_file" | grep -v '^$' | sed 's/^/ - /'
# Ask for confirmation
read -p "Proceed with installation? (Y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Nn]$ ]]; then
print_warning "Installation cancelled"
return 1
fi
# Install
print_info "Installing packages..."
if python -m pip install -r "$req_file"; then
print_success "All packages installed successfully"
return 0
else
print_error "Installation failed"
rollback "$backup_file"
return 1
fi
}
rollback() {
local backup_file=$1
print_header "Rolling Back"
if [ ! -f "$backup_file" ]; then
print_error "Backup file not found: $backup_file"
return 1
fi
print_warning "Rolling back to previous state..."
# Uninstall all current packages
python -m pip freeze | xargs python -m pip uninstall -y
# Reinstall from backup
python -m pip install -r "$backup_file"
if [ $? -eq 0 ]; then
print_success "Rollback completed"
return 0
else
print_error "Rollback failed"
return 1
fi
}
###############################################################################
# Dependency Resolution
###############################################################################
resolve_dependencies() {
print_header "Resolving Dependencies"
# Use pip-tools if available
if command -v pip-compile &> /dev/null; then
print_info "Using pip-tools to resolve dependencies..."
pip-compile --upgrade --generate-hashes requirements.in
else
print_warning "pip-tools not installed. Install with: python -m pip install pip-tools"
print_info "Basic dependency check:"
python -m pip check
fi
}
###############################################################################
# Security Scanning
###############################################################################
security_scan() {
print_header "Security Scanning"
# Use safety if available
if command -v safety &> /dev/null; then
print_info "Running safety check..."
safety check --json | jq '.'
else
print_warning "safety not installed. Install with: python -m pip install safety"
print_info "Skipping security scan"
fi
}
###############################################################################
# Main Logic
###############################################################################
show_help() {
cat << EOF
Safe Package Installation Script
Usage:
$0 [OPTIONS] [PACKAGE] [VERSION]
Options:
--help Show this help message
--create-venv Create virtual environment only
--activate-venv Activate virtual environment (source this script)
--check Check pip and environment
--all Install all project dependencies
-r FILE Install from requirements file
--resolve Resolve dependencies (requires pip-tools)
--scan Run security scan (requires safety)
--backup Create backup of current packages
--rollback FILE Rollback to backup file
Examples:
# Create and activate virtual environment
$0 --create-venv
source venv/bin/activate
# Install a single package
$0 requests
# Install specific version
$0 requests 2.28.0
# Install from requirements.txt
$0 -r requirements.txt
$0 --all # Uses default requirements.txt
# Check environment
$0 --check
# Security scan
$0 --scan
EOF
}
main() {
# Parse arguments
case "$1" in
--help|-h)
show_help
exit 0
;;
--create-venv)
create_venv
exit $?
;;
--check)
activate_venv || exit 1
check_pip
print_info "Python version: $(python --version)"
print_info "Installed packages:"
python -m pip list
exit 0
;;
--all)
activate_venv || exit 1
check_pip || exit 1
install_from_requirements "$REQUIREMENTS_FILE"
exit $?
;;
-r)
activate_venv || exit 1
check_pip || exit 1
install_from_requirements "$2"
exit $?
;;
--resolve)
activate_venv || exit 1
resolve_dependencies
exit $?
;;
--scan)
activate_venv || exit 1
security_scan
exit $?
;;
--backup)
activate_venv || exit 1
create_backup
exit $?
;;
--rollback)
activate_venv || exit 1
rollback "$2"
exit $?
;;
"")
show_help
exit 1
;;
*)
# Install package
activate_venv || exit 1
check_pip || exit 1
safe_install_package "$1" "$2"
exit $?
;;
esac
}
# Run main
main "$@"
+264 -38
View File
@@ -1,55 +1,281 @@
#!/bin/bash
###############################################################################
# GigLez Database Setup Script
# This script creates the PostgreSQL database and user for GigLez
#
# Creates PostgreSQL database, user, and schema for GigLez platform
#
# Usage:
# sudo ./scripts/setup_database.sh
#
# This script must be run with sudo to access PostgreSQL
###############################################################################
set -e # Exit on error
echo "🚀 GigLez Database Setup"
echo "========================"
echo ""
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Database configuration
# Script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Configuration
DB_NAME="giglez"
DB_USER="giglez_user"
DB_PASSWORD="giglez_secure_password_2026" # Change this in production!
PASSWORD_FILE="$PROJECT_ROOT/.db_password"
SCHEMA_FILE="$PROJECT_ROOT/scripts/create_schema.sql"
echo -e "${BLUE}Step 1: Creating PostgreSQL user${NC}"
sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASSWORD}';" 2>/dev/null || echo " User already exists, skipping..."
###############################################################################
# Helper Functions
###############################################################################
echo -e "${BLUE}Step 2: Creating database${NC}"
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" 2>/dev/null || echo " Database already exists, skipping..."
print_header() {
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}========================================${NC}"
}
echo -e "${BLUE}Step 3: Granting privileges${NC}"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};"
print_success() {
echo -e "${GREEN}$1${NC}"
}
echo -e "${BLUE}Step 4: Enabling PostGIS extension${NC}"
sudo -u postgres psql -d ${DB_NAME} -c "CREATE EXTENSION IF NOT EXISTS postgis;"
sudo -u postgres psql -d ${DB_NAME} -c "CREATE EXTENSION IF NOT EXISTS postgis_topology;"
print_error() {
echo -e "${RED}$1${NC}"
}
echo -e "${BLUE}Step 5: Granting schema permissions${NC}"
sudo -u postgres psql -d ${DB_NAME} -c "GRANT ALL ON SCHEMA public TO ${DB_USER};"
sudo -u postgres psql -d ${DB_NAME} -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${DB_USER};"
sudo -u postgres psql -d ${DB_NAME} -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};"
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
echo ""
echo -e "${GREEN}✅ Database setup complete!${NC}"
echo ""
echo "Connection details:"
echo " Database: ${DB_NAME}"
echo " User: ${DB_USER}"
echo " Password: ${DB_PASSWORD}"
echo " Host: localhost"
echo " Port: 5432"
echo ""
echo "Connection string:"
echo " postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}"
echo ""
echo "Test connection:"
echo " psql -U ${DB_USER} -d ${DB_NAME} -h localhost"
echo ""
print_info() {
echo -e "${BLUE} $1${NC}"
}
###############################################################################
# Checks
###############################################################################
check_sudo() {
if [ "$EUID" -ne 0 ]; then
print_error "This script must be run with sudo"
echo "Usage: sudo $0"
exit 1
fi
print_success "Running with sudo privileges"
}
check_postgresql() {
print_header "Checking PostgreSQL Installation"
if ! command -v psql &> /dev/null; then
print_error "PostgreSQL is not installed"
print_info "Install with: sudo apt-get install postgresql postgresql-contrib postgis"
exit 1
fi
print_success "PostgreSQL is installed"
# Check if PostgreSQL service is running
if ! sudo systemctl is-active --quiet postgresql; then
print_warning "PostgreSQL service is not running"
print_info "Starting PostgreSQL service..."
sudo systemctl start postgresql
sleep 2
fi
print_success "PostgreSQL service is running"
}
check_password_file() {
print_header "Checking Password File"
if [ ! -f "$PASSWORD_FILE" ]; then
print_error "Password file not found: $PASSWORD_FILE"
exit 1
fi
DB_PASSWORD=$(cat "$PASSWORD_FILE" | tr -d '\n\r')
if [ -z "$DB_PASSWORD" ]; then
print_error "Password file is empty"
exit 1
fi
print_success "Password loaded from file"
}
###############################################################################
# Database Setup
###############################################################################
create_database() {
print_header "Creating Database"
# Check if database already exists
if sudo -u postgres psql -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then
print_warning "Database '$DB_NAME' already exists"
read -p "Drop and recreate? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "Dropping existing database..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;"
print_success "Existing database dropped"
else
print_info "Keeping existing database"
return 0
fi
fi
# Create database
print_info "Creating database '$DB_NAME'..."
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME;"
print_success "Database created"
}
create_user() {
print_header "Creating Database User"
# Check if user already exists
if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1; then
print_warning "User '$DB_USER' already exists"
read -p "Drop and recreate? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "Dropping existing user..."
sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;"
print_success "Existing user dropped"
else
print_info "Updating password for existing user..."
sudo -u postgres psql -c "ALTER USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
print_success "Password updated"
return 0
fi
fi
# Create user with password
print_info "Creating user '$DB_USER'..."
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
print_success "User created"
}
grant_privileges() {
print_header "Granting Privileges"
print_info "Granting all privileges on database '$DB_NAME' to '$DB_USER'..."
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
# Grant schema privileges
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL ON SCHEMA public TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;"
# Set default privileges for future objects
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;"
print_success "Privileges granted"
}
enable_postgis() {
print_header "Enabling PostGIS Extension"
# Check if PostGIS is available
if ! dpkg -l | grep -q postgis; then
print_warning "PostGIS is not installed"
print_info "Install with: sudo apt-get install postgis postgresql-15-postgis-3"
print_info "Skipping PostGIS extension..."
return 0
fi
print_info "Enabling PostGIS extension..."
sudo -u postgres psql -d $DB_NAME -c "CREATE EXTENSION IF NOT EXISTS postgis;"
print_success "PostGIS extension enabled"
}
create_schema() {
print_header "Creating Database Schema"
if [ -f "$SCHEMA_FILE" ]; then
print_info "Running schema file: $SCHEMA_FILE"
PGPASSWORD="$DB_PASSWORD" psql -U $DB_USER -d $DB_NAME -h localhost -f "$SCHEMA_FILE"
print_success "Schema created from file"
else
print_warning "Schema file not found: $SCHEMA_FILE"
print_info "You'll need to run migrations manually"
fi
}
test_connection() {
print_header "Testing Connection"
print_info "Testing connection as '$DB_USER'..."
if PGPASSWORD="$DB_PASSWORD" psql -U $DB_USER -d $DB_NAME -h localhost -c "SELECT version();" &> /dev/null; then
print_success "Connection successful!"
# Show connection string
print_info ""
print_info "Connection details:"
echo " Database: $DB_NAME"
echo " User: $DB_USER"
echo " Host: localhost"
echo " Port: 5432"
echo ""
echo " Connection string:"
echo " postgresql://$DB_USER:$DB_PASSWORD@localhost:5432/$DB_NAME"
else
print_error "Connection failed"
exit 1
fi
}
###############################################################################
# Main
###############################################################################
main() {
print_header "GigLez Database Setup"
echo ""
# Run checks
check_sudo
check_postgresql
check_password_file
echo ""
# Setup database
create_database
create_user
grant_privileges
enable_postgis
# Create schema if file exists
if [ -f "$SCHEMA_FILE" ]; then
create_schema
fi
# Test connection
test_connection
echo ""
print_header "Setup Complete!"
print_success "Database is ready for use"
echo ""
print_info "Next steps:"
echo " 1. Run import script to load test data:"
echo " cd $PROJECT_ROOT"
echo " source venv/bin/activate"
echo " python scripts/import_with_african_gps.py --limit 5000 --no-matching"
echo ""
echo " 2. Start the API server:"
echo " uvicorn src.api.main:app --reload"
echo ""
}
# Run main
main