Initial commit: RF Mapper v0.3.0-dev
WiFi & Bluetooth signal mapping tool for Raspberry Pi with: - WiFi scanning via iw command - Bluetooth Classic/BLE device discovery - RSSI-based distance estimation - OUI manufacturer lookup - Web dashboard with multiple views: - Radar view (polar plot) - 2D Map (Leaflet/OpenStreetMap) - 3D Map (MapLibre GL JS with building extrusion) - Floor-based device positioning - Live BT tracking mode (auto-starts on page load) - SQLite database for historical device tracking: - RSSI time-series history - Device statistics (avg/min/max) - Movement detection and velocity estimation - Activity patterns (hourly/daily) - New device alerts - Automatic data retention/cleanup - REST API for all functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
405
src/rf_mapper/__main__.py
Normal file
405
src/rf_mapper/__main__.py
Normal file
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entry point for RF Environment Scanner"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .scanner import RFScanner
|
||||
from .visualize import (
|
||||
create_ascii_radar,
|
||||
create_signal_strength_chart,
|
||||
create_environment_analysis,
|
||||
load_latest_scan
|
||||
)
|
||||
from .distance import estimate_distance
|
||||
from .config import Config, get_config
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="RF Environment Scanner - Map WiFi and Bluetooth signals",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
rf-mapper scan # Scan and save results
|
||||
rf-mapper scan -l kitchen # Scan with location label
|
||||
rf-mapper visualize # Visualize latest scan
|
||||
rf-mapper analyze # Analyze RF environment
|
||||
rf-mapper web # Start web server
|
||||
rf-mapper config # Show current configuration
|
||||
|
||||
Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
help='Path to configuration file'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--profile',
|
||||
action='store_true',
|
||||
help='Enable CPU profiling (prints stats on completion)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--profile-memory',
|
||||
action='store_true',
|
||||
help='Enable memory profiling (shows top allocations)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--profile-output',
|
||||
type=Path,
|
||||
metavar='FILE',
|
||||
help='Save CPU profile to file (.prof format)'
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Scan command
|
||||
scan_parser = subparsers.add_parser('scan', help='Perform WiFi and Bluetooth scan')
|
||||
scan_parser.add_argument(
|
||||
'-l', '--location',
|
||||
default='default',
|
||||
help='Location label for this scan (e.g., living_room, office)'
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
'-i', '--interface',
|
||||
help='WiFi interface to use (default from config)'
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
'--no-wifi',
|
||||
action='store_true',
|
||||
help='Skip WiFi scanning'
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
'--no-bt',
|
||||
action='store_true',
|
||||
help='Skip Bluetooth scanning'
|
||||
)
|
||||
|
||||
# Visualize command
|
||||
viz_parser = subparsers.add_parser('visualize', help='Visualize scan results')
|
||||
viz_parser.add_argument(
|
||||
'-f', '--file',
|
||||
help='Specific scan file to visualize (default: latest)'
|
||||
)
|
||||
|
||||
# Analyze command
|
||||
analyze_parser = subparsers.add_parser('analyze', help='Analyze RF environment')
|
||||
analyze_parser.add_argument(
|
||||
'-f', '--file',
|
||||
help='Specific scan file to analyze (default: latest)'
|
||||
)
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List saved scans')
|
||||
|
||||
# Web server command
|
||||
web_parser = subparsers.add_parser('web', help='Start web server')
|
||||
web_parser.add_argument(
|
||||
'-H', '--host',
|
||||
help='Host to bind to (default from config)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'-p', '--port',
|
||||
type=int,
|
||||
help='Port to listen on (default from config)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Enable debug mode'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'--profile-requests',
|
||||
action='store_true',
|
||||
help='Enable per-request profiling (saves profiles to data/profiles/)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'--log-requests',
|
||||
action='store_true',
|
||||
help='Log all requests to data/logs/requests_YYYYMMDD.log'
|
||||
)
|
||||
|
||||
# Config command
|
||||
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||
config_parser.add_argument(
|
||||
'--set-gps',
|
||||
nargs=2,
|
||||
metavar=('LAT', 'LON'),
|
||||
help='Set GPS coordinates'
|
||||
)
|
||||
config_parser.add_argument(
|
||||
'--save',
|
||||
action='store_true',
|
||||
help='Save changes to config file'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load configuration
|
||||
config = Config.load(args.config) if args.config else get_config()
|
||||
data_dir = config.get_data_dir()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def run_command():
|
||||
if args.command == 'scan':
|
||||
run_scan(args, config, data_dir)
|
||||
elif args.command == 'visualize':
|
||||
run_visualize(args, data_dir)
|
||||
elif args.command == 'analyze':
|
||||
run_analyze(args, data_dir)
|
||||
elif args.command == 'list':
|
||||
run_list(data_dir)
|
||||
elif args.command == 'web':
|
||||
run_web(args, config)
|
||||
elif args.command == 'config':
|
||||
run_config(args, config)
|
||||
else:
|
||||
# Default: run interactive scan
|
||||
run_interactive(config, data_dir)
|
||||
|
||||
# Wrap command execution with profilers if requested
|
||||
if args.profile or args.profile_memory:
|
||||
from .profiling import cpu_profiler, memory_profiler
|
||||
|
||||
if args.profile and args.profile_memory:
|
||||
with cpu_profiler(args.profile_output), memory_profiler():
|
||||
run_command()
|
||||
elif args.profile:
|
||||
with cpu_profiler(args.profile_output):
|
||||
run_command()
|
||||
else:
|
||||
with memory_profiler():
|
||||
run_command()
|
||||
else:
|
||||
run_command()
|
||||
|
||||
|
||||
def run_interactive(config: Config, data_dir: Path):
|
||||
"""Run interactive scan mode"""
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ RF ENVIRONMENT SCANNER v1.0 ║
|
||||
║ WiFi + Bluetooth Signal Mapper ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Config: {config._config_path or 'defaults'}
|
||||
GPS: {config.gps.latitude}, {config.gps.longitude}
|
||||
""")
|
||||
|
||||
try:
|
||||
location = input("Enter location label (e.g., 'living_room'): ").strip() or "default"
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
location = "default"
|
||||
|
||||
scanner = RFScanner(data_dir)
|
||||
result, wifi, bt = scanner.full_scan(location)
|
||||
scanner.print_results(wifi, bt)
|
||||
|
||||
# Show visualizations
|
||||
if wifi:
|
||||
print(create_ascii_radar(result.wifi_networks, f"WiFi Networks ({len(wifi)} found)"))
|
||||
if bt:
|
||||
print(create_ascii_radar(result.bluetooth_devices, f"Bluetooth Devices ({len(bt)} found)"))
|
||||
|
||||
print(create_environment_analysis(result.wifi_networks, result.bluetooth_devices))
|
||||
|
||||
# Estimated distances
|
||||
print(f"\n{'='*60}")
|
||||
print("ESTIMATED DISTANCES")
|
||||
print('='*60)
|
||||
|
||||
print("\nWiFi Access Points (nearest 5):")
|
||||
for net in sorted(wifi, key=lambda x: x.rssi, reverse=True)[:5]:
|
||||
dist = estimate_distance(net.rssi, n=config.scanner.path_loss_exponent)
|
||||
print(f" {net.ssid[:25]:<25} ~{dist:.1f}m away")
|
||||
|
||||
if bt:
|
||||
print("\nBluetooth Devices (nearest 5):")
|
||||
for dev in sorted(bt, key=lambda x: x.rssi, reverse=True)[:5]:
|
||||
dist = estimate_distance(dev.rssi, tx_power=-65, n=config.scanner.path_loss_exponent)
|
||||
print(f" {dev.name[:25]:<25} ~{dist:.1f}m away")
|
||||
|
||||
|
||||
def run_scan(args, config: Config, data_dir: Path):
|
||||
"""Run a scan with specified options"""
|
||||
interface = args.interface or config.scanner.wifi_interface
|
||||
scanner = RFScanner(data_dir)
|
||||
|
||||
print(f"Starting scan at location: {args.location}")
|
||||
|
||||
wifi = []
|
||||
bt = []
|
||||
|
||||
if not args.no_wifi:
|
||||
wifi = scanner.scan_wifi(interface)
|
||||
print(f"Found {len(wifi)} WiFi networks")
|
||||
|
||||
if not args.no_bt:
|
||||
bt = scanner.scan_bluetooth(
|
||||
timeout=config.scanner.bt_scan_timeout,
|
||||
auto_identify=config.scanner.auto_identify_bluetooth
|
||||
)
|
||||
print(f"Found {len(bt)} Bluetooth devices")
|
||||
|
||||
# Save results
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
result = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'location_label': args.location,
|
||||
'gps': {
|
||||
'latitude': config.gps.latitude,
|
||||
'longitude': config.gps.longitude
|
||||
},
|
||||
'wifi_networks': [asdict(n) for n in wifi],
|
||||
'bluetooth_devices': [asdict(d) for d in bt]
|
||||
}
|
||||
|
||||
filename = data_dir / f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{args.location}.json"
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(result, f, indent=2)
|
||||
|
||||
print(f"Saved to: {filename}")
|
||||
|
||||
# Print results
|
||||
scanner.print_results(wifi, bt)
|
||||
|
||||
|
||||
def run_visualize(args, data_dir: Path):
|
||||
"""Visualize scan results"""
|
||||
if args.file:
|
||||
import json
|
||||
with open(args.file) as f:
|
||||
scan = json.load(f)
|
||||
else:
|
||||
scan = load_latest_scan(data_dir)
|
||||
|
||||
if not scan:
|
||||
print("No scan data found. Run 'rf-mapper scan' first.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Scan from: {scan['timestamp']}")
|
||||
print(f"Location: {scan['location_label']}")
|
||||
|
||||
wifi = scan.get('wifi_networks', [])
|
||||
bt = scan.get('bluetooth_devices', [])
|
||||
|
||||
if wifi:
|
||||
print(create_ascii_radar(wifi, f"WiFi Networks ({len(wifi)} found)"))
|
||||
print(create_signal_strength_chart(wifi, "WiFi Signal Strengths"))
|
||||
|
||||
if bt:
|
||||
print(create_ascii_radar(bt, f"Bluetooth Devices ({len(bt)} found)"))
|
||||
print(create_signal_strength_chart(bt, "Bluetooth Signal Strengths"))
|
||||
|
||||
|
||||
def run_analyze(args, data_dir: Path):
|
||||
"""Analyze RF environment"""
|
||||
if args.file:
|
||||
import json
|
||||
with open(args.file) as f:
|
||||
scan = json.load(f)
|
||||
else:
|
||||
scan = load_latest_scan(data_dir)
|
||||
|
||||
if not scan:
|
||||
print("No scan data found. Run 'rf-mapper scan' first.")
|
||||
sys.exit(1)
|
||||
|
||||
wifi = scan.get('wifi_networks', [])
|
||||
bt = scan.get('bluetooth_devices', [])
|
||||
|
||||
print(create_environment_analysis(wifi, bt))
|
||||
|
||||
|
||||
def run_list(data_dir: Path):
|
||||
"""List saved scans"""
|
||||
scan_files = sorted(data_dir.glob('scan_*.json'), reverse=True)
|
||||
|
||||
if not scan_files:
|
||||
print("No scans found.")
|
||||
return
|
||||
|
||||
print(f"{'Date/Time':<20} {'Location':<20} {'WiFi':>6} {'BT':>6}")
|
||||
print('-' * 55)
|
||||
|
||||
for f in scan_files[:20]:
|
||||
import json
|
||||
with open(f) as fh:
|
||||
scan = json.load(fh)
|
||||
ts = scan.get('timestamp', '')[:19].replace('T', ' ')
|
||||
loc = scan.get('location_label', 'unknown')
|
||||
wifi_count = len(scan.get('wifi_networks', []))
|
||||
bt_count = len(scan.get('bluetooth_devices', []))
|
||||
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
|
||||
|
||||
|
||||
def run_web(args, config: Config):
|
||||
"""Start the web server"""
|
||||
from .web.app import run_server
|
||||
|
||||
host = args.host
|
||||
port = args.port
|
||||
debug = args.debug
|
||||
profile_requests = getattr(args, 'profile_requests', False)
|
||||
log_requests = getattr(args, 'log_requests', False)
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
config=config,
|
||||
profile_requests=profile_requests,
|
||||
log_requests=log_requests
|
||||
)
|
||||
|
||||
|
||||
def run_config(args, config: Config):
|
||||
"""Show or edit configuration"""
|
||||
if args.set_gps:
|
||||
lat, lon = args.set_gps
|
||||
config.gps.latitude = float(lat)
|
||||
config.gps.longitude = float(lon)
|
||||
print(f"GPS set to: {config.gps.latitude}, {config.gps.longitude}")
|
||||
|
||||
if args.save:
|
||||
config.save()
|
||||
print(f"Configuration saved to: {config._config_path}")
|
||||
|
||||
# Show current config
|
||||
print(f"""
|
||||
RF Mapper Configuration
|
||||
{'='*40}
|
||||
|
||||
Config File: {config._config_path or 'Not found (using defaults)'}
|
||||
|
||||
GPS Position:
|
||||
Latitude: {config.gps.latitude}
|
||||
Longitude: {config.gps.longitude}
|
||||
|
||||
Web Server:
|
||||
Host: {config.web.host}
|
||||
Port: {config.web.port}
|
||||
|
||||
Scanner:
|
||||
WiFi Interface: {config.scanner.wifi_interface}
|
||||
BT Scan Timeout: {config.scanner.bt_scan_timeout}s
|
||||
Path Loss Exponent: {config.scanner.path_loss_exponent}
|
||||
|
||||
Data:
|
||||
Directory: {config.get_data_dir()}
|
||||
Max Scans: {config.data.max_scans}
|
||||
|
||||
Home Assistant:
|
||||
Enabled: {config.home_assistant.enabled}
|
||||
URL: {config.home_assistant.url}
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user