Initial commit: RF Mapper v0.3.0-dev
WiFi & Bluetooth signal mapping tool for Raspberry Pi with: - WiFi scanning via iw command - Bluetooth Classic/BLE device discovery - RSSI-based distance estimation - OUI manufacturer lookup - Web dashboard with multiple views: - Radar view (polar plot) - 2D Map (Leaflet/OpenStreetMap) - 3D Map (MapLibre GL JS with building extrusion) - Floor-based device positioning - Live BT tracking mode (auto-starts on page load) - SQLite database for historical device tracking: - RSSI time-series history - Device statistics (avg/min/max) - Movement detection and velocity estimation - Activity patterns (hourly/daily) - New device alerts - Automatic data retention/cleanup - REST API for all functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Data files (keep structure but ignore large files)
|
||||
data/oui.json
|
||||
data/scan_*.json
|
||||
data/*.db
|
||||
data/profiles/
|
||||
data/logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# RF Mapper - Claude Context
|
||||
|
||||
RF Environment Scanner for WiFi and Bluetooth signal mapping on Linux.
|
||||
|
||||
## Key Documentation
|
||||
|
||||
- **[USAGE.md](USAGE.md)** - User guide with CLI commands, web interface, configuration, and API reference
|
||||
- **[TODO.md](TODO.md)** - Pending features and improvements
|
||||
- **[config.yaml](config.yaml)** - Current configuration (GPS, web server, scanner, building settings)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/rf_mapper/
|
||||
├── __main__.py # CLI entry point and argument parsing
|
||||
├── scanner.py # WiFi/Bluetooth scanning (WifiNetwork, BluetoothDevice dataclasses)
|
||||
├── config.py # Configuration management (Config, BuildingConfig, etc.)
|
||||
├── distance.py # RSSI to distance estimation
|
||||
├── oui.py # MAC address manufacturer lookup
|
||||
├── bluetooth_*.py # Bluetooth device identification and classification
|
||||
├── visualize.py # ASCII radar and chart generation
|
||||
├── profiling.py # CPU/memory profiling utilities
|
||||
└── web/
|
||||
├── app.py # Flask application and API endpoints
|
||||
├── templates/ # Jinja2 HTML templates (base.html, index.html)
|
||||
└── static/ # CSS, JS, vendor libraries (Leaflet, MapLibre GL)
|
||||
```
|
||||
|
||||
## Key Files for Common Tasks
|
||||
|
||||
| Task | Files |
|
||||
|------|-------|
|
||||
| Add CLI command | `src/rf_mapper/__main__.py` |
|
||||
| Add API endpoint | `src/rf_mapper/web/app.py` |
|
||||
| Modify data model | `src/rf_mapper/scanner.py`, `config.py` |
|
||||
| Change web UI | `web/templates/index.html`, `static/js/app.js`, `static/css/style.css` |
|
||||
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
rf-mapper web # Web interface at http://localhost:5000
|
||||
rf-mapper scan -l room # CLI scan
|
||||
rf-mapper --help # All commands
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Python 3.10+, Flask, PyYAML
|
||||
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
|
||||
- Linux tools: `iw`, `hcitool`, `bluetoothctl`
|
||||
137
PROJECT.md
Normal file
137
PROJECT.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# RF Mapper
|
||||
|
||||
**WiFi & Bluetooth Signal Mapping Tool for Raspberry Pi**
|
||||
|
||||
## Overview
|
||||
|
||||
RF Mapper is a Python-based tool that scans, visualizes, and maps RF (Radio Frequency) signals from WiFi networks and Bluetooth devices. It provides a web-based dashboard with multiple visualization modes including radar view, 2D map, and 3D building view with floor-based positioning.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Understanding the RF environment around you is useful for:
|
||||
- Network troubleshooting and optimization
|
||||
- Security auditing (identifying rogue devices)
|
||||
- Asset tracking and device inventory
|
||||
- Indoor positioning research
|
||||
- Home automation device discovery
|
||||
|
||||
## Key Features
|
||||
|
||||
- **WiFi Scanning** - Discover networks with SSID, BSSID, RSSI, channel, encryption
|
||||
- **Bluetooth Scanning** - Classic BT and BLE device discovery with device type inference
|
||||
- **Distance Estimation** - RSSI-based distance calculation using log-distance path loss model
|
||||
- **OUI Lookup** - Manufacturer identification from MAC addresses
|
||||
- **Web Dashboard** - Real-time visualization with multiple views:
|
||||
- Radar view (polar plot)
|
||||
- 2D World Map (Leaflet/OpenStreetMap)
|
||||
- 3D Building Map (MapLibre GL JS)
|
||||
- **Floor-based Positioning** - Assign devices to building floors
|
||||
- **Live Tracking** - Real-time Bluetooth tracking mode
|
||||
- **Auto-scan** - Scheduled background scanning
|
||||
- **Data Export** - JSON scan history with timestamps
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web Browser │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Radar │ │ 2D Map │ │ 3D Map │ │
|
||||
│ │ View │ │ (Leaflet)│ │(MapLibre)│ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│ HTTP/JSON
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ Flask Web Server │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ /api/scan│ │/api/latest│ │/api/config│ │/api/device│ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ RF Scanner Module │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ WiFi │ │Bluetooth │ │ OUI │ │ Distance │ │
|
||||
│ │ Scanner │ │ Scanner │ │ Lookup │ │ Estimator│ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────┴───────────────────────────────────┐
|
||||
│ System Tools │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ iw │ │ hcitool │ │bluetoothctl│ │
|
||||
│ │ (WiFi) │ │ (BT) │ │ (BLE) │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### System
|
||||
- Linux (tested on Raspberry Pi OS / Debian)
|
||||
- Python 3.11+
|
||||
- `iw` - WiFi scanning
|
||||
- `hcitool` / `bluetoothctl` - Bluetooth scanning
|
||||
- `sudo` access for RF scanning
|
||||
|
||||
### Python
|
||||
- Flask - Web framework
|
||||
- PyYAML - Configuration
|
||||
- dataclasses - Data structures
|
||||
|
||||
### Frontend
|
||||
- Leaflet.js - 2D maps
|
||||
- MapLibre GL JS - 3D maps
|
||||
- Vanilla JavaScript
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd ~/git/rf-mapper
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start web interface
|
||||
rf-mapper web
|
||||
|
||||
# CLI scan
|
||||
rf-mapper scan
|
||||
|
||||
# Open http://localhost:5000
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file: `config.yaml`
|
||||
|
||||
```yaml
|
||||
gps:
|
||||
latitude: 50.8585
|
||||
longitude: 4.3978
|
||||
|
||||
scanner:
|
||||
wifi_interface: wlan0
|
||||
bt_scan_timeout: 10
|
||||
path_loss_exponent: 2.5
|
||||
|
||||
building:
|
||||
enabled: true
|
||||
floors: 12
|
||||
current_floor: 11
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/INSTALL.md](docs/INSTALL.md) - Detailed installation
|
||||
- [docs/USAGE.md](docs/USAGE.md) - Usage guide
|
||||
- [docs/CHEATSHEET.md](docs/CHEATSHEET.md) - Quick reference
|
||||
- [docs/API.md](docs/API.md) - REST API documentation
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
119
ROADMAP.md
Normal file
119
ROADMAP.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# RF Mapper Roadmap
|
||||
|
||||
## Current Version: v0.3.0-dev
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0 - Core Scanning (COMPLETED)
|
||||
|
||||
- [x] WiFi network scanning via `iw`
|
||||
- [x] Bluetooth device scanning (Classic + BLE)
|
||||
- [x] OUI manufacturer lookup
|
||||
- [x] Device class decoding
|
||||
- [x] Distance estimation (RSSI-based)
|
||||
- [x] JSON scan output
|
||||
- [x] CLI interface
|
||||
- [x] Basic configuration (YAML)
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 - Web Dashboard (COMPLETED)
|
||||
|
||||
- [x] Flask web server
|
||||
- [x] Radar visualization (canvas)
|
||||
- [x] 2D map view (Leaflet + OSM)
|
||||
- [x] Device cards with signal bars
|
||||
- [x] Manual scan trigger
|
||||
- [x] Auto-scan scheduling
|
||||
- [x] Bluetooth device identification
|
||||
- [x] GPS position configuration
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 - 3D Visualization (IN PROGRESS)
|
||||
|
||||
- [x] MapLibre GL JS integration
|
||||
- [x] 3D building extrusion
|
||||
- [x] Floor-based device positioning
|
||||
- [x] Floor selector UI
|
||||
- [x] Device floor assignment (popup)
|
||||
- [x] Custom distance override
|
||||
- [x] Live BT tracking mode
|
||||
- [x] Moving device detection (purple markers)
|
||||
- [x] Filter-aware scanning (WiFi/BT toggle)
|
||||
- [ ] Reliable RSSI acquisition for movement tracking
|
||||
- [ ] Position smoothing/averaging
|
||||
- [ ] Device trails/history visualization
|
||||
|
||||
---
|
||||
|
||||
## v0.4.0 - Multilateration & Positioning
|
||||
|
||||
- [ ] Multi-point scanning (move scanner, record positions)
|
||||
- [ ] Trilateration algorithm for device positioning
|
||||
- [ ] Heat map visualization
|
||||
- [ ] Signal strength interpolation
|
||||
- [ ] Calibration mode for distance estimation
|
||||
- [ ] Reference point management
|
||||
- [ ] Position confidence indicators
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0 - Persistence & History
|
||||
|
||||
- [x] SQLite database for scan history
|
||||
- [x] Device tracking over time
|
||||
- [ ] Historical signal strength graphs (UI)
|
||||
- [x] First seen / last seen timestamps
|
||||
- [x] Device naming/labeling
|
||||
- [x] Favorites/known devices
|
||||
- [ ] Export to CSV/Excel
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0 - Alerts & Automation
|
||||
|
||||
- [ ] New device alerts
|
||||
- [ ] Signal threshold alerts
|
||||
- [ ] Webhook notifications
|
||||
- [ ] Home Assistant integration (MQTT)
|
||||
- [ ] Presence detection automation
|
||||
- [ ] Device absence detection
|
||||
- [ ] Scheduled reports
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 - Production Ready
|
||||
|
||||
- [ ] Comprehensive test suite
|
||||
- [ ] Performance optimization
|
||||
- [ ] Docker container
|
||||
- [ ] systemd service file
|
||||
- [ ] Multi-user support
|
||||
- [ ] Authentication (optional)
|
||||
- [ ] Complete documentation
|
||||
- [ ] PyPI package release
|
||||
|
||||
---
|
||||
|
||||
## Future Ideas (v2.0+)
|
||||
|
||||
- [ ] Multiple scanner nodes (distributed scanning)
|
||||
- [ ] Mesh network visualization
|
||||
- [ ] Spectrum analyzer integration
|
||||
- [ ] RTL-SDR support for wider RF
|
||||
- [ ] Machine learning device classification
|
||||
- [ ] Mobile app companion
|
||||
- [ ] AR visualization mode
|
||||
- [ ] Integration with Wireshark
|
||||
- [ ] Packet capture correlation
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|------------|
|
||||
| v0.1.0 | 2026-01 | Initial CLI scanner |
|
||||
| v0.2.0 | 2026-01 | Web dashboard |
|
||||
| v0.3.0 | TBD | 3D visualization |
|
||||
105
TASKS.md
Normal file
105
TASKS.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# RF Mapper - Active Tasks
|
||||
|
||||
**Sprint:** v0.3.0 - 3D Visualization
|
||||
**Updated:** 2026-01-31
|
||||
|
||||
---
|
||||
|
||||
## Priority Levels
|
||||
- **P0** - Critical / Blocking
|
||||
- **P1** - High / Current sprint
|
||||
- **P2** - Medium / Next sprint
|
||||
- **P3** - Low / Backlog
|
||||
|
||||
## Status Legend
|
||||
- `[ ]` Todo
|
||||
- `[~]` In Progress
|
||||
- `[x]` Done
|
||||
- `[-]` Blocked
|
||||
|
||||
---
|
||||
|
||||
## P0 - Critical
|
||||
|
||||
| Status | Task | Notes |
|
||||
|--------|------|-------|
|
||||
| [-] | Fix Bluetooth RSSI acquisition | `hcitool rssi` only works for connected devices; `bluetoothctl` doesn't expose RSSI for cached devices |
|
||||
|
||||
---
|
||||
|
||||
## P1 - High Priority (Current Sprint)
|
||||
|
||||
| Status | Task | Notes |
|
||||
|--------|------|-------|
|
||||
| [x] | MapLibre GL JS integration | 3D map with building extrusion |
|
||||
| [x] | Floor-based positioning | Devices assigned to floors |
|
||||
| [x] | Floor selector UI | Dropdown to filter by floor |
|
||||
| [x] | Custom distance override | Set manual distance via popup |
|
||||
| [x] | Live BT tracking mode | 4-second scan interval |
|
||||
| [x] | Moving device detection | Purple markers for RSSI changes |
|
||||
| [x] | Filter-aware scanning | Skip WiFi/BT based on toggle |
|
||||
| [~] | Improve BT discovery reliability | Try alternative scanning methods |
|
||||
| [ ] | Document API endpoints | docs/API.md |
|
||||
| [ ] | Create CHEATSHEET.md | Quick reference guide |
|
||||
|
||||
---
|
||||
|
||||
## P2 - Medium Priority (Next Sprint)
|
||||
|
||||
| Status | Task | Notes |
|
||||
|--------|------|-------|
|
||||
| [ ] | Position smoothing | Average RSSI over multiple samples |
|
||||
| [ ] | Device trails | Show movement history on map |
|
||||
| [ ] | Signal strength graph | Per-device RSSI over time |
|
||||
| [ ] | Scan history browser | View past scans in UI |
|
||||
| [ ] | Export functionality | Download scan data as CSV |
|
||||
|
||||
---
|
||||
|
||||
## P3 - Low Priority (Backlog)
|
||||
|
||||
| Status | Task | Notes |
|
||||
|--------|------|-------|
|
||||
| [x] | SQLite persistence | Historical device tracking enabled |
|
||||
| [x] | Device labeling | Custom names via API |
|
||||
| [ ] | Home Assistant integration | MQTT/webhook |
|
||||
| [ ] | Docker container | Containerized deployment |
|
||||
| [ ] | Unit tests | pytest coverage |
|
||||
|
||||
---
|
||||
|
||||
## Completed This Sprint
|
||||
|
||||
| Task | Completed |
|
||||
|------|-----------|
|
||||
| 3D map view with MapLibre | 2026-01-31 |
|
||||
| Floor assignment in popup | 2026-01-31 |
|
||||
| Custom distance setting | 2026-01-31 |
|
||||
| Live tracking button | 2026-01-31 |
|
||||
| Purple moving indicators | 2026-01-31 |
|
||||
| Smart scanning (filter-aware) | 2026-01-31 |
|
||||
|
||||
---
|
||||
|
||||
## Blockers
|
||||
|
||||
### BT RSSI Acquisition
|
||||
**Problem:** Cannot get reliable RSSI values for Bluetooth devices
|
||||
- `hcitool rssi <addr>` - Only works for connected devices
|
||||
- `bluetoothctl info` - No RSSI for cached devices
|
||||
- `btmgmt find` - Not providing output
|
||||
- BLE scan (`hcitool lescan`) - I/O errors on this adapter
|
||||
|
||||
**Potential Solutions:**
|
||||
1. Use D-Bus BlueZ API directly (needs dbus-python)
|
||||
2. Keep devices paired/connected for RSSI polling
|
||||
3. Focus on WiFi-based tracking instead
|
||||
4. Use dedicated BLE beacon hardware
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Bluetooth scanning is unreliable on Raspberry Pi 5 with built-in adapter
|
||||
- WiFi scanning works well with `iw` command
|
||||
- Consider external USB Bluetooth adapter for better BLE support
|
||||
225
TODO.md
Normal file
225
TODO.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# RF Mapper - TODO / Backlog
|
||||
|
||||
**Last Updated:** 2026-01-31
|
||||
|
||||
---
|
||||
|
||||
## Historical Data & Device Tracking (Priority)
|
||||
|
||||
- [x] SQLite database for device history
|
||||
- [x] Store every scan result with timestamp
|
||||
- [x] Track first_seen / last_seen per device
|
||||
- [x] RSSI history per device (time series)
|
||||
- [x] Calculate average/min/max RSSI per device
|
||||
- [x] Device appearance frequency statistics
|
||||
- [x] Motion detection from RSSI patterns
|
||||
- [x] Velocity estimation from distance changes
|
||||
- [ ] Movement trajectory visualization
|
||||
- [ ] Device presence heatmap over time
|
||||
- [ ] Historical playback mode (scrub through time)
|
||||
- [x] Device activity patterns (daily/weekly)
|
||||
- [x] Alert on new device detection
|
||||
- [ ] Alert on device absence (left the area)
|
||||
- [x] Data retention policies (auto-cleanup old data)
|
||||
|
||||
---
|
||||
|
||||
## Scanning
|
||||
|
||||
- [ ] Support multiple WiFi interfaces
|
||||
- [ ] Scan specific channels only (faster)
|
||||
- [ ] Hidden network detection
|
||||
- [ ] WPA3 detection
|
||||
- [ ] 5GHz/6GHz band identification
|
||||
- [ ] Bluetooth LE advertisement parsing
|
||||
- [ ] iBeacon/Eddystone protocol support
|
||||
- [ ] BLE service UUID decoding
|
||||
- [ ] Scan scheduling with cron expressions
|
||||
- [ ] Concurrent WiFi + BT scanning
|
||||
|
||||
---
|
||||
|
||||
## Distance & Positioning
|
||||
|
||||
- [ ] Configurable TX power per device type
|
||||
- [ ] Path loss exponent calibration wizard
|
||||
- [ ] Environment presets (office, home, warehouse)
|
||||
- [ ] Wall attenuation factor
|
||||
- [ ] Multi-floor path loss adjustment
|
||||
- [ ] Trilateration from multiple scan points
|
||||
- [ ] Kalman filter for position smoothing
|
||||
- [ ] Dead reckoning with IMU (if available)
|
||||
- [ ] Fingerprinting-based positioning
|
||||
- [ ] Machine learning position estimation
|
||||
|
||||
---
|
||||
|
||||
## Visualization
|
||||
|
||||
- [ ] Dark/light theme toggle
|
||||
- [ ] Custom marker icons per device type
|
||||
- [ ] Signal strength color gradient
|
||||
- [ ] Animated radar sweep effect
|
||||
- [ ] Mini-map in corner
|
||||
- [ ] Fullscreen mode
|
||||
- [ ] Split view (radar + map)
|
||||
- [ ] Device clustering at zoom levels
|
||||
- [ ] Floor plan image overlay
|
||||
- [ ] Custom building polygon drawing
|
||||
- [ ] SVG export for diagrams
|
||||
- [ ] Device trail/path visualization
|
||||
- [ ] Speed/direction indicators on moving devices
|
||||
|
||||
---
|
||||
|
||||
## Statistics & Analytics
|
||||
|
||||
- [ ] Dashboard with device counts over time
|
||||
- [ ] Signal quality trends per device
|
||||
- [ ] Busiest hours/days heatmap
|
||||
- [ ] Device type distribution pie chart
|
||||
- [ ] Manufacturer breakdown
|
||||
- [ ] Floor occupancy statistics
|
||||
- [ ] Motion events timeline
|
||||
- [ ] Exportable reports (PDF)
|
||||
|
||||
---
|
||||
|
||||
## Data & Storage
|
||||
|
||||
- [ ] SQLite database backend
|
||||
- [ ] Automatic scan rotation/cleanup
|
||||
- [ ] Compressed JSON storage
|
||||
- [ ] Cloud backup option
|
||||
- [ ] Import from other tools (Kismet, etc.)
|
||||
- [ ] Merge scans from multiple sessions
|
||||
- [ ] Data anonymization option
|
||||
- [ ] GDPR compliance features
|
||||
|
||||
---
|
||||
|
||||
## API & Integration
|
||||
|
||||
- [ ] OpenAPI/Swagger documentation
|
||||
- [ ] WebSocket for real-time updates
|
||||
- [ ] GraphQL endpoint (optional)
|
||||
- [ ] MQTT publishing
|
||||
- [ ] Home Assistant auto-discovery
|
||||
- [ ] Webhook on device events
|
||||
- [ ] Prometheus metrics endpoint
|
||||
- [ ] Grafana dashboard template
|
||||
- [ ] Node-RED integration nodes
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] Optional authentication (basic/token)
|
||||
- [ ] HTTPS support
|
||||
- [ ] Rate limiting
|
||||
- [ ] Audit logging
|
||||
- [ ] MAC address randomization detection
|
||||
- [ ] Rogue AP detection
|
||||
- [ ] Deauth attack detection
|
||||
- [ ] Known device allowlist
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- [ ] Async scanning with asyncio
|
||||
- [ ] Scan result caching
|
||||
- [ ] Lazy loading for large device lists
|
||||
- [ ] WebWorker for frontend processing
|
||||
- [ ] Gzip compression for API responses
|
||||
- [ ] Database query optimization
|
||||
- [ ] Memory profiling and optimization
|
||||
|
||||
---
|
||||
|
||||
## DevOps
|
||||
|
||||
- [ ] GitHub Actions CI/CD
|
||||
- [ ] Automated testing on PR
|
||||
- [ ] Docker multi-arch builds
|
||||
- [ ] Helm chart for Kubernetes
|
||||
- [ ] Ansible playbook for deployment
|
||||
- [ ] systemd service with watchdog
|
||||
- [ ] Log rotation configuration
|
||||
- [ ] Health check endpoint
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Video tutorial
|
||||
- [ ] Architecture diagrams (Mermaid)
|
||||
- [ ] Troubleshooting guide
|
||||
- [ ] FAQ section
|
||||
- [ ] Contribution guidelines
|
||||
- [ ] Code of conduct
|
||||
- [ ] Security policy
|
||||
- [ ] Changelog automation
|
||||
|
||||
---
|
||||
|
||||
## Hardware Support
|
||||
|
||||
- [ ] External USB WiFi adapters
|
||||
- [ ] External USB Bluetooth adapters
|
||||
- [ ] RTL-SDR integration
|
||||
- [ ] HackRF support
|
||||
- [ ] ESP32 as remote scanner node
|
||||
- [ ] Raspberry Pi Pico W support
|
||||
- [ ] GPS module integration (for mobile scanning)
|
||||
- [ ] PoE-powered deployment
|
||||
|
||||
---
|
||||
|
||||
## Ideas / Research
|
||||
|
||||
- [ ] Crowd-sourced device fingerprints
|
||||
- [ ] AI-based device classification
|
||||
- [ ] Augmented reality view (WebXR)
|
||||
- [ ] Voice control integration
|
||||
- [ ] Mesh network topology mapping
|
||||
- [ ] Time-based access patterns
|
||||
- [ ] Anomaly detection
|
||||
- [ ] Digital twin integration
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt
|
||||
|
||||
- [ ] Refactor scanner.py (too large)
|
||||
- [ ] Split app.py into blueprints
|
||||
- [ ] Type hints for all functions
|
||||
- [ ] Consistent error handling
|
||||
- [ ] Remove deprecated code paths
|
||||
- [ ] Update dependencies
|
||||
- [ ] Fix linting warnings
|
||||
- [ ] Improve test coverage (target: 80%)
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Check if OpenStreetMap provides 3D view capabilities
|
||||
- [x] Allow placement of height (floor) in the building for device positioning
|
||||
- [x] Implement 3D navigable map (using MapLibre GL JS with building extrusion)
|
||||
- [x] Device floor assignment UI (click device to assign floor)
|
||||
- [x] SQLite database for historical device tracking
|
||||
- [x] RSSI time-series history with statistics
|
||||
- [x] Movement detection and velocity estimation
|
||||
- [x] Device labeling and favorites
|
||||
- [x] New device alerts
|
||||
- [x] Automatic data retention/cleanup
|
||||
|
||||
---
|
||||
|
||||
## Won't Do (Decided Against)
|
||||
|
||||
- ~~Active WiFi probing~~ - Too intrusive, passive only
|
||||
- ~~Packet injection~~ - Legal concerns
|
||||
- ~~Deauth capabilities~~ - Malicious use potential
|
||||
- ~~Commercial tracking~~ - Privacy concerns
|
||||
234
USAGE.md
Normal file
234
USAGE.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# RF Mapper Usage Guide
|
||||
|
||||
RF Mapper is a WiFi and Bluetooth signal mapper for Linux. It scans nearby devices, estimates distances, and visualizes results in multiple views including radar, 2.5D, world map, and 3D map with building support.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with WiFi and Bluetooth hardware
|
||||
- Python 3.10+
|
||||
- `sudo` access (required for scanning)
|
||||
- System tools: `iw`, `hcitool`, `bluetoothctl`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Create and activate virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Activate venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Run interactive scan
|
||||
rf-mapper
|
||||
|
||||
# Start web interface
|
||||
rf-mapper web
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Scanning
|
||||
|
||||
```bash
|
||||
# Basic scan (interactive mode)
|
||||
rf-mapper
|
||||
|
||||
# Scan with location label
|
||||
rf-mapper scan -l kitchen
|
||||
|
||||
# Scan WiFi only
|
||||
rf-mapper scan --no-bt
|
||||
|
||||
# Scan Bluetooth only
|
||||
rf-mapper scan --no-wifi
|
||||
|
||||
# Use specific WiFi interface
|
||||
rf-mapper scan -i wlan1
|
||||
```
|
||||
|
||||
### Visualization (CLI)
|
||||
|
||||
```bash
|
||||
# Visualize latest scan (ASCII radar + charts)
|
||||
rf-mapper visualize
|
||||
|
||||
# Visualize specific scan file
|
||||
rf-mapper visualize -f data/scan_20240131_120000_kitchen.json
|
||||
|
||||
# Analyze RF environment
|
||||
rf-mapper analyze
|
||||
```
|
||||
|
||||
### Scan History
|
||||
|
||||
```bash
|
||||
# List saved scans
|
||||
rf-mapper list
|
||||
```
|
||||
|
||||
### Web Interface
|
||||
|
||||
```bash
|
||||
# Start web server (default: http://0.0.0.0:5000)
|
||||
rf-mapper web
|
||||
|
||||
# Custom host/port
|
||||
rf-mapper web -H 127.0.0.1 -p 8080
|
||||
|
||||
# Debug mode
|
||||
rf-mapper web --debug
|
||||
|
||||
# With request profiling
|
||||
rf-mapper web --profile-requests
|
||||
|
||||
# With request logging
|
||||
rf-mapper web --log-requests
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Show current configuration
|
||||
rf-mapper config
|
||||
|
||||
# Set GPS coordinates
|
||||
rf-mapper config --set-gps 50.8585 4.3978 --save
|
||||
```
|
||||
|
||||
### Profiling
|
||||
|
||||
```bash
|
||||
# Profile CPU usage
|
||||
rf-mapper --profile scan
|
||||
|
||||
# Profile memory usage
|
||||
rf-mapper --profile-memory scan
|
||||
|
||||
# Save profile to file
|
||||
rf-mapper --profile --profile-output scan.prof scan
|
||||
```
|
||||
|
||||
## Web Interface Views
|
||||
|
||||
The web dashboard offers 3 visualization modes:
|
||||
|
||||
| View | Description |
|
||||
|------|-------------|
|
||||
| **Radar** | Classic radar display with distance rings |
|
||||
| **World Map** | Leaflet map with device markers on real geography |
|
||||
| **3D Map** | MapLibre GL 3D view with building extrusion |
|
||||
|
||||
### Features
|
||||
|
||||
- Real-time scanning via "New Scan" button
|
||||
- Auto-scan mode with configurable interval
|
||||
- WiFi/Bluetooth filter toggles
|
||||
- Floor filtering for multi-story buildings
|
||||
- Click devices for detailed info popups
|
||||
- Device lists with signal strength indicators
|
||||
|
||||
## Configuration File
|
||||
|
||||
Configuration is loaded from (in order):
|
||||
1. `./config.yaml`
|
||||
2. `~/.config/rf-mapper/config.yaml`
|
||||
3. `/etc/rf-mapper/config.yaml`
|
||||
|
||||
### Example `config.yaml`
|
||||
|
||||
```yaml
|
||||
gps:
|
||||
latitude: 50.8585853
|
||||
longitude: 4.3978724
|
||||
|
||||
web:
|
||||
host: "0.0.0.0"
|
||||
port: 5000
|
||||
debug: false
|
||||
|
||||
scanner:
|
||||
wifi_interface: "wlan0"
|
||||
bt_scan_timeout: 10
|
||||
path_loss_exponent: 2.5
|
||||
auto_identify_bluetooth: true
|
||||
|
||||
data:
|
||||
directory: "data"
|
||||
max_scans: 100
|
||||
|
||||
building:
|
||||
enabled: false
|
||||
name: "My Building"
|
||||
floors: 3
|
||||
floor_height_m: 3.0
|
||||
ground_floor_number: 0
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RF_MAPPER_LAT` | Override GPS latitude |
|
||||
| `RF_MAPPER_LON` | Override GPS longitude |
|
||||
| `RF_MAPPER_HOST` | Override web server host |
|
||||
| `RF_MAPPER_PORT` | Override web server port |
|
||||
| `HA_TOKEN` | Home Assistant API token |
|
||||
| `HA_URL` | Home Assistant URL |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The web server exposes a REST API:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/scan` | Trigger new scan |
|
||||
| GET | `/api/latest` | Get most recent scan |
|
||||
| GET | `/api/scans` | List all scans |
|
||||
| GET | `/api/scans/<filename>` | Get specific scan |
|
||||
| GET/POST | `/api/position` | Get/set GPS position |
|
||||
| GET/POST | `/api/config` | Get/update configuration |
|
||||
| GET/POST | `/api/building` | Get/update building config |
|
||||
| POST | `/api/device/<id>/floor` | Assign floor to device |
|
||||
| GET | `/api/autoscan` | Get auto-scan status |
|
||||
| POST | `/api/autoscan/start` | Start auto-scanning |
|
||||
| POST | `/api/autoscan/stop` | Stop auto-scanning |
|
||||
| GET | `/api/bluetooth/identify/<addr>` | Identify BT device |
|
||||
|
||||
## Data Storage
|
||||
|
||||
Scan results are saved as JSON in the data directory:
|
||||
- Default: `./data/`
|
||||
- Files: `scan_YYYYMMDD_HHMMSS_<location>.json`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "WiFi scan error: Operation not permitted"
|
||||
Run with sudo or ensure your user has permissions:
|
||||
```bash
|
||||
sudo rf-mapper scan
|
||||
```
|
||||
|
||||
### "No Bluetooth adapter found"
|
||||
Ensure Bluetooth is enabled:
|
||||
```bash
|
||||
sudo systemctl start bluetooth
|
||||
sudo hciconfig hci0 up
|
||||
```
|
||||
|
||||
### Web interface shows no devices
|
||||
1. Run a scan first: click "New Scan" or use CLI
|
||||
2. Check if data directory has scan files
|
||||
3. Verify filters aren't hiding devices
|
||||
|
||||
### 3D buildings not showing
|
||||
- Zoom in to level 15+ for buildings to appear
|
||||
- Not all areas have building data in OpenStreetMap
|
||||
- The map style must support vector tiles with building data
|
||||
70
config.yaml
Normal file
70
config.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
# RF Mapper Configuration
|
||||
# ========================
|
||||
|
||||
# GPS Reference Position
|
||||
# Set this to your Home Assistant or scanner location
|
||||
gps:
|
||||
# Belgium (Brussels area) - Update with your actual coordinates
|
||||
latitude: 50.8585853
|
||||
longitude: 4.3978724
|
||||
# You can get precise coordinates from Home Assistant at:
|
||||
# http://192.168.129.10:8123/config/zone
|
||||
|
||||
# Web Server Settings
|
||||
web:
|
||||
host: "0.0.0.0"
|
||||
port: 5000
|
||||
debug: false
|
||||
|
||||
# Scanner Settings
|
||||
scanner:
|
||||
# WiFi interface name
|
||||
wifi_interface: "wlan0"
|
||||
# Bluetooth scan timeout in seconds
|
||||
bt_scan_timeout: 10
|
||||
# Path loss exponent for distance estimation
|
||||
# 2.0 = free space, 2.5 = light indoor, 3.0-4.0 = walls
|
||||
path_loss_exponent: 2.5
|
||||
# Automatically identify Bluetooth devices (queries device services)
|
||||
auto_identify_bluetooth: true
|
||||
|
||||
# Data Storage
|
||||
data:
|
||||
# Directory for scan data (relative to project root or absolute path)
|
||||
directory: "data"
|
||||
# Maximum number of scans to keep (0 = unlimited)
|
||||
max_scans: 100
|
||||
|
||||
# SQLite Database for Device History
|
||||
database:
|
||||
# Enable historical tracking
|
||||
enabled: true
|
||||
# Database filename (stored in data directory)
|
||||
filename: "devices.db"
|
||||
# Data retention period in days (auto-cleanup older data)
|
||||
retention_days: 30
|
||||
# Enable automatic daily cleanup
|
||||
auto_cleanup: true
|
||||
|
||||
# Home Assistant Integration (optional)
|
||||
home_assistant:
|
||||
enabled: false
|
||||
url: "http://192.168.129.10:8123"
|
||||
# Token can be set here or via HA_TOKEN environment variable
|
||||
# Generate at: http://192.168.129.10:8123/profile -> Long-Lived Access Tokens
|
||||
token: ""
|
||||
|
||||
# Building Configuration (for 3D map view)
|
||||
building:
|
||||
# Enable 3D building visualization
|
||||
enabled: true
|
||||
# Building name (displayed in UI)
|
||||
name: "Home"
|
||||
# Number of floors in the building
|
||||
floors: 12
|
||||
# Height of each floor in meters
|
||||
floor_height_m: 3.0
|
||||
# Ground floor number (0 in most countries, 1 in US)
|
||||
ground_floor_number: 0
|
||||
# Scanner's current floor (devices scanned will be assigned to this floor)
|
||||
current_floor: 11
|
||||
74
pyproject.toml
Normal file
74
pyproject.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rf-mapper"
|
||||
version = "1.0.0"
|
||||
description = "RF Environment Scanner - WiFi and Bluetooth signal mapper"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "User"}
|
||||
]
|
||||
keywords = ["wifi", "bluetooth", "rssi", "signal", "mapping", "scanner"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Networking",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
visualization = [
|
||||
"matplotlib>=3.5.0",
|
||||
"numpy>=1.20.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"black>=23.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
all = [
|
||||
"rf-mapper[visualization,dev]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
rf-mapper = "rf_mapper.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/user/rf-mapper"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py310", "py311", "py312"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
25
src/rf_mapper/__init__.py
Normal file
25
src/rf_mapper/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""RF Environment Scanner - WiFi and Bluetooth signal mapper"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "User"
|
||||
|
||||
from .scanner import RFScanner, WifiNetwork, BluetoothDevice, ScanResult
|
||||
from .oui import OUILookup
|
||||
from .bluetooth_class import BluetoothClassDecoder
|
||||
from .visualize import create_ascii_radar, create_signal_strength_chart
|
||||
from .distance import estimate_distance
|
||||
from .config import Config, get_config
|
||||
|
||||
__all__ = [
|
||||
"RFScanner",
|
||||
"WifiNetwork",
|
||||
"BluetoothDevice",
|
||||
"ScanResult",
|
||||
"OUILookup",
|
||||
"BluetoothClassDecoder",
|
||||
"create_ascii_radar",
|
||||
"create_signal_strength_chart",
|
||||
"estimate_distance",
|
||||
"Config",
|
||||
"get_config",
|
||||
]
|
||||
405
src/rf_mapper/__main__.py
Normal file
405
src/rf_mapper/__main__.py
Normal file
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entry point for RF Environment Scanner"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .scanner import RFScanner
|
||||
from .visualize import (
|
||||
create_ascii_radar,
|
||||
create_signal_strength_chart,
|
||||
create_environment_analysis,
|
||||
load_latest_scan
|
||||
)
|
||||
from .distance import estimate_distance
|
||||
from .config import Config, get_config
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="RF Environment Scanner - Map WiFi and Bluetooth signals",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
rf-mapper scan # Scan and save results
|
||||
rf-mapper scan -l kitchen # Scan with location label
|
||||
rf-mapper visualize # Visualize latest scan
|
||||
rf-mapper analyze # Analyze RF environment
|
||||
rf-mapper web # Start web server
|
||||
rf-mapper config # Show current configuration
|
||||
|
||||
Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
help='Path to configuration file'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--profile',
|
||||
action='store_true',
|
||||
help='Enable CPU profiling (prints stats on completion)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--profile-memory',
|
||||
action='store_true',
|
||||
help='Enable memory profiling (shows top allocations)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--profile-output',
|
||||
type=Path,
|
||||
metavar='FILE',
|
||||
help='Save CPU profile to file (.prof format)'
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Scan command
|
||||
scan_parser = subparsers.add_parser('scan', help='Perform WiFi and Bluetooth scan')
|
||||
scan_parser.add_argument(
|
||||
'-l', '--location',
|
||||
default='default',
|
||||
help='Location label for this scan (e.g., living_room, office)'
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
'-i', '--interface',
|
||||
help='WiFi interface to use (default from config)'
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
'--no-wifi',
|
||||
action='store_true',
|
||||
help='Skip WiFi scanning'
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
'--no-bt',
|
||||
action='store_true',
|
||||
help='Skip Bluetooth scanning'
|
||||
)
|
||||
|
||||
# Visualize command
|
||||
viz_parser = subparsers.add_parser('visualize', help='Visualize scan results')
|
||||
viz_parser.add_argument(
|
||||
'-f', '--file',
|
||||
help='Specific scan file to visualize (default: latest)'
|
||||
)
|
||||
|
||||
# Analyze command
|
||||
analyze_parser = subparsers.add_parser('analyze', help='Analyze RF environment')
|
||||
analyze_parser.add_argument(
|
||||
'-f', '--file',
|
||||
help='Specific scan file to analyze (default: latest)'
|
||||
)
|
||||
|
||||
# List command
|
||||
subparsers.add_parser('list', help='List saved scans')
|
||||
|
||||
# Web server command
|
||||
web_parser = subparsers.add_parser('web', help='Start web server')
|
||||
web_parser.add_argument(
|
||||
'-H', '--host',
|
||||
help='Host to bind to (default from config)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'-p', '--port',
|
||||
type=int,
|
||||
help='Port to listen on (default from config)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Enable debug mode'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'--profile-requests',
|
||||
action='store_true',
|
||||
help='Enable per-request profiling (saves profiles to data/profiles/)'
|
||||
)
|
||||
web_parser.add_argument(
|
||||
'--log-requests',
|
||||
action='store_true',
|
||||
help='Log all requests to data/logs/requests_YYYYMMDD.log'
|
||||
)
|
||||
|
||||
# Config command
|
||||
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||
config_parser.add_argument(
|
||||
'--set-gps',
|
||||
nargs=2,
|
||||
metavar=('LAT', 'LON'),
|
||||
help='Set GPS coordinates'
|
||||
)
|
||||
config_parser.add_argument(
|
||||
'--save',
|
||||
action='store_true',
|
||||
help='Save changes to config file'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load configuration
|
||||
config = Config.load(args.config) if args.config else get_config()
|
||||
data_dir = config.get_data_dir()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def run_command():
|
||||
if args.command == 'scan':
|
||||
run_scan(args, config, data_dir)
|
||||
elif args.command == 'visualize':
|
||||
run_visualize(args, data_dir)
|
||||
elif args.command == 'analyze':
|
||||
run_analyze(args, data_dir)
|
||||
elif args.command == 'list':
|
||||
run_list(data_dir)
|
||||
elif args.command == 'web':
|
||||
run_web(args, config)
|
||||
elif args.command == 'config':
|
||||
run_config(args, config)
|
||||
else:
|
||||
# Default: run interactive scan
|
||||
run_interactive(config, data_dir)
|
||||
|
||||
# Wrap command execution with profilers if requested
|
||||
if args.profile or args.profile_memory:
|
||||
from .profiling import cpu_profiler, memory_profiler
|
||||
|
||||
if args.profile and args.profile_memory:
|
||||
with cpu_profiler(args.profile_output), memory_profiler():
|
||||
run_command()
|
||||
elif args.profile:
|
||||
with cpu_profiler(args.profile_output):
|
||||
run_command()
|
||||
else:
|
||||
with memory_profiler():
|
||||
run_command()
|
||||
else:
|
||||
run_command()
|
||||
|
||||
|
||||
def run_interactive(config: Config, data_dir: Path):
|
||||
"""Run interactive scan mode"""
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ RF ENVIRONMENT SCANNER v1.0 ║
|
||||
║ WiFi + Bluetooth Signal Mapper ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Config: {config._config_path or 'defaults'}
|
||||
GPS: {config.gps.latitude}, {config.gps.longitude}
|
||||
""")
|
||||
|
||||
try:
|
||||
location = input("Enter location label (e.g., 'living_room'): ").strip() or "default"
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
location = "default"
|
||||
|
||||
scanner = RFScanner(data_dir)
|
||||
result, wifi, bt = scanner.full_scan(location)
|
||||
scanner.print_results(wifi, bt)
|
||||
|
||||
# Show visualizations
|
||||
if wifi:
|
||||
print(create_ascii_radar(result.wifi_networks, f"WiFi Networks ({len(wifi)} found)"))
|
||||
if bt:
|
||||
print(create_ascii_radar(result.bluetooth_devices, f"Bluetooth Devices ({len(bt)} found)"))
|
||||
|
||||
print(create_environment_analysis(result.wifi_networks, result.bluetooth_devices))
|
||||
|
||||
# Estimated distances
|
||||
print(f"\n{'='*60}")
|
||||
print("ESTIMATED DISTANCES")
|
||||
print('='*60)
|
||||
|
||||
print("\nWiFi Access Points (nearest 5):")
|
||||
for net in sorted(wifi, key=lambda x: x.rssi, reverse=True)[:5]:
|
||||
dist = estimate_distance(net.rssi, n=config.scanner.path_loss_exponent)
|
||||
print(f" {net.ssid[:25]:<25} ~{dist:.1f}m away")
|
||||
|
||||
if bt:
|
||||
print("\nBluetooth Devices (nearest 5):")
|
||||
for dev in sorted(bt, key=lambda x: x.rssi, reverse=True)[:5]:
|
||||
dist = estimate_distance(dev.rssi, tx_power=-65, n=config.scanner.path_loss_exponent)
|
||||
print(f" {dev.name[:25]:<25} ~{dist:.1f}m away")
|
||||
|
||||
|
||||
def run_scan(args, config: Config, data_dir: Path):
|
||||
"""Run a scan with specified options"""
|
||||
interface = args.interface or config.scanner.wifi_interface
|
||||
scanner = RFScanner(data_dir)
|
||||
|
||||
print(f"Starting scan at location: {args.location}")
|
||||
|
||||
wifi = []
|
||||
bt = []
|
||||
|
||||
if not args.no_wifi:
|
||||
wifi = scanner.scan_wifi(interface)
|
||||
print(f"Found {len(wifi)} WiFi networks")
|
||||
|
||||
if not args.no_bt:
|
||||
bt = scanner.scan_bluetooth(
|
||||
timeout=config.scanner.bt_scan_timeout,
|
||||
auto_identify=config.scanner.auto_identify_bluetooth
|
||||
)
|
||||
print(f"Found {len(bt)} Bluetooth devices")
|
||||
|
||||
# Save results
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
result = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'location_label': args.location,
|
||||
'gps': {
|
||||
'latitude': config.gps.latitude,
|
||||
'longitude': config.gps.longitude
|
||||
},
|
||||
'wifi_networks': [asdict(n) for n in wifi],
|
||||
'bluetooth_devices': [asdict(d) for d in bt]
|
||||
}
|
||||
|
||||
filename = data_dir / f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{args.location}.json"
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(result, f, indent=2)
|
||||
|
||||
print(f"Saved to: {filename}")
|
||||
|
||||
# Print results
|
||||
scanner.print_results(wifi, bt)
|
||||
|
||||
|
||||
def run_visualize(args, data_dir: Path):
|
||||
"""Visualize scan results"""
|
||||
if args.file:
|
||||
import json
|
||||
with open(args.file) as f:
|
||||
scan = json.load(f)
|
||||
else:
|
||||
scan = load_latest_scan(data_dir)
|
||||
|
||||
if not scan:
|
||||
print("No scan data found. Run 'rf-mapper scan' first.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Scan from: {scan['timestamp']}")
|
||||
print(f"Location: {scan['location_label']}")
|
||||
|
||||
wifi = scan.get('wifi_networks', [])
|
||||
bt = scan.get('bluetooth_devices', [])
|
||||
|
||||
if wifi:
|
||||
print(create_ascii_radar(wifi, f"WiFi Networks ({len(wifi)} found)"))
|
||||
print(create_signal_strength_chart(wifi, "WiFi Signal Strengths"))
|
||||
|
||||
if bt:
|
||||
print(create_ascii_radar(bt, f"Bluetooth Devices ({len(bt)} found)"))
|
||||
print(create_signal_strength_chart(bt, "Bluetooth Signal Strengths"))
|
||||
|
||||
|
||||
def run_analyze(args, data_dir: Path):
|
||||
"""Analyze RF environment"""
|
||||
if args.file:
|
||||
import json
|
||||
with open(args.file) as f:
|
||||
scan = json.load(f)
|
||||
else:
|
||||
scan = load_latest_scan(data_dir)
|
||||
|
||||
if not scan:
|
||||
print("No scan data found. Run 'rf-mapper scan' first.")
|
||||
sys.exit(1)
|
||||
|
||||
wifi = scan.get('wifi_networks', [])
|
||||
bt = scan.get('bluetooth_devices', [])
|
||||
|
||||
print(create_environment_analysis(wifi, bt))
|
||||
|
||||
|
||||
def run_list(data_dir: Path):
|
||||
"""List saved scans"""
|
||||
scan_files = sorted(data_dir.glob('scan_*.json'), reverse=True)
|
||||
|
||||
if not scan_files:
|
||||
print("No scans found.")
|
||||
return
|
||||
|
||||
print(f"{'Date/Time':<20} {'Location':<20} {'WiFi':>6} {'BT':>6}")
|
||||
print('-' * 55)
|
||||
|
||||
for f in scan_files[:20]:
|
||||
import json
|
||||
with open(f) as fh:
|
||||
scan = json.load(fh)
|
||||
ts = scan.get('timestamp', '')[:19].replace('T', ' ')
|
||||
loc = scan.get('location_label', 'unknown')
|
||||
wifi_count = len(scan.get('wifi_networks', []))
|
||||
bt_count = len(scan.get('bluetooth_devices', []))
|
||||
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
|
||||
|
||||
|
||||
def run_web(args, config: Config):
|
||||
"""Start the web server"""
|
||||
from .web.app import run_server
|
||||
|
||||
host = args.host
|
||||
port = args.port
|
||||
debug = args.debug
|
||||
profile_requests = getattr(args, 'profile_requests', False)
|
||||
log_requests = getattr(args, 'log_requests', False)
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug,
|
||||
config=config,
|
||||
profile_requests=profile_requests,
|
||||
log_requests=log_requests
|
||||
)
|
||||
|
||||
|
||||
def run_config(args, config: Config):
|
||||
"""Show or edit configuration"""
|
||||
if args.set_gps:
|
||||
lat, lon = args.set_gps
|
||||
config.gps.latitude = float(lat)
|
||||
config.gps.longitude = float(lon)
|
||||
print(f"GPS set to: {config.gps.latitude}, {config.gps.longitude}")
|
||||
|
||||
if args.save:
|
||||
config.save()
|
||||
print(f"Configuration saved to: {config._config_path}")
|
||||
|
||||
# Show current config
|
||||
print(f"""
|
||||
RF Mapper Configuration
|
||||
{'='*40}
|
||||
|
||||
Config File: {config._config_path or 'Not found (using defaults)'}
|
||||
|
||||
GPS Position:
|
||||
Latitude: {config.gps.latitude}
|
||||
Longitude: {config.gps.longitude}
|
||||
|
||||
Web Server:
|
||||
Host: {config.web.host}
|
||||
Port: {config.web.port}
|
||||
|
||||
Scanner:
|
||||
WiFi Interface: {config.scanner.wifi_interface}
|
||||
BT Scan Timeout: {config.scanner.bt_scan_timeout}s
|
||||
Path Loss Exponent: {config.scanner.path_loss_exponent}
|
||||
|
||||
Data:
|
||||
Directory: {config.get_data_dir()}
|
||||
Max Scans: {config.data.max_scans}
|
||||
|
||||
Home Assistant:
|
||||
Enabled: {config.home_assistant.enabled}
|
||||
URL: {config.home_assistant.url}
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
126
src/rf_mapper/bluetooth_class.py
Normal file
126
src/rf_mapper/bluetooth_class.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Bluetooth Class of Device decoder"""
|
||||
|
||||
|
||||
class BluetoothClassDecoder:
|
||||
"""Decode Bluetooth Class of Device (CoD) codes"""
|
||||
|
||||
MAJOR_DEVICE_CLASSES = {
|
||||
0: "Miscellaneous",
|
||||
1: "Computer",
|
||||
2: "Phone",
|
||||
3: "LAN/Network",
|
||||
4: "Audio/Video",
|
||||
5: "Peripheral",
|
||||
6: "Imaging",
|
||||
7: "Wearable",
|
||||
8: "Toy",
|
||||
9: "Health",
|
||||
31: "Uncategorized"
|
||||
}
|
||||
|
||||
MINOR_COMPUTER = {
|
||||
0: "Uncategorized",
|
||||
1: "Desktop",
|
||||
2: "Server",
|
||||
3: "Laptop",
|
||||
4: "Handheld/PDA",
|
||||
5: "Palm/PDA",
|
||||
6: "Wearable"
|
||||
}
|
||||
|
||||
MINOR_PHONE = {
|
||||
0: "Uncategorized",
|
||||
1: "Cellular",
|
||||
2: "Cordless",
|
||||
3: "Smartphone",
|
||||
4: "Modem/Gateway",
|
||||
5: "ISDN"
|
||||
}
|
||||
|
||||
MINOR_AV = {
|
||||
0: "Uncategorized",
|
||||
1: "Wearable Headset",
|
||||
2: "Hands-free",
|
||||
4: "Microphone",
|
||||
5: "Loudspeaker",
|
||||
6: "Headphones",
|
||||
7: "Portable Audio",
|
||||
8: "Car Audio",
|
||||
9: "Set-top Box",
|
||||
10: "HiFi Audio",
|
||||
11: "VCR",
|
||||
12: "Video Camera",
|
||||
13: "Camcorder",
|
||||
14: "Video Monitor",
|
||||
15: "Video Display and Loudspeaker",
|
||||
16: "Video Conferencing",
|
||||
18: "Gaming/Toy"
|
||||
}
|
||||
|
||||
MINOR_PERIPHERAL = {
|
||||
0: "Uncategorized",
|
||||
1: "Keyboard",
|
||||
2: "Pointing Device",
|
||||
3: "Combo Keyboard/Pointing"
|
||||
}
|
||||
|
||||
MINOR_IMAGING = {
|
||||
1: "Display",
|
||||
2: "Camera",
|
||||
4: "Scanner",
|
||||
8: "Printer"
|
||||
}
|
||||
|
||||
MINOR_WEARABLE = {
|
||||
1: "Wristwatch",
|
||||
2: "Pager",
|
||||
3: "Jacket",
|
||||
4: "Helmet",
|
||||
5: "Glasses"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def decode(cls, class_hex: str) -> tuple[str, str]:
|
||||
"""
|
||||
Decode Class of Device hex string into device type and category.
|
||||
|
||||
Args:
|
||||
class_hex: Hex string of Class of Device (e.g., "0x240404")
|
||||
|
||||
Returns:
|
||||
Tuple of (major_class, minor_class)
|
||||
"""
|
||||
try:
|
||||
cod = int(class_hex, 16)
|
||||
major = (cod >> 8) & 0x1F
|
||||
minor = (cod >> 2) & 0x3F
|
||||
|
||||
major_str = cls.MAJOR_DEVICE_CLASSES.get(major, f"Unknown ({major})")
|
||||
|
||||
minor_str = ""
|
||||
if major == 1:
|
||||
minor_str = cls.MINOR_COMPUTER.get(minor, f"Unknown ({minor})")
|
||||
elif major == 2:
|
||||
minor_str = cls.MINOR_PHONE.get(minor, f"Unknown ({minor})")
|
||||
elif major == 4:
|
||||
minor_str = cls.MINOR_AV.get(minor, f"Unknown ({minor})")
|
||||
elif major == 5:
|
||||
minor_str = cls.MINOR_PERIPHERAL.get(minor & 0x03, f"Unknown ({minor})")
|
||||
elif major == 6:
|
||||
minor_str = cls.MINOR_IMAGING.get(minor & 0x0F, f"Unknown ({minor})")
|
||||
elif major == 7:
|
||||
minor_str = cls.MINOR_WEARABLE.get(minor, f"Unknown ({minor})")
|
||||
else:
|
||||
minor_str = str(minor) if minor else ""
|
||||
|
||||
return major_str, minor_str
|
||||
except (ValueError, TypeError):
|
||||
return "Unknown", ""
|
||||
|
||||
@classmethod
|
||||
def decode_to_string(cls, class_hex: str) -> str:
|
||||
"""Decode to a single descriptive string"""
|
||||
major, minor = cls.decode(class_hex)
|
||||
if minor:
|
||||
return f"{major} ({minor})"
|
||||
return major
|
||||
657
src/rf_mapper/bluetooth_identify.py
Normal file
657
src/rf_mapper/bluetooth_identify.py
Normal file
@@ -0,0 +1,657 @@
|
||||
"""Enhanced Bluetooth device identification using SDP and GATT"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def is_random_mac(mac: str) -> bool:
|
||||
"""Check if MAC address is locally administered (randomized)"""
|
||||
try:
|
||||
first_byte = int(mac.split(":")[0], 16)
|
||||
return (first_byte & 0x02) != 0
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
# Manufacturer to likely device type mapping (for BLE devices with no name)
|
||||
MANUFACTURER_DEVICE_HINTS = {
|
||||
# Audio companies
|
||||
"harman": "Speaker/Audio",
|
||||
"jbl": "Speaker/Audio",
|
||||
"bose": "Speaker/Audio",
|
||||
"sonos": "Speaker",
|
||||
"bang & olufsen": "Speaker/Audio",
|
||||
"sennheiser": "Headphones/Audio",
|
||||
"jabra": "Headset/Audio",
|
||||
"plantronics": "Headset",
|
||||
"skullcandy": "Headphones",
|
||||
"beats": "Headphones",
|
||||
"sony": "Audio/Media Device",
|
||||
"marshall": "Speaker",
|
||||
"ultimate ears": "Speaker",
|
||||
"anker": "Audio/Charger",
|
||||
"soundcore": "Speaker/Audio",
|
||||
"hui zhou gaoshengda": "Speaker/Audio", # Makes JBL/Harman products
|
||||
|
||||
# Phone/computing companies
|
||||
"apple": "Apple Device",
|
||||
"samsung": "Samsung Device",
|
||||
"google": "Google Device",
|
||||
"huawei": "Huawei Device",
|
||||
"xiaomi": "Xiaomi Device",
|
||||
"oneplus": "OnePlus Device",
|
||||
"oppo": "Phone/Wearable",
|
||||
"vivo": "Phone/Wearable",
|
||||
"realme": "Phone/Wearable",
|
||||
"motorola": "Phone",
|
||||
"lg": "Phone/TV",
|
||||
"nokia": "Phone",
|
||||
"asus": "Computer/Phone",
|
||||
"lenovo": "Computer/Tablet",
|
||||
"dell": "Computer",
|
||||
"hp": "Computer/Printer",
|
||||
"microsoft": "Computer/Accessory",
|
||||
"intel": "Computer",
|
||||
|
||||
# Wearables
|
||||
"fitbit": "Fitness Tracker",
|
||||
"garmin": "GPS/Fitness Watch",
|
||||
"polar": "Fitness Watch",
|
||||
"suunto": "Sports Watch",
|
||||
"amazfit": "Smartwatch",
|
||||
"zepp": "Smartwatch",
|
||||
"fossil": "Smartwatch",
|
||||
"mobvoi": "Smartwatch",
|
||||
"withings": "Health Device",
|
||||
"oura": "Smart Ring",
|
||||
|
||||
# Smart home
|
||||
"philips": "Smart Home/Light",
|
||||
"signify": "Smart Light (Hue)",
|
||||
"ikea": "Smart Home",
|
||||
"lutron": "Smart Light/Switch",
|
||||
"ecobee": "Thermostat",
|
||||
"nest": "Smart Home",
|
||||
"ring": "Doorbell/Camera",
|
||||
"arlo": "Camera",
|
||||
"wyze": "Smart Home",
|
||||
"tuya": "Smart Home",
|
||||
"espressif": "IoT Device",
|
||||
"nordic": "IoT/Sensor",
|
||||
"texas instruments": "IoT/Sensor",
|
||||
"silicon labs": "IoT Device",
|
||||
"dialog": "IoT Device",
|
||||
"telink": "IoT/Smart Home",
|
||||
|
||||
# TV/Media
|
||||
"roku": "Streaming Device",
|
||||
"amazon": "Echo/Fire Device",
|
||||
"tcl": "TV",
|
||||
"hisense": "TV",
|
||||
"vizio": "TV",
|
||||
|
||||
# Gaming
|
||||
"nintendo": "Game Controller",
|
||||
"valve": "Game Controller",
|
||||
"8bitdo": "Game Controller",
|
||||
|
||||
# Peripherals
|
||||
"logitech": "Peripheral (KB/Mouse)",
|
||||
"razer": "Gaming Peripheral",
|
||||
"steelseries": "Gaming Peripheral",
|
||||
"corsair": "Gaming Peripheral",
|
||||
|
||||
# Automotive
|
||||
"continental": "Vehicle/OBD",
|
||||
"bosch": "Vehicle/Tool",
|
||||
"denso": "Vehicle",
|
||||
|
||||
# Health
|
||||
"omron": "Health Monitor",
|
||||
"withings": "Health Device",
|
||||
"dexcom": "Glucose Monitor",
|
||||
"abbott": "Health Device",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothDeviceInfo:
|
||||
"""Extended Bluetooth device information"""
|
||||
address: str
|
||||
name: str
|
||||
alias: str
|
||||
device_class: str
|
||||
device_type: str
|
||||
services: list[str]
|
||||
manufacturer: str
|
||||
model: str
|
||||
is_ble: bool
|
||||
paired: bool
|
||||
connected: bool
|
||||
trusted: bool
|
||||
icon: str
|
||||
|
||||
|
||||
# Common Bluetooth service UUIDs and their names
|
||||
SERVICE_UUIDS = {
|
||||
"0x1101": "Serial Port",
|
||||
"0x1102": "LAN Access",
|
||||
"0x1103": "Dialup Networking",
|
||||
"0x1104": "IrMC Sync",
|
||||
"0x1105": "OBEX Object Push",
|
||||
"0x1106": "OBEX File Transfer",
|
||||
"0x1107": "IrMC Sync Command",
|
||||
"0x1108": "Headset",
|
||||
"0x1109": "Cordless Telephony",
|
||||
"0x110a": "Audio Source",
|
||||
"0x110b": "Audio Sink",
|
||||
"0x110c": "A/V Remote Control Target",
|
||||
"0x110d": "Advanced Audio Distribution",
|
||||
"0x110e": "A/V Remote Control",
|
||||
"0x110f": "A/V Remote Control Controller",
|
||||
"0x1110": "Intercom",
|
||||
"0x1111": "Fax",
|
||||
"0x1112": "Headset Audio Gateway",
|
||||
"0x1113": "WAP",
|
||||
"0x1114": "WAP Client",
|
||||
"0x1115": "PANU",
|
||||
"0x1116": "NAP",
|
||||
"0x1117": "GN",
|
||||
"0x1118": "Direct Printing",
|
||||
"0x1119": "Reference Printing",
|
||||
"0x111a": "Basic Imaging Profile",
|
||||
"0x111b": "Imaging Responder",
|
||||
"0x111c": "Imaging Automatic Archive",
|
||||
"0x111d": "Imaging Referenced Objects",
|
||||
"0x111e": "Handsfree",
|
||||
"0x111f": "Handsfree Audio Gateway",
|
||||
"0x1120": "Direct Printing Reference Objects",
|
||||
"0x1121": "Reflected UI",
|
||||
"0x1122": "Basic Printing",
|
||||
"0x1123": "Printing Status",
|
||||
"0x1124": "Human Interface Device",
|
||||
"0x1125": "Hardcopy Cable Replacement",
|
||||
"0x1126": "HCR Print",
|
||||
"0x1127": "HCR Scan",
|
||||
"0x1128": "Common ISDN Access",
|
||||
"0x112d": "SIM Access",
|
||||
"0x112e": "Phonebook Access PCE",
|
||||
"0x112f": "Phonebook Access PSE",
|
||||
"0x1130": "Phonebook Access",
|
||||
"0x1131": "Headset HS",
|
||||
"0x1132": "Message Access Server",
|
||||
"0x1133": "Message Notification Server",
|
||||
"0x1134": "Message Access Profile",
|
||||
"0x1135": "GNSS",
|
||||
"0x1136": "GNSS Server",
|
||||
"0x1200": "PnP Information",
|
||||
"0x1201": "Generic Networking",
|
||||
"0x1202": "Generic File Transfer",
|
||||
"0x1203": "Generic Audio",
|
||||
"0x1204": "Generic Telephony",
|
||||
"0x1205": "UPNP Service",
|
||||
"0x1206": "UPNP IP Service",
|
||||
"0x1300": "ESDP UPNP IP PAN",
|
||||
"0x1301": "ESDP UPNP IP LAP",
|
||||
"0x1302": "ESDP UPNP L2CAP",
|
||||
"0x1303": "Video Source",
|
||||
"0x1304": "Video Sink",
|
||||
"0x1305": "Video Distribution",
|
||||
"0x1400": "HDP",
|
||||
"0x1401": "HDP Source",
|
||||
"0x1402": "HDP Sink",
|
||||
"0x1800": "Generic Access",
|
||||
"0x1801": "Generic Attribute",
|
||||
"0x1802": "Immediate Alert",
|
||||
"0x1803": "Link Loss",
|
||||
"0x1804": "Tx Power",
|
||||
"0x1805": "Current Time Service",
|
||||
"0x1806": "Reference Time Update",
|
||||
"0x1807": "Next DST Change",
|
||||
"0x1808": "Glucose",
|
||||
"0x1809": "Health Thermometer",
|
||||
"0x180a": "Device Information",
|
||||
"0x180b": "Network Availability",
|
||||
"0x180d": "Heart Rate",
|
||||
"0x180e": "Phone Alert Status",
|
||||
"0x180f": "Battery Service",
|
||||
"0x1810": "Blood Pressure",
|
||||
"0x1811": "Alert Notification",
|
||||
"0x1812": "Human Interface Device (BLE)",
|
||||
"0x1813": "Scan Parameters",
|
||||
"0x1814": "Running Speed and Cadence",
|
||||
"0x1815": "Automation IO",
|
||||
"0x1816": "Cycling Speed and Cadence",
|
||||
"0x1818": "Cycling Power",
|
||||
"0x1819": "Location and Navigation",
|
||||
"0x181a": "Environmental Sensing",
|
||||
"0x181b": "Body Composition",
|
||||
"0x181c": "User Data",
|
||||
"0x181d": "Weight Scale",
|
||||
"0x181e": "Bond Management",
|
||||
"0x181f": "Continuous Glucose Monitoring",
|
||||
"0x1820": "Internet Protocol Support",
|
||||
"0x1821": "Indoor Positioning",
|
||||
"0x1822": "Pulse Oximeter",
|
||||
"0x1823": "HTTP Proxy",
|
||||
"0x1824": "Transport Discovery",
|
||||
"0x1825": "Object Transfer",
|
||||
"0x1826": "Fitness Machine",
|
||||
"0x1827": "Mesh Provisioning",
|
||||
"0x1828": "Mesh Proxy",
|
||||
}
|
||||
|
||||
# Icon to device type mapping
|
||||
ICON_TO_TYPE = {
|
||||
"audio-card": "Audio Device",
|
||||
"audio-headphones": "Headphones",
|
||||
"audio-headset": "Headset",
|
||||
"audio-speakers": "Speaker",
|
||||
"camera-photo": "Camera",
|
||||
"camera-video": "Video Camera",
|
||||
"computer": "Computer",
|
||||
"input-gaming": "Game Controller",
|
||||
"input-keyboard": "Keyboard",
|
||||
"input-mouse": "Mouse",
|
||||
"input-tablet": "Tablet/Stylus",
|
||||
"modem": "Modem",
|
||||
"multimedia-player": "Media Player",
|
||||
"network-wireless": "Wireless Device",
|
||||
"phone": "Phone",
|
||||
"printer": "Printer",
|
||||
"scanner": "Scanner",
|
||||
"video-display": "Display/TV",
|
||||
}
|
||||
|
||||
# Name patterns to device type (regex patterns, case-insensitive)
|
||||
NAME_PATTERNS = [
|
||||
# Audio devices
|
||||
(r"airpods|earpods", "Earbuds (Apple)"),
|
||||
(r"galaxy\s*buds", "Earbuds (Samsung)"),
|
||||
(r"pixel\s*buds", "Earbuds (Google)"),
|
||||
(r"buds|earbuds|ear\s*bud", "Earbuds"),
|
||||
(r"headphone|head\s*phone", "Headphones"),
|
||||
(r"headset|head\s*set", "Headset"),
|
||||
(r"airpod|pod", "Earbuds"),
|
||||
(r"speaker|soundbar|sound\s*bar|boom|bose|jbl|sonos|harman|marshall|bang", "Speaker"),
|
||||
(r"soundcore|anker.*audio", "Speaker"),
|
||||
(r"beats|sony\s*wh|sony\s*wf|sennheiser|jabra|plantronics|poly", "Headphones"),
|
||||
|
||||
# Wearables
|
||||
(r"watch|smartwatch|smart\s*watch|galaxy\s*watch|apple\s*watch|fitbit|garmin|polar|suunto", "Smartwatch"),
|
||||
(r"band|mi\s*band|honor\s*band|huawei\s*band|fitness", "Fitness Band"),
|
||||
(r"ring|oura", "Smart Ring"),
|
||||
(r"glasses|spectacles|ray-?ban|meta", "Smart Glasses"),
|
||||
|
||||
# Input devices
|
||||
(r"keyboard|keeb|kbd", "Keyboard"),
|
||||
(r"mouse|mice|trackpad|trackball|logitech\s*m\d", "Mouse"),
|
||||
(r"controller|gamepad|game\s*pad|joystick|xbox|playstation|ps[345]|dualsense|dualshock|nintendo|joycon|joy-?con|switch\s*pro", "Game Controller"),
|
||||
(r"remote|clicker|presenter", "Remote Control"),
|
||||
(r"stylus|pen|pencil|wacom|s-?pen", "Stylus/Pen"),
|
||||
|
||||
# Phones and tablets
|
||||
(r"iphone|ipad|ipod", "Apple Device"),
|
||||
(r"galaxy|samsung\s*(sm|gt)", "Samsung Device"),
|
||||
(r"pixel|nexus", "Google Device"),
|
||||
(r"oneplus|huawei|xiaomi|redmi|poco|oppo|vivo|realme", "Android Phone"),
|
||||
(r"phone|mobile|cell|handset", "Phone"),
|
||||
(r"tablet|tab\s", "Tablet"),
|
||||
|
||||
# Computers
|
||||
(r"macbook|imac|mac\s*pro|mac\s*mini|mac\s*studio", "Mac"),
|
||||
(r"thinkpad|latitude|xps|surface|chromebook|laptop|notebook", "Laptop"),
|
||||
(r"desktop|workstation|tower|pc", "Desktop PC"),
|
||||
(r"raspberry|rpi|pi\d", "Raspberry Pi"),
|
||||
|
||||
# TV and media
|
||||
(r"tv|television|bravia|roku|fire\s*tv|chromecast|appletv|apple\s*tv|shield|nvidia", "TV/Streaming"),
|
||||
(r"projector|beamer", "Projector"),
|
||||
(r"receiver|amplifier|amp|av\s*receiver|denon|yamaha|marantz|onkyo", "AV Receiver"),
|
||||
|
||||
# Smart home
|
||||
(r"echo|alexa|dot|show", "Amazon Echo"),
|
||||
(r"home\s*mini|nest|google\s*home", "Google Home"),
|
||||
(r"homepod", "HomePod"),
|
||||
(r"hub|bridge|gateway|homekit|smartthings|hue|tradfri|zigbee|zwave", "Smart Home Hub"),
|
||||
(r"bulb|light|lamp|led|hue|lifx|nanoleaf|wiz", "Smart Light"),
|
||||
(r"plug|socket|outlet|switch|relay", "Smart Plug/Switch"),
|
||||
(r"thermostat|nest\s*t|ecobee|tado", "Thermostat"),
|
||||
(r"lock|doorbell|ring\s", "Smart Lock/Doorbell"),
|
||||
(r"camera|cam|blink|arlo|wyze|eufy|nest\s*cam", "Camera"),
|
||||
(r"sensor|motion|door.*sensor|window.*sensor|temp.*sensor", "Sensor"),
|
||||
|
||||
# Health and fitness
|
||||
(r"scale|weight", "Smart Scale"),
|
||||
(r"blood\s*pressure|bp\s*monitor", "Blood Pressure Monitor"),
|
||||
(r"pulse\s*ox|oximeter|spo2", "Pulse Oximeter"),
|
||||
(r"thermometer|temp", "Thermometer"),
|
||||
(r"glucose|cgm|dexcom|freestyle", "Glucose Monitor"),
|
||||
(r"heart.*rate|hrm|chest\s*strap", "Heart Rate Monitor"),
|
||||
|
||||
# Vehicles
|
||||
(r"car|vehicle|obd|elm327|carplay|android\s*auto", "Vehicle/OBD"),
|
||||
(r"ebike|e-?bike|scooter|segway|ninebot", "Electric Vehicle"),
|
||||
(r"tire|tpms|pressure\s*monitor", "Tire Pressure Monitor"),
|
||||
|
||||
# Other
|
||||
(r"printer|print", "Printer"),
|
||||
(r"scanner|scan", "Scanner"),
|
||||
(r"tag|airtag|tile|smarttag|chipolo|tracker", "Tracker Tag"),
|
||||
(r"beacon|ibeacon|eddystone", "Beacon"),
|
||||
(r"toothbrush|oral-?b|sonicare", "Smart Toothbrush"),
|
||||
(r"brush|shaver|razor", "Personal Care"),
|
||||
]
|
||||
|
||||
|
||||
def get_device_info_bluetoothctl(address: str) -> dict:
|
||||
"""Get device info using bluetoothctl"""
|
||||
info = {
|
||||
"name": "",
|
||||
"alias": "",
|
||||
"class": "",
|
||||
"icon": "",
|
||||
"paired": False,
|
||||
"trusted": False,
|
||||
"connected": False,
|
||||
"services": [],
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bluetoothctl", "info", address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith("Name:"):
|
||||
info["name"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Alias:"):
|
||||
info["alias"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Class:"):
|
||||
info["class"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Icon:"):
|
||||
info["icon"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Paired:"):
|
||||
info["paired"] = "yes" in line.lower()
|
||||
elif line.startswith("Trusted:"):
|
||||
info["trusted"] = "yes" in line.lower()
|
||||
elif line.startswith("Connected:"):
|
||||
info["connected"] = "yes" in line.lower()
|
||||
elif line.startswith("UUID:"):
|
||||
# Extract UUID name
|
||||
match = re.match(r'UUID:\s*(.+?)\s*\(', line)
|
||||
if match:
|
||||
info["services"].append(match.group(1))
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def query_sdp_services(address: str) -> list[str]:
|
||||
"""Query SDP services for classic Bluetooth device"""
|
||||
services = []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "sdptool", "browse", address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
current_service = None
|
||||
for line in result.stdout.split('\n'):
|
||||
if "Service Name:" in line:
|
||||
name = line.split(":", 1)[1].strip()
|
||||
if name and name not in services:
|
||||
services.append(name)
|
||||
elif "Service Class ID List:" in line:
|
||||
# Next lines contain service UUIDs
|
||||
pass
|
||||
elif '"' in line and "0x" in line.lower():
|
||||
# Try to extract service UUID
|
||||
match = re.search(r'"([^"]+)"', line)
|
||||
if match:
|
||||
name = match.group(1)
|
||||
if name and name not in services:
|
||||
services.append(name)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def infer_device_type_from_manufacturer(manufacturer: str) -> str | None:
|
||||
"""Infer likely device type from manufacturer name"""
|
||||
if not manufacturer or manufacturer in ('Unknown', ''):
|
||||
return None
|
||||
|
||||
mfr_lower = manufacturer.lower()
|
||||
|
||||
for mfr_pattern, device_type in MANUFACTURER_DEVICE_HINTS.items():
|
||||
if mfr_pattern in mfr_lower:
|
||||
return device_type
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_device_type_from_name(name: str) -> str | None:
|
||||
"""Infer device type from device name using pattern matching"""
|
||||
if not name or name in ('<unknown>', '(unknown)', 'Unknown'):
|
||||
return None
|
||||
|
||||
name_lower = name.lower()
|
||||
|
||||
for pattern, device_type in NAME_PATTERNS:
|
||||
if re.search(pattern, name_lower):
|
||||
return device_type
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_device_type(
|
||||
services: list[str],
|
||||
icon: str,
|
||||
device_class: str,
|
||||
name: str = "",
|
||||
manufacturer: str = ""
|
||||
) -> str:
|
||||
"""Infer device type from available information"""
|
||||
|
||||
# Check icon first (most reliable from bluetoothctl)
|
||||
if icon and icon in ICON_TO_TYPE:
|
||||
return ICON_TO_TYPE[icon]
|
||||
|
||||
# Check services
|
||||
services_lower = [s.lower() for s in services]
|
||||
|
||||
if any("a2dp" in s or "audio sink" in s or "audio source" in s for s in services_lower):
|
||||
if any("headset" in s or "handsfree" in s for s in services_lower):
|
||||
return "Headset/Headphones"
|
||||
return "Audio Device"
|
||||
|
||||
if any("headset" in s for s in services_lower):
|
||||
return "Headset"
|
||||
|
||||
if any("handsfree" in s for s in services_lower):
|
||||
return "Hands-free Device"
|
||||
|
||||
if any("human interface" in s or "hid" in s for s in services_lower):
|
||||
return "Input Device (HID)"
|
||||
|
||||
if any("phone" in s or "pbap" in s or "map" in s for s in services_lower):
|
||||
return "Phone"
|
||||
|
||||
if any("heart rate" in s for s in services_lower):
|
||||
return "Heart Rate Monitor"
|
||||
|
||||
if any("fitness" in s or "running" in s or "cycling" in s for s in services_lower):
|
||||
return "Fitness Device"
|
||||
|
||||
if any("battery" in s for s in services_lower):
|
||||
return "Battery-powered Device"
|
||||
|
||||
if any("printer" in s for s in services_lower):
|
||||
return "Printer"
|
||||
|
||||
if any("file transfer" in s or "obex" in s for s in services_lower):
|
||||
return "File Transfer Device"
|
||||
|
||||
if any("serial" in s for s in services_lower):
|
||||
return "Serial Device"
|
||||
|
||||
if any("network" in s or "pan" in s or "nap" in s for s in services_lower):
|
||||
return "Network Device"
|
||||
|
||||
# Fall back to name-based inference
|
||||
name_type = infer_device_type_from_name(name)
|
||||
if name_type:
|
||||
return name_type
|
||||
|
||||
# Fall back to manufacturer-based inference
|
||||
mfr_type = infer_device_type_from_manufacturer(manufacturer)
|
||||
if mfr_type:
|
||||
return mfr_type
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def identify_device(address: str, is_ble: bool = False) -> BluetoothDeviceInfo:
|
||||
"""
|
||||
Perform comprehensive device identification.
|
||||
|
||||
Args:
|
||||
address: Bluetooth MAC address
|
||||
is_ble: Whether this is a BLE-only device
|
||||
|
||||
Returns:
|
||||
BluetoothDeviceInfo with all discovered information
|
||||
"""
|
||||
# Get basic info from bluetoothctl
|
||||
bt_info = get_device_info_bluetoothctl(address)
|
||||
|
||||
services = bt_info.get("services", [])
|
||||
|
||||
# For classic Bluetooth, also try SDP
|
||||
if not is_ble:
|
||||
sdp_services = query_sdp_services(address)
|
||||
services.extend([s for s in sdp_services if s not in services])
|
||||
|
||||
# Infer device type (using name from bluetoothctl or provided name)
|
||||
device_name = bt_info.get("name", "") or bt_info.get("alias", "")
|
||||
device_type = infer_device_type(
|
||||
services,
|
||||
bt_info.get("icon", ""),
|
||||
bt_info.get("class", ""),
|
||||
device_name
|
||||
)
|
||||
|
||||
return BluetoothDeviceInfo(
|
||||
address=address,
|
||||
name=bt_info.get("name", ""),
|
||||
alias=bt_info.get("alias", ""),
|
||||
device_class=bt_info.get("class", ""),
|
||||
device_type=device_type,
|
||||
services=services,
|
||||
manufacturer="", # Would need OUI lookup
|
||||
model="",
|
||||
is_ble=is_ble,
|
||||
paired=bt_info.get("paired", False),
|
||||
connected=bt_info.get("connected", False),
|
||||
trusted=bt_info.get("trusted", False),
|
||||
icon=bt_info.get("icon", "")
|
||||
)
|
||||
|
||||
|
||||
def scan_and_identify(timeout: int = 10) -> list[BluetoothDeviceInfo]:
|
||||
"""
|
||||
Scan for Bluetooth devices and identify them.
|
||||
|
||||
Args:
|
||||
timeout: Scan duration in seconds
|
||||
|
||||
Returns:
|
||||
List of identified devices
|
||||
"""
|
||||
devices = []
|
||||
seen_addresses = set()
|
||||
|
||||
# Start scan using bluetoothctl
|
||||
try:
|
||||
# Enable scanning
|
||||
subprocess.run(
|
||||
["sudo", "bluetoothctl", "scan", "on"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
# Wait for scan
|
||||
import time
|
||||
time.sleep(timeout)
|
||||
|
||||
# Stop scanning
|
||||
subprocess.run(
|
||||
["sudo", "bluetoothctl", "scan", "off"],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
|
||||
# Get discovered devices
|
||||
result = subprocess.run(
|
||||
["bluetoothctl", "devices"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(r'Device\s+([0-9A-Fa-f:]+)\s+(.*)', line)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
name = match.group(2).strip()
|
||||
|
||||
if addr not in seen_addresses:
|
||||
seen_addresses.add(addr)
|
||||
|
||||
# Identify the device
|
||||
info = identify_device(addr)
|
||||
if not info.name:
|
||||
info.name = name
|
||||
|
||||
devices.append(info)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Scan error: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def identify_single_device(address: str) -> dict:
|
||||
"""
|
||||
Identify a single device by address.
|
||||
Returns a dictionary suitable for JSON serialization.
|
||||
"""
|
||||
info = identify_device(address)
|
||||
|
||||
return {
|
||||
"address": info.address,
|
||||
"name": info.name or info.alias or "<unknown>",
|
||||
"alias": info.alias,
|
||||
"device_class": info.device_class,
|
||||
"device_type": info.device_type,
|
||||
"services": info.services,
|
||||
"icon": info.icon,
|
||||
"paired": info.paired,
|
||||
"connected": info.connected,
|
||||
"trusted": info.trusted,
|
||||
}
|
||||
338
src/rf_mapper/config.py
Normal file
338
src/rf_mapper/config.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""Configuration management for RF Mapper"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSConfig:
|
||||
latitude: float = 50.8585853
|
||||
longitude: float = 4.3978724
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebConfig:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 5000
|
||||
debug: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScannerConfig:
|
||||
wifi_interface: str = "wlan0"
|
||||
bt_scan_timeout: int = 10
|
||||
path_loss_exponent: float = 2.5
|
||||
wifi_tx_power: float = -59 # Calibrated TX power at 1m for WiFi (dBm)
|
||||
bt_tx_power: float = -72 # Calibrated TX power at 1m for Bluetooth (dBm)
|
||||
auto_identify_bluetooth: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataConfig:
|
||||
directory: str = "data"
|
||||
max_scans: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
enabled: bool = True
|
||||
filename: str = "devices.db"
|
||||
retention_days: int = 30 # Auto-cleanup data older than this
|
||||
auto_cleanup: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomeAssistantConfig:
|
||||
enabled: bool = False
|
||||
url: str = "http://192.168.129.10:8123"
|
||||
token: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfilingConfig:
|
||||
enabled: bool = False
|
||||
cpu: bool = True
|
||||
memory: bool = False
|
||||
output_dir: str = "data/profiles"
|
||||
sort_by: str = "cumtime" # cumtime, tottime, calls
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoScanConfig:
|
||||
enabled: bool = False
|
||||
interval_minutes: int = 5
|
||||
location_label: str = "auto_scan"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildingConfig:
|
||||
enabled: bool = False
|
||||
name: str = ""
|
||||
floors: int = 1
|
||||
floor_height_m: float = 3.0
|
||||
ground_floor_number: int = 0
|
||||
current_floor: int = 0 # Scanner's current floor
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
gps: GPSConfig = field(default_factory=GPSConfig)
|
||||
web: WebConfig = field(default_factory=WebConfig)
|
||||
scanner: ScannerConfig = field(default_factory=ScannerConfig)
|
||||
data: DataConfig = field(default_factory=DataConfig)
|
||||
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||
home_assistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
|
||||
profiling: ProfilingConfig = field(default_factory=ProfilingConfig)
|
||||
auto_scan: AutoScanConfig = field(default_factory=AutoScanConfig)
|
||||
building: BuildingConfig = field(default_factory=BuildingConfig)
|
||||
|
||||
_config_path: Path | None = field(default=None, repr=False)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: Path | str | None = None) -> "Config":
|
||||
"""
|
||||
Load configuration from YAML file.
|
||||
|
||||
Search order:
|
||||
1. Explicit path if provided
|
||||
2. ./config.yaml
|
||||
3. ~/.config/rf-mapper/config.yaml
|
||||
4. /etc/rf-mapper/config.yaml
|
||||
"""
|
||||
search_paths = []
|
||||
|
||||
if config_path:
|
||||
search_paths.append(Path(config_path))
|
||||
else:
|
||||
# Project directory
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
search_paths.append(project_root / "config.yaml")
|
||||
|
||||
# User config
|
||||
search_paths.append(Path.home() / ".config" / "rf-mapper" / "config.yaml")
|
||||
|
||||
# System config
|
||||
search_paths.append(Path("/etc/rf-mapper/config.yaml"))
|
||||
|
||||
config = cls()
|
||||
|
||||
for path in search_paths:
|
||||
if path.exists():
|
||||
config = cls._load_from_file(path)
|
||||
config._config_path = path
|
||||
break
|
||||
|
||||
# Override with environment variables
|
||||
config._apply_env_overrides()
|
||||
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def _load_from_file(cls, path: Path) -> "Config":
|
||||
"""Load config from a specific file"""
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
config = cls()
|
||||
|
||||
# GPS
|
||||
if "gps" in data:
|
||||
config.gps = GPSConfig(
|
||||
latitude=data["gps"].get("latitude", config.gps.latitude),
|
||||
longitude=data["gps"].get("longitude", config.gps.longitude)
|
||||
)
|
||||
|
||||
# Web
|
||||
if "web" in data:
|
||||
config.web = WebConfig(
|
||||
host=data["web"].get("host", config.web.host),
|
||||
port=data["web"].get("port", config.web.port),
|
||||
debug=data["web"].get("debug", config.web.debug)
|
||||
)
|
||||
|
||||
# Scanner
|
||||
if "scanner" in data:
|
||||
config.scanner = ScannerConfig(
|
||||
wifi_interface=data["scanner"].get("wifi_interface", config.scanner.wifi_interface),
|
||||
bt_scan_timeout=data["scanner"].get("bt_scan_timeout", config.scanner.bt_scan_timeout),
|
||||
path_loss_exponent=data["scanner"].get("path_loss_exponent", config.scanner.path_loss_exponent),
|
||||
wifi_tx_power=data["scanner"].get("wifi_tx_power", config.scanner.wifi_tx_power),
|
||||
bt_tx_power=data["scanner"].get("bt_tx_power", config.scanner.bt_tx_power),
|
||||
auto_identify_bluetooth=data["scanner"].get("auto_identify_bluetooth", config.scanner.auto_identify_bluetooth)
|
||||
)
|
||||
|
||||
# Data
|
||||
if "data" in data:
|
||||
config.data = DataConfig(
|
||||
directory=data["data"].get("directory", config.data.directory),
|
||||
max_scans=data["data"].get("max_scans", config.data.max_scans)
|
||||
)
|
||||
|
||||
# Database
|
||||
if "database" in data:
|
||||
config.database = DatabaseConfig(
|
||||
enabled=data["database"].get("enabled", config.database.enabled),
|
||||
filename=data["database"].get("filename", config.database.filename),
|
||||
retention_days=data["database"].get("retention_days", config.database.retention_days),
|
||||
auto_cleanup=data["database"].get("auto_cleanup", config.database.auto_cleanup)
|
||||
)
|
||||
|
||||
# Home Assistant
|
||||
if "home_assistant" in data:
|
||||
config.home_assistant = HomeAssistantConfig(
|
||||
enabled=data["home_assistant"].get("enabled", config.home_assistant.enabled),
|
||||
url=data["home_assistant"].get("url", config.home_assistant.url),
|
||||
token=data["home_assistant"].get("token", config.home_assistant.token)
|
||||
)
|
||||
|
||||
# Profiling
|
||||
if "profiling" in data:
|
||||
config.profiling = ProfilingConfig(
|
||||
enabled=data["profiling"].get("enabled", config.profiling.enabled),
|
||||
cpu=data["profiling"].get("cpu", config.profiling.cpu),
|
||||
memory=data["profiling"].get("memory", config.profiling.memory),
|
||||
output_dir=data["profiling"].get("output_dir", config.profiling.output_dir),
|
||||
sort_by=data["profiling"].get("sort_by", config.profiling.sort_by)
|
||||
)
|
||||
|
||||
# Auto-scan
|
||||
if "auto_scan" in data:
|
||||
config.auto_scan = AutoScanConfig(
|
||||
enabled=data["auto_scan"].get("enabled", config.auto_scan.enabled),
|
||||
interval_minutes=data["auto_scan"].get("interval_minutes", config.auto_scan.interval_minutes),
|
||||
location_label=data["auto_scan"].get("location_label", config.auto_scan.location_label)
|
||||
)
|
||||
|
||||
# Building
|
||||
if "building" in data:
|
||||
config.building = BuildingConfig(
|
||||
enabled=data["building"].get("enabled", config.building.enabled),
|
||||
name=data["building"].get("name", config.building.name),
|
||||
floors=data["building"].get("floors", config.building.floors),
|
||||
floor_height_m=data["building"].get("floor_height_m", config.building.floor_height_m),
|
||||
ground_floor_number=data["building"].get("ground_floor_number", config.building.ground_floor_number),
|
||||
current_floor=data["building"].get("current_floor", config.building.current_floor)
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
def _apply_env_overrides(self):
|
||||
"""Override config values with environment variables"""
|
||||
# GPS
|
||||
if os.getenv("RF_MAPPER_LAT"):
|
||||
self.gps.latitude = float(os.environ["RF_MAPPER_LAT"])
|
||||
if os.getenv("RF_MAPPER_LON"):
|
||||
self.gps.longitude = float(os.environ["RF_MAPPER_LON"])
|
||||
|
||||
# Web
|
||||
if os.getenv("RF_MAPPER_HOST"):
|
||||
self.web.host = os.environ["RF_MAPPER_HOST"]
|
||||
if os.getenv("RF_MAPPER_PORT"):
|
||||
self.web.port = int(os.environ["RF_MAPPER_PORT"])
|
||||
|
||||
# Home Assistant
|
||||
if os.getenv("HA_TOKEN"):
|
||||
self.home_assistant.token = os.environ["HA_TOKEN"]
|
||||
if os.getenv("HA_URL"):
|
||||
self.home_assistant.url = os.environ["HA_URL"]
|
||||
|
||||
def get_data_dir(self) -> Path:
|
||||
"""Get the data directory as an absolute Path"""
|
||||
data_path = Path(self.data.directory)
|
||||
|
||||
if data_path.is_absolute():
|
||||
return data_path
|
||||
|
||||
# Relative to project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / data_path
|
||||
|
||||
def get_database_path(self) -> Path:
|
||||
"""Get the database file path"""
|
||||
return self.get_data_dir() / self.database.filename
|
||||
|
||||
def save(self, path: Path | None = None):
|
||||
"""Save current configuration to file"""
|
||||
save_path = path or self._config_path
|
||||
if not save_path:
|
||||
save_path = Path(__file__).parent.parent.parent / "config.yaml"
|
||||
|
||||
data = {
|
||||
"gps": {
|
||||
"latitude": self.gps.latitude,
|
||||
"longitude": self.gps.longitude
|
||||
},
|
||||
"web": {
|
||||
"host": self.web.host,
|
||||
"port": self.web.port,
|
||||
"debug": self.web.debug
|
||||
},
|
||||
"scanner": {
|
||||
"wifi_interface": self.scanner.wifi_interface,
|
||||
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
||||
"path_loss_exponent": self.scanner.path_loss_exponent,
|
||||
"wifi_tx_power": self.scanner.wifi_tx_power,
|
||||
"bt_tx_power": self.scanner.bt_tx_power
|
||||
},
|
||||
"data": {
|
||||
"directory": self.data.directory,
|
||||
"max_scans": self.data.max_scans
|
||||
},
|
||||
"database": {
|
||||
"enabled": self.database.enabled,
|
||||
"filename": self.database.filename,
|
||||
"retention_days": self.database.retention_days,
|
||||
"auto_cleanup": self.database.auto_cleanup
|
||||
},
|
||||
"home_assistant": {
|
||||
"enabled": self.home_assistant.enabled,
|
||||
"url": self.home_assistant.url,
|
||||
"token": self.home_assistant.token
|
||||
},
|
||||
"profiling": {
|
||||
"enabled": self.profiling.enabled,
|
||||
"cpu": self.profiling.cpu,
|
||||
"memory": self.profiling.memory,
|
||||
"output_dir": self.profiling.output_dir,
|
||||
"sort_by": self.profiling.sort_by
|
||||
},
|
||||
"auto_scan": {
|
||||
"enabled": self.auto_scan.enabled,
|
||||
"interval_minutes": self.auto_scan.interval_minutes,
|
||||
"location_label": self.auto_scan.location_label
|
||||
},
|
||||
"building": {
|
||||
"enabled": self.building.enabled,
|
||||
"name": self.building.name,
|
||||
"floors": self.building.floors,
|
||||
"floor_height_m": self.building.floor_height_m,
|
||||
"ground_floor_number": self.building.ground_floor_number,
|
||||
"current_floor": self.building.current_floor
|
||||
}
|
||||
}
|
||||
|
||||
save_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(save_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
# Global config instance
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the global config instance"""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.load()
|
||||
return _config
|
||||
|
||||
|
||||
def reload_config(path: Path | str | None = None) -> Config:
|
||||
"""Reload configuration from file"""
|
||||
global _config
|
||||
_config = Config.load(path)
|
||||
return _config
|
||||
643
src/rf_mapper/database.py
Normal file
643
src/rf_mapper/database.py
Normal file
@@ -0,0 +1,643 @@
|
||||
"""SQLite database for RF Mapper historical data and device tracking"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import threading
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceStats:
|
||||
"""Statistics for a device"""
|
||||
device_id: str
|
||||
device_type: str # 'wifi' or 'bluetooth'
|
||||
name: str
|
||||
manufacturer: str
|
||||
first_seen: str
|
||||
last_seen: str
|
||||
total_observations: int
|
||||
avg_rssi: float
|
||||
min_rssi: int
|
||||
max_rssi: int
|
||||
avg_distance_m: float
|
||||
min_distance_m: float
|
||||
max_distance_m: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class RSSIObservation:
|
||||
"""Single RSSI observation"""
|
||||
timestamp: str
|
||||
rssi: int
|
||||
distance_m: float
|
||||
floor: Optional[int] = None
|
||||
|
||||
|
||||
class DeviceDatabase:
|
||||
"""SQLite database for device history and statistics"""
|
||||
|
||||
def __init__(self, db_path: Path | str):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._local = threading.local()
|
||||
self._init_schema()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get thread-local database connection"""
|
||||
if not hasattr(self._local, 'conn') or self._local.conn is None:
|
||||
self._local.conn = sqlite3.connect(str(self.db_path))
|
||||
self._local.conn.row_factory = sqlite3.Row
|
||||
return self._local.conn
|
||||
|
||||
def _init_schema(self):
|
||||
"""Initialize database schema"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Devices table - master record for each unique device
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
device_type TEXT NOT NULL, -- 'wifi' or 'bluetooth'
|
||||
name TEXT,
|
||||
ssid TEXT, -- For WiFi only
|
||||
manufacturer TEXT,
|
||||
device_class TEXT, -- For Bluetooth
|
||||
bt_device_type TEXT, -- For Bluetooth
|
||||
encryption TEXT, -- For WiFi
|
||||
channel INTEGER, -- For WiFi
|
||||
frequency INTEGER, -- For WiFi
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
total_observations INTEGER DEFAULT 0,
|
||||
custom_label TEXT, -- User-assigned name
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# RSSI observations - time series data
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rssi_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
rssi INTEGER NOT NULL,
|
||||
distance_m REAL,
|
||||
floor INTEGER,
|
||||
scan_id TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Scans table - record of each scan
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
scan_id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_label TEXT,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
wifi_count INTEGER DEFAULT 0,
|
||||
bt_count INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Device statistics - pre-computed for performance
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS device_stats (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
avg_rssi REAL,
|
||||
min_rssi INTEGER,
|
||||
max_rssi INTEGER,
|
||||
avg_distance_m REAL,
|
||||
min_distance_m REAL,
|
||||
max_distance_m REAL,
|
||||
appearance_count INTEGER DEFAULT 0,
|
||||
last_computed TEXT,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Movement events - detected motion
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movement_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
rssi_delta INTEGER,
|
||||
distance_delta_m REAL,
|
||||
direction TEXT, -- 'approaching', 'receding', 'stationary'
|
||||
velocity_m_s REAL,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Alerts table - for new device detection, absence alerts
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
alert_type TEXT NOT NULL, -- 'new_device', 'device_absent', 'rssi_threshold'
|
||||
device_id TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
message TEXT,
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(device_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for performance
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rssi_device_time ON rssi_history(device_id, timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rssi_timestamp ON rssi_history(timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_devices_type ON devices(device_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movement_device ON movement_events(device_id, timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(alert_type, acknowledged)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_scan(self, scan_id: str, timestamp: str, location_label: str,
|
||||
lat: float, lon: float, wifi_count: int, bt_count: int):
|
||||
"""Record a scan event"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO scans (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_wifi_observation(self, bssid: str, ssid: str, rssi: int, distance_m: float,
|
||||
channel: int, frequency: int, encryption: str,
|
||||
manufacturer: str, floor: Optional[int] = None,
|
||||
scan_id: Optional[str] = None):
|
||||
"""Record a WiFi network observation"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Insert or update device
|
||||
cursor.execute("""
|
||||
INSERT INTO devices (device_id, device_type, name, ssid, manufacturer, encryption, channel, frequency, first_seen, last_seen, total_observations)
|
||||
VALUES (?, 'wifi', ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, devices.name),
|
||||
ssid = COALESCE(excluded.ssid, devices.ssid),
|
||||
manufacturer = COALESCE(excluded.manufacturer, devices.manufacturer),
|
||||
encryption = COALESCE(excluded.encryption, devices.encryption),
|
||||
channel = COALESCE(excluded.channel, devices.channel),
|
||||
frequency = COALESCE(excluded.frequency, devices.frequency),
|
||||
last_seen = excluded.last_seen,
|
||||
total_observations = devices.total_observations + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (bssid, ssid, ssid, manufacturer, encryption, channel, frequency, timestamp, timestamp))
|
||||
|
||||
# Insert RSSI observation
|
||||
cursor.execute("""
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (bssid, timestamp, rssi, distance_m, floor, scan_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Check if this is a new device
|
||||
cursor.execute("SELECT total_observations FROM devices WHERE device_id = ?", (bssid,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['total_observations'] == 1:
|
||||
self._create_alert('new_device', bssid, f"New WiFi network detected: {ssid} ({manufacturer})")
|
||||
|
||||
def record_bluetooth_observation(self, address: str, name: str, rssi: int, distance_m: float,
|
||||
device_class: str, device_type: str, manufacturer: str,
|
||||
floor: Optional[int] = None, scan_id: Optional[str] = None):
|
||||
"""Record a Bluetooth device observation"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Get previous observation for movement detection
|
||||
cursor.execute("""
|
||||
SELECT rssi, distance_m, timestamp FROM rssi_history
|
||||
WHERE device_id = ? ORDER BY timestamp DESC LIMIT 1
|
||||
""", (address,))
|
||||
prev = cursor.fetchone()
|
||||
|
||||
# Insert or update device
|
||||
cursor.execute("""
|
||||
INSERT INTO devices (device_id, device_type, name, manufacturer, device_class, bt_device_type, first_seen, last_seen, total_observations)
|
||||
VALUES (?, 'bluetooth', ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
name = CASE WHEN excluded.name != '<unknown>' AND excluded.name != '' THEN excluded.name ELSE devices.name END,
|
||||
manufacturer = COALESCE(NULLIF(excluded.manufacturer, ''), devices.manufacturer),
|
||||
device_class = COALESCE(excluded.device_class, devices.device_class),
|
||||
bt_device_type = COALESCE(excluded.bt_device_type, devices.bt_device_type),
|
||||
last_seen = excluded.last_seen,
|
||||
total_observations = devices.total_observations + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (address, name, manufacturer, device_class, device_type, timestamp, timestamp))
|
||||
|
||||
# Insert RSSI observation
|
||||
cursor.execute("""
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (address, timestamp, rssi, distance_m, floor, scan_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Movement detection
|
||||
if prev:
|
||||
rssi_delta = rssi - prev['rssi']
|
||||
distance_delta = distance_m - prev['distance_m']
|
||||
prev_time = datetime.fromisoformat(prev['timestamp'])
|
||||
time_delta = (datetime.now() - prev_time).total_seconds()
|
||||
|
||||
if abs(distance_delta) > 0.5 and time_delta > 0: # More than 0.5m movement
|
||||
velocity = distance_delta / time_delta if time_delta > 0 else 0
|
||||
direction = 'approaching' if distance_delta < 0 else 'receding'
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO movement_events (device_id, timestamp, rssi_delta, distance_delta_m, direction, velocity_m_s)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (address, timestamp, rssi_delta, distance_delta, direction, velocity))
|
||||
conn.commit()
|
||||
|
||||
# Check if this is a new device
|
||||
cursor.execute("SELECT total_observations FROM devices WHERE device_id = ?", (address,))
|
||||
row = cursor.fetchone()
|
||||
if row and row['total_observations'] == 1:
|
||||
self._create_alert('new_device', address, f"New Bluetooth device detected: {name} ({manufacturer})")
|
||||
|
||||
def _create_alert(self, alert_type: str, device_id: str, message: str):
|
||||
"""Create an alert"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO alerts (alert_type, device_id, timestamp, message)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (alert_type, device_id, timestamp, message))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[dict]:
|
||||
"""Get device details"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM devices WHERE device_id = ?", (device_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_all_devices(self, device_type: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 100) -> list[dict]:
|
||||
"""Get all devices with optional filtering"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM devices WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if device_type:
|
||||
query += " AND device_type = ?"
|
||||
params.append(device_type)
|
||||
|
||||
if since:
|
||||
query += " AND last_seen >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY last_seen DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_device_rssi_history(self, device_id: str,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 1000) -> list[RSSIObservation]:
|
||||
"""Get RSSI history for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT timestamp, rssi, distance_m, floor FROM rssi_history WHERE device_id = ?"
|
||||
params = [device_id]
|
||||
|
||||
if since:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [RSSIObservation(
|
||||
timestamp=row['timestamp'],
|
||||
rssi=row['rssi'],
|
||||
distance_m=row['distance_m'],
|
||||
floor=row['floor']
|
||||
) for row in cursor.fetchall()]
|
||||
|
||||
def get_device_stats(self, device_id: str) -> Optional[DeviceStats]:
|
||||
"""Get computed statistics for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get device info
|
||||
cursor.execute("SELECT * FROM devices WHERE device_id = ?", (device_id,))
|
||||
device = cursor.fetchone()
|
||||
if not device:
|
||||
return None
|
||||
|
||||
# Compute stats from RSSI history
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
AVG(rssi) as avg_rssi,
|
||||
MIN(rssi) as min_rssi,
|
||||
MAX(rssi) as max_rssi,
|
||||
AVG(distance_m) as avg_distance_m,
|
||||
MIN(distance_m) as min_distance_m,
|
||||
MAX(distance_m) as max_distance_m
|
||||
FROM rssi_history WHERE device_id = ?
|
||||
""", (device_id,))
|
||||
stats = cursor.fetchone()
|
||||
|
||||
return DeviceStats(
|
||||
device_id=device_id,
|
||||
device_type=device['device_type'],
|
||||
name=device['custom_label'] or device['name'] or device['ssid'] or device_id,
|
||||
manufacturer=device['manufacturer'] or '',
|
||||
first_seen=device['first_seen'],
|
||||
last_seen=device['last_seen'],
|
||||
total_observations=device['total_observations'],
|
||||
avg_rssi=round(stats['avg_rssi'], 1) if stats['avg_rssi'] else 0,
|
||||
min_rssi=stats['min_rssi'] or 0,
|
||||
max_rssi=stats['max_rssi'] or 0,
|
||||
avg_distance_m=round(stats['avg_distance_m'], 2) if stats['avg_distance_m'] else 0,
|
||||
min_distance_m=round(stats['min_distance_m'], 2) if stats['min_distance_m'] else 0,
|
||||
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
|
||||
)
|
||||
|
||||
def get_movement_events(self, device_id: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 100) -> list[dict]:
|
||||
"""Get movement events"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM movement_events WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if device_id:
|
||||
query += " AND device_id = ?"
|
||||
params.append(device_id)
|
||||
|
||||
if since:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_alerts(self, acknowledged: Optional[bool] = None,
|
||||
alert_type: Optional[str] = None,
|
||||
limit: int = 50) -> list[dict]:
|
||||
"""Get alerts"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM alerts WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if acknowledged is not None:
|
||||
query += " AND acknowledged = ?"
|
||||
params.append(1 if acknowledged else 0)
|
||||
|
||||
if alert_type:
|
||||
query += " AND alert_type = ?"
|
||||
params.append(alert_type)
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def acknowledge_alert(self, alert_id: int):
|
||||
"""Mark an alert as acknowledged"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("UPDATE alerts SET acknowledged = 1 WHERE id = ?", (alert_id,))
|
||||
conn.commit()
|
||||
|
||||
def set_device_label(self, device_id: str, label: str):
|
||||
"""Set a custom label for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET custom_label = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (label, device_id))
|
||||
conn.commit()
|
||||
|
||||
def set_device_favorite(self, device_id: str, is_favorite: bool):
|
||||
"""Mark a device as favorite"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET is_favorite = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (1 if is_favorite else 0, device_id))
|
||||
conn.commit()
|
||||
|
||||
def get_recent_activity(self, hours: int = 24) -> dict:
|
||||
"""Get activity summary for the last N hours"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = (datetime.now() - timedelta(hours=hours)).isoformat()
|
||||
|
||||
# Count active devices
|
||||
cursor.execute("""
|
||||
SELECT device_type, COUNT(*) as count
|
||||
FROM devices WHERE last_seen >= ?
|
||||
GROUP BY device_type
|
||||
""", (since,))
|
||||
active_counts = {row['device_type']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
# Count observations
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM rssi_history WHERE timestamp >= ?
|
||||
""", (since,))
|
||||
observation_count = cursor.fetchone()['count']
|
||||
|
||||
# Count movement events
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM movement_events WHERE timestamp >= ?
|
||||
""", (since,))
|
||||
movement_count = cursor.fetchone()['count']
|
||||
|
||||
# Count new devices
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM devices WHERE first_seen >= ?
|
||||
""", (since,))
|
||||
new_device_count = cursor.fetchone()['count']
|
||||
|
||||
# Count scans
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as count FROM scans WHERE timestamp >= ?
|
||||
""", (since,))
|
||||
scan_count = cursor.fetchone()['count']
|
||||
|
||||
return {
|
||||
"period_hours": hours,
|
||||
"since": since,
|
||||
"active_wifi_devices": active_counts.get('wifi', 0),
|
||||
"active_bt_devices": active_counts.get('bluetooth', 0),
|
||||
"total_observations": observation_count,
|
||||
"movement_events": movement_count,
|
||||
"new_devices": new_device_count,
|
||||
"scan_count": scan_count
|
||||
}
|
||||
|
||||
def get_device_activity_pattern(self, device_id: str, days: int = 7) -> dict:
|
||||
"""Get hourly activity pattern for a device over the last N days"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
since = (datetime.now() - timedelta(days=days)).isoformat()
|
||||
|
||||
# Count observations per hour of day
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
CAST(strftime('%H', timestamp) AS INTEGER) as hour,
|
||||
COUNT(*) as count,
|
||||
AVG(rssi) as avg_rssi
|
||||
FROM rssi_history
|
||||
WHERE device_id = ? AND timestamp >= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
""", (device_id, since))
|
||||
|
||||
hourly = {row['hour']: {'count': row['count'], 'avg_rssi': round(row['avg_rssi'], 1)}
|
||||
for row in cursor.fetchall()}
|
||||
|
||||
# Count observations per day of week (0=Monday, 6=Sunday)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
CAST(strftime('%w', timestamp) AS INTEGER) as dow,
|
||||
COUNT(*) as count
|
||||
FROM rssi_history
|
||||
WHERE device_id = ? AND timestamp >= ?
|
||||
GROUP BY dow
|
||||
ORDER BY dow
|
||||
""", (device_id, since))
|
||||
|
||||
daily = {row['dow']: row['count'] for row in cursor.fetchall()}
|
||||
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"period_days": days,
|
||||
"hourly_pattern": hourly,
|
||||
"daily_pattern": daily
|
||||
}
|
||||
|
||||
def cleanup_old_data(self, retention_days: int = 30):
|
||||
"""Remove data older than retention period"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=retention_days)).isoformat()
|
||||
|
||||
# Delete old RSSI history (keep summary in devices table)
|
||||
cursor.execute("DELETE FROM rssi_history WHERE timestamp < ?", (cutoff,))
|
||||
|
||||
# Delete old movement events
|
||||
cursor.execute("DELETE FROM movement_events WHERE timestamp < ?", (cutoff,))
|
||||
|
||||
# Delete old acknowledged alerts
|
||||
cursor.execute("DELETE FROM alerts WHERE timestamp < ? AND acknowledged = 1", (cutoff,))
|
||||
|
||||
# Delete old scans
|
||||
cursor.execute("DELETE FROM scans WHERE timestamp < ?", (cutoff,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"retention_days": retention_days,
|
||||
"cutoff": cutoff,
|
||||
"cleaned_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def get_database_stats(self) -> dict:
|
||||
"""Get database statistics"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM devices")
|
||||
device_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM rssi_history")
|
||||
observation_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM scans")
|
||||
scan_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM movement_events")
|
||||
movement_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as count FROM alerts WHERE acknowledged = 0")
|
||||
unread_alerts = cursor.fetchone()['count']
|
||||
|
||||
# Get database file size
|
||||
db_size = self.db_path.stat().st_size if self.db_path.exists() else 0
|
||||
|
||||
return {
|
||||
"total_devices": device_count,
|
||||
"total_observations": observation_count,
|
||||
"total_scans": scan_count,
|
||||
"total_movement_events": movement_count,
|
||||
"unread_alerts": unread_alerts,
|
||||
"database_size_bytes": db_size,
|
||||
"database_size_mb": round(db_size / 1024 / 1024, 2)
|
||||
}
|
||||
|
||||
def close(self):
|
||||
"""Close database connection"""
|
||||
if hasattr(self._local, 'conn') and self._local.conn:
|
||||
self._local.conn.close()
|
||||
self._local.conn = None
|
||||
|
||||
|
||||
# Global database instance
|
||||
_db: DeviceDatabase | None = None
|
||||
|
||||
|
||||
def get_database(db_path: Path | str | None = None) -> DeviceDatabase:
|
||||
"""Get the global database instance"""
|
||||
global _db
|
||||
if _db is None:
|
||||
if db_path is None:
|
||||
db_path = Path.home() / "git" / "rf-mapper" / "data" / "devices.db"
|
||||
_db = DeviceDatabase(db_path)
|
||||
return _db
|
||||
|
||||
|
||||
def init_database(db_path: Path | str) -> DeviceDatabase:
|
||||
"""Initialize the global database instance"""
|
||||
global _db
|
||||
_db = DeviceDatabase(db_path)
|
||||
return _db
|
||||
120
src/rf_mapper/distance.py
Normal file
120
src/rf_mapper/distance.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Distance estimation from RSSI values"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def estimate_distance(
|
||||
rssi: int,
|
||||
tx_power: int = -59,
|
||||
n: float = 2.5
|
||||
) -> float:
|
||||
"""
|
||||
Estimate distance from RSSI using log-distance path loss model.
|
||||
|
||||
The formula is: distance = 10 ^ ((tx_power - rssi) / (10 * n))
|
||||
|
||||
Args:
|
||||
rssi: Received signal strength indicator in dBm
|
||||
tx_power: Calibrated TX power at 1 meter (default -59 dBm for typical WiFi)
|
||||
n: Path loss exponent:
|
||||
- 2.0 = free space
|
||||
- 2.5 = typical indoor, some obstacles
|
||||
- 3.0-4.0 = indoor with walls
|
||||
- 4.0-6.0 = dense urban/building penetration
|
||||
|
||||
Returns:
|
||||
Estimated distance in meters
|
||||
"""
|
||||
if rssi >= tx_power:
|
||||
return 0.1 # Very close, less than 1m
|
||||
|
||||
return 10 ** ((tx_power - rssi) / (10 * n))
|
||||
|
||||
|
||||
def rssi_to_quality(rssi: int) -> str:
|
||||
"""Convert RSSI to human-readable quality description"""
|
||||
if rssi >= -50:
|
||||
return "Excellent"
|
||||
elif rssi >= -60:
|
||||
return "Good"
|
||||
elif rssi >= -70:
|
||||
return "Fair"
|
||||
elif rssi >= -80:
|
||||
return "Weak"
|
||||
else:
|
||||
return "Very Weak"
|
||||
|
||||
|
||||
def rssi_bar(rssi: int, width: int = 20) -> str:
|
||||
"""Generate a visual RSSI bar"""
|
||||
# RSSI typically ranges from -30 (excellent) to -90 (poor)
|
||||
normalized = max(0, min(width, (rssi + 90) * width // 60))
|
||||
return '█' * normalized + '░' * (width - normalized)
|
||||
|
||||
|
||||
def estimate_wall_count(rssi: int, expected_rssi: int = -50, db_per_wall: float = 6.0) -> int:
|
||||
"""
|
||||
Estimate number of walls between transmitter and receiver.
|
||||
|
||||
Typical wall attenuation:
|
||||
- Drywall: 3-5 dB
|
||||
- Concrete/brick: 6-10 dB
|
||||
- Metal/reinforced: 10-15 dB
|
||||
|
||||
Args:
|
||||
rssi: Measured RSSI
|
||||
expected_rssi: Expected RSSI without walls (at estimated distance)
|
||||
db_per_wall: Attenuation per wall (default 6 dB for typical interior wall)
|
||||
|
||||
Returns:
|
||||
Estimated number of walls
|
||||
"""
|
||||
loss = expected_rssi - rssi
|
||||
if loss <= 0:
|
||||
return 0
|
||||
return max(0, int(loss / db_per_wall))
|
||||
|
||||
|
||||
def trilaterate_2d(
|
||||
positions: list[tuple[float, float]],
|
||||
distances: list[float]
|
||||
) -> tuple[float, float] | None:
|
||||
"""
|
||||
Estimate position from multiple distance measurements using trilateration.
|
||||
|
||||
This is a simplified least-squares approach for 2D positioning.
|
||||
|
||||
Args:
|
||||
positions: List of (x, y) coordinates of known reference points
|
||||
distances: List of distances to each reference point
|
||||
|
||||
Returns:
|
||||
Estimated (x, y) position or None if insufficient data
|
||||
"""
|
||||
if len(positions) < 3 or len(distances) < 3:
|
||||
return None
|
||||
|
||||
# Use first three points for basic trilateration
|
||||
x1, y1 = positions[0]
|
||||
x2, y2 = positions[1]
|
||||
x3, y3 = positions[2]
|
||||
r1, r2, r3 = distances[0], distances[1], distances[2]
|
||||
|
||||
# Solve system of equations
|
||||
A = 2 * (x2 - x1)
|
||||
B = 2 * (y2 - y1)
|
||||
C = r1**2 - r2**2 - x1**2 + x2**2 - y1**2 + y2**2
|
||||
|
||||
D = 2 * (x3 - x2)
|
||||
E = 2 * (y3 - y2)
|
||||
F = r2**2 - r3**2 - x2**2 + x3**2 - y2**2 + y3**2
|
||||
|
||||
# Check for degenerate case
|
||||
denom = A * E - B * D
|
||||
if abs(denom) < 1e-10:
|
||||
return None
|
||||
|
||||
x = (C * E - F * B) / denom
|
||||
y = (A * F - D * C) / denom
|
||||
|
||||
return (x, y)
|
||||
105
src/rf_mapper/oui.py
Normal file
105
src/rf_mapper/oui.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""OUI (Organizationally Unique Identifier) lookup for MAC addresses"""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Default OUI database path
|
||||
DEFAULT_OUI_DB_PATH = Path(__file__).parent.parent.parent.parent / "data" / "oui.json"
|
||||
OUI_DB_URL = "https://maclookup.app/downloads/json-database/get-db"
|
||||
|
||||
|
||||
class OUILookup:
|
||||
"""MAC address manufacturer lookup using OUI database"""
|
||||
|
||||
def __init__(self, db_path: Path | None = None):
|
||||
self.db_path = db_path or DEFAULT_OUI_DB_PATH
|
||||
self.oui_db: dict[str, str] = {}
|
||||
self._load_or_download_db()
|
||||
|
||||
def _load_or_download_db(self):
|
||||
"""Load OUI database from file or download if not present"""
|
||||
if self.db_path.exists():
|
||||
try:
|
||||
with open(self.db_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
for entry in data:
|
||||
prefix = entry.get('macPrefix', '').upper().replace(':', '')
|
||||
if prefix:
|
||||
self.oui_db[prefix] = entry.get('vendorName', 'Unknown')
|
||||
print(f"Loaded {len(self.oui_db)} OUI entries from cache")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error loading OUI cache: {e}")
|
||||
|
||||
print("Downloading OUI database (this may take a moment)...")
|
||||
try:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
req = urllib.request.Request(OUI_DB_URL, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
with open(self.db_path, 'w') as f:
|
||||
json.dump(data, f)
|
||||
for entry in data:
|
||||
prefix = entry.get('macPrefix', '').upper().replace(':', '')
|
||||
if prefix:
|
||||
self.oui_db[prefix] = entry.get('vendorName', 'Unknown')
|
||||
print(f"Downloaded {len(self.oui_db)} OUI entries")
|
||||
except Exception as e:
|
||||
print(f"Could not download OUI database: {e}")
|
||||
print("Using built-in common manufacturers")
|
||||
self._use_builtin_oui()
|
||||
|
||||
def _use_builtin_oui(self):
|
||||
"""Fallback to common OUI prefixes"""
|
||||
self.oui_db = {
|
||||
# Proximus/Belgacom
|
||||
'00173F': 'Proximus (Belgacom)',
|
||||
'0019CB': 'Proximus (Belgacom)',
|
||||
'001E58': 'Proximus (Belgacom)',
|
||||
'002275': 'Belgacom',
|
||||
'00248C': 'Proximus',
|
||||
'78D294': 'Proximus',
|
||||
'3C7D0A': 'Proximus',
|
||||
'E0B9E5': 'Proximus',
|
||||
# Sagem/Sagemcom
|
||||
'DCCF96': 'Sagem/Sagemcom',
|
||||
'002569': 'Sagem',
|
||||
'001430': 'Sagem',
|
||||
'8C9A8F': 'Sagemcom Broadband SAS',
|
||||
'102BAA': 'Sagemcom Broadband SAS',
|
||||
'7C2664': 'Sagemcom Broadband SAS',
|
||||
'B88C2B': 'Sagemcom Broadband SAS',
|
||||
# Apple
|
||||
'2C3361': 'Apple', '38C986': 'Apple', '8C8590': 'Apple',
|
||||
'F0D1A9': 'Apple', '14109F': 'Apple', '00A040': 'Apple',
|
||||
'64A2F9': 'Apple', 'AC87A3': 'Apple', '28F076': 'Apple',
|
||||
'9C8BA0': 'Apple', '3C2EF9': 'Apple', '78CA39': 'Apple',
|
||||
# Samsung
|
||||
'30CBF8': 'Samsung', '8CB64F': 'Samsung', '4C3C16': 'Samsung',
|
||||
'AC5A14': 'Samsung', '9463D1': 'Samsung', 'E4E0C5': 'Samsung',
|
||||
'F8042E': 'Samsung', 'E0036B': 'Samsung', 'B0E45C': 'Samsung',
|
||||
# Other common
|
||||
'F80F84': 'Google', '54609A': 'Google',
|
||||
'28254B': 'Amazon', '74C246': 'Amazon',
|
||||
'E89120': 'TP-Link', '5C899A': 'TP-Link',
|
||||
'B827EB': 'Raspberry Pi', '2CCF67': 'Raspberry Pi',
|
||||
'DC44C3': 'Intel', '001517': 'Intel',
|
||||
'3C37C6': 'Espressif (ESP32/ESP8266)',
|
||||
'34CC': 'Compal Broadband Network',
|
||||
'C4EA1D': 'Vantiva Technologies Belgium',
|
||||
'08B055': 'ASKEY COMPUTER CORP',
|
||||
'38E7C0': 'Hui Zhou Gaoshengda Technology',
|
||||
'409CA7': 'CHINA DRAGON TECHNOLOGY',
|
||||
}
|
||||
|
||||
def lookup(self, mac_address: str) -> str:
|
||||
"""Look up manufacturer from MAC address"""
|
||||
mac = mac_address.upper().replace(':', '').replace('-', '').replace('.', '')
|
||||
|
||||
for prefix_len in [9, 7, 6]:
|
||||
prefix = mac[:prefix_len]
|
||||
if prefix in self.oui_db:
|
||||
return self.oui_db[prefix]
|
||||
|
||||
return "Unknown"
|
||||
160
src/rf_mapper/profiling.py
Normal file
160
src/rf_mapper/profiling.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Profiling utilities for RF Mapper"""
|
||||
|
||||
import cProfile
|
||||
import pstats
|
||||
import tracemalloc
|
||||
import time
|
||||
import logging
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cpu_profiler(output_path: Path | None = None, sort_by: str = "cumtime"):
|
||||
"""Context manager for CPU profiling using cProfile.
|
||||
|
||||
Args:
|
||||
output_path: Optional path to save .prof file for later analysis
|
||||
sort_by: Sort key for stats (cumtime, tottime, calls)
|
||||
|
||||
Yields:
|
||||
cProfile.Profile instance
|
||||
"""
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
try:
|
||||
yield profiler
|
||||
finally:
|
||||
profiler.disable()
|
||||
# Output stats to stdout
|
||||
stream = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=stream)
|
||||
stats.sort_stats(sort_by)
|
||||
stats.print_stats(30) # Top 30 functions
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("CPU PROFILE RESULTS")
|
||||
print('='*60)
|
||||
print(stream.getvalue())
|
||||
|
||||
if output_path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
profiler.dump_stats(str(output_path))
|
||||
print(f"Profile saved to: {output_path}")
|
||||
print("Analyze with: python -m pstats {output_path}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def memory_profiler(top_n: int = 10):
|
||||
"""Context manager for memory profiling using tracemalloc.
|
||||
|
||||
Args:
|
||||
top_n: Number of top memory allocations to display
|
||||
|
||||
Yields:
|
||||
None
|
||||
"""
|
||||
tracemalloc.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
snapshot = tracemalloc.take_snapshot()
|
||||
top_stats = snapshot.statistics("lineno")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"TOP {top_n} MEMORY ALLOCATIONS")
|
||||
print('='*60)
|
||||
for stat in top_stats[:top_n]:
|
||||
print(stat)
|
||||
|
||||
current, peak = tracemalloc.get_traced_memory()
|
||||
print(f"\nCurrent memory: {current / 1024 / 1024:.2f} MB")
|
||||
print(f"Peak memory: {peak / 1024 / 1024:.2f} MB")
|
||||
tracemalloc.stop()
|
||||
|
||||
|
||||
def add_profiler_middleware(app, profile_dir: Path):
|
||||
"""Wrap Flask app with Werkzeug ProfilerMiddleware.
|
||||
|
||||
Each HTTP request will generate a .prof file in profile_dir.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
profile_dir: Directory to save per-request profile files
|
||||
|
||||
Returns:
|
||||
The app with profiler middleware applied
|
||||
"""
|
||||
from werkzeug.middleware.profiler import ProfilerMiddleware
|
||||
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.wsgi_app = ProfilerMiddleware(
|
||||
app.wsgi_app,
|
||||
profile_dir=str(profile_dir),
|
||||
restrictions=[30] # Top 30 functions per request
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def setup_request_logging(app, log_file: Path):
|
||||
"""Add request logging middleware to Flask app.
|
||||
|
||||
Logs each request with timestamp, method, path, status, and duration.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
log_file: Path to the log file
|
||||
|
||||
Returns:
|
||||
The app with logging configured
|
||||
"""
|
||||
from flask import request, g
|
||||
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Configure file handler
|
||||
file_handler = logging.FileHandler(str(log_file))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s | %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
))
|
||||
|
||||
# Create a dedicated logger for requests
|
||||
request_logger = logging.getLogger('rf_mapper.requests')
|
||||
request_logger.setLevel(logging.INFO)
|
||||
request_logger.addHandler(file_handler)
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
g.start_time = time.time()
|
||||
|
||||
@app.after_request
|
||||
def log_request(response):
|
||||
duration_ms = (time.time() - g.start_time) * 1000
|
||||
request_logger.info(
|
||||
f"{request.method} {request.path} | "
|
||||
f"{response.status_code} | "
|
||||
f"{duration_ms:.1f}ms | "
|
||||
f"{request.remote_addr}"
|
||||
)
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def add_request_logging_middleware(app, log_dir: Path):
|
||||
"""Add request logging with daily rotation.
|
||||
|
||||
Args:
|
||||
app: Flask application instance
|
||||
log_dir: Directory to save log files
|
||||
|
||||
Returns:
|
||||
The app with logging configured
|
||||
"""
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / f"requests_{datetime.now().strftime('%Y%m%d')}.log"
|
||||
return setup_request_logging(app, log_file)
|
||||
428
src/rf_mapper/scanner.py
Normal file
428
src/rf_mapper/scanner.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""RF Environment Scanner - WiFi and Bluetooth device discovery"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from .oui import OUILookup
|
||||
from .bluetooth_class import BluetoothClassDecoder
|
||||
from .distance import estimate_distance, rssi_to_quality, rssi_bar
|
||||
from .bluetooth_identify import (
|
||||
identify_device,
|
||||
infer_device_type_from_name,
|
||||
infer_device_type_from_manufacturer,
|
||||
is_random_mac,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WifiNetwork:
|
||||
"""Represents a discovered WiFi network"""
|
||||
ssid: str
|
||||
bssid: str
|
||||
rssi: int # dBm
|
||||
channel: int
|
||||
frequency: int # MHz
|
||||
encryption: str
|
||||
manufacturer: str = ""
|
||||
floor: int | None = None # Floor number (0=ground)
|
||||
height_m: float | None = None # Height in meters
|
||||
|
||||
@property
|
||||
def estimated_distance(self) -> float:
|
||||
"""Estimate distance in meters"""
|
||||
return estimate_distance(self.rssi)
|
||||
|
||||
@property
|
||||
def signal_quality(self) -> str:
|
||||
"""Human-readable signal quality"""
|
||||
return rssi_to_quality(self.rssi)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BluetoothDevice:
|
||||
"""Represents a discovered Bluetooth device"""
|
||||
address: str
|
||||
name: str
|
||||
rssi: int # dBm
|
||||
device_class: str
|
||||
device_type: str
|
||||
manufacturer: str = ""
|
||||
floor: int | None = None # Floor number (0=ground)
|
||||
height_m: float | None = None # Height in meters
|
||||
|
||||
@property
|
||||
def estimated_distance(self) -> float:
|
||||
"""Estimate distance in meters (BT has lower TX power)"""
|
||||
return estimate_distance(self.rssi, tx_power=-65)
|
||||
|
||||
@property
|
||||
def signal_quality(self) -> str:
|
||||
"""Human-readable signal quality"""
|
||||
return rssi_to_quality(self.rssi)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Container for scan results"""
|
||||
timestamp: str
|
||||
location_label: str
|
||||
wifi_networks: list
|
||||
bluetooth_devices: list
|
||||
|
||||
|
||||
class RFScanner:
|
||||
"""Main RF scanning class for WiFi and Bluetooth"""
|
||||
|
||||
def __init__(self, data_dir: Path | None = None):
|
||||
self.oui_lookup = OUILookup()
|
||||
self.bt_decoder = BluetoothClassDecoder()
|
||||
self.scan_history: list[ScanResult] = []
|
||||
self.data_dir = data_dir or Path.home() / "git" / "rf-mapper" / "data"
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def scan_wifi(self, interface: str = "wlan0") -> list[WifiNetwork]:
|
||||
"""
|
||||
Scan for WiFi networks using iw.
|
||||
|
||||
Args:
|
||||
interface: WiFi interface name (default: wlan0)
|
||||
|
||||
Returns:
|
||||
List of discovered WiFi networks
|
||||
"""
|
||||
networks = []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', 'iw', 'dev', interface, 'scan'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"WiFi scan error: {result.stderr}")
|
||||
return networks
|
||||
|
||||
current_network: dict = {}
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('BSS '):
|
||||
if current_network and 'bssid' in current_network:
|
||||
networks.append(self._create_wifi_network(current_network))
|
||||
match = re.match(r'BSS ([0-9a-fA-F:]+)', line)
|
||||
if match:
|
||||
current_network = {'bssid': match.group(1)}
|
||||
|
||||
elif line.startswith('signal:'):
|
||||
match = re.search(r'(-?\d+\.?\d*)\s*dBm', line)
|
||||
if match:
|
||||
current_network['rssi'] = int(float(match.group(1)))
|
||||
|
||||
elif line.startswith('freq:'):
|
||||
match = re.search(r'(\d+)', line)
|
||||
if match:
|
||||
current_network['frequency'] = int(match.group(1))
|
||||
|
||||
elif line.startswith('SSID:'):
|
||||
ssid = line.replace('SSID:', '').strip()
|
||||
current_network['ssid'] = ssid if ssid else '<hidden>'
|
||||
|
||||
elif line.startswith('DS Parameter set: channel'):
|
||||
match = re.search(r'channel\s+(\d+)', line)
|
||||
if match:
|
||||
current_network['channel'] = int(match.group(1))
|
||||
|
||||
elif 'RSN:' in line or 'WPA:' in line:
|
||||
current_network['encryption'] = 'WPA/WPA2'
|
||||
elif 'WEP' in line:
|
||||
current_network['encryption'] = 'WEP'
|
||||
elif 'Privacy' in line and 'encryption' not in current_network:
|
||||
current_network['encryption'] = 'Encrypted'
|
||||
|
||||
if current_network and 'bssid' in current_network:
|
||||
networks.append(self._create_wifi_network(current_network))
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("WiFi scan timed out")
|
||||
except Exception as e:
|
||||
print(f"WiFi scan error: {e}")
|
||||
|
||||
return networks
|
||||
|
||||
def _create_wifi_network(self, data: dict) -> WifiNetwork:
|
||||
"""Create WifiNetwork from parsed data"""
|
||||
bssid = data.get('bssid', '')
|
||||
return WifiNetwork(
|
||||
ssid=data.get('ssid', '<unknown>'),
|
||||
bssid=bssid,
|
||||
rssi=data.get('rssi', -100),
|
||||
channel=data.get('channel', 0),
|
||||
frequency=data.get('frequency', 0),
|
||||
encryption=data.get('encryption', 'Open'),
|
||||
manufacturer=self.oui_lookup.lookup(bssid)
|
||||
)
|
||||
|
||||
def scan_bluetooth(self, timeout: int = 10, auto_identify: bool = True) -> list[BluetoothDevice]:
|
||||
"""
|
||||
Scan for Bluetooth devices (Classic and BLE).
|
||||
|
||||
Args:
|
||||
timeout: Scan duration in seconds
|
||||
auto_identify: Automatically identify unknown devices
|
||||
|
||||
Returns:
|
||||
List of discovered Bluetooth devices
|
||||
"""
|
||||
devices = []
|
||||
|
||||
# Classic Bluetooth scan
|
||||
try:
|
||||
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'inq', '--flush'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 10
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(
|
||||
r'\s*([0-9A-Fa-f:]+)\s+clock offset:\s*\S+\s+class:\s*(\S+)',
|
||||
line
|
||||
)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
device_class = match.group(2)
|
||||
name = self._get_bt_name(addr)
|
||||
rssi = self._get_bt_rssi(addr)
|
||||
dev_type, dev_subtype = self.bt_decoder.decode(device_class)
|
||||
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=rssi,
|
||||
device_class=device_class,
|
||||
device_type=f"{dev_type}" + (f" ({dev_subtype})" if dev_subtype else ""),
|
||||
manufacturer=self.oui_lookup.lookup(addr)
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Classic BT scan error: {e}")
|
||||
|
||||
# BLE scan
|
||||
try:
|
||||
print(f"Scanning BLE devices ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'timeout', str(timeout), 'hcitool', 'lescan', '--duplicates'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5
|
||||
)
|
||||
|
||||
seen_addrs = {d.address for d in devices}
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(r'([0-9A-Fa-f:]+)\s*(.*)', line)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
name = match.group(2).strip() or '<unknown>'
|
||||
|
||||
if addr not in seen_addrs and addr != 'LE':
|
||||
seen_addrs.add(addr)
|
||||
manufacturer = self.oui_lookup.lookup(addr)
|
||||
|
||||
# Try to infer device type from name first, then manufacturer
|
||||
inferred_type = infer_device_type_from_name(name)
|
||||
if not inferred_type:
|
||||
inferred_type = infer_device_type_from_manufacturer(manufacturer)
|
||||
|
||||
# Mark randomized MAC devices if still unknown
|
||||
if not inferred_type:
|
||||
if is_random_mac(addr):
|
||||
device_type = "BLE Device (Random MAC)"
|
||||
else:
|
||||
device_type = "Low Energy Device"
|
||||
else:
|
||||
device_type = inferred_type
|
||||
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=-70, # Default estimate for BLE
|
||||
device_class="BLE",
|
||||
device_type=device_type,
|
||||
manufacturer=manufacturer
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"BLE scan error: {e}")
|
||||
|
||||
# Auto-identify unknown devices
|
||||
if auto_identify and devices:
|
||||
devices = self._auto_identify_devices(devices)
|
||||
|
||||
return devices
|
||||
|
||||
def _auto_identify_devices(self, devices: list[BluetoothDevice]) -> list[BluetoothDevice]:
|
||||
"""Automatically identify devices with unknown names or types"""
|
||||
identified = []
|
||||
|
||||
# Types that are considered "unidentified" and need deeper lookup
|
||||
generic_types = {'Low Energy Device', 'Unknown', '', 'BLE Device (Random MAC)'}
|
||||
|
||||
for dev in devices:
|
||||
# Skip if already well-identified (has good name and specific type)
|
||||
is_name_known = dev.name and dev.name not in ('<unknown>', '(unknown)', 'Unknown', '')
|
||||
is_type_known = dev.device_type and dev.device_type not in generic_types
|
||||
|
||||
if is_name_known and is_type_known:
|
||||
identified.append(dev)
|
||||
continue
|
||||
|
||||
# Try to identify via bluetoothctl
|
||||
try:
|
||||
print(f" Identifying {dev.address}...")
|
||||
info = identify_device(dev.address, is_ble=(dev.device_class == "BLE"))
|
||||
|
||||
# Update device with identified info
|
||||
new_name = info.name or info.alias or dev.name
|
||||
new_type = dev.device_type # Keep existing type as fallback
|
||||
|
||||
# Use bluetoothctl-discovered type if better than what we have
|
||||
if info.device_type and info.device_type != "Unknown":
|
||||
new_type = info.device_type
|
||||
elif dev.device_type in generic_types:
|
||||
# Try name-based inference with potentially better name
|
||||
inferred = infer_device_type_from_name(new_name)
|
||||
if not inferred:
|
||||
# Try manufacturer-based inference
|
||||
inferred = infer_device_type_from_manufacturer(dev.manufacturer)
|
||||
if inferred:
|
||||
new_type = inferred
|
||||
elif is_random_mac(dev.address):
|
||||
new_type = "BLE Device (Random MAC)"
|
||||
|
||||
# Build services string if available
|
||||
services_str = ""
|
||||
if info.services:
|
||||
services_str = f" [{', '.join(info.services[:3])}]"
|
||||
|
||||
identified.append(BluetoothDevice(
|
||||
address=dev.address,
|
||||
name=new_name,
|
||||
rssi=dev.rssi,
|
||||
device_class=dev.device_class,
|
||||
device_type=new_type + services_str if services_str else new_type,
|
||||
manufacturer=dev.manufacturer
|
||||
))
|
||||
except Exception as e:
|
||||
# Keep original device if identification fails
|
||||
identified.append(dev)
|
||||
|
||||
return identified
|
||||
|
||||
def _get_bt_name(self, address: str) -> str:
|
||||
"""Get Bluetooth device name"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'name', address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
name = result.stdout.strip()
|
||||
return name if name else '<unknown>'
|
||||
except:
|
||||
return '<unknown>'
|
||||
|
||||
def _get_bt_rssi(self, address: str) -> int:
|
||||
"""Get Bluetooth RSSI for a device"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'rssi', address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
match = re.search(r'RSSI return value:\s*(-?\d+)', result.stdout)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
except:
|
||||
pass
|
||||
return -80
|
||||
|
||||
def full_scan(
|
||||
self,
|
||||
location_label: str = "default",
|
||||
auto_identify_bt: bool = True
|
||||
) -> tuple[ScanResult, list[WifiNetwork], list[BluetoothDevice]]:
|
||||
"""
|
||||
Perform a full WiFi and Bluetooth scan.
|
||||
|
||||
Args:
|
||||
location_label: Label for this scan location
|
||||
auto_identify_bt: Automatically identify Bluetooth devices
|
||||
|
||||
Returns:
|
||||
Tuple of (ScanResult, wifi_networks, bluetooth_devices)
|
||||
"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Starting RF Environment Scan at {datetime.now().isoformat()}")
|
||||
print(f"Location: {location_label}")
|
||||
print('='*60)
|
||||
|
||||
print("\n[1/2] Scanning WiFi networks...")
|
||||
wifi_networks = self.scan_wifi()
|
||||
print(f" Found {len(wifi_networks)} WiFi networks")
|
||||
|
||||
print("\n[2/2] Scanning Bluetooth devices...")
|
||||
bt_devices = self.scan_bluetooth(auto_identify=auto_identify_bt)
|
||||
print(f" Found {len(bt_devices)} Bluetooth devices")
|
||||
|
||||
result = ScanResult(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
location_label=location_label,
|
||||
wifi_networks=[asdict(n) for n in wifi_networks],
|
||||
bluetooth_devices=[asdict(d) for d in bt_devices]
|
||||
)
|
||||
|
||||
self.scan_history.append(result)
|
||||
|
||||
# Save to file
|
||||
filename = self.data_dir / f"scan_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{location_label}.json"
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(asdict(result), f, indent=2)
|
||||
print(f"\nScan saved to: {filename}")
|
||||
|
||||
return result, wifi_networks, bt_devices
|
||||
|
||||
def print_results(self, wifi_networks: list[WifiNetwork], bt_devices: list[BluetoothDevice]):
|
||||
"""Pretty print scan results"""
|
||||
print(f"\n{'='*80}")
|
||||
print("WiFi NETWORKS")
|
||||
print('='*80)
|
||||
|
||||
wifi_sorted = sorted(wifi_networks, key=lambda x: x.rssi, reverse=True)
|
||||
|
||||
print(f"{'SSID':<25} {'BSSID':<18} {'RSSI':>6} {'Ch':>4} {'Manufacturer':<25}")
|
||||
print('-'*80)
|
||||
|
||||
for net in wifi_sorted:
|
||||
bar = rssi_bar(net.rssi)
|
||||
print(f"{net.ssid[:24]:<25} {net.bssid:<18} {net.rssi:>4}dB {net.channel:>4} {net.manufacturer[:24]:<25}")
|
||||
print(f" Signal: {bar} ({net.signal_quality})")
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print("BLUETOOTH DEVICES")
|
||||
print('='*80)
|
||||
|
||||
bt_sorted = sorted(bt_devices, key=lambda x: x.rssi, reverse=True)
|
||||
|
||||
print(f"{'Name':<20} {'Address':<18} {'RSSI':>6} {'Type':<20} {'Manufacturer':<20}")
|
||||
print('-'*80)
|
||||
|
||||
for dev in bt_sorted:
|
||||
print(f"{dev.name[:19]:<20} {dev.address:<18} {dev.rssi:>4}dB {dev.device_type[:19]:<20} {dev.manufacturer[:19]:<20}")
|
||||
217
src/rf_mapper/visualize.py
Normal file
217
src/rf_mapper/visualize.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""ASCII-based visualization for RF scan data"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from .distance import estimate_distance
|
||||
|
||||
|
||||
def create_ascii_radar(devices: list[dict], title: str = "RF Environment") -> str:
|
||||
"""
|
||||
Create an ASCII radar-style visualization.
|
||||
|
||||
Args:
|
||||
devices: List of device dicts with 'rssi' and 'ssid'/'name' keys
|
||||
title: Title for the visualization
|
||||
|
||||
Returns:
|
||||
ASCII art string
|
||||
"""
|
||||
radius = 15
|
||||
center = radius
|
||||
|
||||
# Create empty grid
|
||||
grid = [[' ' for _ in range(center * 2 + 1)] for _ in range(center * 2 + 1)]
|
||||
|
||||
# Draw radar circles (distance rings)
|
||||
for r in [5, 10, 15]:
|
||||
for angle in range(0, 360, 10):
|
||||
x = int(center + r * math.cos(math.radians(angle)))
|
||||
y = int(center + r * math.sin(math.radians(angle)))
|
||||
if 0 <= x < len(grid[0]) and 0 <= y < len(grid):
|
||||
if grid[y][x] == ' ':
|
||||
grid[y][x] = '·'
|
||||
|
||||
# Draw axes
|
||||
for i in range(len(grid)):
|
||||
if grid[center][i] == ' ':
|
||||
grid[center][i] = '─'
|
||||
if grid[i][center] == ' ':
|
||||
grid[i][center] = '│'
|
||||
grid[center][center] = '┼'
|
||||
|
||||
# Place devices
|
||||
device_legend = []
|
||||
markers = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789'
|
||||
|
||||
for i, dev in enumerate(devices[:len(markers)]):
|
||||
rssi = dev.get('rssi', -80)
|
||||
name = dev.get('ssid', dev.get('name', 'Unknown'))[:15]
|
||||
manufacturer = dev.get('manufacturer', 'Unknown')
|
||||
|
||||
distance = estimate_distance(rssi)
|
||||
norm_dist = min(radius - 1, int(distance / 2))
|
||||
|
||||
# Use hash of name for consistent angle
|
||||
angle = hash(name) % 360
|
||||
|
||||
x = int(center + norm_dist * math.cos(math.radians(angle)))
|
||||
y = int(center + norm_dist * math.sin(math.radians(angle)))
|
||||
|
||||
if 0 <= x < len(grid[0]) and 0 <= y < len(grid):
|
||||
marker = markers[i]
|
||||
grid[y][x] = marker
|
||||
device_legend.append(
|
||||
f" [{marker}] {name:<15} {rssi:>4}dBm ~{distance:.1f}m ({manufacturer})"
|
||||
)
|
||||
|
||||
output = []
|
||||
output.append(f"\n{'='*50}")
|
||||
output.append(f" {title}")
|
||||
output.append(f" Distance rings: 5m · 10m · 15m")
|
||||
output.append(f" [YOU] = Center")
|
||||
output.append('='*50)
|
||||
output.append('')
|
||||
|
||||
for row in grid:
|
||||
output.append(' ' + ''.join(row))
|
||||
|
||||
output.append('')
|
||||
output.append(' Legend:')
|
||||
output.extend(device_legend)
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
def create_signal_strength_chart(devices: list[dict], title: str = "Signal Strength") -> str:
|
||||
"""
|
||||
Create ASCII bar chart of signal strengths.
|
||||
|
||||
Args:
|
||||
devices: List of device dicts
|
||||
title: Chart title
|
||||
|
||||
Returns:
|
||||
ASCII chart string
|
||||
"""
|
||||
output = []
|
||||
output.append(f"\n{'='*70}")
|
||||
output.append(f" {title}")
|
||||
output.append('='*70)
|
||||
|
||||
sorted_devs = sorted(devices, key=lambda x: x.get('rssi', -100), reverse=True)
|
||||
|
||||
for dev in sorted_devs[:20]:
|
||||
rssi = dev.get('rssi', -100)
|
||||
name = dev.get('ssid', dev.get('name', 'Unknown'))[:18]
|
||||
manufacturer = dev.get('manufacturer', '')[:15]
|
||||
|
||||
bar_len = max(0, (rssi + 100) // 2)
|
||||
|
||||
if rssi >= -50:
|
||||
bar_char = '█'
|
||||
quality = 'STRONG'
|
||||
elif rssi >= -65:
|
||||
bar_char = '▓'
|
||||
quality = 'GOOD '
|
||||
elif rssi >= -75:
|
||||
bar_char = '▒'
|
||||
quality = 'FAIR '
|
||||
else:
|
||||
bar_char = '░'
|
||||
quality = 'WEAK '
|
||||
|
||||
bar = bar_char * bar_len + ' ' * (35 - bar_len)
|
||||
output.append(f" {name:<18} │{bar}│ {rssi:>4}dBm {quality} {manufacturer}")
|
||||
|
||||
output.append('')
|
||||
output.append(' Signal: █ Strong (>-50) ▓ Good (-50 to -65) ▒ Fair (-65 to -75) ░ Weak (<-75)')
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
def create_environment_analysis(wifi: list[dict], bt: list[dict]) -> str:
|
||||
"""
|
||||
Analyze RF environment and generate insights.
|
||||
|
||||
Args:
|
||||
wifi: List of WiFi network dicts
|
||||
bt: List of Bluetooth device dicts
|
||||
|
||||
Returns:
|
||||
Analysis text
|
||||
"""
|
||||
output = []
|
||||
output.append(f"\n{'='*70}")
|
||||
output.append(" ENVIRONMENTAL ANALYSIS")
|
||||
output.append('='*70)
|
||||
|
||||
if len(wifi) >= 2:
|
||||
rssi_values = [n['rssi'] for n in wifi]
|
||||
avg_rssi = sum(rssi_values) / len(rssi_values)
|
||||
rssi_spread = max(rssi_values) - min(rssi_values)
|
||||
|
||||
output.append(f"\n WiFi Environment:")
|
||||
output.append(f" Networks detected: {len(wifi)}")
|
||||
output.append(f" Average signal: {avg_rssi:.1f} dBm")
|
||||
output.append(f" Signal spread: {rssi_spread} dB")
|
||||
|
||||
if rssi_spread > 30:
|
||||
output.append(" → High variation suggests walls/obstacles between APs")
|
||||
elif rssi_spread > 15:
|
||||
output.append(" → Moderate variation, some obstacles present")
|
||||
else:
|
||||
output.append(" → Low variation, relatively open environment")
|
||||
|
||||
strong = len([r for r in rssi_values if r > -60])
|
||||
weak = len([r for r in rssi_values if r < -75])
|
||||
|
||||
output.append(f"\n Strong signals (>-60dBm): {strong}")
|
||||
output.append(f" Weak signals (<-75dBm): {weak}")
|
||||
|
||||
if weak > strong * 2:
|
||||
output.append(" → Many distant APs: dense urban/apartment environment")
|
||||
elif strong > weak:
|
||||
output.append(" → Mostly nearby APs: residential/small office")
|
||||
|
||||
# Analyze frequencies
|
||||
freq_2g = len([n for n in wifi if n.get('frequency', 0) < 3000])
|
||||
freq_5g = len([n for n in wifi if n.get('frequency', 0) >= 5000])
|
||||
output.append(f"\n 2.4 GHz networks: {freq_2g}")
|
||||
output.append(f" 5 GHz networks: {freq_5g}")
|
||||
|
||||
if bt:
|
||||
output.append(f"\n Bluetooth Environment:")
|
||||
output.append(f" Devices detected: {len(bt)}")
|
||||
|
||||
device_types: dict[str, int] = {}
|
||||
manufacturers: dict[str, int] = {}
|
||||
|
||||
for d in bt:
|
||||
dt = d.get('device_type', 'Unknown')
|
||||
mfr = d.get('manufacturer', 'Unknown')
|
||||
device_types[dt] = device_types.get(dt, 0) + 1
|
||||
if mfr != 'Unknown':
|
||||
manufacturers[mfr] = manufacturers.get(mfr, 0) + 1
|
||||
|
||||
output.append("\n Device types:")
|
||||
for dt, count in sorted(device_types.items(), key=lambda x: -x[1]):
|
||||
output.append(f" {dt}: {count}")
|
||||
|
||||
if manufacturers:
|
||||
output.append("\n Manufacturers:")
|
||||
for mfr, count in sorted(manufacturers.items(), key=lambda x: -x[1])[:5]:
|
||||
output.append(f" {mfr}: {count}")
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
def load_latest_scan(data_dir: Path) -> dict | None:
|
||||
"""Load the most recent scan file"""
|
||||
scan_files = sorted(data_dir.glob('scan_*.json'), reverse=True)
|
||||
if not scan_files:
|
||||
return None
|
||||
|
||||
with open(scan_files[0]) as f:
|
||||
return json.load(f)
|
||||
5
src/rf_mapper/web/__init__.py
Normal file
5
src/rf_mapper/web/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Flask web application for RF Mapper"""
|
||||
|
||||
from .app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
1176
src/rf_mapper/web/app.py
Normal file
1176
src/rf_mapper/web/app.py
Normal file
File diff suppressed because it is too large
Load Diff
948
src/rf_mapper/web/static/css/style.css
Normal file
948
src/rf_mapper/web/static/css/style.css
Normal file
@@ -0,0 +1,948 @@
|
||||
/* RF Mapper - Main Stylesheet */
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-tertiary: #0f3460;
|
||||
--bg-dark: #0a0a1a;
|
||||
|
||||
--color-primary: #00ff88;
|
||||
--color-secondary: #4dabf7;
|
||||
--color-warning: #ffd93d;
|
||||
--color-danger: #ff6b6b;
|
||||
--color-text: #eee;
|
||||
--color-text-muted: #888;
|
||||
--color-text-dim: #666;
|
||||
|
||||
--border-color: #0f3460;
|
||||
--border-radius: 4px;
|
||||
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background: var(--bg-primary);
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--color-danger);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.main-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.view-toggle {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-toggle button:first-child {
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
.view-toggle button:last-child {
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.view-toggle button:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Filter Controls */
|
||||
.filter-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn.wifi {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-btn.wifi.inactive {
|
||||
color: #555;
|
||||
border-color: #333;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.filter-btn.bluetooth {
|
||||
color: var(--color-secondary);
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.filter-btn.bluetooth.inactive {
|
||||
color: #555;
|
||||
border-color: #333;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.filter-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.filter-btn.inactive .filter-indicator {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 2px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.device-count {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Device Cards */
|
||||
.device-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.device-card.wifi {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.device-card.bluetooth {
|
||||
border-left: 3px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Signal Bars */
|
||||
.signal-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.signal-bar span {
|
||||
width: 4px;
|
||||
background: #333;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.signal-bar span.active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.signal-bar.weak span.active {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.signal-bar.fair span.active {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Radar Canvas */
|
||||
.radar-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.radar-canvas.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#leaflet-map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#leaflet-map.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Misc */
|
||||
.scan-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.manufacturer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.position-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.position-input input {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.distance-badge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-primary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Bluetooth Identify */
|
||||
.identify-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-secondary);
|
||||
color: var(--color-secondary);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.identify-btn:hover {
|
||||
background: var(--color-secondary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.identify-btn.loading {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.device-services {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-secondary);
|
||||
margin-top: 0.3rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.device-services.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.service-tag {
|
||||
display: inline-block;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-secondary);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 2px;
|
||||
margin: 0.1rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.device-type-badge {
|
||||
background: #1a3a5c;
|
||||
color: var(--color-secondary);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
margin-top: 0.3rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Auto-Scan Controls */
|
||||
.autoscan-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.autoscan-status.running {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.autoscan-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.autoscan-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.autoscan-row label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.autoscan-row input {
|
||||
width: 80px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.autoscan-row input[type="text"] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
#autoscan-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-dim);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.autoscan-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #1a5a8a;
|
||||
}
|
||||
|
||||
#autoscan-btn.active {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Device Detail Panel */
|
||||
.device-detail-panel {
|
||||
position: absolute;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
min-width: 220px;
|
||||
max-width: 300px;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.device-detail-panel.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.device-detail-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.detail-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.device-detail-panel.wifi {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.device-detail-panel.bluetooth {
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 4px 20px rgba(77, 171, 247, 0.2);
|
||||
}
|
||||
|
||||
/* Radar hover tooltip */
|
||||
.radar-tooltip {
|
||||
position: absolute;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.radar-tooltip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 3D Map View */
|
||||
.map-3d {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
.map-3d.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-3d.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* MapLibre popup overrides to match theme */
|
||||
.maplibregl-popup-content {
|
||||
background: var(--bg-secondary) !important;
|
||||
color: var(--color-text) !important;
|
||||
border: 1px solid var(--color-primary) !important;
|
||||
border-radius: var(--border-radius) !important;
|
||||
padding: 0.75rem !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
||||
border-top-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||
border-bottom-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-left .maplibregl-popup-tip {
|
||||
border-right-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-anchor-right .maplibregl-popup-tip {
|
||||
border-left-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: var(--color-text-muted) !important;
|
||||
font-size: 1.2rem !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button:hover {
|
||||
color: var(--color-text) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Popup floor control */
|
||||
.popup-floor-control {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.popup-floor-control:first-of-type {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.popup-floor-control label {
|
||||
font-weight: bold;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.popup-floor-control select {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup-floor-control select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"] {
|
||||
flex: 1;
|
||||
max-width: 80px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"]:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-floor-control input[type="number"]::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Live Track Button */
|
||||
#live-track-btn.active {
|
||||
background: var(--color-accent);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Moving device markers - purple */
|
||||
.marker-3d.moving .marker-icon {
|
||||
background: #9b59b6 !important;
|
||||
box-shadow: 0 0 15px rgba(155, 89, 182, 0.8), 0 2px 8px rgba(0, 0, 0, 0.5) !important;
|
||||
animation: moving-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.marker-3d.moving .marker-floor {
|
||||
background: #9b59b6 !important;
|
||||
}
|
||||
|
||||
@keyframes moving-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* 3D Markers */
|
||||
.marker-3d {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.marker-3d .marker-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.marker-3d:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.marker-3d .marker-floor {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker-3d.wifi .marker-icon {
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 15px var(--color-primary), 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.marker-3d.bluetooth .marker-icon {
|
||||
background: var(--color-secondary);
|
||||
box-shadow: 0 0 15px var(--color-secondary), 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.marker-3d.center .marker-icon {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8), 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
border: 3px solid var(--color-primary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Floor Controls */
|
||||
.floor-section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.floor-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.floor-controls select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.floor-controls select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.floor-controls select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.floor-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
#floor-device-count {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 3D Map Controls Override */
|
||||
.maplibregl-ctrl-group {
|
||||
background: var(--bg-secondary) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button:hover {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
background: rgba(22, 33, 62, 0.8) !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-height: 40vh;
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
661
src/rf_mapper/web/static/css/vendor/leaflet.css
vendored
Normal file
661
src/rf_mapper/web/static/css/vendor/leaflet.css
vendored
Normal file
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
1
src/rf_mapper/web/static/css/vendor/maplibre-gl.css
vendored
Normal file
1
src/rf_mapper/web/static/css/vendor/maplibre-gl.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/rf_mapper/web/static/images/marker-icon-2x.png
Normal file
BIN
src/rf_mapper/web/static/images/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/rf_mapper/web/static/images/marker-icon.png
Normal file
BIN
src/rf_mapper/web/static/images/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/rf_mapper/web/static/images/marker-shadow.png
Normal file
BIN
src/rf_mapper/web/static/images/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
1542
src/rf_mapper/web/static/js/app.js
Normal file
1542
src/rf_mapper/web/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
6
src/rf_mapper/web/static/js/vendor/leaflet.js
vendored
Normal file
6
src/rf_mapper/web/static/js/vendor/leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
59
src/rf_mapper/web/static/js/vendor/maplibre-gl.js
vendored
Normal file
59
src/rf_mapper/web/static/js/vendor/maplibre-gl.js
vendored
Normal file
File diff suppressed because one or more lines are too long
74
src/rf_mapper/web/templates/base.html
Normal file
74
src/rf_mapper/web/templates/base.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}RF Mapper{% endblock %}</title>
|
||||
|
||||
<!-- Leaflet CSS (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/leaflet.css') }}">
|
||||
|
||||
<!-- MapLibre GL CSS (local) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/maplibre-gl.css') }}">
|
||||
|
||||
<!-- App CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>{{ config.app_name | default('📡 RF Mapper') }}</h1>
|
||||
<div class="header-controls">
|
||||
{% block header_controls %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Leaflet JS (local) -->
|
||||
<script src="{{ url_for('static', filename='js/vendor/leaflet.js') }}"></script>
|
||||
|
||||
<!-- MapLibre GL JS (local) -->
|
||||
<script src="{{ url_for('static', filename='js/vendor/maplibre-gl.js') }}"></script>
|
||||
|
||||
<!-- Fix Leaflet icon paths for local hosting -->
|
||||
<script>
|
||||
L.Icon.Default.imagePath = '{{ url_for("static", filename="images") }}/';
|
||||
</script>
|
||||
|
||||
<!-- App Config -->
|
||||
<script>
|
||||
const APP_CONFIG = {
|
||||
defaultLat: {{ lat | default(50.8503) }},
|
||||
defaultLon: {{ lon | default(4.3517) }},
|
||||
apiBase: '{{ url_for("index") }}api',
|
||||
colors: {
|
||||
wifi: '#00ff88',
|
||||
bluetooth: '#4dabf7',
|
||||
warning: '#ffd93d',
|
||||
danger: '#ff6b6b'
|
||||
},
|
||||
radar: {
|
||||
maxDistance: 30,
|
||||
distances: [5, 10, 15, 20, 30]
|
||||
},
|
||||
building: {
|
||||
enabled: {{ building.enabled | default(false) | tojson }},
|
||||
name: {{ building.name | default('') | tojson }},
|
||||
floors: {{ building.floors | default(1) }},
|
||||
floorHeightM: {{ building.floor_height_m | default(3.0) }},
|
||||
groundFloorNumber: {{ building.ground_floor_number | default(0) }},
|
||||
currentFloor: {{ building.current_floor | default(0) }}
|
||||
},
|
||||
maplibre: {
|
||||
style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
176
src/rf_mapper/web/templates/index.html
Normal file
176
src/rf_mapper/web/templates/index.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
||||
|
||||
{% block header_controls %}
|
||||
<span id="scan-status" class="scan-info">Ready</span>
|
||||
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
||||
🔍 New Scan
|
||||
</button>
|
||||
<button class="btn" id="live-track-btn" onclick="toggleLiveTracking()">
|
||||
▶ Live Track
|
||||
</button>
|
||||
<button class="btn" id="autoscan-btn" onclick="toggleAutoScan()">
|
||||
⏱️ Auto: Off
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="main-container">
|
||||
<div class="map-container">
|
||||
<div class="view-toggle">
|
||||
<button id="btn-radar" onclick="setView('radar')">Radar</button>
|
||||
<button id="btn-map" onclick="setView('map')">World Map</button>
|
||||
<button id="btn-3d" class="active" onclick="setView('3d')">3D Map</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-controls">
|
||||
<button id="filter-wifi" class="filter-btn wifi" onclick="toggleFilter('wifi')">
|
||||
<span class="filter-indicator"></span>
|
||||
<span>WiFi</span>
|
||||
</button>
|
||||
<button id="filter-bt" class="filter-btn bluetooth" onclick="toggleFilter('bluetooth')">
|
||||
<span class="filter-indicator"></span>
|
||||
<span>Bluetooth</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
||||
<div id="leaflet-map" class="hidden"></div>
|
||||
<div id="map-3d" class="map-3d active"></div>
|
||||
|
||||
<!-- Device Detail Panel -->
|
||||
<div id="device-detail-panel" class="device-detail-panel hidden">
|
||||
<button class="detail-close" onclick="closeDetailPanel()">×</button>
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon" id="detail-icon"></span>
|
||||
<span class="detail-name" id="detail-name"></span>
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Signal:</span>
|
||||
<span class="detail-value" id="detail-signal"></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Distance:</span>
|
||||
<span class="detail-value" id="detail-distance"></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Manufacturer:</span>
|
||||
<span class="detail-value" id="detail-manufacturer"></span>
|
||||
</div>
|
||||
<div class="detail-row" id="detail-channel-row">
|
||||
<span class="detail-label">Channel:</span>
|
||||
<span class="detail-value" id="detail-channel"></span>
|
||||
</div>
|
||||
<div class="detail-row" id="detail-type-row">
|
||||
<span class="detail-label">Type:</span>
|
||||
<span class="detail-value" id="detail-type"></span>
|
||||
</div>
|
||||
<div class="detail-row" id="detail-address-row">
|
||||
<span class="detail-label">Address:</span>
|
||||
<span class="detail-value" id="detail-address"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📍 Position</span>
|
||||
</div>
|
||||
<div class="position-input">
|
||||
<input type="number" id="lat-input" placeholder="Latitude" step="0.0001" value="{{ lat }}">
|
||||
<input type="number" id="lon-input" placeholder="Longitude" step="0.0001" value="{{ lon }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section floor-section" id="floor-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">🏢 Floor Filter</span>
|
||||
</div>
|
||||
<div class="floor-controls">
|
||||
<select id="floor-select" onchange="setFloorFilter(this.value)">
|
||||
<option value="all" selected>All Floors</option>
|
||||
</select>
|
||||
<div class="floor-info" id="floor-info">
|
||||
<span id="floor-device-count">--</span> devices on selected floor
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">⏱️ Auto-Scan</span>
|
||||
<span class="autoscan-status" id="autoscan-status">Off</span>
|
||||
</div>
|
||||
<div class="autoscan-controls">
|
||||
<div class="autoscan-row">
|
||||
<label for="autoscan-interval">Interval (min):</label>
|
||||
<input type="number" id="autoscan-interval" min="1" max="60" value="5">
|
||||
</div>
|
||||
<div class="autoscan-row">
|
||||
<label for="autoscan-label">Location label:</label>
|
||||
<input type="text" id="autoscan-label" value="auto_scan" placeholder="auto_scan">
|
||||
</div>
|
||||
<div class="autoscan-row">
|
||||
<span id="autoscan-info">Last scan: --</span>
|
||||
</div>
|
||||
<div class="autoscan-buttons">
|
||||
<button class="btn btn-small" onclick="startAutoScan()">Start</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="stopAutoScan()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📊 Statistics</span>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="wifi-count">0</div>
|
||||
<div class="stat-label">WiFi Networks</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="bt-count">0</div>
|
||||
<div class="stat-label">Bluetooth</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="avg-signal">--</div>
|
||||
<div class="stat-label">Avg Signal</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="nearest">--</div>
|
||||
<div class="stat-label">Nearest (m)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📶 WiFi Networks</span>
|
||||
<span class="device-count" id="wifi-list-count">0</span>
|
||||
</div>
|
||||
<div class="device-list" id="wifi-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">🔵 Bluetooth Devices</span>
|
||||
<span class="device-count" id="bt-list-count">0</span>
|
||||
</div>
|
||||
<div class="device-list" id="bt-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user