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:
@@ -0,0 +1,25 @@
|
||||
"""RF Environment Scanner - WiFi and Bluetooth signal mapper"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "User"
|
||||
|
||||
from .scanner import RFScanner, WifiNetwork, BluetoothDevice, ScanResult
|
||||
from .oui import OUILookup
|
||||
from .bluetooth_class import BluetoothClassDecoder
|
||||
from .visualize import create_ascii_radar, create_signal_strength_chart
|
||||
from .distance import estimate_distance
|
||||
from .config import Config, get_config
|
||||
|
||||
__all__ = [
|
||||
"RFScanner",
|
||||
"WifiNetwork",
|
||||
"BluetoothDevice",
|
||||
"ScanResult",
|
||||
"OUILookup",
|
||||
"BluetoothClassDecoder",
|
||||
"create_ascii_radar",
|
||||
"create_signal_strength_chart",
|
||||
"estimate_distance",
|
||||
"Config",
|
||||
"get_config",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Bluetooth Class of Device decoder"""
|
||||
|
||||
|
||||
class BluetoothClassDecoder:
|
||||
"""Decode Bluetooth Class of Device (CoD) codes"""
|
||||
|
||||
MAJOR_DEVICE_CLASSES = {
|
||||
0: "Miscellaneous",
|
||||
1: "Computer",
|
||||
2: "Phone",
|
||||
3: "LAN/Network",
|
||||
4: "Audio/Video",
|
||||
5: "Peripheral",
|
||||
6: "Imaging",
|
||||
7: "Wearable",
|
||||
8: "Toy",
|
||||
9: "Health",
|
||||
31: "Uncategorized"
|
||||
}
|
||||
|
||||
MINOR_COMPUTER = {
|
||||
0: "Uncategorized",
|
||||
1: "Desktop",
|
||||
2: "Server",
|
||||
3: "Laptop",
|
||||
4: "Handheld/PDA",
|
||||
5: "Palm/PDA",
|
||||
6: "Wearable"
|
||||
}
|
||||
|
||||
MINOR_PHONE = {
|
||||
0: "Uncategorized",
|
||||
1: "Cellular",
|
||||
2: "Cordless",
|
||||
3: "Smartphone",
|
||||
4: "Modem/Gateway",
|
||||
5: "ISDN"
|
||||
}
|
||||
|
||||
MINOR_AV = {
|
||||
0: "Uncategorized",
|
||||
1: "Wearable Headset",
|
||||
2: "Hands-free",
|
||||
4: "Microphone",
|
||||
5: "Loudspeaker",
|
||||
6: "Headphones",
|
||||
7: "Portable Audio",
|
||||
8: "Car Audio",
|
||||
9: "Set-top Box",
|
||||
10: "HiFi Audio",
|
||||
11: "VCR",
|
||||
12: "Video Camera",
|
||||
13: "Camcorder",
|
||||
14: "Video Monitor",
|
||||
15: "Video Display and Loudspeaker",
|
||||
16: "Video Conferencing",
|
||||
18: "Gaming/Toy"
|
||||
}
|
||||
|
||||
MINOR_PERIPHERAL = {
|
||||
0: "Uncategorized",
|
||||
1: "Keyboard",
|
||||
2: "Pointing Device",
|
||||
3: "Combo Keyboard/Pointing"
|
||||
}
|
||||
|
||||
MINOR_IMAGING = {
|
||||
1: "Display",
|
||||
2: "Camera",
|
||||
4: "Scanner",
|
||||
8: "Printer"
|
||||
}
|
||||
|
||||
MINOR_WEARABLE = {
|
||||
1: "Wristwatch",
|
||||
2: "Pager",
|
||||
3: "Jacket",
|
||||
4: "Helmet",
|
||||
5: "Glasses"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decode(cls, class_hex: str) -> tuple[str, str]:
|
||||
"""
|
||||
Decode Class of Device hex string into device type and category.
|
||||
|
||||
Args:
|
||||
class_hex: Hex string of Class of Device (e.g., "0x240404")
|
||||
|
||||
Returns:
|
||||
Tuple of (major_class, minor_class)
|
||||
"""
|
||||
try:
|
||||
cod = int(class_hex, 16)
|
||||
major = (cod >> 8) & 0x1F
|
||||
minor = (cod >> 2) & 0x3F
|
||||
|
||||
major_str = cls.MAJOR_DEVICE_CLASSES.get(major, f"Unknown ({major})")
|
||||
|
||||
minor_str = ""
|
||||
if major == 1:
|
||||
minor_str = cls.MINOR_COMPUTER.get(minor, f"Unknown ({minor})")
|
||||
elif major == 2:
|
||||
minor_str = cls.MINOR_PHONE.get(minor, f"Unknown ({minor})")
|
||||
elif major == 4:
|
||||
minor_str = cls.MINOR_AV.get(minor, f"Unknown ({minor})")
|
||||
elif major == 5:
|
||||
minor_str = cls.MINOR_PERIPHERAL.get(minor & 0x03, f"Unknown ({minor})")
|
||||
elif major == 6:
|
||||
minor_str = cls.MINOR_IMAGING.get(minor & 0x0F, f"Unknown ({minor})")
|
||||
elif major == 7:
|
||||
minor_str = cls.MINOR_WEARABLE.get(minor, f"Unknown ({minor})")
|
||||
else:
|
||||
minor_str = str(minor) if minor else ""
|
||||
|
||||
return major_str, minor_str
|
||||
except (ValueError, TypeError):
|
||||
return "Unknown", ""
|
||||
|
||||
@classmethod
|
||||
def decode_to_string(cls, class_hex: str) -> str:
|
||||
"""Decode to a single descriptive string"""
|
||||
major, minor = cls.decode(class_hex)
|
||||
if minor:
|
||||
return f"{major} ({minor})"
|
||||
return major
|
||||
@@ -0,0 +1,657 @@
|
||||
"""Enhanced Bluetooth device identification using SDP and GATT"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def is_random_mac(mac: str) -> bool:
|
||||
"""Check if MAC address is locally administered (randomized)"""
|
||||
try:
|
||||
first_byte = int(mac.split(":")[0], 16)
|
||||
return (first_byte & 0x02) != 0
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
# Manufacturer to likely device type mapping (for BLE devices with no name)
|
||||
MANUFACTURER_DEVICE_HINTS = {
|
||||
# Audio companies
|
||||
"harman": "Speaker/Audio",
|
||||
"jbl": "Speaker/Audio",
|
||||
"bose": "Speaker/Audio",
|
||||
"sonos": "Speaker",
|
||||
"bang & olufsen": "Speaker/Audio",
|
||||
"sennheiser": "Headphones/Audio",
|
||||
"jabra": "Headset/Audio",
|
||||
"plantronics": "Headset",
|
||||
"skullcandy": "Headphones",
|
||||
"beats": "Headphones",
|
||||
"sony": "Audio/Media Device",
|
||||
"marshall": "Speaker",
|
||||
"ultimate ears": "Speaker",
|
||||
"anker": "Audio/Charger",
|
||||
"soundcore": "Speaker/Audio",
|
||||
"hui zhou gaoshengda": "Speaker/Audio", # Makes JBL/Harman products
|
||||
|
||||
# Phone/computing companies
|
||||
"apple": "Apple Device",
|
||||
"samsung": "Samsung Device",
|
||||
"google": "Google Device",
|
||||
"huawei": "Huawei Device",
|
||||
"xiaomi": "Xiaomi Device",
|
||||
"oneplus": "OnePlus Device",
|
||||
"oppo": "Phone/Wearable",
|
||||
"vivo": "Phone/Wearable",
|
||||
"realme": "Phone/Wearable",
|
||||
"motorola": "Phone",
|
||||
"lg": "Phone/TV",
|
||||
"nokia": "Phone",
|
||||
"asus": "Computer/Phone",
|
||||
"lenovo": "Computer/Tablet",
|
||||
"dell": "Computer",
|
||||
"hp": "Computer/Printer",
|
||||
"microsoft": "Computer/Accessory",
|
||||
"intel": "Computer",
|
||||
|
||||
# Wearables
|
||||
"fitbit": "Fitness Tracker",
|
||||
"garmin": "GPS/Fitness Watch",
|
||||
"polar": "Fitness Watch",
|
||||
"suunto": "Sports Watch",
|
||||
"amazfit": "Smartwatch",
|
||||
"zepp": "Smartwatch",
|
||||
"fossil": "Smartwatch",
|
||||
"mobvoi": "Smartwatch",
|
||||
"withings": "Health Device",
|
||||
"oura": "Smart Ring",
|
||||
|
||||
# Smart home
|
||||
"philips": "Smart Home/Light",
|
||||
"signify": "Smart Light (Hue)",
|
||||
"ikea": "Smart Home",
|
||||
"lutron": "Smart Light/Switch",
|
||||
"ecobee": "Thermostat",
|
||||
"nest": "Smart Home",
|
||||
"ring": "Doorbell/Camera",
|
||||
"arlo": "Camera",
|
||||
"wyze": "Smart Home",
|
||||
"tuya": "Smart Home",
|
||||
"espressif": "IoT Device",
|
||||
"nordic": "IoT/Sensor",
|
||||
"texas instruments": "IoT/Sensor",
|
||||
"silicon labs": "IoT Device",
|
||||
"dialog": "IoT Device",
|
||||
"telink": "IoT/Smart Home",
|
||||
|
||||
# TV/Media
|
||||
"roku": "Streaming Device",
|
||||
"amazon": "Echo/Fire Device",
|
||||
"tcl": "TV",
|
||||
"hisense": "TV",
|
||||
"vizio": "TV",
|
||||
|
||||
# Gaming
|
||||
"nintendo": "Game Controller",
|
||||
"valve": "Game Controller",
|
||||
"8bitdo": "Game Controller",
|
||||
|
||||
# Peripherals
|
||||
"logitech": "Peripheral (KB/Mouse)",
|
||||
"razer": "Gaming Peripheral",
|
||||
"steelseries": "Gaming Peripheral",
|
||||
"corsair": "Gaming Peripheral",
|
||||
|
||||
# Automotive
|
||||
"continental": "Vehicle/OBD",
|
||||
"bosch": "Vehicle/Tool",
|
||||
"denso": "Vehicle",
|
||||
|
||||
# Health
|
||||
"omron": "Health Monitor",
|
||||
"withings": "Health Device",
|
||||
"dexcom": "Glucose Monitor",
|
||||
"abbott": "Health Device",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothDeviceInfo:
|
||||
"""Extended Bluetooth device information"""
|
||||
address: str
|
||||
name: str
|
||||
alias: str
|
||||
device_class: str
|
||||
device_type: str
|
||||
services: list[str]
|
||||
manufacturer: str
|
||||
model: str
|
||||
is_ble: bool
|
||||
paired: bool
|
||||
connected: bool
|
||||
trusted: bool
|
||||
icon: str
|
||||
|
||||
|
||||
# Common Bluetooth service UUIDs and their names
|
||||
SERVICE_UUIDS = {
|
||||
"0x1101": "Serial Port",
|
||||
"0x1102": "LAN Access",
|
||||
"0x1103": "Dialup Networking",
|
||||
"0x1104": "IrMC Sync",
|
||||
"0x1105": "OBEX Object Push",
|
||||
"0x1106": "OBEX File Transfer",
|
||||
"0x1107": "IrMC Sync Command",
|
||||
"0x1108": "Headset",
|
||||
"0x1109": "Cordless Telephony",
|
||||
"0x110a": "Audio Source",
|
||||
"0x110b": "Audio Sink",
|
||||
"0x110c": "A/V Remote Control Target",
|
||||
"0x110d": "Advanced Audio Distribution",
|
||||
"0x110e": "A/V Remote Control",
|
||||
"0x110f": "A/V Remote Control Controller",
|
||||
"0x1110": "Intercom",
|
||||
"0x1111": "Fax",
|
||||
"0x1112": "Headset Audio Gateway",
|
||||
"0x1113": "WAP",
|
||||
"0x1114": "WAP Client",
|
||||
"0x1115": "PANU",
|
||||
"0x1116": "NAP",
|
||||
"0x1117": "GN",
|
||||
"0x1118": "Direct Printing",
|
||||
"0x1119": "Reference Printing",
|
||||
"0x111a": "Basic Imaging Profile",
|
||||
"0x111b": "Imaging Responder",
|
||||
"0x111c": "Imaging Automatic Archive",
|
||||
"0x111d": "Imaging Referenced Objects",
|
||||
"0x111e": "Handsfree",
|
||||
"0x111f": "Handsfree Audio Gateway",
|
||||
"0x1120": "Direct Printing Reference Objects",
|
||||
"0x1121": "Reflected UI",
|
||||
"0x1122": "Basic Printing",
|
||||
"0x1123": "Printing Status",
|
||||
"0x1124": "Human Interface Device",
|
||||
"0x1125": "Hardcopy Cable Replacement",
|
||||
"0x1126": "HCR Print",
|
||||
"0x1127": "HCR Scan",
|
||||
"0x1128": "Common ISDN Access",
|
||||
"0x112d": "SIM Access",
|
||||
"0x112e": "Phonebook Access PCE",
|
||||
"0x112f": "Phonebook Access PSE",
|
||||
"0x1130": "Phonebook Access",
|
||||
"0x1131": "Headset HS",
|
||||
"0x1132": "Message Access Server",
|
||||
"0x1133": "Message Notification Server",
|
||||
"0x1134": "Message Access Profile",
|
||||
"0x1135": "GNSS",
|
||||
"0x1136": "GNSS Server",
|
||||
"0x1200": "PnP Information",
|
||||
"0x1201": "Generic Networking",
|
||||
"0x1202": "Generic File Transfer",
|
||||
"0x1203": "Generic Audio",
|
||||
"0x1204": "Generic Telephony",
|
||||
"0x1205": "UPNP Service",
|
||||
"0x1206": "UPNP IP Service",
|
||||
"0x1300": "ESDP UPNP IP PAN",
|
||||
"0x1301": "ESDP UPNP IP LAP",
|
||||
"0x1302": "ESDP UPNP L2CAP",
|
||||
"0x1303": "Video Source",
|
||||
"0x1304": "Video Sink",
|
||||
"0x1305": "Video Distribution",
|
||||
"0x1400": "HDP",
|
||||
"0x1401": "HDP Source",
|
||||
"0x1402": "HDP Sink",
|
||||
"0x1800": "Generic Access",
|
||||
"0x1801": "Generic Attribute",
|
||||
"0x1802": "Immediate Alert",
|
||||
"0x1803": "Link Loss",
|
||||
"0x1804": "Tx Power",
|
||||
"0x1805": "Current Time Service",
|
||||
"0x1806": "Reference Time Update",
|
||||
"0x1807": "Next DST Change",
|
||||
"0x1808": "Glucose",
|
||||
"0x1809": "Health Thermometer",
|
||||
"0x180a": "Device Information",
|
||||
"0x180b": "Network Availability",
|
||||
"0x180d": "Heart Rate",
|
||||
"0x180e": "Phone Alert Status",
|
||||
"0x180f": "Battery Service",
|
||||
"0x1810": "Blood Pressure",
|
||||
"0x1811": "Alert Notification",
|
||||
"0x1812": "Human Interface Device (BLE)",
|
||||
"0x1813": "Scan Parameters",
|
||||
"0x1814": "Running Speed and Cadence",
|
||||
"0x1815": "Automation IO",
|
||||
"0x1816": "Cycling Speed and Cadence",
|
||||
"0x1818": "Cycling Power",
|
||||
"0x1819": "Location and Navigation",
|
||||
"0x181a": "Environmental Sensing",
|
||||
"0x181b": "Body Composition",
|
||||
"0x181c": "User Data",
|
||||
"0x181d": "Weight Scale",
|
||||
"0x181e": "Bond Management",
|
||||
"0x181f": "Continuous Glucose Monitoring",
|
||||
"0x1820": "Internet Protocol Support",
|
||||
"0x1821": "Indoor Positioning",
|
||||
"0x1822": "Pulse Oximeter",
|
||||
"0x1823": "HTTP Proxy",
|
||||
"0x1824": "Transport Discovery",
|
||||
"0x1825": "Object Transfer",
|
||||
"0x1826": "Fitness Machine",
|
||||
"0x1827": "Mesh Provisioning",
|
||||
"0x1828": "Mesh Proxy",
|
||||
}
|
||||
|
||||
# Icon to device type mapping
|
||||
ICON_TO_TYPE = {
|
||||
"audio-card": "Audio Device",
|
||||
"audio-headphones": "Headphones",
|
||||
"audio-headset": "Headset",
|
||||
"audio-speakers": "Speaker",
|
||||
"camera-photo": "Camera",
|
||||
"camera-video": "Video Camera",
|
||||
"computer": "Computer",
|
||||
"input-gaming": "Game Controller",
|
||||
"input-keyboard": "Keyboard",
|
||||
"input-mouse": "Mouse",
|
||||
"input-tablet": "Tablet/Stylus",
|
||||
"modem": "Modem",
|
||||
"multimedia-player": "Media Player",
|
||||
"network-wireless": "Wireless Device",
|
||||
"phone": "Phone",
|
||||
"printer": "Printer",
|
||||
"scanner": "Scanner",
|
||||
"video-display": "Display/TV",
|
||||
}
|
||||
|
||||
# Name patterns to device type (regex patterns, case-insensitive)
|
||||
NAME_PATTERNS = [
|
||||
# Audio devices
|
||||
(r"airpods|earpods", "Earbuds (Apple)"),
|
||||
(r"galaxy\s*buds", "Earbuds (Samsung)"),
|
||||
(r"pixel\s*buds", "Earbuds (Google)"),
|
||||
(r"buds|earbuds|ear\s*bud", "Earbuds"),
|
||||
(r"headphone|head\s*phone", "Headphones"),
|
||||
(r"headset|head\s*set", "Headset"),
|
||||
(r"airpod|pod", "Earbuds"),
|
||||
(r"speaker|soundbar|sound\s*bar|boom|bose|jbl|sonos|harman|marshall|bang", "Speaker"),
|
||||
(r"soundcore|anker.*audio", "Speaker"),
|
||||
(r"beats|sony\s*wh|sony\s*wf|sennheiser|jabra|plantronics|poly", "Headphones"),
|
||||
|
||||
# Wearables
|
||||
(r"watch|smartwatch|smart\s*watch|galaxy\s*watch|apple\s*watch|fitbit|garmin|polar|suunto", "Smartwatch"),
|
||||
(r"band|mi\s*band|honor\s*band|huawei\s*band|fitness", "Fitness Band"),
|
||||
(r"ring|oura", "Smart Ring"),
|
||||
(r"glasses|spectacles|ray-?ban|meta", "Smart Glasses"),
|
||||
|
||||
# Input devices
|
||||
(r"keyboard|keeb|kbd", "Keyboard"),
|
||||
(r"mouse|mice|trackpad|trackball|logitech\s*m\d", "Mouse"),
|
||||
(r"controller|gamepad|game\s*pad|joystick|xbox|playstation|ps[345]|dualsense|dualshock|nintendo|joycon|joy-?con|switch\s*pro", "Game Controller"),
|
||||
(r"remote|clicker|presenter", "Remote Control"),
|
||||
(r"stylus|pen|pencil|wacom|s-?pen", "Stylus/Pen"),
|
||||
|
||||
# Phones and tablets
|
||||
(r"iphone|ipad|ipod", "Apple Device"),
|
||||
(r"galaxy|samsung\s*(sm|gt)", "Samsung Device"),
|
||||
(r"pixel|nexus", "Google Device"),
|
||||
(r"oneplus|huawei|xiaomi|redmi|poco|oppo|vivo|realme", "Android Phone"),
|
||||
(r"phone|mobile|cell|handset", "Phone"),
|
||||
(r"tablet|tab\s", "Tablet"),
|
||||
|
||||
# Computers
|
||||
(r"macbook|imac|mac\s*pro|mac\s*mini|mac\s*studio", "Mac"),
|
||||
(r"thinkpad|latitude|xps|surface|chromebook|laptop|notebook", "Laptop"),
|
||||
(r"desktop|workstation|tower|pc", "Desktop PC"),
|
||||
(r"raspberry|rpi|pi\d", "Raspberry Pi"),
|
||||
|
||||
# TV and media
|
||||
(r"tv|television|bravia|roku|fire\s*tv|chromecast|appletv|apple\s*tv|shield|nvidia", "TV/Streaming"),
|
||||
(r"projector|beamer", "Projector"),
|
||||
(r"receiver|amplifier|amp|av\s*receiver|denon|yamaha|marantz|onkyo", "AV Receiver"),
|
||||
|
||||
# Smart home
|
||||
(r"echo|alexa|dot|show", "Amazon Echo"),
|
||||
(r"home\s*mini|nest|google\s*home", "Google Home"),
|
||||
(r"homepod", "HomePod"),
|
||||
(r"hub|bridge|gateway|homekit|smartthings|hue|tradfri|zigbee|zwave", "Smart Home Hub"),
|
||||
(r"bulb|light|lamp|led|hue|lifx|nanoleaf|wiz", "Smart Light"),
|
||||
(r"plug|socket|outlet|switch|relay", "Smart Plug/Switch"),
|
||||
(r"thermostat|nest\s*t|ecobee|tado", "Thermostat"),
|
||||
(r"lock|doorbell|ring\s", "Smart Lock/Doorbell"),
|
||||
(r"camera|cam|blink|arlo|wyze|eufy|nest\s*cam", "Camera"),
|
||||
(r"sensor|motion|door.*sensor|window.*sensor|temp.*sensor", "Sensor"),
|
||||
|
||||
# Health and fitness
|
||||
(r"scale|weight", "Smart Scale"),
|
||||
(r"blood\s*pressure|bp\s*monitor", "Blood Pressure Monitor"),
|
||||
(r"pulse\s*ox|oximeter|spo2", "Pulse Oximeter"),
|
||||
(r"thermometer|temp", "Thermometer"),
|
||||
(r"glucose|cgm|dexcom|freestyle", "Glucose Monitor"),
|
||||
(r"heart.*rate|hrm|chest\s*strap", "Heart Rate Monitor"),
|
||||
|
||||
# Vehicles
|
||||
(r"car|vehicle|obd|elm327|carplay|android\s*auto", "Vehicle/OBD"),
|
||||
(r"ebike|e-?bike|scooter|segway|ninebot", "Electric Vehicle"),
|
||||
(r"tire|tpms|pressure\s*monitor", "Tire Pressure Monitor"),
|
||||
|
||||
# Other
|
||||
(r"printer|print", "Printer"),
|
||||
(r"scanner|scan", "Scanner"),
|
||||
(r"tag|airtag|tile|smarttag|chipolo|tracker", "Tracker Tag"),
|
||||
(r"beacon|ibeacon|eddystone", "Beacon"),
|
||||
(r"toothbrush|oral-?b|sonicare", "Smart Toothbrush"),
|
||||
(r"brush|shaver|razor", "Personal Care"),
|
||||
]
|
||||
|
||||
|
||||
def get_device_info_bluetoothctl(address: str) -> dict:
|
||||
"""Get device info using bluetoothctl"""
|
||||
info = {
|
||||
"name": "",
|
||||
"alias": "",
|
||||
"class": "",
|
||||
"icon": "",
|
||||
"paired": False,
|
||||
"trusted": False,
|
||||
"connected": False,
|
||||
"services": [],
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bluetoothctl", "info", address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith("Name:"):
|
||||
info["name"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Alias:"):
|
||||
info["alias"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Class:"):
|
||||
info["class"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Icon:"):
|
||||
info["icon"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Paired:"):
|
||||
info["paired"] = "yes" in line.lower()
|
||||
elif line.startswith("Trusted:"):
|
||||
info["trusted"] = "yes" in line.lower()
|
||||
elif line.startswith("Connected:"):
|
||||
info["connected"] = "yes" in line.lower()
|
||||
elif line.startswith("UUID:"):
|
||||
# Extract UUID name
|
||||
match = re.match(r'UUID:\s*(.+?)\s*\(', line)
|
||||
if match:
|
||||
info["services"].append(match.group(1))
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def query_sdp_services(address: str) -> list[str]:
|
||||
"""Query SDP services for classic Bluetooth device"""
|
||||
services = []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "sdptool", "browse", address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
current_service = None
|
||||
for line in result.stdout.split('\n'):
|
||||
if "Service Name:" in line:
|
||||
name = line.split(":", 1)[1].strip()
|
||||
if name and name not in services:
|
||||
services.append(name)
|
||||
elif "Service Class ID List:" in line:
|
||||
# Next lines contain service UUIDs
|
||||
pass
|
||||
elif '"' in line and "0x" in line.lower():
|
||||
# Try to extract service UUID
|
||||
match = re.search(r'"([^"]+)"', line)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if name and name not in services:
|
||||
services.append(name)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def infer_device_type_from_manufacturer(manufacturer: str) -> str | None:
|
||||
"""Infer likely device type from manufacturer name"""
|
||||
if not manufacturer or manufacturer in ('Unknown', ''):
|
||||
return None
|
||||
|
||||
mfr_lower = manufacturer.lower()
|
||||
|
||||
for mfr_pattern, device_type in MANUFACTURER_DEVICE_HINTS.items():
|
||||
if mfr_pattern in mfr_lower:
|
||||
return device_type
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_device_type_from_name(name: str) -> str | None:
|
||||
"""Infer device type from device name using pattern matching"""
|
||||
if not name or name in ('<unknown>', '(unknown)', 'Unknown'):
|
||||
return None
|
||||
|
||||
name_lower = name.lower()
|
||||
|
||||
for pattern, device_type in NAME_PATTERNS:
|
||||
if re.search(pattern, name_lower):
|
||||
return device_type
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_device_type(
|
||||
services: list[str],
|
||||
icon: str,
|
||||
device_class: str,
|
||||
name: str = "",
|
||||
manufacturer: str = ""
|
||||
) -> str:
|
||||
"""Infer device type from available information"""
|
||||
|
||||
# Check icon first (most reliable from bluetoothctl)
|
||||
if icon and icon in ICON_TO_TYPE:
|
||||
return ICON_TO_TYPE[icon]
|
||||
|
||||
# Check services
|
||||
services_lower = [s.lower() for s in services]
|
||||
|
||||
if any("a2dp" in s or "audio sink" in s or "audio source" in s for s in services_lower):
|
||||
if any("headset" in s or "handsfree" in s for s in services_lower):
|
||||
return "Headset/Headphones"
|
||||
return "Audio Device"
|
||||
|
||||
if any("headset" in s for s in services_lower):
|
||||
return "Headset"
|
||||
|
||||
if any("handsfree" in s for s in services_lower):
|
||||
return "Hands-free Device"
|
||||
|
||||
if any("human interface" in s or "hid" in s for s in services_lower):
|
||||
return "Input Device (HID)"
|
||||
|
||||
if any("phone" in s or "pbap" in s or "map" in s for s in services_lower):
|
||||
return "Phone"
|
||||
|
||||
if any("heart rate" in s for s in services_lower):
|
||||
return "Heart Rate Monitor"
|
||||
|
||||
if any("fitness" in s or "running" in s or "cycling" in s for s in services_lower):
|
||||
return "Fitness Device"
|
||||
|
||||
if any("battery" in s for s in services_lower):
|
||||
return "Battery-powered Device"
|
||||
|
||||
if any("printer" in s for s in services_lower):
|
||||
return "Printer"
|
||||
|
||||
if any("file transfer" in s or "obex" in s for s in services_lower):
|
||||
return "File Transfer Device"
|
||||
|
||||
if any("serial" in s for s in services_lower):
|
||||
return "Serial Device"
|
||||
|
||||
if any("network" in s or "pan" in s or "nap" in s for s in services_lower):
|
||||
return "Network Device"
|
||||
|
||||
# Fall back to name-based inference
|
||||
name_type = infer_device_type_from_name(name)
|
||||
if name_type:
|
||||
return name_type
|
||||
|
||||
# Fall back to manufacturer-based inference
|
||||
mfr_type = infer_device_type_from_manufacturer(manufacturer)
|
||||
if mfr_type:
|
||||
return mfr_type
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def identify_device(address: str, is_ble: bool = False) -> BluetoothDeviceInfo:
|
||||
"""
|
||||
Perform comprehensive device identification.
|
||||
|
||||
Args:
|
||||
address: Bluetooth MAC address
|
||||
is_ble: Whether this is a BLE-only device
|
||||
|
||||
Returns:
|
||||
BluetoothDeviceInfo with all discovered information
|
||||
"""
|
||||
# Get basic info from bluetoothctl
|
||||
bt_info = get_device_info_bluetoothctl(address)
|
||||
|
||||
services = bt_info.get("services", [])
|
||||
|
||||
# For classic Bluetooth, also try SDP
|
||||
if not is_ble:
|
||||
sdp_services = query_sdp_services(address)
|
||||
services.extend([s for s in sdp_services if s not in services])
|
||||
|
||||
# Infer device type (using name from bluetoothctl or provided name)
|
||||
device_name = bt_info.get("name", "") or bt_info.get("alias", "")
|
||||
device_type = infer_device_type(
|
||||
services,
|
||||
bt_info.get("icon", ""),
|
||||
bt_info.get("class", ""),
|
||||
device_name
|
||||
)
|
||||
|
||||
return BluetoothDeviceInfo(
|
||||
address=address,
|
||||
name=bt_info.get("name", ""),
|
||||
alias=bt_info.get("alias", ""),
|
||||
device_class=bt_info.get("class", ""),
|
||||
device_type=device_type,
|
||||
services=services,
|
||||
manufacturer="", # Would need OUI lookup
|
||||
model="",
|
||||
is_ble=is_ble,
|
||||
paired=bt_info.get("paired", False),
|
||||
connected=bt_info.get("connected", False),
|
||||
trusted=bt_info.get("trusted", False),
|
||||
icon=bt_info.get("icon", "")
|
||||
)
|
||||
|
||||
|
||||
def scan_and_identify(timeout: int = 10) -> list[BluetoothDeviceInfo]:
|
||||
"""
|
||||
Scan for Bluetooth devices and identify them.
|
||||
|
||||
Args:
|
||||
timeout: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of identified devices
|
||||
"""
|
||||
devices = []
|
||||
seen_addresses = set()
|
||||
|
||||
# Start scan using bluetoothctl
|
||||
try:
|
||||
# Enable scanning
|
||||
subprocess.run(
|
||||
["sudo", "bluetoothctl", "scan", "on"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
# Wait for scan
|
||||
import time
|
||||
time.sleep(timeout)
|
||||
|
||||
# Stop scanning
|
||||
subprocess.run(
|
||||
["sudo", "bluetoothctl", "scan", "off"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
# Get discovered devices
|
||||
result = subprocess.run(
|
||||
["bluetoothctl", "devices"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(r'Device\s+([0-9A-Fa-f:]+)\s+(.*)', line)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
name = match.group(2).strip()
|
||||
|
||||
if addr not in seen_addresses:
|
||||
seen_addresses.add(addr)
|
||||
|
||||
# Identify the device
|
||||
info = identify_device(addr)
|
||||
if not info.name:
|
||||
info.name = name
|
||||
|
||||
devices.append(info)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Scan error: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def identify_single_device(address: str) -> dict:
|
||||
"""
|
||||
Identify a single device by address.
|
||||
Returns a dictionary suitable for JSON serialization.
|
||||
"""
|
||||
info = identify_device(address)
|
||||
|
||||
return {
|
||||
"address": info.address,
|
||||
"name": info.name or info.alias or "<unknown>",
|
||||
"alias": info.alias,
|
||||
"device_class": info.device_class,
|
||||
"device_type": info.device_type,
|
||||
"services": info.services,
|
||||
"icon": info.icon,
|
||||
"paired": info.paired,
|
||||
"connected": info.connected,
|
||||
"trusted": info.trusted,
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Configuration management for RF Mapper"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSConfig:
|
||||
latitude: float = 50.8585853
|
||||
longitude: float = 4.3978724
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebConfig:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 5000
|
||||
debug: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScannerConfig:
|
||||
wifi_interface: str = "wlan0"
|
||||
bt_scan_timeout: int = 10
|
||||
path_loss_exponent: float = 2.5
|
||||
wifi_tx_power: float = -59 # Calibrated TX power at 1m for WiFi (dBm)
|
||||
bt_tx_power: float = -72 # Calibrated TX power at 1m for Bluetooth (dBm)
|
||||
auto_identify_bluetooth: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataConfig:
|
||||
directory: str = "data"
|
||||
max_scans: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
enabled: bool = True
|
||||
filename: str = "devices.db"
|
||||
retention_days: int = 30 # Auto-cleanup data older than this
|
||||
auto_cleanup: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomeAssistantConfig:
|
||||
enabled: bool = False
|
||||
url: str = "http://192.168.129.10:8123"
|
||||
token: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfilingConfig:
|
||||
enabled: bool = False
|
||||
cpu: bool = True
|
||||
memory: bool = False
|
||||
output_dir: str = "data/profiles"
|
||||
sort_by: str = "cumtime" # cumtime, tottime, calls
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoScanConfig:
|
||||
enabled: bool = False
|
||||
interval_minutes: int = 5
|
||||
location_label: str = "auto_scan"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildingConfig:
|
||||
enabled: bool = False
|
||||
name: str = ""
|
||||
floors: int = 1
|
||||
floor_height_m: float = 3.0
|
||||
ground_floor_number: int = 0
|
||||
current_floor: int = 0 # Scanner's current floor
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
gps: GPSConfig = field(default_factory=GPSConfig)
|
||||
web: WebConfig = field(default_factory=WebConfig)
|
||||
scanner: ScannerConfig = field(default_factory=ScannerConfig)
|
||||
data: DataConfig = field(default_factory=DataConfig)
|
||||
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||
home_assistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
|
||||
profiling: ProfilingConfig = field(default_factory=ProfilingConfig)
|
||||
auto_scan: AutoScanConfig = field(default_factory=AutoScanConfig)
|
||||
building: BuildingConfig = field(default_factory=BuildingConfig)
|
||||
|
||||
_config_path: Path | None = field(default=None, repr=False)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: Path | str | None = None) -> "Config":
|
||||
"""
|
||||
Load configuration from YAML file.
|
||||
|
||||
Search order:
|
||||
1. Explicit path if provided
|
||||
2. ./config.yaml
|
||||
3. ~/.config/rf-mapper/config.yaml
|
||||
4. /etc/rf-mapper/config.yaml
|
||||
"""
|
||||
search_paths = []
|
||||
|
||||
if config_path:
|
||||
search_paths.append(Path(config_path))
|
||||
else:
|
||||
# Project directory
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
search_paths.append(project_root / "config.yaml")
|
||||
|
||||
# User config
|
||||
search_paths.append(Path.home() / ".config" / "rf-mapper" / "config.yaml")
|
||||
|
||||
# System config
|
||||
search_paths.append(Path("/etc/rf-mapper/config.yaml"))
|
||||
|
||||
config = cls()
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
config = cls._load_from_file(path)
|
||||
config._config_path = path
|
||||
break
|
||||
|
||||
# Override with environment variables
|
||||
config._apply_env_overrides()
|
||||
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def _load_from_file(cls, path: Path) -> "Config":
|
||||
"""Load config from a specific file"""
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
config = cls()
|
||||
|
||||
# GPS
|
||||
if "gps" in data:
|
||||
config.gps = GPSConfig(
|
||||
latitude=data["gps"].get("latitude", config.gps.latitude),
|
||||
longitude=data["gps"].get("longitude", config.gps.longitude)
|
||||
)
|
||||
|
||||
# Web
|
||||
if "web" in data:
|
||||
config.web = WebConfig(
|
||||
host=data["web"].get("host", config.web.host),
|
||||
port=data["web"].get("port", config.web.port),
|
||||
debug=data["web"].get("debug", config.web.debug)
|
||||
)
|
||||
|
||||
# Scanner
|
||||
if "scanner" in data:
|
||||
config.scanner = ScannerConfig(
|
||||
wifi_interface=data["scanner"].get("wifi_interface", config.scanner.wifi_interface),
|
||||
bt_scan_timeout=data["scanner"].get("bt_scan_timeout", config.scanner.bt_scan_timeout),
|
||||
path_loss_exponent=data["scanner"].get("path_loss_exponent", config.scanner.path_loss_exponent),
|
||||
wifi_tx_power=data["scanner"].get("wifi_tx_power", config.scanner.wifi_tx_power),
|
||||
bt_tx_power=data["scanner"].get("bt_tx_power", config.scanner.bt_tx_power),
|
||||
auto_identify_bluetooth=data["scanner"].get("auto_identify_bluetooth", config.scanner.auto_identify_bluetooth)
|
||||
)
|
||||
|
||||
# Data
|
||||
if "data" in data:
|
||||
config.data = DataConfig(
|
||||
directory=data["data"].get("directory", config.data.directory),
|
||||
max_scans=data["data"].get("max_scans", config.data.max_scans)
|
||||
)
|
||||
|
||||
# Database
|
||||
if "database" in data:
|
||||
config.database = DatabaseConfig(
|
||||
enabled=data["database"].get("enabled", config.database.enabled),
|
||||
filename=data["database"].get("filename", config.database.filename),
|
||||
retention_days=data["database"].get("retention_days", config.database.retention_days),
|
||||
auto_cleanup=data["database"].get("auto_cleanup", config.database.auto_cleanup)
|
||||
)
|
||||
|
||||
# Home Assistant
|
||||
if "home_assistant" in data:
|
||||
config.home_assistant = HomeAssistantConfig(
|
||||
enabled=data["home_assistant"].get("enabled", config.home_assistant.enabled),
|
||||
url=data["home_assistant"].get("url", config.home_assistant.url),
|
||||
token=data["home_assistant"].get("token", config.home_assistant.token)
|
||||
)
|
||||
|
||||
# Profiling
|
||||
if "profiling" in data:
|
||||
config.profiling = ProfilingConfig(
|
||||
enabled=data["profiling"].get("enabled", config.profiling.enabled),
|
||||
cpu=data["profiling"].get("cpu", config.profiling.cpu),
|
||||
memory=data["profiling"].get("memory", config.profiling.memory),
|
||||
output_dir=data["profiling"].get("output_dir", config.profiling.output_dir),
|
||||
sort_by=data["profiling"].get("sort_by", config.profiling.sort_by)
|
||||
)
|
||||
|
||||
# Auto-scan
|
||||
if "auto_scan" in data:
|
||||
config.auto_scan = AutoScanConfig(
|
||||
enabled=data["auto_scan"].get("enabled", config.auto_scan.enabled),
|
||||
interval_minutes=data["auto_scan"].get("interval_minutes", config.auto_scan.interval_minutes),
|
||||
location_label=data["auto_scan"].get("location_label", config.auto_scan.location_label)
|
||||
)
|
||||
|
||||
# Building
|
||||
if "building" in data:
|
||||
config.building = BuildingConfig(
|
||||
enabled=data["building"].get("enabled", config.building.enabled),
|
||||
name=data["building"].get("name", config.building.name),
|
||||
floors=data["building"].get("floors", config.building.floors),
|
||||
floor_height_m=data["building"].get("floor_height_m", config.building.floor_height_m),
|
||||
ground_floor_number=data["building"].get("ground_floor_number", config.building.ground_floor_number),
|
||||
current_floor=data["building"].get("current_floor", config.building.current_floor)
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
def _apply_env_overrides(self):
|
||||
"""Override config values with environment variables"""
|
||||
# GPS
|
||||
if os.getenv("RF_MAPPER_LAT"):
|
||||
self.gps.latitude = float(os.environ["RF_MAPPER_LAT"])
|
||||
if os.getenv("RF_MAPPER_LON"):
|
||||
self.gps.longitude = float(os.environ["RF_MAPPER_LON"])
|
||||
|
||||
# Web
|
||||
if os.getenv("RF_MAPPER_HOST"):
|
||||
self.web.host = os.environ["RF_MAPPER_HOST"]
|
||||
if os.getenv("RF_MAPPER_PORT"):
|
||||
self.web.port = int(os.environ["RF_MAPPER_PORT"])
|
||||
|
||||
# Home Assistant
|
||||
if os.getenv("HA_TOKEN"):
|
||||
self.home_assistant.token = os.environ["HA_TOKEN"]
|
||||
if os.getenv("HA_URL"):
|
||||
self.home_assistant.url = os.environ["HA_URL"]
|
||||
|
||||
def get_data_dir(self) -> Path:
|
||||
"""Get the data directory as an absolute Path"""
|
||||
data_path = Path(self.data.directory)
|
||||
|
||||
if data_path.is_absolute():
|
||||
return data_path
|
||||
|
||||
# Relative to project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / data_path
|
||||
|
||||
def get_database_path(self) -> Path:
|
||||
"""Get the database file path"""
|
||||
return self.get_data_dir() / self.database.filename
|
||||
|
||||
def save(self, path: Path | None = None):
|
||||
"""Save current configuration to file"""
|
||||
save_path = path or self._config_path
|
||||
if not save_path:
|
||||
save_path = Path(__file__).parent.parent.parent / "config.yaml"
|
||||
|
||||
data = {
|
||||
"gps": {
|
||||
"latitude": self.gps.latitude,
|
||||
"longitude": self.gps.longitude
|
||||
},
|
||||
"web": {
|
||||
"host": self.web.host,
|
||||
"port": self.web.port,
|
||||
"debug": self.web.debug
|
||||
},
|
||||
"scanner": {
|
||||
"wifi_interface": self.scanner.wifi_interface,
|
||||
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
||||
"path_loss_exponent": self.scanner.path_loss_exponent,
|
||||
"wifi_tx_power": self.scanner.wifi_tx_power,
|
||||
"bt_tx_power": self.scanner.bt_tx_power
|
||||
},
|
||||
"data": {
|
||||
"directory": self.data.directory,
|
||||
"max_scans": self.data.max_scans
|
||||
},
|
||||
"database": {
|
||||
"enabled": self.database.enabled,
|
||||
"filename": self.database.filename,
|
||||
"retention_days": self.database.retention_days,
|
||||
"auto_cleanup": self.database.auto_cleanup
|
||||
},
|
||||
"home_assistant": {
|
||||
"enabled": self.home_assistant.enabled,
|
||||
"url": self.home_assistant.url,
|
||||
"token": self.home_assistant.token
|
||||
},
|
||||
"profiling": {
|
||||
"enabled": self.profiling.enabled,
|
||||
"cpu": self.profiling.cpu,
|
||||
"memory": self.profiling.memory,
|
||||
"output_dir": self.profiling.output_dir,
|
||||
"sort_by": self.profiling.sort_by
|
||||
},
|
||||
"auto_scan": {
|
||||
"enabled": self.auto_scan.enabled,
|
||||
"interval_minutes": self.auto_scan.interval_minutes,
|
||||
"location_label": self.auto_scan.location_label
|
||||
},
|
||||
"building": {
|
||||
"enabled": self.building.enabled,
|
||||
"name": self.building.name,
|
||||
"floors": self.building.floors,
|
||||
"floor_height_m": self.building.floor_height_m,
|
||||
"ground_floor_number": self.building.ground_floor_number,
|
||||
"current_floor": self.building.current_floor
|
||||
}
|
||||
}
|
||||
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(save_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
# Global config instance
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the global config instance"""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.load()
|
||||
return _config
|
||||
|
||||
|
||||
def reload_config(path: Path | str | None = None) -> Config:
|
||||
"""Reload configuration from file"""
|
||||
global _config
|
||||
_config = Config.load(path)
|
||||
return _config
|
||||
@@ -0,0 +1,643 @@
|
||||
"""SQLite database for RF Mapper historical data and device tracking"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import threading
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceStats:
|
||||
"""Statistics for a device"""
|
||||
device_id: str
|
||||
device_type: str # 'wifi' or 'bluetooth'
|
||||
name: str
|
||||
manufacturer: str
|
||||
first_seen: str
|
||||
last_seen: str
|
||||
total_observations: int
|
||||
avg_rssi: float
|
||||
min_rssi: int
|
||||
max_rssi: int
|
||||
avg_distance_m: float
|
||||
min_distance_m: float
|
||||
max_distance_m: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class RSSIObservation:
|
||||
"""Single RSSI observation"""
|
||||
timestamp: str
|
||||
rssi: int
|
||||
distance_m: float
|
||||
floor: Optional[int] = None
|
||||
|
||||
|
||||
class DeviceDatabase:
|
||||
"""SQLite database for device history and statistics"""
|
||||
|
||||
def __init__(self, db_path: Path | str):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._local = threading.local()
|
||||
self._init_schema()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get thread-local database connection"""
|
||||
if not hasattr(self._local, 'conn') or self._local.conn is None:
|
||||
self._local.conn = sqlite3.connect(str(self.db_path))
|
||||
self._local.conn.row_factory = sqlite3.Row
|
||||
return self._local.conn
|
||||
|
||||
def _init_schema(self):
|
||||
"""Initialize database schema"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Devices table - master record for each unique device
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_type TEXT NOT NULL, -- 'wifi' or 'bluetooth'
|
||||
name TEXT,
|
||||
ssid TEXT, -- For WiFi only
|
||||
manufacturer TEXT,
|
||||
device_class TEXT, -- For Bluetooth
|
||||
bt_device_type TEXT, -- For Bluetooth
|
||||
encryption TEXT, -- For WiFi
|
||||
channel INTEGER, -- For WiFi
|
||||
frequency INTEGER, -- For WiFi
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
total_observations INTEGER DEFAULT 0,
|
||||
custom_label TEXT, -- User-assigned name
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# RSSI observations - time series data
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rssi_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
rssi INTEGER NOT NULL,
|
||||
distance_m REAL,
|
||||
floor INTEGER,
|
||||
scan_id TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Scans table - record of each scan
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
scan_id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_label TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
wifi_count INTEGER DEFAULT 0,
|
||||
bt_count INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Device statistics - pre-computed for performance
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS device_stats (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
avg_rssi REAL,
|
||||
min_rssi INTEGER,
|
||||
max_rssi INTEGER,
|
||||
avg_distance_m REAL,
|
||||
min_distance_m REAL,
|
||||
max_distance_m REAL,
|
||||
appearance_count INTEGER DEFAULT 0,
|
||||
last_computed TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Movement events - detected motion
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movement_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
rssi_delta INTEGER,
|
||||
distance_delta_m REAL,
|
||||
direction TEXT, -- 'approaching', 'receding', 'stationary'
|
||||
velocity_m_s REAL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Alerts table - for new device detection, absence alerts
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alert_type TEXT NOT NULL, -- 'new_device', 'device_absent', 'rssi_threshold'
|
||||
device_id TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
message TEXT,
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for performance
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rssi_device_time ON rssi_history(device_id, timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rssi_timestamp ON rssi_history(timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_devices_type ON devices(device_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movement_device ON movement_events(device_id, timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(alert_type, acknowledged)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_scan(self, scan_id: str, timestamp: str, location_label: str,
|
||||
lat: float, lon: float, wifi_count: int, bt_count: int):
|
||||
"""Record a scan event"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO scans (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_wifi_observation(self, bssid: str, ssid: str, rssi: int, distance_m: float,
|
||||
channel: int, frequency: int, encryption: str,
|
||||
manufacturer: str, floor: Optional[int] = None,
|
||||
scan_id: Optional[str] = None):
|
||||
"""Record a WiFi network observation"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Insert or update device
|
||||
cursor.execute("""
|
||||
INSERT INTO devices (device_id, device_type, name, ssid, manufacturer, encryption, channel, frequency, first_seen, last_seen, total_observations)
|
||||
VALUES (?, 'wifi', ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, devices.name),
|
||||
ssid = COALESCE(excluded.ssid, devices.ssid),
|
||||
manufacturer = COALESCE(excluded.manufacturer, devices.manufacturer),
|
||||
encryption = COALESCE(excluded.encryption, devices.encryption),
|
||||
channel = COALESCE(excluded.channel, devices.channel),
|
||||
frequency = COALESCE(excluded.frequency, devices.frequency),
|
||||
last_seen = excluded.last_seen,
|
||||
total_observations = devices.total_observations + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (bssid, ssid, ssid, manufacturer, encryption, channel, frequency, timestamp, timestamp))
|
||||
|
||||
# Insert RSSI observation
|
||||
cursor.execute("""
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (bssid, timestamp, rssi, distance_m, floor, scan_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Check if this is a new device
|
||||
cursor.execute("SELECT total_observations FROM devices WHERE device_id = ?", (bssid,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['total_observations'] == 1:
|
||||
self._create_alert('new_device', bssid, f"New WiFi network detected: {ssid} ({manufacturer})")
|
||||
|
||||
def record_bluetooth_observation(self, address: str, name: str, rssi: int, distance_m: float,
|
||||
device_class: str, device_type: str, manufacturer: str,
|
||||
floor: Optional[int] = None, scan_id: Optional[str] = None):
|
||||
"""Record a Bluetooth device observation"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Get previous observation for movement detection
|
||||
cursor.execute("""
|
||||
SELECT rssi, distance_m, timestamp FROM rssi_history
|
||||
WHERE device_id = ? ORDER BY timestamp DESC LIMIT 1
|
||||
""", (address,))
|
||||
prev = cursor.fetchone()
|
||||
|
||||
# Insert or update device
|
||||
cursor.execute("""
|
||||
INSERT INTO devices (device_id, device_type, name, manufacturer, device_class, bt_device_type, first_seen, last_seen, total_observations)
|
||||
VALUES (?, 'bluetooth', ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
name = CASE WHEN excluded.name != '<unknown>' AND excluded.name != '' THEN excluded.name ELSE devices.name END,
|
||||
manufacturer = COALESCE(NULLIF(excluded.manufacturer, ''), devices.manufacturer),
|
||||
device_class = COALESCE(excluded.device_class, devices.device_class),
|
||||
bt_device_type = COALESCE(excluded.bt_device_type, devices.bt_device_type),
|
||||
last_seen = excluded.last_seen,
|
||||
total_observations = devices.total_observations + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (address, name, manufacturer, device_class, device_type, timestamp, timestamp))
|
||||
|
||||
# Insert RSSI observation
|
||||
cursor.execute("""
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (address, timestamp, rssi, distance_m, floor, scan_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Movement detection
|
||||
if prev:
|
||||
rssi_delta = rssi - prev['rssi']
|
||||
distance_delta = distance_m - prev['distance_m']
|
||||
prev_time = datetime.fromisoformat(prev['timestamp'])
|
||||
time_delta = (datetime.now() - prev_time).total_seconds()
|
||||
|
||||
if abs(distance_delta) > 0.5 and time_delta > 0: # More than 0.5m movement
|
||||
velocity = distance_delta / time_delta if time_delta > 0 else 0
|
||||
direction = 'approaching' if distance_delta < 0 else 'receding'
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO movement_events (device_id, timestamp, rssi_delta, distance_delta_m, direction, velocity_m_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (address, timestamp, rssi_delta, distance_delta, direction, velocity))
|
||||
conn.commit()
|
||||
|
||||
# Check if this is a new device
|
||||
cursor.execute("SELECT total_observations FROM devices WHERE device_id = ?", (address,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['total_observations'] == 1:
|
||||
self._create_alert('new_device', address, f"New Bluetooth device detected: {name} ({manufacturer})")
|
||||
|
||||
def _create_alert(self, alert_type: str, device_id: str, message: str):
|
||||
"""Create an alert"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO alerts (alert_type, device_id, timestamp, message)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (alert_type, device_id, timestamp, message))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[dict]:
|
||||
"""Get device details"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM devices WHERE device_id = ?", (device_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_all_devices(self, device_type: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 100) -> list[dict]:
|
||||
"""Get all devices with optional filtering"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM devices WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if device_type:
|
||||
query += " AND device_type = ?"
|
||||
params.append(device_type)
|
||||
|
||||
if since:
|
||||
query += " AND last_seen >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY last_seen DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_device_rssi_history(self, device_id: str,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 1000) -> list[RSSIObservation]:
|
||||
"""Get RSSI history for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT timestamp, rssi, distance_m, floor FROM rssi_history WHERE device_id = ?"
|
||||
params = [device_id]
|
||||
|
||||
if since:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [RSSIObservation(
|
||||
timestamp=row['timestamp'],
|
||||
rssi=row['rssi'],
|
||||
distance_m=row['distance_m'],
|
||||
floor=row['floor']
|
||||
) for row in cursor.fetchall()]
|
||||
|
||||
def get_device_stats(self, device_id: str) -> Optional[DeviceStats]:
|
||||
"""Get computed statistics for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get device info
|
||||
cursor.execute("SELECT * FROM devices WHERE device_id = ?", (device_id,))
|
||||
device = cursor.fetchone()
|
||||
if not device:
|
||||
return None
|
||||
|
||||
# Compute stats from RSSI history
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
AVG(rssi) as avg_rssi,
|
||||
MIN(rssi) as min_rssi,
|
||||
MAX(rssi) as max_rssi,
|
||||
AVG(distance_m) as avg_distance_m,
|
||||
MIN(distance_m) as min_distance_m,
|
||||
MAX(distance_m) as max_distance_m
|
||||
FROM rssi_history WHERE device_id = ?
|
||||
""", (device_id,))
|
||||
stats = cursor.fetchone()
|
||||
|
||||
return DeviceStats(
|
||||
device_id=device_id,
|
||||
device_type=device['device_type'],
|
||||
name=device['custom_label'] or device['name'] or device['ssid'] or device_id,
|
||||
manufacturer=device['manufacturer'] or '',
|
||||
first_seen=device['first_seen'],
|
||||
last_seen=device['last_seen'],
|
||||
total_observations=device['total_observations'],
|
||||
avg_rssi=round(stats['avg_rssi'], 1) if stats['avg_rssi'] else 0,
|
||||
min_rssi=stats['min_rssi'] or 0,
|
||||
max_rssi=stats['max_rssi'] or 0,
|
||||
avg_distance_m=round(stats['avg_distance_m'], 2) if stats['avg_distance_m'] else 0,
|
||||
min_distance_m=round(stats['min_distance_m'], 2) if stats['min_distance_m'] else 0,
|
||||
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
|
||||
)
|
||||
|
||||
def get_movement_events(self, device_id: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 100) -> list[dict]:
|
||||
"""Get movement events"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM movement_events WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if device_id:
|
||||
query += " AND device_id = ?"
|
||||
params.append(device_id)
|
||||
|
||||
if since:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_alerts(self, acknowledged: Optional[bool] = None,
|
||||
alert_type: Optional[str] = None,
|
||||
limit: int = 50) -> list[dict]:
|
||||
"""Get alerts"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM alerts WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if acknowledged is not None:
|
||||
query += " AND acknowledged = ?"
|
||||
params.append(1 if acknowledged else 0)
|
||||
|
||||
if alert_type:
|
||||
query += " AND alert_type = ?"
|
||||
params.append(alert_type)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def acknowledge_alert(self, alert_id: int):
|
||||
"""Mark an alert as acknowledged"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("UPDATE alerts SET acknowledged = 1 WHERE id = ?", (alert_id,))
|
||||
conn.commit()
|
||||
|
||||
def set_device_label(self, device_id: str, label: str):
|
||||
"""Set a custom label for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET custom_label = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (label, device_id))
|
||||
conn.commit()
|
||||
|
||||
def set_device_favorite(self, device_id: str, is_favorite: bool):
|
||||
"""Mark a device as favorite"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (1 if is_favorite else 0, device_id))
|
||||
conn.commit()
|
||||
|
||||
def get_recent_activity(self, hours: int = 24) -> dict:
|
||||
"""Get activity summary for the last N hours"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = (datetime.now() - timedelta(hours=hours)).isoformat()
|
||||
|
||||
# Count active devices
|
||||
cursor.execute("""
|
||||
SELECT device_type, COUNT(*) as count
|
||||
FROM devices WHERE last_seen >= ?
|
||||
GROUP BY device_type
|
||||
""", (since,))
|
||||
active_counts = {row['device_type']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
# Count observations
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM rssi_history WHERE timestamp >= ?
|
||||
""", (since,))
|
||||
observation_count = cursor.fetchone()['count']
|
||||
|
||||
# Count movement events
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM movement_events WHERE timestamp >= ?
|
||||
""", (since,))
|
||||
movement_count = cursor.fetchone()['count']
|
||||
|
||||
# Count new devices
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM devices WHERE first_seen >= ?
|
||||
""", (since,))
|
||||
new_device_count = cursor.fetchone()['count']
|
||||
|
||||
# Count scans
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM scans WHERE timestamp >= ?
|
||||
""", (since,))
|
||||
scan_count = cursor.fetchone()['count']
|
||||
|
||||
return {
|
||||
"period_hours": hours,
|
||||
"since": since,
|
||||
"active_wifi_devices": active_counts.get('wifi', 0),
|
||||
"active_bt_devices": active_counts.get('bluetooth', 0),
|
||||
"total_observations": observation_count,
|
||||
"movement_events": movement_count,
|
||||
"new_devices": new_device_count,
|
||||
"scan_count": scan_count
|
||||
}
|
||||
|
||||
def get_device_activity_pattern(self, device_id: str, days: int = 7) -> dict:
|
||||
"""Get hourly activity pattern for a device over the last N days"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
# Count observations per hour of day
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
CAST(strftime('%H', timestamp) AS INTEGER) as hour,
|
||||
COUNT(*) as count,
|
||||
AVG(rssi) as avg_rssi
|
||||
FROM rssi_history
|
||||
WHERE device_id = ? AND timestamp >= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
""", (device_id, since))
|
||||
|
||||
hourly = {row['hour']: {'count': row['count'], 'avg_rssi': round(row['avg_rssi'], 1)}
|
||||
for row in cursor.fetchall()}
|
||||
|
||||
# Count observations per day of week (0=Monday, 6=Sunday)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
CAST(strftime('%w', timestamp) AS INTEGER) as dow,
|
||||
COUNT(*) as count
|
||||
FROM rssi_history
|
||||
WHERE device_id = ? AND timestamp >= ?
|
||||
GROUP BY dow
|
||||
ORDER BY dow
|
||||
""", (device_id, since))
|
||||
|
||||
daily = {row['dow']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"period_days": days,
|
||||
"hourly_pattern": hourly,
|
||||
"daily_pattern": daily
|
||||
}
|
||||
|
||||
def cleanup_old_data(self, retention_days: int = 30):
|
||||
"""Remove data older than retention period"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=retention_days)).isoformat()
|
||||
|
||||
# Delete old RSSI history (keep summary in devices table)
|
||||
cursor.execute("DELETE FROM rssi_history WHERE timestamp < ?", (cutoff,))
|
||||
|
||||
# Delete old movement events
|
||||
cursor.execute("DELETE FROM movement_events WHERE timestamp < ?", (cutoff,))
|
||||
|
||||
# Delete old acknowledged alerts
|
||||
cursor.execute("DELETE FROM alerts WHERE timestamp < ? AND acknowledged = 1", (cutoff,))
|
||||
|
||||
# Delete old scans
|
||||
cursor.execute("DELETE FROM scans WHERE timestamp < ?", (cutoff,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"retention_days": retention_days,
|
||||
"cutoff": cutoff,
|
||||
"cleaned_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def get_database_stats(self) -> dict:
|
||||
"""Get database statistics"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM devices")
|
||||
device_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM rssi_history")
|
||||
observation_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM scans")
|
||||
scan_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM movement_events")
|
||||
movement_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM alerts WHERE acknowledged = 0")
|
||||
unread_alerts = cursor.fetchone()['count']
|
||||
|
||||
# Get database file size
|
||||
db_size = self.db_path.stat().st_size if self.db_path.exists() else 0
|
||||
|
||||
return {
|
||||
"total_devices": device_count,
|
||||
"total_observations": observation_count,
|
||||
"total_scans": scan_count,
|
||||
"total_movement_events": movement_count,
|
||||
"unread_alerts": unread_alerts,
|
||||
"database_size_bytes": db_size,
|
||||
"database_size_mb": round(db_size / 1024 / 1024, 2)
|
||||
}
|
||||
|
||||
def close(self):
|
||||
"""Close database connection"""
|
||||
if hasattr(self._local, 'conn') and self._local.conn:
|
||||
self._local.conn.close()
|
||||
self._local.conn = None
|
||||
|
||||
|
||||
# Global database instance
|
||||
_db: DeviceDatabase | None = None
|
||||
|
||||
|
||||
def get_database(db_path: Path | str | None = None) -> DeviceDatabase:
|
||||
"""Get the global database instance"""
|
||||
global _db
|
||||
if _db is None:
|
||||
if db_path is None:
|
||||
db_path = Path.home() / "git" / "rf-mapper" / "data" / "devices.db"
|
||||
_db = DeviceDatabase(db_path)
|
||||
return _db
|
||||
|
||||
|
||||
def init_database(db_path: Path | str) -> DeviceDatabase:
|
||||
"""Initialize the global database instance"""
|
||||
global _db
|
||||
_db = DeviceDatabase(db_path)
|
||||
return _db
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Distance estimation from RSSI values"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def estimate_distance(
|
||||
rssi: int,
|
||||
tx_power: int = -59,
|
||||
n: float = 2.5
|
||||
) -> float:
|
||||
"""
|
||||
Estimate distance from RSSI using log-distance path loss model.
|
||||
|
||||
The formula is: distance = 10 ^ ((tx_power - rssi) / (10 * n))
|
||||
|
||||
Args:
|
||||
rssi: Received signal strength indicator in dBm
|
||||
tx_power: Calibrated TX power at 1 meter (default -59 dBm for typical WiFi)
|
||||
n: Path loss exponent:
|
||||
- 2.0 = free space
|
||||
- 2.5 = typical indoor, some obstacles
|
||||
- 3.0-4.0 = indoor with walls
|
||||
- 4.0-6.0 = dense urban/building penetration
|
||||
|
||||
Returns:
|
||||
Estimated distance in meters
|
||||
"""
|
||||
if rssi >= tx_power:
|
||||
return 0.1 # Very close, less than 1m
|
||||
|
||||
return 10 ** ((tx_power - rssi) / (10 * n))
|
||||
|
||||
|
||||
def rssi_to_quality(rssi: int) -> str:
|
||||
"""Convert RSSI to human-readable quality description"""
|
||||
if rssi >= -50:
|
||||
return "Excellent"
|
||||
elif rssi >= -60:
|
||||
return "Good"
|
||||
elif rssi >= -70:
|
||||
return "Fair"
|
||||
elif rssi >= -80:
|
||||
return "Weak"
|
||||
else:
|
||||
return "Very Weak"
|
||||
|
||||
|
||||
def rssi_bar(rssi: int, width: int = 20) -> str:
|
||||
"""Generate a visual RSSI bar"""
|
||||
# RSSI typically ranges from -30 (excellent) to -90 (poor)
|
||||
normalized = max(0, min(width, (rssi + 90) * width // 60))
|
||||
return '█' * normalized + '░' * (width - normalized)
|
||||
|
||||
|
||||
def estimate_wall_count(rssi: int, expected_rssi: int = -50, db_per_wall: float = 6.0) -> int:
|
||||
"""
|
||||
Estimate number of walls between transmitter and receiver.
|
||||
|
||||
Typical wall attenuation:
|
||||
- Drywall: 3-5 dB
|
||||
- Concrete/brick: 6-10 dB
|
||||
- Metal/reinforced: 10-15 dB
|
||||
|
||||
Args:
|
||||
rssi: Measured RSSI
|
||||
expected_rssi: Expected RSSI without walls (at estimated distance)
|
||||
db_per_wall: Attenuation per wall (default 6 dB for typical interior wall)
|
||||
|
||||
Returns:
|
||||
Estimated number of walls
|
||||
"""
|
||||
loss = expected_rssi - rssi
|
||||
if loss <= 0:
|
||||
return 0
|
||||
return max(0, int(loss / db_per_wall))
|
||||
|
||||
|
||||
def trilaterate_2d(
|
||||
positions: list[tuple[float, float]],
|
||||
distances: list[float]
|
||||
) -> tuple[float, float] | None:
|
||||
"""
|
||||
Estimate position from multiple distance measurements using trilateration.
|
||||
|
||||
This is a simplified least-squares approach for 2D positioning.
|
||||
|
||||
Args:
|
||||
positions: List of (x, y) coordinates of known reference points
|
||||
distances: List of distances to each reference point
|
||||
|
||||
Returns:
|
||||
Estimated (x, y) position or None if insufficient data
|
||||
"""
|
||||
if len(positions) < 3 or len(distances) < 3:
|
||||
return None
|
||||
|
||||
# Use first three points for basic trilateration
|
||||
x1, y1 = positions[0]
|
||||
x2, y2 = positions[1]
|
||||
x3, y3 = positions[2]
|
||||
r1, r2, r3 = distances[0], distances[1], distances[2]
|
||||
|
||||
# Solve system of equations
|
||||
A = 2 * (x2 - x1)
|
||||
B = 2 * (y2 - y1)
|
||||
C = r1**2 - r2**2 - x1**2 + x2**2 - y1**2 + y2**2
|
||||
|
||||
D = 2 * (x3 - x2)
|
||||
E = 2 * (y3 - y2)
|
||||
F = r2**2 - r3**2 - x2**2 + x3**2 - y2**2 + y3**2
|
||||
|
||||
# Check for degenerate case
|
||||
denom = A * E - B * D
|
||||
if abs(denom) < 1e-10:
|
||||
return None
|
||||
|
||||
x = (C * E - F * B) / denom
|
||||
y = (A * F - D * C) / denom
|
||||
|
||||
return (x, y)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""OUI (Organizationally Unique Identifier) lookup for MAC addresses"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Default OUI database path
|
||||
DEFAULT_OUI_DB_PATH = Path(__file__).parent.parent.parent.parent / "data" / "oui.json"
|
||||
OUI_DB_URL = "https://maclookup.app/downloads/json-database/get-db"
|
||||
|
||||
|
||||
class OUILookup:
|
||||
"""MAC address manufacturer lookup using OUI database"""
|
||||
|
||||
def __init__(self, db_path: Path | None = None):
|
||||
self.db_path = db_path or DEFAULT_OUI_DB_PATH
|
||||
self.oui_db: dict[str, str] = {}
|
||||
self._load_or_download_db()
|
||||
|
||||
def _load_or_download_db(self):
|
||||
"""Load OUI database from file or download if not present"""
|
||||
if self.db_path.exists():
|
||||
try:
|
||||
with open(self.db_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
for entry in data:
|
||||
prefix = entry.get('macPrefix', '').upper().replace(':', '')
|
||||
if prefix:
|
||||
self.oui_db[prefix] = entry.get('vendorName', 'Unknown')
|
||||
print(f"Loaded {len(self.oui_db)} OUI entries from cache")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error loading OUI cache: {e}")
|
||||
|
||||
print("Downloading OUI database (this may take a moment)...")
|
||||
try:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
req = urllib.request.Request(OUI_DB_URL, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
with open(self.db_path, 'w') as f:
|
||||
json.dump(data, f)
|
||||
for entry in data:
|
||||
prefix = entry.get('macPrefix', '').upper().replace(':', '')
|
||||
if prefix:
|
||||
self.oui_db[prefix] = entry.get('vendorName', 'Unknown')
|
||||
print(f"Downloaded {len(self.oui_db)} OUI entries")
|
||||
except Exception as e:
|
||||
print(f"Could not download OUI database: {e}")
|
||||
print("Using built-in common manufacturers")
|
||||
self._use_builtin_oui()
|
||||
|
||||
def _use_builtin_oui(self):
|
||||
"""Fallback to common OUI prefixes"""
|
||||
self.oui_db = {
|
||||
# Proximus/Belgacom
|
||||
'00173F': 'Proximus (Belgacom)',
|
||||
'0019CB': 'Proximus (Belgacom)',
|
||||
'001E58': 'Proximus (Belgacom)',
|
||||
'002275': 'Belgacom',
|
||||
'00248C': 'Proximus',
|
||||
'78D294': 'Proximus',
|
||||
'3C7D0A': 'Proximus',
|
||||
'E0B9E5': 'Proximus',
|
||||
# Sagem/Sagemcom
|
||||
'DCCF96': 'Sagem/Sagemcom',
|
||||
'002569': 'Sagem',
|
||||
'001430': 'Sagem',
|
||||
'8C9A8F': 'Sagemcom Broadband SAS',
|
||||
'102BAA': 'Sagemcom Broadband SAS',
|
||||
'7C2664': 'Sagemcom Broadband SAS',
|
||||
'B88C2B': 'Sagemcom Broadband SAS',
|
||||
# Apple
|
||||
'2C3361': 'Apple', '38C986': 'Apple', '8C8590': 'Apple',
|
||||
'F0D1A9': 'Apple', '14109F': 'Apple', '00A040': 'Apple',
|
||||
'64A2F9': 'Apple', 'AC87A3': 'Apple', '28F076': 'Apple',
|
||||
'9C8BA0': 'Apple', '3C2EF9': 'Apple', '78CA39': 'Apple',
|
||||
# Samsung
|
||||
'30CBF8': 'Samsung', '8CB64F': 'Samsung', '4C3C16': 'Samsung',
|
||||
'AC5A14': 'Samsung', '9463D1': 'Samsung', 'E4E0C5': 'Samsung',
|
||||
'F8042E': 'Samsung', 'E0036B': 'Samsung', 'B0E45C': 'Samsung',
|
||||
# Other common
|
||||
'F80F84': 'Google', '54609A': 'Google',
|
||||
'28254B': 'Amazon', '74C246': 'Amazon',
|
||||
'E89120': 'TP-Link', '5C899A': 'TP-Link',
|
||||
'B827EB': 'Raspberry Pi', '2CCF67': 'Raspberry Pi',
|
||||
'DC44C3': 'Intel', '001517': 'Intel',
|
||||
'3C37C6': 'Espressif (ESP32/ESP8266)',
|
||||
'34CC': 'Compal Broadband Network',
|
||||
'C4EA1D': 'Vantiva Technologies Belgium',
|
||||
'08B055': 'ASKEY COMPUTER CORP',
|
||||
'38E7C0': 'Hui Zhou Gaoshengda Technology',
|
||||
'409CA7': 'CHINA DRAGON TECHNOLOGY',
|
||||
}
|
||||
|
||||
def lookup(self, mac_address: str) -> str:
|
||||
"""Look up manufacturer from MAC address"""
|
||||
mac = mac_address.upper().replace(':', '').replace('-', '').replace('.', '')
|
||||
|
||||
for prefix_len in [9, 7, 6]:
|
||||
prefix = mac[:prefix_len]
|
||||
if prefix in self.oui_db:
|
||||
return self.oui_db[prefix]
|
||||
|
||||
return "Unknown"
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Profiling utilities for RF Mapper"""
|
||||
|
||||
import cProfile
|
||||
import pstats
|
||||
import tracemalloc
|
||||
import time
|
||||
import logging
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cpu_profiler(output_path: Path | None = None, sort_by: str = "cumtime"):
|
||||
"""Context manager for CPU profiling using cProfile.
|
||||
|
||||
Args:
|
||||
output_path: Optional path to save .prof file for later analysis
|
||||
sort_by: Sort key for stats (cumtime, tottime, calls)
|
||||
|
||||
Yields:
|
||||
cProfile.Profile instance
|
||||
"""
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
try:
|
||||
yield profiler
|
||||
finally:
|
||||
profiler.disable()
|
||||
# Output stats to stdout
|
||||
stream = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=stream)
|
||||
stats.sort_stats(sort_by)
|
||||
stats.print_stats(30) # Top 30 functions
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("CPU PROFILE RESULTS")
|
||||
print('='*60)
|
||||
print(stream.getvalue())
|
||||
|
||||
if output_path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
profiler.dump_stats(str(output_path))
|
||||
print(f"Profile saved to: {output_path}")
|
||||
print("Analyze with: python -m pstats {output_path}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def memory_profiler(top_n: int = 10):
|
||||
"""Context manager for memory profiling using tracemalloc.
|
||||
|
||||
Args:
|
||||
top_n: Number of top memory allocations to display
|
||||
|
||||
Yields:
|
||||
None
|
||||
"""
|
||||
tracemalloc.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
snapshot = tracemalloc.take_snapshot()
|
||||
top_stats = snapshot.statistics("lineno")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"TOP {top_n} MEMORY ALLOCATIONS")
|
||||
print('='*60)
|
||||
for stat in top_stats[:top_n]:
|
||||
print(stat)
|
||||
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
print(f"\nCurrent memory: {current / 1024 / 1024:.2f} MB")
|
||||
print(f"Peak memory: {peak / 1024 / 1024:.2f} MB")
|
||||
tracemalloc.stop()
|
||||
|
||||
|
||||
def add_profiler_middleware(app, profile_dir: Path):
|
||||
"""Wrap Flask app with Werkzeug ProfilerMiddleware.
|
||||
|
||||
Each HTTP request will generate a .prof file in profile_dir.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
profile_dir: Directory to save per-request profile files
|
||||
|
||||
Returns:
|
||||
The app with profiler middleware applied
|
||||
"""
|
||||
from werkzeug.middleware.profiler import ProfilerMiddleware
|
||||
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.wsgi_app = ProfilerMiddleware(
|
||||
app.wsgi_app,
|
||||
profile_dir=str(profile_dir),
|
||||
restrictions=[30] # Top 30 functions per request
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def setup_request_logging(app, log_file: Path):
|
||||
"""Add request logging middleware to Flask app.
|
||||
|
||||
Logs each request with timestamp, method, path, status, and duration.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
log_file: Path to the log file
|
||||
|
||||
Returns:
|
||||
The app with logging configured
|
||||
"""
|
||||
from flask import request, g
|
||||
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Configure file handler
|
||||
file_handler = logging.FileHandler(str(log_file))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
))
|
||||
|
||||
# Create a dedicated logger for requests
|
||||
request_logger = logging.getLogger('rf_mapper.requests')
|
||||
request_logger.setLevel(logging.INFO)
|
||||
request_logger.addHandler(file_handler)
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
g.start_time = time.time()
|
||||
|
||||
@app.after_request
|
||||
def log_request(response):
|
||||
duration_ms = (time.time() - g.start_time) * 1000
|
||||
request_logger.info(
|
||||
f"{request.method} {request.path} | "
|
||||
f"{response.status_code} | "
|
||||
f"{duration_ms:.1f}ms | "
|
||||
f"{request.remote_addr}"
|
||||
)
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def add_request_logging_middleware(app, log_dir: Path):
|
||||
"""Add request logging with daily rotation.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
log_dir: Directory to save log files
|
||||
|
||||
Returns:
|
||||
The app with logging configured
|
||||
"""
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / f"requests_{datetime.now().strftime('%Y%m%d')}.log"
|
||||
return setup_request_logging(app, log_file)
|
||||
@@ -0,0 +1,428 @@
|
||||
"""RF Environment Scanner - WiFi and Bluetooth device discovery"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from .oui import OUILookup
|
||||
from .bluetooth_class import BluetoothClassDecoder
|
||||
from .distance import estimate_distance, rssi_to_quality, rssi_bar
|
||||
from .bluetooth_identify import (
|
||||
identify_device,
|
||||
infer_device_type_from_name,
|
||||
infer_device_type_from_manufacturer,
|
||||
is_random_mac,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WifiNetwork:
|
||||
"""Represents a discovered WiFi network"""
|
||||
ssid: str
|
||||
bssid: str
|
||||
rssi: int # dBm
|
||||
channel: int
|
||||
frequency: int # MHz
|
||||
encryption: str
|
||||
manufacturer: str = ""
|
||||
floor: int | None = None # Floor number (0=ground)
|
||||
height_m: float | None = None # Height in meters
|
||||
|
||||
@property
|
||||
def estimated_distance(self) -> float:
|
||||
"""Estimate distance in meters"""
|
||||
return estimate_distance(self.rssi)
|
||||
|
||||
@property
|
||||
def signal_quality(self) -> str:
|
||||
"""Human-readable signal quality"""
|
||||
return rssi_to_quality(self.rssi)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothDevice:
|
||||
"""Represents a discovered Bluetooth device"""
|
||||
address: str
|
||||
name: str
|
||||
rssi: int # dBm
|
||||
device_class: str
|
||||
device_type: str
|
||||
manufacturer: str = ""
|
||||
floor: int | None = None # Floor number (0=ground)
|
||||
height_m: float | None = None # Height in meters
|
||||
|
||||
@property
|
||||
def estimated_distance(self) -> float:
|
||||
"""Estimate distance in meters (BT has lower TX power)"""
|
||||
return estimate_distance(self.rssi, tx_power=-65)
|
||||
|
||||
@property
|
||||
def signal_quality(self) -> str:
|
||||
"""Human-readable signal quality"""
|
||||
return rssi_to_quality(self.rssi)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Container for scan results"""
|
||||
timestamp: str
|
||||
location_label: str
|
||||
wifi_networks: list
|
||||
bluetooth_devices: list
|
||||
|
||||
|
||||
class RFScanner:
|
||||
"""Main RF scanning class for WiFi and Bluetooth"""
|
||||
|
||||
def __init__(self, data_dir: Path | None = None):
|
||||
self.oui_lookup = OUILookup()
|
||||
self.bt_decoder = BluetoothClassDecoder()
|
||||
self.scan_history: list[ScanResult] = []
|
||||
self.data_dir = data_dir or Path.home() / "git" / "rf-mapper" / "data"
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def scan_wifi(self, interface: str = "wlan0") -> list[WifiNetwork]:
|
||||
"""
|
||||
Scan for WiFi networks using iw.
|
||||
|
||||
Args:
|
||||
interface: WiFi interface name (default: wlan0)
|
||||
|
||||
Returns:
|
||||
List of discovered WiFi networks
|
||||
"""
|
||||
networks = []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', 'iw', 'dev', interface, 'scan'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"WiFi scan error: {result.stderr}")
|
||||
return networks
|
||||
|
||||
current_network: dict = {}
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('BSS '):
|
||||
if current_network and 'bssid' in current_network:
|
||||
networks.append(self._create_wifi_network(current_network))
|
||||
match = re.match(r'BSS ([0-9a-fA-F:]+)', line)
|
||||
if match:
|
||||
current_network = {'bssid': match.group(1)}
|
||||
|
||||
elif line.startswith('signal:'):
|
||||
match = re.search(r'(-?\d+\.?\d*)\s*dBm', line)
|
||||
if match:
|
||||
current_network['rssi'] = int(float(match.group(1)))
|
||||
|
||||
elif line.startswith('freq:'):
|
||||
match = re.search(r'(\d+)', line)
|
||||
if match:
|
||||
current_network['frequency'] = int(match.group(1))
|
||||
|
||||
elif line.startswith('SSID:'):
|
||||
ssid = line.replace('SSID:', '').strip()
|
||||
current_network['ssid'] = ssid if ssid else '<hidden>'
|
||||
|
||||
elif line.startswith('DS Parameter set: channel'):
|
||||
match = re.search(r'channel\s+(\d+)', line)
|
||||
if match:
|
||||
current_network['channel'] = int(match.group(1))
|
||||
|
||||
elif 'RSN:' in line or 'WPA:' in line:
|
||||
current_network['encryption'] = 'WPA/WPA2'
|
||||
elif 'WEP' in line:
|
||||
current_network['encryption'] = 'WEP'
|
||||
elif 'Privacy' in line and 'encryption' not in current_network:
|
||||
current_network['encryption'] = 'Encrypted'
|
||||
|
||||
if current_network and 'bssid' in current_network:
|
||||
networks.append(self._create_wifi_network(current_network))
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("WiFi scan timed out")
|
||||
except Exception as e:
|
||||
print(f"WiFi scan error: {e}")
|
||||
|
||||
return networks
|
||||
|
||||
def _create_wifi_network(self, data: dict) -> WifiNetwork:
|
||||
"""Create WifiNetwork from parsed data"""
|
||||
bssid = data.get('bssid', '')
|
||||
return WifiNetwork(
|
||||
ssid=data.get('ssid', '<unknown>'),
|
||||
bssid=bssid,
|
||||
rssi=data.get('rssi', -100),
|
||||
channel=data.get('channel', 0),
|
||||
frequency=data.get('frequency', 0),
|
||||
encryption=data.get('encryption', 'Open'),
|
||||
manufacturer=self.oui_lookup.lookup(bssid)
|
||||
)
|
||||
|
||||
def scan_bluetooth(self, timeout: int = 10, auto_identify: bool = True) -> list[BluetoothDevice]:
|
||||
"""
|
||||
Scan for Bluetooth devices (Classic and BLE).
|
||||
|
||||
Args:
|
||||
timeout: Scan duration in seconds
|
||||
auto_identify: Automatically identify unknown devices
|
||||
|
||||
Returns:
|
||||
List of discovered Bluetooth devices
|
||||
"""
|
||||
devices = []
|
||||
|
||||
# Classic Bluetooth scan
|
||||
try:
|
||||
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'inq', '--flush'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 10
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(
|
||||
r'\s*([0-9A-Fa-f:]+)\s+clock offset:\s*\S+\s+class:\s*(\S+)',
|
||||
line
|
||||
)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
device_class = match.group(2)
|
||||
name = self._get_bt_name(addr)
|
||||
rssi = self._get_bt_rssi(addr)
|
||||
dev_type, dev_subtype = self.bt_decoder.decode(device_class)
|
||||
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=rssi,
|
||||
device_class=device_class,
|
||||
device_type=f"{dev_type}" + (f" ({dev_subtype})" if dev_subtype else ""),
|
||||
manufacturer=self.oui_lookup.lookup(addr)
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Classic BT scan error: {e}")
|
||||
|
||||
# BLE scan
|
||||
try:
|
||||
print(f"Scanning BLE devices ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'timeout', str(timeout), 'hcitool', 'lescan', '--duplicates'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5
|
||||
)
|
||||
|
||||
seen_addrs = {d.address for d in devices}
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(r'([0-9A-Fa-f:]+)\s*(.*)', line)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
name = match.group(2).strip() or '<unknown>'
|
||||
|
||||
if addr not in seen_addrs and addr != 'LE':
|
||||
seen_addrs.add(addr)
|
||||
manufacturer = self.oui_lookup.lookup(addr)
|
||||
|
||||
# Try to infer device type from name first, then manufacturer
|
||||
inferred_type = infer_device_type_from_name(name)
|
||||
if not inferred_type:
|
||||
inferred_type = infer_device_type_from_manufacturer(manufacturer)
|
||||
|
||||
# Mark randomized MAC devices if still unknown
|
||||
if not inferred_type:
|
||||
if is_random_mac(addr):
|
||||
device_type = "BLE Device (Random MAC)"
|
||||
else:
|
||||
device_type = "Low Energy Device"
|
||||
else:
|
||||
device_type = inferred_type
|
||||
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=-70, # Default estimate for BLE
|
||||
device_class="BLE",
|
||||
device_type=device_type,
|
||||
manufacturer=manufacturer
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"BLE scan error: {e}")
|
||||
|
||||
# Auto-identify unknown devices
|
||||
if auto_identify and devices:
|
||||
devices = self._auto_identify_devices(devices)
|
||||
|
||||
return devices
|
||||
|
||||
def _auto_identify_devices(self, devices: list[BluetoothDevice]) -> list[BluetoothDevice]:
|
||||
"""Automatically identify devices with unknown names or types"""
|
||||
identified = []
|
||||
|
||||
# Types that are considered "unidentified" and need deeper lookup
|
||||
generic_types = {'Low Energy Device', 'Unknown', '', 'BLE Device (Random MAC)'}
|
||||
|
||||
for dev in devices:
|
||||
# Skip if already well-identified (has good name and specific type)
|
||||
is_name_known = dev.name and dev.name not in ('<unknown>', '(unknown)', 'Unknown', '')
|
||||
is_type_known = dev.device_type and dev.device_type not in generic_types
|
||||
|
||||
if is_name_known and is_type_known:
|
||||
identified.append(dev)
|
||||
continue
|
||||
|
||||
# Try to identify via bluetoothctl
|
||||
try:
|
||||
print(f" Identifying {dev.address}...")
|
||||
info = identify_device(dev.address, is_ble=(dev.device_class == "BLE"))
|
||||
|
||||
# Update device with identified info
|
||||
new_name = info.name or info.alias or dev.name
|
||||
new_type = dev.device_type # Keep existing type as fallback
|
||||
|
||||
# Use bluetoothctl-discovered type if better than what we have
|
||||
if info.device_type and info.device_type != "Unknown":
|
||||
new_type = info.device_type
|
||||
elif dev.device_type in generic_types:
|
||||
# Try name-based inference with potentially better name
|
||||
inferred = infer_device_type_from_name(new_name)
|
||||
if not inferred:
|
||||
# Try manufacturer-based inference
|
||||
inferred = infer_device_type_from_manufacturer(dev.manufacturer)
|
||||
if inferred:
|
||||
new_type = inferred
|
||||
elif is_random_mac(dev.address):
|
||||
new_type = "BLE Device (Random MAC)"
|
||||
|
||||
# Build services string if available
|
||||
services_str = ""
|
||||
if info.services:
|
||||
services_str = f" [{', '.join(info.services[:3])}]"
|
||||
|
||||
identified.append(BluetoothDevice(
|
||||
address=dev.address,
|
||||
name=new_name,
|
||||
rssi=dev.rssi,
|
||||
device_class=dev.device_class,
|
||||
device_type=new_type + services_str if services_str else new_type,
|
||||
manufacturer=dev.manufacturer
|
||||
))
|
||||
except Exception as e:
|
||||
# Keep original device if identification fails
|
||||
identified.append(dev)
|
||||
|
||||
return identified
|
||||
|
||||
def _get_bt_name(self, address: str) -> str:
|
||||
"""Get Bluetooth device name"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'name', address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
name = result.stdout.strip()
|
||||
return name if name else '<unknown>'
|
||||
except:
|
||||
return '<unknown>'
|
||||
|
||||
def _get_bt_rssi(self, address: str) -> int:
|
||||
"""Get Bluetooth RSSI for a device"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'rssi', address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
match = re.search(r'RSSI return value:\s*(-?\d+)', result.stdout)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
except:
|
||||
pass
|
||||
return -80
|
||||
|
||||
def full_scan(
|
||||
self,
|
||||
location_label: str = "default",
|
||||
auto_identify_bt: bool = True
|
||||
) -> tuple[ScanResult, list[WifiNetwork], list[BluetoothDevice]]:
|
||||
"""
|
||||
Perform a full WiFi and Bluetooth scan.
|
||||
|
||||
Args:
|
||||
location_label: Label for this scan location
|
||||
auto_identify_bt: Automatically identify Bluetooth devices
|
||||
|
||||
Returns:
|
||||
Tuple of (ScanResult, wifi_networks, bluetooth_devices)
|
||||
"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Starting RF Environment Scan at {datetime.now().isoformat()}")
|
||||
print(f"Location: {location_label}")
|
||||
print('='*60)
|
||||
|
||||
print("\n[1/2] Scanning WiFi networks...")
|
||||
wifi_networks = self.scan_wifi()
|
||||
print(f" Found {len(wifi_networks)} WiFi networks")
|
||||
|
||||
print("\n[2/2] Scanning Bluetooth devices...")
|
||||
bt_devices = self.scan_bluetooth(auto_identify=auto_identify_bt)
|
||||
print(f" Found {len(bt_devices)} Bluetooth devices")
|
||||
|
||||
result = ScanResult(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
location_label=location_label,
|
||||
wifi_networks=[asdict(n) for n in wifi_networks],
|
||||
bluetooth_devices=[asdict(d) for d in bt_devices]
|
||||
)
|
||||
|
||||
self.scan_history.append(result)
|
||||
|
||||
# Save to file
|
||||
filename = self.data_dir / f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{location_label}.json"
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(asdict(result), f, indent=2)
|
||||
print(f"\nScan saved to: {filename}")
|
||||
|
||||
return result, wifi_networks, bt_devices
|
||||
|
||||
def print_results(self, wifi_networks: list[WifiNetwork], bt_devices: list[BluetoothDevice]):
|
||||
"""Pretty print scan results"""
|
||||
print(f"\n{'='*80}")
|
||||
print("WiFi NETWORKS")
|
||||
print('='*80)
|
||||
|
||||
wifi_sorted = sorted(wifi_networks, key=lambda x: x.rssi, reverse=True)
|
||||
|
||||
print(f"{'SSID':<25} {'BSSID':<18} {'RSSI':>6} {'Ch':>4} {'Manufacturer':<25}")
|
||||
print('-'*80)
|
||||
|
||||
for net in wifi_sorted:
|
||||
bar = rssi_bar(net.rssi)
|
||||
print(f"{net.ssid[:24]:<25} {net.bssid:<18} {net.rssi:>4}dB {net.channel:>4} {net.manufacturer[:24]:<25}")
|
||||
print(f" Signal: {bar} ({net.signal_quality})")
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print("BLUETOOTH DEVICES")
|
||||
print('='*80)
|
||||
|
||||
bt_sorted = sorted(bt_devices, key=lambda x: x.rssi, reverse=True)
|
||||
|
||||
print(f"{'Name':<20} {'Address':<18} {'RSSI':>6} {'Type':<20} {'Manufacturer':<20}")
|
||||
print('-'*80)
|
||||
|
||||
for dev in bt_sorted:
|
||||
print(f"{dev.name[:19]:<20} {dev.address:<18} {dev.rssi:>4}dB {dev.device_type[:19]:<20} {dev.manufacturer[:19]:<20}")
|
||||
@@ -0,0 +1,217 @@
|
||||
"""ASCII-based visualization for RF scan data"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from .distance import estimate_distance
|
||||
|
||||
|
||||
def create_ascii_radar(devices: list[dict], title: str = "RF Environment") -> str:
|
||||
"""
|
||||
Create an ASCII radar-style visualization.
|
||||
|
||||
Args:
|
||||
devices: List of device dicts with 'rssi' and 'ssid'/'name' keys
|
||||
title: Title for the visualization
|
||||
|
||||
Returns:
|
||||
ASCII art string
|
||||
"""
|
||||
radius = 15
|
||||
center = radius
|
||||
|
||||
# Create empty grid
|
||||
grid = [[' ' for _ in range(center * 2 + 1)] for _ in range(center * 2 + 1)]
|
||||
|
||||
# Draw radar circles (distance rings)
|
||||
for r in [5, 10, 15]:
|
||||
for angle in range(0, 360, 10):
|
||||
x = int(center + r * math.cos(math.radians(angle)))
|
||||
y = int(center + r * math.sin(math.radians(angle)))
|
||||
if 0 <= x < len(grid[0]) and 0 <= y < len(grid):
|
||||
if grid[y][x] == ' ':
|
||||
grid[y][x] = '·'
|
||||
|
||||
# Draw axes
|
||||
for i in range(len(grid)):
|
||||
if grid[center][i] == ' ':
|
||||
grid[center][i] = '─'
|
||||
if grid[i][center] == ' ':
|
||||
grid[i][center] = '│'
|
||||
grid[center][center] = '┼'
|
||||
|
||||
# Place devices
|
||||
device_legend = []
|
||||
markers = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789'
|
||||
|
||||
for i, dev in enumerate(devices[:len(markers)]):
|
||||
rssi = dev.get('rssi', -80)
|
||||
name = dev.get('ssid', dev.get('name', 'Unknown'))[:15]
|
||||
manufacturer = dev.get('manufacturer', 'Unknown')
|
||||
|
||||
distance = estimate_distance(rssi)
|
||||
norm_dist = min(radius - 1, int(distance / 2))
|
||||
|
||||
# Use hash of name for consistent angle
|
||||
angle = hash(name) % 360
|
||||
|
||||
x = int(center + norm_dist * math.cos(math.radians(angle)))
|
||||
y = int(center + norm_dist * math.sin(math.radians(angle)))
|
||||
|
||||
if 0 <= x < len(grid[0]) and 0 <= y < len(grid):
|
||||
marker = markers[i]
|
||||
grid[y][x] = marker
|
||||
device_legend.append(
|
||||
f" [{marker}] {name:<15} {rssi:>4}dBm ~{distance:.1f}m ({manufacturer})"
|
||||
)
|
||||
|
||||
output = []
|
||||
output.append(f"\n{'='*50}")
|
||||
output.append(f" {title}")
|
||||
output.append(f" Distance rings: 5m · 10m · 15m")
|
||||
output.append(f" [YOU] = Center")
|
||||
output.append('='*50)
|
||||
output.append('')
|
||||
|
||||
for row in grid:
|
||||
output.append(' ' + ''.join(row))
|
||||
|
||||
output.append('')
|
||||
output.append(' Legend:')
|
||||
output.extend(device_legend)
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
def create_signal_strength_chart(devices: list[dict], title: str = "Signal Strength") -> str:
|
||||
"""
|
||||
Create ASCII bar chart of signal strengths.
|
||||
|
||||
Args:
|
||||
devices: List of device dicts
|
||||
title: Chart title
|
||||
|
||||
Returns:
|
||||
ASCII chart string
|
||||
"""
|
||||
output = []
|
||||
output.append(f"\n{'='*70}")
|
||||
output.append(f" {title}")
|
||||
output.append('='*70)
|
||||
|
||||
sorted_devs = sorted(devices, key=lambda x: x.get('rssi', -100), reverse=True)
|
||||
|
||||
for dev in sorted_devs[:20]:
|
||||
rssi = dev.get('rssi', -100)
|
||||
name = dev.get('ssid', dev.get('name', 'Unknown'))[:18]
|
||||
manufacturer = dev.get('manufacturer', '')[:15]
|
||||
|
||||
bar_len = max(0, (rssi + 100) // 2)
|
||||
|
||||
if rssi >= -50:
|
||||
bar_char = '█'
|
||||
quality = 'STRONG'
|
||||
elif rssi >= -65:
|
||||
bar_char = '▓'
|
||||
quality = 'GOOD '
|
||||
elif rssi >= -75:
|
||||
bar_char = '▒'
|
||||
quality = 'FAIR '
|
||||
else:
|
||||
bar_char = '░'
|
||||
quality = 'WEAK '
|
||||
|
||||
bar = bar_char * bar_len + ' ' * (35 - bar_len)
|
||||
output.append(f" {name:<18} │{bar}│ {rssi:>4}dBm {quality} {manufacturer}")
|
||||
|
||||
output.append('')
|
||||
output.append(' Signal: █ Strong (>-50) ▓ Good (-50 to -65) ▒ Fair (-65 to -75) ░ Weak (<-75)')
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
def create_environment_analysis(wifi: list[dict], bt: list[dict]) -> str:
|
||||
"""
|
||||
Analyze RF environment and generate insights.
|
||||
|
||||
Args:
|
||||
wifi: List of WiFi network dicts
|
||||
bt: List of Bluetooth device dicts
|
||||
|
||||
Returns:
|
||||
Analysis text
|
||||
"""
|
||||
output = []
|
||||
output.append(f"\n{'='*70}")
|
||||
output.append(" ENVIRONMENTAL ANALYSIS")
|
||||
output.append('='*70)
|
||||
|
||||
if len(wifi) >= 2:
|
||||
rssi_values = [n['rssi'] for n in wifi]
|
||||
avg_rssi = sum(rssi_values) / len(rssi_values)
|
||||
rssi_spread = max(rssi_values) - min(rssi_values)
|
||||
|
||||
output.append(f"\n WiFi Environment:")
|
||||
output.append(f" Networks detected: {len(wifi)}")
|
||||
output.append(f" Average signal: {avg_rssi:.1f} dBm")
|
||||
output.append(f" Signal spread: {rssi_spread} dB")
|
||||
|
||||
if rssi_spread > 30:
|
||||
output.append(" → High variation suggests walls/obstacles between APs")
|
||||
elif rssi_spread > 15:
|
||||
output.append(" → Moderate variation, some obstacles present")
|
||||
else:
|
||||
output.append(" → Low variation, relatively open environment")
|
||||
|
||||
strong = len([r for r in rssi_values if r > -60])
|
||||
weak = len([r for r in rssi_values if r < -75])
|
||||
|
||||
output.append(f"\n Strong signals (>-60dBm): {strong}")
|
||||
output.append(f" Weak signals (<-75dBm): {weak}")
|
||||
|
||||
if weak > strong * 2:
|
||||
output.append(" → Many distant APs: dense urban/apartment environment")
|
||||
elif strong > weak:
|
||||
output.append(" → Mostly nearby APs: residential/small office")
|
||||
|
||||
# Analyze frequencies
|
||||
freq_2g = len([n for n in wifi if n.get('frequency', 0) < 3000])
|
||||
freq_5g = len([n for n in wifi if n.get('frequency', 0) >= 5000])
|
||||
output.append(f"\n 2.4 GHz networks: {freq_2g}")
|
||||
output.append(f" 5 GHz networks: {freq_5g}")
|
||||
|
||||
if bt:
|
||||
output.append(f"\n Bluetooth Environment:")
|
||||
output.append(f" Devices detected: {len(bt)}")
|
||||
|
||||
device_types: dict[str, int] = {}
|
||||
manufacturers: dict[str, int] = {}
|
||||
|
||||
for d in bt:
|
||||
dt = d.get('device_type', 'Unknown')
|
||||
mfr = d.get('manufacturer', 'Unknown')
|
||||
device_types[dt] = device_types.get(dt, 0) + 1
|
||||
if mfr != 'Unknown':
|
||||
manufacturers[mfr] = manufacturers.get(mfr, 0) + 1
|
||||
|
||||
output.append("\n Device types:")
|
||||
for dt, count in sorted(device_types.items(), key=lambda x: -x[1]):
|
||||
output.append(f" {dt}: {count}")
|
||||
|
||||
if manufacturers:
|
||||
output.append("\n Manufacturers:")
|
||||
for mfr, count in sorted(manufacturers.items(), key=lambda x: -x[1])[:5]:
|
||||
output.append(f" {mfr}: {count}")
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
def load_latest_scan(data_dir: Path) -> dict | None:
|
||||
"""Load the most recent scan file"""
|
||||
scan_files = sorted(data_dir.glob('scan_*.json'), reverse=True)
|
||||
if not scan_files:
|
||||
return None
|
||||
|
||||
with open(scan_files[0]) as f:
|
||||
return json.load(f)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Flask web application for RF Mapper"""
|
||||
|
||||
from .app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,948 @@
|
||||
/* RF Mapper - Main Stylesheet */
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-tertiary: #0f3460;
|
||||
--bg-dark: #0a0a1a;
|
||||
|
||||
--color-primary: #00ff88;
|
||||
--color-secondary: #4dabf7;
|
||||
--color-warning: #ffd93d;
|
||||
--color-danger: #ff6b6b;
|
||||
--color-text: #eee;
|
||||
--color-text-muted: #888;
|
||||
--color-text-dim: #666;
|
||||
|
||||
--border-color: #0f3460;
|
||||
--border-radius: 4px;
|
||||
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background: var(--bg-primary);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--color-danger);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.main-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.view-toggle {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-toggle button:first-child {
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.view-toggle button:last-child {
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.view-toggle button:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Filter Controls */
|
||||
.filter-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn.wifi {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-btn.wifi.inactive {
|
||||
color: #555;
|
||||
border-color: #333;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.filter-btn.bluetooth {
|
||||
color: var(--color-secondary);
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.filter-btn.bluetooth.inactive {
|
||||
color: #555;
|
||||
border-color: #333;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.filter-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .filter-indicator {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 2px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.device-count {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Device Cards */
|
||||
.device-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.device-card.wifi {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.device-card.bluetooth {
|
||||
border-left: 3px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Signal Bars */
|
||||
.signal-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.signal-bar span {
|
||||
width: 4px;
|
||||
background: #333;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.signal-bar span.active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.signal-bar.weak span.active {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.signal-bar.fair span.active {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Radar Canvas */
|
||||
.radar-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.radar-canvas.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#leaflet-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#leaflet-map.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Misc */
|
||||
.scan-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.manufacturer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.position-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.position-input input {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Bluetooth Identify */
|
||||
.identify-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-secondary);
|
||||
color: var(--color-secondary);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.identify-btn:hover {
|
||||
background: var(--color-secondary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.identify-btn.loading {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.device-services {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-secondary);
|
||||
margin-top: 0.3rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.device-services.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.service-tag {
|
||||
display: inline-block;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-secondary);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 2px;
|
||||
margin: 0.1rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.device-type-badge {
|
||||
background: #1a3a5c;
|
||||
color: var(--color-secondary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.3rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Auto-Scan Controls */
|
||||
.autoscan-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.autoscan-status.running {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.autoscan-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.autoscan-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.autoscan-row label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.autoscan-row input {
|
||||
width: 80px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.autoscan-row input[type="text"] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
#autoscan-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-dim);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.autoscan-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #1a5a8a;
|
||||
}
|
||||
|
||||
#autoscan-btn.active {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Device Detail Panel */
|
||||
.device-detail-panel {
|
||||
position: absolute;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.device-detail-panel.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.device-detail-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.detail-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.device-detail-panel.wifi {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.device-detail-panel.bluetooth {
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 4px 20px rgba(77, 171, 247, 0.2);
|
||||
}
|
||||
|
||||
/* Radar hover tooltip */
|
||||
.radar-tooltip {
|
||||
position: absolute;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.radar-tooltip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 3D Map View */
|
||||
.map-3d {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
.map-3d.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-3d.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* MapLibre popup overrides to match theme */
|
||||
.maplibregl-popup-content {
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--color-text) !important;
|
||||
border: 1px solid var(--color-primary) !important;
|
||||
border-radius: var(--border-radius) !important;
|
||||
padding: 0.75rem !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
||||
border-top-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||
border-bottom-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-left .maplibregl-popup-tip {
|
||||
border-right-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-right .maplibregl-popup-tip {
|
||||
border-left-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: var(--color-text-muted) !important;
|
||||
font-size: 1.2rem !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button:hover {
|
||||
color: var(--color-text) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Popup floor control */
|
||||
.popup-floor-control {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-floor-control:first-of-type {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.popup-floor-control label {
|
||||
font-weight: bold;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.popup-floor-control select {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup-floor-control select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"] {
|
||||
flex: 1;
|
||||
max-width: 80px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"]:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"]::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Live Track Button */
|
||||
#live-track-btn.active {
|
||||
background: var(--color-accent);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Moving device markers - purple */
|
||||
.marker-3d.moving .marker-icon {
|
||||
background: #9b59b6 !important;
|
||||
box-shadow: 0 0 15px rgba(155, 89, 182, 0.8), 0 2px 8px rgba(0, 0, 0, 0.5) !important;
|
||||
animation: moving-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.marker-3d.moving .marker-floor {
|
||||
background: #9b59b6 !important;
|
||||
}
|
||||
|
||||
@keyframes moving-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* 3D Markers */
|
||||
.marker-3d {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.marker-3d .marker-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.marker-3d:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.marker-3d .marker-floor {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker-3d.wifi .marker-icon {
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 15px var(--color-primary), 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.marker-3d.bluetooth .marker-icon {
|
||||
background: var(--color-secondary);
|
||||
box-shadow: 0 0 15px var(--color-secondary), 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.marker-3d.center .marker-icon {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8), 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
border: 3px solid var(--color-primary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Floor Controls */
|
||||
.floor-section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.floor-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.floor-controls select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.floor-controls select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.floor-controls select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.floor-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
#floor-device-count {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 3D Map Controls Override */
|
||||
.maplibregl-ctrl-group {
|
||||
background: var(--bg-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button:hover {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
background: rgba(22, 33, 62, 0.8) !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: 40vh;
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}RF Mapper{% endblock %}</title>
|
||||
|
||||
<!-- Leaflet CSS (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/leaflet.css') }}">
|
||||
|
||||
<!-- MapLibre GL CSS (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/maplibre-gl.css') }}">
|
||||
|
||||
<!-- App CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>{{ config.app_name | default('📡 RF Mapper') }}</h1>
|
||||
<div class="header-controls">
|
||||
{% block header_controls %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Leaflet JS (local) -->
|
||||
<script src="{{ url_for('static', filename='js/vendor/leaflet.js') }}"></script>
|
||||
|
||||
<!-- MapLibre GL JS (local) -->
|
||||
<script src="{{ url_for('static', filename='js/vendor/maplibre-gl.js') }}"></script>
|
||||
|
||||
<!-- Fix Leaflet icon paths for local hosting -->
|
||||
<script>
|
||||
L.Icon.Default.imagePath = '{{ url_for("static", filename="images") }}/';
|
||||
</script>
|
||||
|
||||
<!-- App Config -->
|
||||
<script>
|
||||
const APP_CONFIG = {
|
||||
defaultLat: {{ lat | default(50.8503) }},
|
||||
defaultLon: {{ lon | default(4.3517) }},
|
||||
apiBase: '{{ url_for("index") }}api',
|
||||
colors: {
|
||||
wifi: '#00ff88',
|
||||
bluetooth: '#4dabf7',
|
||||
warning: '#ffd93d',
|
||||
danger: '#ff6b6b'
|
||||
},
|
||||
radar: {
|
||||
maxDistance: 30,
|
||||
distances: [5, 10, 15, 20, 30]
|
||||
},
|
||||
building: {
|
||||
enabled: {{ building.enabled | default(false) | tojson }},
|
||||
name: {{ building.name | default('') | tojson }},
|
||||
floors: {{ building.floors | default(1) }},
|
||||
floorHeightM: {{ building.floor_height_m | default(3.0) }},
|
||||
groundFloorNumber: {{ building.ground_floor_number | default(0) }},
|
||||
currentFloor: {{ building.current_floor | default(0) }}
|
||||
},
|
||||
maplibre: {
|
||||
style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
||||
|
||||
{% block header_controls %}
|
||||
<span id="scan-status" class="scan-info">Ready</span>
|
||||
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
||||
🔍 New Scan
|
||||
</button>
|
||||
<button class="btn" id="live-track-btn" onclick="toggleLiveTracking()">
|
||||
▶ Live Track
|
||||
</button>
|
||||
<button class="btn" id="autoscan-btn" onclick="toggleAutoScan()">
|
||||
⏱️ Auto: Off
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="main-container">
|
||||
<div class="map-container">
|
||||
<div class="view-toggle">
|
||||
<button id="btn-radar" onclick="setView('radar')">Radar</button>
|
||||
<button id="btn-map" onclick="setView('map')">World Map</button>
|
||||
<button id="btn-3d" class="active" onclick="setView('3d')">3D Map</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-controls">
|
||||
<button id="filter-wifi" class="filter-btn wifi" onclick="toggleFilter('wifi')">
|
||||
<span class="filter-indicator"></span>
|
||||
<span>WiFi</span>
|
||||
</button>
|
||||
<button id="filter-bt" class="filter-btn bluetooth" onclick="toggleFilter('bluetooth')">
|
||||
<span class="filter-indicator"></span>
|
||||
<span>Bluetooth</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
||||
<div id="leaflet-map" class="hidden"></div>
|
||||
<div id="map-3d" class="map-3d active"></div>
|
||||
|
||||
<!-- Device Detail Panel -->
|
||||
<div id="device-detail-panel" class="device-detail-panel hidden">
|
||||
<button class="detail-close" onclick="closeDetailPanel()">×</button>
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon" id="detail-icon"></span>
|
||||
<span class="detail-name" id="detail-name"></span>
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Signal:</span>
|
||||
<span class="detail-value" id="detail-signal"></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Distance:</span>
|
||||
<span class="detail-value" id="detail-distance"></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Manufacturer:</span>
|
||||
<span class="detail-value" id="detail-manufacturer"></span>
|
||||
</div>
|
||||
<div class="detail-row" id="detail-channel-row">
|
||||
<span class="detail-label">Channel:</span>
|
||||
<span class="detail-value" id="detail-channel"></span>
|
||||
</div>
|
||||
<div class="detail-row" id="detail-type-row">
|
||||
<span class="detail-label">Type:</span>
|
||||
<span class="detail-value" id="detail-type"></span>
|
||||
</div>
|
||||
<div class="detail-row" id="detail-address-row">
|
||||
<span class="detail-label">Address:</span>
|
||||
<span class="detail-value" id="detail-address"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📍 Position</span>
|
||||
</div>
|
||||
<div class="position-input">
|
||||
<input type="number" id="lat-input" placeholder="Latitude" step="0.0001" value="{{ lat }}">
|
||||
<input type="number" id="lon-input" placeholder="Longitude" step="0.0001" value="{{ lon }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section floor-section" id="floor-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">🏢 Floor Filter</span>
|
||||
</div>
|
||||
<div class="floor-controls">
|
||||
<select id="floor-select" onchange="setFloorFilter(this.value)">
|
||||
<option value="all" selected>All Floors</option>
|
||||
</select>
|
||||
<div class="floor-info" id="floor-info">
|
||||
<span id="floor-device-count">--</span> devices on selected floor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">⏱️ Auto-Scan</span>
|
||||
<span class="autoscan-status" id="autoscan-status">Off</span>
|
||||
</div>
|
||||
<div class="autoscan-controls">
|
||||
<div class="autoscan-row">
|
||||
<label for="autoscan-interval">Interval (min):</label>
|
||||
<input type="number" id="autoscan-interval" min="1" max="60" value="5">
|
||||
</div>
|
||||
<div class="autoscan-row">
|
||||
<label for="autoscan-label">Location label:</label>
|
||||
<input type="text" id="autoscan-label" value="auto_scan" placeholder="auto_scan">
|
||||
</div>
|
||||
<div class="autoscan-row">
|
||||
<span id="autoscan-info">Last scan: --</span>
|
||||
</div>
|
||||
<div class="autoscan-buttons">
|
||||
<button class="btn btn-small" onclick="startAutoScan()">Start</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="stopAutoScan()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📊 Statistics</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="wifi-count">0</div>
|
||||
<div class="stat-label">WiFi Networks</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="bt-count">0</div>
|
||||
<div class="stat-label">Bluetooth</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="avg-signal">--</div>
|
||||
<div class="stat-label">Avg Signal</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="nearest">--</div>
|
||||
<div class="stat-label">Nearest (m)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📶 WiFi Networks</span>
|
||||
<span class="device-count" id="wifi-list-count">0</span>
|
||||
</div>
|
||||
<div class="device-list" id="wifi-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">🔵 Bluetooth Devices</span>
|
||||
<span class="device-count" id="bt-list-count">0</span>
|
||||
</div>
|
||||
<div class="device-list" id="bt-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user