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