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:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal 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
52
CLAUDE.md
Normal 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
137
PROJECT.md
Normal 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
119
ROADMAP.md
Normal 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
105
TASKS.md
Normal 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
225
TODO.md
Normal 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
234
USAGE.md
Normal 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
70
config.yaml
Normal 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
74
pyproject.toml
Normal 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
25
src/rf_mapper/__init__.py
Normal 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
405
src/rf_mapper/__main__.py
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI entry point for RF Environment Scanner"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .scanner import RFScanner
|
||||||
|
from .visualize import (
|
||||||
|
create_ascii_radar,
|
||||||
|
create_signal_strength_chart,
|
||||||
|
create_environment_analysis,
|
||||||
|
load_latest_scan
|
||||||
|
)
|
||||||
|
from .distance import estimate_distance
|
||||||
|
from .config import Config, get_config
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="RF Environment Scanner - Map WiFi and Bluetooth signals",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
rf-mapper scan # Scan and save results
|
||||||
|
rf-mapper scan -l kitchen # Scan with location label
|
||||||
|
rf-mapper visualize # Visualize latest scan
|
||||||
|
rf-mapper analyze # Analyze RF environment
|
||||||
|
rf-mapper web # Start web server
|
||||||
|
rf-mapper config # Show current configuration
|
||||||
|
|
||||||
|
Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'-c', '--config',
|
||||||
|
help='Path to configuration file'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--profile',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable CPU profiling (prints stats on completion)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--profile-memory',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable memory profiling (shows top allocations)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--profile-output',
|
||||||
|
type=Path,
|
||||||
|
metavar='FILE',
|
||||||
|
help='Save CPU profile to file (.prof format)'
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||||
|
|
||||||
|
# Scan command
|
||||||
|
scan_parser = subparsers.add_parser('scan', help='Perform WiFi and Bluetooth scan')
|
||||||
|
scan_parser.add_argument(
|
||||||
|
'-l', '--location',
|
||||||
|
default='default',
|
||||||
|
help='Location label for this scan (e.g., living_room, office)'
|
||||||
|
)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
'-i', '--interface',
|
||||||
|
help='WiFi interface to use (default from config)'
|
||||||
|
)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
'--no-wifi',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip WiFi scanning'
|
||||||
|
)
|
||||||
|
scan_parser.add_argument(
|
||||||
|
'--no-bt',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip Bluetooth scanning'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Visualize command
|
||||||
|
viz_parser = subparsers.add_parser('visualize', help='Visualize scan results')
|
||||||
|
viz_parser.add_argument(
|
||||||
|
'-f', '--file',
|
||||||
|
help='Specific scan file to visualize (default: latest)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Analyze command
|
||||||
|
analyze_parser = subparsers.add_parser('analyze', help='Analyze RF environment')
|
||||||
|
analyze_parser.add_argument(
|
||||||
|
'-f', '--file',
|
||||||
|
help='Specific scan file to analyze (default: latest)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# List command
|
||||||
|
subparsers.add_parser('list', help='List saved scans')
|
||||||
|
|
||||||
|
# Web server command
|
||||||
|
web_parser = subparsers.add_parser('web', help='Start web server')
|
||||||
|
web_parser.add_argument(
|
||||||
|
'-H', '--host',
|
||||||
|
help='Host to bind to (default from config)'
|
||||||
|
)
|
||||||
|
web_parser.add_argument(
|
||||||
|
'-p', '--port',
|
||||||
|
type=int,
|
||||||
|
help='Port to listen on (default from config)'
|
||||||
|
)
|
||||||
|
web_parser.add_argument(
|
||||||
|
'--debug',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable debug mode'
|
||||||
|
)
|
||||||
|
web_parser.add_argument(
|
||||||
|
'--profile-requests',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable per-request profiling (saves profiles to data/profiles/)'
|
||||||
|
)
|
||||||
|
web_parser.add_argument(
|
||||||
|
'--log-requests',
|
||||||
|
action='store_true',
|
||||||
|
help='Log all requests to data/logs/requests_YYYYMMDD.log'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Config command
|
||||||
|
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||||
|
config_parser.add_argument(
|
||||||
|
'--set-gps',
|
||||||
|
nargs=2,
|
||||||
|
metavar=('LAT', 'LON'),
|
||||||
|
help='Set GPS coordinates'
|
||||||
|
)
|
||||||
|
config_parser.add_argument(
|
||||||
|
'--save',
|
||||||
|
action='store_true',
|
||||||
|
help='Save changes to config file'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config = Config.load(args.config) if args.config else get_config()
|
||||||
|
data_dir = config.get_data_dir()
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def run_command():
|
||||||
|
if args.command == 'scan':
|
||||||
|
run_scan(args, config, data_dir)
|
||||||
|
elif args.command == 'visualize':
|
||||||
|
run_visualize(args, data_dir)
|
||||||
|
elif args.command == 'analyze':
|
||||||
|
run_analyze(args, data_dir)
|
||||||
|
elif args.command == 'list':
|
||||||
|
run_list(data_dir)
|
||||||
|
elif args.command == 'web':
|
||||||
|
run_web(args, config)
|
||||||
|
elif args.command == 'config':
|
||||||
|
run_config(args, config)
|
||||||
|
else:
|
||||||
|
# Default: run interactive scan
|
||||||
|
run_interactive(config, data_dir)
|
||||||
|
|
||||||
|
# Wrap command execution with profilers if requested
|
||||||
|
if args.profile or args.profile_memory:
|
||||||
|
from .profiling import cpu_profiler, memory_profiler
|
||||||
|
|
||||||
|
if args.profile and args.profile_memory:
|
||||||
|
with cpu_profiler(args.profile_output), memory_profiler():
|
||||||
|
run_command()
|
||||||
|
elif args.profile:
|
||||||
|
with cpu_profiler(args.profile_output):
|
||||||
|
run_command()
|
||||||
|
else:
|
||||||
|
with memory_profiler():
|
||||||
|
run_command()
|
||||||
|
else:
|
||||||
|
run_command()
|
||||||
|
|
||||||
|
|
||||||
|
def run_interactive(config: Config, data_dir: Path):
|
||||||
|
"""Run interactive scan mode"""
|
||||||
|
print(f"""
|
||||||
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ RF ENVIRONMENT SCANNER v1.0 ║
|
||||||
|
║ WiFi + Bluetooth Signal Mapper ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
Config: {config._config_path or 'defaults'}
|
||||||
|
GPS: {config.gps.latitude}, {config.gps.longitude}
|
||||||
|
""")
|
||||||
|
|
||||||
|
try:
|
||||||
|
location = input("Enter location label (e.g., 'living_room'): ").strip() or "default"
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
location = "default"
|
||||||
|
|
||||||
|
scanner = RFScanner(data_dir)
|
||||||
|
result, wifi, bt = scanner.full_scan(location)
|
||||||
|
scanner.print_results(wifi, bt)
|
||||||
|
|
||||||
|
# Show visualizations
|
||||||
|
if wifi:
|
||||||
|
print(create_ascii_radar(result.wifi_networks, f"WiFi Networks ({len(wifi)} found)"))
|
||||||
|
if bt:
|
||||||
|
print(create_ascii_radar(result.bluetooth_devices, f"Bluetooth Devices ({len(bt)} found)"))
|
||||||
|
|
||||||
|
print(create_environment_analysis(result.wifi_networks, result.bluetooth_devices))
|
||||||
|
|
||||||
|
# Estimated distances
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("ESTIMATED DISTANCES")
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
print("\nWiFi Access Points (nearest 5):")
|
||||||
|
for net in sorted(wifi, key=lambda x: x.rssi, reverse=True)[:5]:
|
||||||
|
dist = estimate_distance(net.rssi, n=config.scanner.path_loss_exponent)
|
||||||
|
print(f" {net.ssid[:25]:<25} ~{dist:.1f}m away")
|
||||||
|
|
||||||
|
if bt:
|
||||||
|
print("\nBluetooth Devices (nearest 5):")
|
||||||
|
for dev in sorted(bt, key=lambda x: x.rssi, reverse=True)[:5]:
|
||||||
|
dist = estimate_distance(dev.rssi, tx_power=-65, n=config.scanner.path_loss_exponent)
|
||||||
|
print(f" {dev.name[:25]:<25} ~{dist:.1f}m away")
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan(args, config: Config, data_dir: Path):
|
||||||
|
"""Run a scan with specified options"""
|
||||||
|
interface = args.interface or config.scanner.wifi_interface
|
||||||
|
scanner = RFScanner(data_dir)
|
||||||
|
|
||||||
|
print(f"Starting scan at location: {args.location}")
|
||||||
|
|
||||||
|
wifi = []
|
||||||
|
bt = []
|
||||||
|
|
||||||
|
if not args.no_wifi:
|
||||||
|
wifi = scanner.scan_wifi(interface)
|
||||||
|
print(f"Found {len(wifi)} WiFi networks")
|
||||||
|
|
||||||
|
if not args.no_bt:
|
||||||
|
bt = scanner.scan_bluetooth(
|
||||||
|
timeout=config.scanner.bt_scan_timeout,
|
||||||
|
auto_identify=config.scanner.auto_identify_bluetooth
|
||||||
|
)
|
||||||
|
print(f"Found {len(bt)} Bluetooth devices")
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'location_label': args.location,
|
||||||
|
'gps': {
|
||||||
|
'latitude': config.gps.latitude,
|
||||||
|
'longitude': config.gps.longitude
|
||||||
|
},
|
||||||
|
'wifi_networks': [asdict(n) for n in wifi],
|
||||||
|
'bluetooth_devices': [asdict(d) for d in bt]
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = data_dir / f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{args.location}.json"
|
||||||
|
with open(filename, 'w') as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
|
||||||
|
print(f"Saved to: {filename}")
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
scanner.print_results(wifi, bt)
|
||||||
|
|
||||||
|
|
||||||
|
def run_visualize(args, data_dir: Path):
|
||||||
|
"""Visualize scan results"""
|
||||||
|
if args.file:
|
||||||
|
import json
|
||||||
|
with open(args.file) as f:
|
||||||
|
scan = json.load(f)
|
||||||
|
else:
|
||||||
|
scan = load_latest_scan(data_dir)
|
||||||
|
|
||||||
|
if not scan:
|
||||||
|
print("No scan data found. Run 'rf-mapper scan' first.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Scan from: {scan['timestamp']}")
|
||||||
|
print(f"Location: {scan['location_label']}")
|
||||||
|
|
||||||
|
wifi = scan.get('wifi_networks', [])
|
||||||
|
bt = scan.get('bluetooth_devices', [])
|
||||||
|
|
||||||
|
if wifi:
|
||||||
|
print(create_ascii_radar(wifi, f"WiFi Networks ({len(wifi)} found)"))
|
||||||
|
print(create_signal_strength_chart(wifi, "WiFi Signal Strengths"))
|
||||||
|
|
||||||
|
if bt:
|
||||||
|
print(create_ascii_radar(bt, f"Bluetooth Devices ({len(bt)} found)"))
|
||||||
|
print(create_signal_strength_chart(bt, "Bluetooth Signal Strengths"))
|
||||||
|
|
||||||
|
|
||||||
|
def run_analyze(args, data_dir: Path):
|
||||||
|
"""Analyze RF environment"""
|
||||||
|
if args.file:
|
||||||
|
import json
|
||||||
|
with open(args.file) as f:
|
||||||
|
scan = json.load(f)
|
||||||
|
else:
|
||||||
|
scan = load_latest_scan(data_dir)
|
||||||
|
|
||||||
|
if not scan:
|
||||||
|
print("No scan data found. Run 'rf-mapper scan' first.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
wifi = scan.get('wifi_networks', [])
|
||||||
|
bt = scan.get('bluetooth_devices', [])
|
||||||
|
|
||||||
|
print(create_environment_analysis(wifi, bt))
|
||||||
|
|
||||||
|
|
||||||
|
def run_list(data_dir: Path):
|
||||||
|
"""List saved scans"""
|
||||||
|
scan_files = sorted(data_dir.glob('scan_*.json'), reverse=True)
|
||||||
|
|
||||||
|
if not scan_files:
|
||||||
|
print("No scans found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{'Date/Time':<20} {'Location':<20} {'WiFi':>6} {'BT':>6}")
|
||||||
|
print('-' * 55)
|
||||||
|
|
||||||
|
for f in scan_files[:20]:
|
||||||
|
import json
|
||||||
|
with open(f) as fh:
|
||||||
|
scan = json.load(fh)
|
||||||
|
ts = scan.get('timestamp', '')[:19].replace('T', ' ')
|
||||||
|
loc = scan.get('location_label', 'unknown')
|
||||||
|
wifi_count = len(scan.get('wifi_networks', []))
|
||||||
|
bt_count = len(scan.get('bluetooth_devices', []))
|
||||||
|
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_web(args, config: Config):
|
||||||
|
"""Start the web server"""
|
||||||
|
from .web.app import run_server
|
||||||
|
|
||||||
|
host = args.host
|
||||||
|
port = args.port
|
||||||
|
debug = args.debug
|
||||||
|
profile_requests = getattr(args, 'profile_requests', False)
|
||||||
|
log_requests = getattr(args, 'log_requests', False)
|
||||||
|
|
||||||
|
run_server(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
debug=debug,
|
||||||
|
config=config,
|
||||||
|
profile_requests=profile_requests,
|
||||||
|
log_requests=log_requests
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_config(args, config: Config):
|
||||||
|
"""Show or edit configuration"""
|
||||||
|
if args.set_gps:
|
||||||
|
lat, lon = args.set_gps
|
||||||
|
config.gps.latitude = float(lat)
|
||||||
|
config.gps.longitude = float(lon)
|
||||||
|
print(f"GPS set to: {config.gps.latitude}, {config.gps.longitude}")
|
||||||
|
|
||||||
|
if args.save:
|
||||||
|
config.save()
|
||||||
|
print(f"Configuration saved to: {config._config_path}")
|
||||||
|
|
||||||
|
# Show current config
|
||||||
|
print(f"""
|
||||||
|
RF Mapper Configuration
|
||||||
|
{'='*40}
|
||||||
|
|
||||||
|
Config File: {config._config_path or 'Not found (using defaults)'}
|
||||||
|
|
||||||
|
GPS Position:
|
||||||
|
Latitude: {config.gps.latitude}
|
||||||
|
Longitude: {config.gps.longitude}
|
||||||
|
|
||||||
|
Web Server:
|
||||||
|
Host: {config.web.host}
|
||||||
|
Port: {config.web.port}
|
||||||
|
|
||||||
|
Scanner:
|
||||||
|
WiFi Interface: {config.scanner.wifi_interface}
|
||||||
|
BT Scan Timeout: {config.scanner.bt_scan_timeout}s
|
||||||
|
Path Loss Exponent: {config.scanner.path_loss_exponent}
|
||||||
|
|
||||||
|
Data:
|
||||||
|
Directory: {config.get_data_dir()}
|
||||||
|
Max Scans: {config.data.max_scans}
|
||||||
|
|
||||||
|
Home Assistant:
|
||||||
|
Enabled: {config.home_assistant.enabled}
|
||||||
|
URL: {config.home_assistant.url}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
126
src/rf_mapper/bluetooth_class.py
Normal file
126
src/rf_mapper/bluetooth_class.py
Normal 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
|
||||||
657
src/rf_mapper/bluetooth_identify.py
Normal file
657
src/rf_mapper/bluetooth_identify.py
Normal 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
338
src/rf_mapper/config.py
Normal 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
643
src/rf_mapper/database.py
Normal 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
120
src/rf_mapper/distance.py
Normal 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
105
src/rf_mapper/oui.py
Normal 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
160
src/rf_mapper/profiling.py
Normal 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
428
src/rf_mapper/scanner.py
Normal 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
217
src/rf_mapper/visualize.py
Normal 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)
|
||||||
5
src/rf_mapper/web/__init__.py
Normal file
5
src/rf_mapper/web/__init__.py
Normal 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
1176
src/rf_mapper/web/app.py
Normal file
File diff suppressed because it is too large
Load Diff
948
src/rf_mapper/web/static/css/style.css
Normal file
948
src/rf_mapper/web/static/css/style.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
661
src/rf_mapper/web/static/css/vendor/leaflet.css
vendored
Normal file
661
src/rf_mapper/web/static/css/vendor/leaflet.css
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/rf_mapper/web/static/css/vendor/maplibre-gl.css
vendored
Normal file
1
src/rf_mapper/web/static/css/vendor/maplibre-gl.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/rf_mapper/web/static/images/marker-icon-2x.png
Normal file
BIN
src/rf_mapper/web/static/images/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/rf_mapper/web/static/images/marker-icon.png
Normal file
BIN
src/rf_mapper/web/static/images/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/rf_mapper/web/static/images/marker-shadow.png
Normal file
BIN
src/rf_mapper/web/static/images/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
1542
src/rf_mapper/web/static/js/app.js
Normal file
1542
src/rf_mapper/web/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
6
src/rf_mapper/web/static/js/vendor/leaflet.js
vendored
Normal file
6
src/rf_mapper/web/static/js/vendor/leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
59
src/rf_mapper/web/static/js/vendor/maplibre-gl.js
vendored
Normal file
59
src/rf_mapper/web/static/js/vendor/maplibre-gl.js
vendored
Normal file
File diff suppressed because one or more lines are too long
74
src/rf_mapper/web/templates/base.html
Normal file
74
src/rf_mapper/web/templates/base.html
Normal 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>
|
||||||
176
src/rf_mapper/web/templates/index.html
Normal file
176
src/rf_mapper/web/templates/index.html
Normal 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()">×</button>
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-icon" id="detail-icon"></span>
|
||||||
|
<span class="detail-name" id="detail-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Signal:</span>
|
||||||
|
<span class="detail-value" id="detail-signal"></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Distance:</span>
|
||||||
|
<span class="detail-value" id="detail-distance"></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Manufacturer:</span>
|
||||||
|
<span class="detail-value" id="detail-manufacturer"></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" id="detail-channel-row">
|
||||||
|
<span class="detail-label">Channel:</span>
|
||||||
|
<span class="detail-value" id="detail-channel"></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" id="detail-type-row">
|
||||||
|
<span class="detail-label">Type:</span>
|
||||||
|
<span class="detail-value" id="detail-type"></span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row" id="detail-address-row">
|
||||||
|
<span class="detail-label">Address:</span>
|
||||||
|
<span class="detail-value" id="detail-address"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">📍 Position</span>
|
||||||
|
</div>
|
||||||
|
<div class="position-input">
|
||||||
|
<input type="number" id="lat-input" placeholder="Latitude" step="0.0001" value="{{ lat }}">
|
||||||
|
<input type="number" id="lon-input" placeholder="Longitude" step="0.0001" value="{{ lon }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section floor-section" id="floor-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">🏢 Floor Filter</span>
|
||||||
|
</div>
|
||||||
|
<div class="floor-controls">
|
||||||
|
<select id="floor-select" onchange="setFloorFilter(this.value)">
|
||||||
|
<option value="all" selected>All Floors</option>
|
||||||
|
</select>
|
||||||
|
<div class="floor-info" id="floor-info">
|
||||||
|
<span id="floor-device-count">--</span> devices on selected floor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">⏱️ Auto-Scan</span>
|
||||||
|
<span class="autoscan-status" id="autoscan-status">Off</span>
|
||||||
|
</div>
|
||||||
|
<div class="autoscan-controls">
|
||||||
|
<div class="autoscan-row">
|
||||||
|
<label for="autoscan-interval">Interval (min):</label>
|
||||||
|
<input type="number" id="autoscan-interval" min="1" max="60" value="5">
|
||||||
|
</div>
|
||||||
|
<div class="autoscan-row">
|
||||||
|
<label for="autoscan-label">Location label:</label>
|
||||||
|
<input type="text" id="autoscan-label" value="auto_scan" placeholder="auto_scan">
|
||||||
|
</div>
|
||||||
|
<div class="autoscan-row">
|
||||||
|
<span id="autoscan-info">Last scan: --</span>
|
||||||
|
</div>
|
||||||
|
<div class="autoscan-buttons">
|
||||||
|
<button class="btn btn-small" onclick="startAutoScan()">Start</button>
|
||||||
|
<button class="btn btn-small btn-secondary" onclick="stopAutoScan()">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">📊 Statistics</span>
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="wifi-count">0</div>
|
||||||
|
<div class="stat-label">WiFi Networks</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="bt-count">0</div>
|
||||||
|
<div class="stat-label">Bluetooth</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="avg-signal">--</div>
|
||||||
|
<div class="stat-label">Avg Signal</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="nearest">--</div>
|
||||||
|
<div class="stat-label">Nearest (m)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">📶 WiFi Networks</span>
|
||||||
|
<span class="device-count" id="wifi-list-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-list" id="wifi-list">
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">🔵 Bluetooth Devices</span>
|
||||||
|
<span class="device-count" id="bt-list-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-list" id="bt-list">
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user