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:
User
2026-02-01 00:08:21 +01:00
commit 52df6421be
33 changed files with 8939 additions and 0 deletions

25
src/rf_mapper/__init__.py Normal file
View File

@@ -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",
]

405
src/rf_mapper/__main__.py Normal file
View File

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

View File

@@ -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

View File

@@ -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,
}

338
src/rf_mapper/config.py Normal file
View File

@@ -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

643
src/rf_mapper/database.py Normal file
View File

@@ -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

120
src/rf_mapper/distance.py Normal file
View File

@@ -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)

105
src/rf_mapper/oui.py Normal file
View File

@@ -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"

160
src/rf_mapper/profiling.py Normal file
View File

@@ -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)

428
src/rf_mapper/scanner.py Normal file
View 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}")

217
src/rf_mapper/visualize.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,5 @@
"""Flask web application for RF Mapper"""
from .app import create_app
__all__ = ["create_app"]

1176
src/rf_mapper/web/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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()">&times;</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 %}