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

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
.coverage
htmlcov/
.pytest_cache/
.tox/
# Data files (keep structure but ignore large files)
data/oui.json
data/scan_*.json
data/*.db
data/profiles/
data/logs/
# OS
.DS_Store
Thumbs.db

52
CLAUDE.md Normal file
View File

@@ -0,0 +1,52 @@
# RF Mapper - Claude Context
RF Environment Scanner for WiFi and Bluetooth signal mapping on Linux.
## Key Documentation
- **[USAGE.md](USAGE.md)** - User guide with CLI commands, web interface, configuration, and API reference
- **[TODO.md](TODO.md)** - Pending features and improvements
- **[config.yaml](config.yaml)** - Current configuration (GPS, web server, scanner, building settings)
## Project Structure
```
src/rf_mapper/
├── __main__.py # CLI entry point and argument parsing
├── scanner.py # WiFi/Bluetooth scanning (WifiNetwork, BluetoothDevice dataclasses)
├── config.py # Configuration management (Config, BuildingConfig, etc.)
├── distance.py # RSSI to distance estimation
├── oui.py # MAC address manufacturer lookup
├── bluetooth_*.py # Bluetooth device identification and classification
├── visualize.py # ASCII radar and chart generation
├── profiling.py # CPU/memory profiling utilities
└── web/
├── app.py # Flask application and API endpoints
├── templates/ # Jinja2 HTML templates (base.html, index.html)
└── static/ # CSS, JS, vendor libraries (Leaflet, MapLibre GL)
```
## Key Files for Common Tasks
| Task | Files |
|------|-------|
| Add CLI command | `src/rf_mapper/__main__.py` |
| Add API endpoint | `src/rf_mapper/web/app.py` |
| Modify data model | `src/rf_mapper/scanner.py`, `config.py` |
| Change web UI | `web/templates/index.html`, `static/js/app.js`, `static/css/style.css` |
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
## Running
```bash
source venv/bin/activate
rf-mapper web # Web interface at http://localhost:5000
rf-mapper scan -l room # CLI scan
rf-mapper --help # All commands
```
## Tech Stack
- Python 3.10+, Flask, PyYAML
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
- Linux tools: `iw`, `hcitool`, `bluetoothctl`

137
PROJECT.md Normal file
View File

@@ -0,0 +1,137 @@
# RF Mapper
**WiFi & Bluetooth Signal Mapping Tool for Raspberry Pi**
## Overview
RF Mapper is a Python-based tool that scans, visualizes, and maps RF (Radio Frequency) signals from WiFi networks and Bluetooth devices. It provides a web-based dashboard with multiple visualization modes including radar view, 2D map, and 3D building view with floor-based positioning.
## Problem Statement
Understanding the RF environment around you is useful for:
- Network troubleshooting and optimization
- Security auditing (identifying rogue devices)
- Asset tracking and device inventory
- Indoor positioning research
- Home automation device discovery
## Key Features
- **WiFi Scanning** - Discover networks with SSID, BSSID, RSSI, channel, encryption
- **Bluetooth Scanning** - Classic BT and BLE device discovery with device type inference
- **Distance Estimation** - RSSI-based distance calculation using log-distance path loss model
- **OUI Lookup** - Manufacturer identification from MAC addresses
- **Web Dashboard** - Real-time visualization with multiple views:
- Radar view (polar plot)
- 2D World Map (Leaflet/OpenStreetMap)
- 3D Building Map (MapLibre GL JS)
- **Floor-based Positioning** - Assign devices to building floors
- **Live Tracking** - Real-time Bluetooth tracking mode
- **Auto-scan** - Scheduled background scanning
- **Data Export** - JSON scan history with timestamps
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Web Browser │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Radar │ │ 2D Map │ │ 3D Map │ │
│ │ View │ │ (Leaflet)│ │(MapLibre)│ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────┬───────────────────────────────────┘
│ HTTP/JSON
┌─────────────────────────┴───────────────────────────────────┐
│ Flask Web Server │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ /api/scan│ │/api/latest│ │/api/config│ │/api/device│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────┬───────────────────────────────────┘
┌─────────────────────────┴───────────────────────────────────┐
│ RF Scanner Module │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ WiFi │ │Bluetooth │ │ OUI │ │ Distance │ │
│ │ Scanner │ │ Scanner │ │ Lookup │ │ Estimator│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────┬───────────────────────────────────┘
┌─────────────────────────┴───────────────────────────────────┐
│ System Tools │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ iw │ │ hcitool │ │bluetoothctl│ │
│ │ (WiFi) │ │ (BT) │ │ (BLE) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Dependencies
### System
- Linux (tested on Raspberry Pi OS / Debian)
- Python 3.11+
- `iw` - WiFi scanning
- `hcitool` / `bluetoothctl` - Bluetooth scanning
- `sudo` access for RF scanning
### Python
- Flask - Web framework
- PyYAML - Configuration
- dataclasses - Data structures
### Frontend
- Leaflet.js - 2D maps
- MapLibre GL JS - 3D maps
- Vanilla JavaScript
## Installation
```bash
cd ~/git/rf-mapper
python -m venv venv
source venv/bin/activate
pip install -e .
```
## Quick Start
```bash
# Start web interface
rf-mapper web
# CLI scan
rf-mapper scan
# Open http://localhost:5000
```
## Configuration
Config file: `config.yaml`
```yaml
gps:
latitude: 50.8585
longitude: 4.3978
scanner:
wifi_interface: wlan0
bt_scan_timeout: 10
path_loss_exponent: 2.5
building:
enabled: true
floors: 12
current_floor: 11
```
## Documentation
- [docs/INSTALL.md](docs/INSTALL.md) - Detailed installation
- [docs/USAGE.md](docs/USAGE.md) - Usage guide
- [docs/CHEATSHEET.md](docs/CHEATSHEET.md) - Quick reference
- [docs/API.md](docs/API.md) - REST API documentation
## License
MIT License

119
ROADMAP.md Normal file
View File

@@ -0,0 +1,119 @@
# RF Mapper Roadmap
## Current Version: v0.3.0-dev
---
## v0.1.0 - Core Scanning (COMPLETED)
- [x] WiFi network scanning via `iw`
- [x] Bluetooth device scanning (Classic + BLE)
- [x] OUI manufacturer lookup
- [x] Device class decoding
- [x] Distance estimation (RSSI-based)
- [x] JSON scan output
- [x] CLI interface
- [x] Basic configuration (YAML)
---
## v0.2.0 - Web Dashboard (COMPLETED)
- [x] Flask web server
- [x] Radar visualization (canvas)
- [x] 2D map view (Leaflet + OSM)
- [x] Device cards with signal bars
- [x] Manual scan trigger
- [x] Auto-scan scheduling
- [x] Bluetooth device identification
- [x] GPS position configuration
---
## v0.3.0 - 3D Visualization (IN PROGRESS)
- [x] MapLibre GL JS integration
- [x] 3D building extrusion
- [x] Floor-based device positioning
- [x] Floor selector UI
- [x] Device floor assignment (popup)
- [x] Custom distance override
- [x] Live BT tracking mode
- [x] Moving device detection (purple markers)
- [x] Filter-aware scanning (WiFi/BT toggle)
- [ ] Reliable RSSI acquisition for movement tracking
- [ ] Position smoothing/averaging
- [ ] Device trails/history visualization
---
## v0.4.0 - Multilateration & Positioning
- [ ] Multi-point scanning (move scanner, record positions)
- [ ] Trilateration algorithm for device positioning
- [ ] Heat map visualization
- [ ] Signal strength interpolation
- [ ] Calibration mode for distance estimation
- [ ] Reference point management
- [ ] Position confidence indicators
---
## v0.5.0 - Persistence & History
- [x] SQLite database for scan history
- [x] Device tracking over time
- [ ] Historical signal strength graphs (UI)
- [x] First seen / last seen timestamps
- [x] Device naming/labeling
- [x] Favorites/known devices
- [ ] Export to CSV/Excel
---
## v0.6.0 - Alerts & Automation
- [ ] New device alerts
- [ ] Signal threshold alerts
- [ ] Webhook notifications
- [ ] Home Assistant integration (MQTT)
- [ ] Presence detection automation
- [ ] Device absence detection
- [ ] Scheduled reports
---
## v1.0.0 - Production Ready
- [ ] Comprehensive test suite
- [ ] Performance optimization
- [ ] Docker container
- [ ] systemd service file
- [ ] Multi-user support
- [ ] Authentication (optional)
- [ ] Complete documentation
- [ ] PyPI package release
---
## Future Ideas (v2.0+)
- [ ] Multiple scanner nodes (distributed scanning)
- [ ] Mesh network visualization
- [ ] Spectrum analyzer integration
- [ ] RTL-SDR support for wider RF
- [ ] Machine learning device classification
- [ ] Mobile app companion
- [ ] AR visualization mode
- [ ] Integration with Wireshark
- [ ] Packet capture correlation
---
## Version History
| Version | Date | Highlights |
|---------|------|------------|
| v0.1.0 | 2026-01 | Initial CLI scanner |
| v0.2.0 | 2026-01 | Web dashboard |
| v0.3.0 | TBD | 3D visualization |

105
TASKS.md Normal file
View File

@@ -0,0 +1,105 @@
# RF Mapper - Active Tasks
**Sprint:** v0.3.0 - 3D Visualization
**Updated:** 2026-01-31
---
## Priority Levels
- **P0** - Critical / Blocking
- **P1** - High / Current sprint
- **P2** - Medium / Next sprint
- **P3** - Low / Backlog
## Status Legend
- `[ ]` Todo
- `[~]` In Progress
- `[x]` Done
- `[-]` Blocked
---
## P0 - Critical
| Status | Task | Notes |
|--------|------|-------|
| [-] | Fix Bluetooth RSSI acquisition | `hcitool rssi` only works for connected devices; `bluetoothctl` doesn't expose RSSI for cached devices |
---
## P1 - High Priority (Current Sprint)
| Status | Task | Notes |
|--------|------|-------|
| [x] | MapLibre GL JS integration | 3D map with building extrusion |
| [x] | Floor-based positioning | Devices assigned to floors |
| [x] | Floor selector UI | Dropdown to filter by floor |
| [x] | Custom distance override | Set manual distance via popup |
| [x] | Live BT tracking mode | 4-second scan interval |
| [x] | Moving device detection | Purple markers for RSSI changes |
| [x] | Filter-aware scanning | Skip WiFi/BT based on toggle |
| [~] | Improve BT discovery reliability | Try alternative scanning methods |
| [ ] | Document API endpoints | docs/API.md |
| [ ] | Create CHEATSHEET.md | Quick reference guide |
---
## P2 - Medium Priority (Next Sprint)
| Status | Task | Notes |
|--------|------|-------|
| [ ] | Position smoothing | Average RSSI over multiple samples |
| [ ] | Device trails | Show movement history on map |
| [ ] | Signal strength graph | Per-device RSSI over time |
| [ ] | Scan history browser | View past scans in UI |
| [ ] | Export functionality | Download scan data as CSV |
---
## P3 - Low Priority (Backlog)
| Status | Task | Notes |
|--------|------|-------|
| [x] | SQLite persistence | Historical device tracking enabled |
| [x] | Device labeling | Custom names via API |
| [ ] | Home Assistant integration | MQTT/webhook |
| [ ] | Docker container | Containerized deployment |
| [ ] | Unit tests | pytest coverage |
---
## Completed This Sprint
| Task | Completed |
|------|-----------|
| 3D map view with MapLibre | 2026-01-31 |
| Floor assignment in popup | 2026-01-31 |
| Custom distance setting | 2026-01-31 |
| Live tracking button | 2026-01-31 |
| Purple moving indicators | 2026-01-31 |
| Smart scanning (filter-aware) | 2026-01-31 |
---
## Blockers
### BT RSSI Acquisition
**Problem:** Cannot get reliable RSSI values for Bluetooth devices
- `hcitool rssi <addr>` - Only works for connected devices
- `bluetoothctl info` - No RSSI for cached devices
- `btmgmt find` - Not providing output
- BLE scan (`hcitool lescan`) - I/O errors on this adapter
**Potential Solutions:**
1. Use D-Bus BlueZ API directly (needs dbus-python)
2. Keep devices paired/connected for RSSI polling
3. Focus on WiFi-based tracking instead
4. Use dedicated BLE beacon hardware
---
## Notes
- Bluetooth scanning is unreliable on Raspberry Pi 5 with built-in adapter
- WiFi scanning works well with `iw` command
- Consider external USB Bluetooth adapter for better BLE support

225
TODO.md Normal file
View File

@@ -0,0 +1,225 @@
# RF Mapper - TODO / Backlog
**Last Updated:** 2026-01-31
---
## Historical Data & Device Tracking (Priority)
- [x] SQLite database for device history
- [x] Store every scan result with timestamp
- [x] Track first_seen / last_seen per device
- [x] RSSI history per device (time series)
- [x] Calculate average/min/max RSSI per device
- [x] Device appearance frequency statistics
- [x] Motion detection from RSSI patterns
- [x] Velocity estimation from distance changes
- [ ] Movement trajectory visualization
- [ ] Device presence heatmap over time
- [ ] Historical playback mode (scrub through time)
- [x] Device activity patterns (daily/weekly)
- [x] Alert on new device detection
- [ ] Alert on device absence (left the area)
- [x] Data retention policies (auto-cleanup old data)
---
## Scanning
- [ ] Support multiple WiFi interfaces
- [ ] Scan specific channels only (faster)
- [ ] Hidden network detection
- [ ] WPA3 detection
- [ ] 5GHz/6GHz band identification
- [ ] Bluetooth LE advertisement parsing
- [ ] iBeacon/Eddystone protocol support
- [ ] BLE service UUID decoding
- [ ] Scan scheduling with cron expressions
- [ ] Concurrent WiFi + BT scanning
---
## Distance & Positioning
- [ ] Configurable TX power per device type
- [ ] Path loss exponent calibration wizard
- [ ] Environment presets (office, home, warehouse)
- [ ] Wall attenuation factor
- [ ] Multi-floor path loss adjustment
- [ ] Trilateration from multiple scan points
- [ ] Kalman filter for position smoothing
- [ ] Dead reckoning with IMU (if available)
- [ ] Fingerprinting-based positioning
- [ ] Machine learning position estimation
---
## Visualization
- [ ] Dark/light theme toggle
- [ ] Custom marker icons per device type
- [ ] Signal strength color gradient
- [ ] Animated radar sweep effect
- [ ] Mini-map in corner
- [ ] Fullscreen mode
- [ ] Split view (radar + map)
- [ ] Device clustering at zoom levels
- [ ] Floor plan image overlay
- [ ] Custom building polygon drawing
- [ ] SVG export for diagrams
- [ ] Device trail/path visualization
- [ ] Speed/direction indicators on moving devices
---
## Statistics & Analytics
- [ ] Dashboard with device counts over time
- [ ] Signal quality trends per device
- [ ] Busiest hours/days heatmap
- [ ] Device type distribution pie chart
- [ ] Manufacturer breakdown
- [ ] Floor occupancy statistics
- [ ] Motion events timeline
- [ ] Exportable reports (PDF)
---
## Data & Storage
- [ ] SQLite database backend
- [ ] Automatic scan rotation/cleanup
- [ ] Compressed JSON storage
- [ ] Cloud backup option
- [ ] Import from other tools (Kismet, etc.)
- [ ] Merge scans from multiple sessions
- [ ] Data anonymization option
- [ ] GDPR compliance features
---
## API & Integration
- [ ] OpenAPI/Swagger documentation
- [ ] WebSocket for real-time updates
- [ ] GraphQL endpoint (optional)
- [ ] MQTT publishing
- [ ] Home Assistant auto-discovery
- [ ] Webhook on device events
- [ ] Prometheus metrics endpoint
- [ ] Grafana dashboard template
- [ ] Node-RED integration nodes
---
## Security
- [ ] Optional authentication (basic/token)
- [ ] HTTPS support
- [ ] Rate limiting
- [ ] Audit logging
- [ ] MAC address randomization detection
- [ ] Rogue AP detection
- [ ] Deauth attack detection
- [ ] Known device allowlist
---
## Performance
- [ ] Async scanning with asyncio
- [ ] Scan result caching
- [ ] Lazy loading for large device lists
- [ ] WebWorker for frontend processing
- [ ] Gzip compression for API responses
- [ ] Database query optimization
- [ ] Memory profiling and optimization
---
## DevOps
- [ ] GitHub Actions CI/CD
- [ ] Automated testing on PR
- [ ] Docker multi-arch builds
- [ ] Helm chart for Kubernetes
- [ ] Ansible playbook for deployment
- [ ] systemd service with watchdog
- [ ] Log rotation configuration
- [ ] Health check endpoint
---
## Documentation
- [ ] Video tutorial
- [ ] Architecture diagrams (Mermaid)
- [ ] Troubleshooting guide
- [ ] FAQ section
- [ ] Contribution guidelines
- [ ] Code of conduct
- [ ] Security policy
- [ ] Changelog automation
---
## Hardware Support
- [ ] External USB WiFi adapters
- [ ] External USB Bluetooth adapters
- [ ] RTL-SDR integration
- [ ] HackRF support
- [ ] ESP32 as remote scanner node
- [ ] Raspberry Pi Pico W support
- [ ] GPS module integration (for mobile scanning)
- [ ] PoE-powered deployment
---
## Ideas / Research
- [ ] Crowd-sourced device fingerprints
- [ ] AI-based device classification
- [ ] Augmented reality view (WebXR)
- [ ] Voice control integration
- [ ] Mesh network topology mapping
- [ ] Time-based access patterns
- [ ] Anomaly detection
- [ ] Digital twin integration
---
## Technical Debt
- [ ] Refactor scanner.py (too large)
- [ ] Split app.py into blueprints
- [ ] Type hints for all functions
- [ ] Consistent error handling
- [ ] Remove deprecated code paths
- [ ] Update dependencies
- [ ] Fix linting warnings
- [ ] Improve test coverage (target: 80%)
---
## Completed
- [x] Check if OpenStreetMap provides 3D view capabilities
- [x] Allow placement of height (floor) in the building for device positioning
- [x] Implement 3D navigable map (using MapLibre GL JS with building extrusion)
- [x] Device floor assignment UI (click device to assign floor)
- [x] SQLite database for historical device tracking
- [x] RSSI time-series history with statistics
- [x] Movement detection and velocity estimation
- [x] Device labeling and favorites
- [x] New device alerts
- [x] Automatic data retention/cleanup
---
## Won't Do (Decided Against)
- ~~Active WiFi probing~~ - Too intrusive, passive only
- ~~Packet injection~~ - Legal concerns
- ~~Deauth capabilities~~ - Malicious use potential
- ~~Commercial tracking~~ - Privacy concerns

234
USAGE.md Normal file
View File

@@ -0,0 +1,234 @@
# RF Mapper Usage Guide
RF Mapper is a WiFi and Bluetooth signal mapper for Linux. It scans nearby devices, estimates distances, and visualizes results in multiple views including radar, 2.5D, world map, and 3D map with building support.
## Requirements
- Linux with WiFi and Bluetooth hardware
- Python 3.10+
- `sudo` access (required for scanning)
- System tools: `iw`, `hcitool`, `bluetoothctl`
## Installation
```bash
# Create and activate virtual environment
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install -e .
```
## Quick Start
```bash
# Activate venv
source venv/bin/activate
# Run interactive scan
rf-mapper
# Start web interface
rf-mapper web
```
## CLI Commands
### Scanning
```bash
# Basic scan (interactive mode)
rf-mapper
# Scan with location label
rf-mapper scan -l kitchen
# Scan WiFi only
rf-mapper scan --no-bt
# Scan Bluetooth only
rf-mapper scan --no-wifi
# Use specific WiFi interface
rf-mapper scan -i wlan1
```
### Visualization (CLI)
```bash
# Visualize latest scan (ASCII radar + charts)
rf-mapper visualize
# Visualize specific scan file
rf-mapper visualize -f data/scan_20240131_120000_kitchen.json
# Analyze RF environment
rf-mapper analyze
```
### Scan History
```bash
# List saved scans
rf-mapper list
```
### Web Interface
```bash
# Start web server (default: http://0.0.0.0:5000)
rf-mapper web
# Custom host/port
rf-mapper web -H 127.0.0.1 -p 8080
# Debug mode
rf-mapper web --debug
# With request profiling
rf-mapper web --profile-requests
# With request logging
rf-mapper web --log-requests
```
### Configuration
```bash
# Show current configuration
rf-mapper config
# Set GPS coordinates
rf-mapper config --set-gps 50.8585 4.3978 --save
```
### Profiling
```bash
# Profile CPU usage
rf-mapper --profile scan
# Profile memory usage
rf-mapper --profile-memory scan
# Save profile to file
rf-mapper --profile --profile-output scan.prof scan
```
## Web Interface Views
The web dashboard offers 3 visualization modes:
| View | Description |
|------|-------------|
| **Radar** | Classic radar display with distance rings |
| **World Map** | Leaflet map with device markers on real geography |
| **3D Map** | MapLibre GL 3D view with building extrusion |
### Features
- Real-time scanning via "New Scan" button
- Auto-scan mode with configurable interval
- WiFi/Bluetooth filter toggles
- Floor filtering for multi-story buildings
- Click devices for detailed info popups
- Device lists with signal strength indicators
## Configuration File
Configuration is loaded from (in order):
1. `./config.yaml`
2. `~/.config/rf-mapper/config.yaml`
3. `/etc/rf-mapper/config.yaml`
### Example `config.yaml`
```yaml
gps:
latitude: 50.8585853
longitude: 4.3978724
web:
host: "0.0.0.0"
port: 5000
debug: false
scanner:
wifi_interface: "wlan0"
bt_scan_timeout: 10
path_loss_exponent: 2.5
auto_identify_bluetooth: true
data:
directory: "data"
max_scans: 100
building:
enabled: false
name: "My Building"
floors: 3
floor_height_m: 3.0
ground_floor_number: 0
```
### Environment Variables
| Variable | Description |
|----------|-------------|
| `RF_MAPPER_LAT` | Override GPS latitude |
| `RF_MAPPER_LON` | Override GPS longitude |
| `RF_MAPPER_HOST` | Override web server host |
| `RF_MAPPER_PORT` | Override web server port |
| `HA_TOKEN` | Home Assistant API token |
| `HA_URL` | Home Assistant URL |
## API Endpoints
The web server exposes a REST API:
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/scan` | Trigger new scan |
| GET | `/api/latest` | Get most recent scan |
| GET | `/api/scans` | List all scans |
| GET | `/api/scans/<filename>` | Get specific scan |
| GET/POST | `/api/position` | Get/set GPS position |
| GET/POST | `/api/config` | Get/update configuration |
| GET/POST | `/api/building` | Get/update building config |
| POST | `/api/device/<id>/floor` | Assign floor to device |
| GET | `/api/autoscan` | Get auto-scan status |
| POST | `/api/autoscan/start` | Start auto-scanning |
| POST | `/api/autoscan/stop` | Stop auto-scanning |
| GET | `/api/bluetooth/identify/<addr>` | Identify BT device |
## Data Storage
Scan results are saved as JSON in the data directory:
- Default: `./data/`
- Files: `scan_YYYYMMDD_HHMMSS_<location>.json`
## Troubleshooting
### "WiFi scan error: Operation not permitted"
Run with sudo or ensure your user has permissions:
```bash
sudo rf-mapper scan
```
### "No Bluetooth adapter found"
Ensure Bluetooth is enabled:
```bash
sudo systemctl start bluetooth
sudo hciconfig hci0 up
```
### Web interface shows no devices
1. Run a scan first: click "New Scan" or use CLI
2. Check if data directory has scan files
3. Verify filters aren't hiding devices
### 3D buildings not showing
- Zoom in to level 15+ for buildings to appear
- Not all areas have building data in OpenStreetMap
- The map style must support vector tiles with building data

70
config.yaml Normal file
View File

@@ -0,0 +1,70 @@
# RF Mapper Configuration
# ========================
# GPS Reference Position
# Set this to your Home Assistant or scanner location
gps:
# Belgium (Brussels area) - Update with your actual coordinates
latitude: 50.8585853
longitude: 4.3978724
# You can get precise coordinates from Home Assistant at:
# http://192.168.129.10:8123/config/zone
# Web Server Settings
web:
host: "0.0.0.0"
port: 5000
debug: false
# Scanner Settings
scanner:
# WiFi interface name
wifi_interface: "wlan0"
# Bluetooth scan timeout in seconds
bt_scan_timeout: 10
# Path loss exponent for distance estimation
# 2.0 = free space, 2.5 = light indoor, 3.0-4.0 = walls
path_loss_exponent: 2.5
# Automatically identify Bluetooth devices (queries device services)
auto_identify_bluetooth: true
# Data Storage
data:
# Directory for scan data (relative to project root or absolute path)
directory: "data"
# Maximum number of scans to keep (0 = unlimited)
max_scans: 100
# SQLite Database for Device History
database:
# Enable historical tracking
enabled: true
# Database filename (stored in data directory)
filename: "devices.db"
# Data retention period in days (auto-cleanup older data)
retention_days: 30
# Enable automatic daily cleanup
auto_cleanup: true
# Home Assistant Integration (optional)
home_assistant:
enabled: false
url: "http://192.168.129.10:8123"
# Token can be set here or via HA_TOKEN environment variable
# Generate at: http://192.168.129.10:8123/profile -> Long-Lived Access Tokens
token: ""
# Building Configuration (for 3D map view)
building:
# Enable 3D building visualization
enabled: true
# Building name (displayed in UI)
name: "Home"
# Number of floors in the building
floors: 12
# Height of each floor in meters
floor_height_m: 3.0
# Ground floor number (0 in most countries, 1 in US)
ground_floor_number: 0
# Scanner's current floor (devices scanned will be assigned to this floor)
current_floor: 11

74
pyproject.toml Normal file
View File

@@ -0,0 +1,74 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "rf-mapper"
version = "1.0.0"
description = "RF Environment Scanner - WiFi and Bluetooth signal mapper"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "User"}
]
keywords = ["wifi", "bluetooth", "rssi", "signal", "mapping", "scanner"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: System :: Networking",
"Topic :: Utilities",
]
dependencies = [
"flask>=3.0.0",
"pyyaml>=6.0",
]
[project.optional-dependencies]
visualization = [
"matplotlib>=3.5.0",
"numpy>=1.20.0",
]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"black>=23.0.0",
"ruff>=0.1.0",
]
all = [
"rf-mapper[visualization,dev]",
]
[project.scripts]
rf-mapper = "rf_mapper.__main__:main"
[project.urls]
Homepage = "https://github.com/user/rf-mapper"
[tool.setuptools.packages.find]
where = ["src"]
[tool.black]
line-length = 100
target-version = ["py310", "py311", "py312"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"

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 %}