Compare commits
41 Commits
6a3e3e8448
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc7c65454 | ||
|
|
579cea57dc | ||
|
|
5def3e2214 | ||
|
|
cc6d9ee58d | ||
|
|
b1efb4ae3c | ||
|
|
91536860ad | ||
|
|
322c53d513 | ||
|
|
9c9f27e55f | ||
|
|
bc2e23c3ca | ||
|
|
e27200c5b5 | ||
|
|
cee36e2ce1 | ||
|
|
3ff43de5ea | ||
|
|
b0e6d1107c | ||
|
|
4fef21c06f | ||
|
|
446bec278d | ||
|
|
b695e19079 | ||
|
|
5b9612dfae | ||
|
|
522174721d | ||
|
|
f787ccd426 | ||
|
|
7ccbf486c5 | ||
|
|
7e469d6a0a | ||
|
|
ae235ebef8 | ||
|
|
588102ddf4 | ||
|
|
f04ce5aed3 | ||
|
|
9b275f4606 | ||
|
|
320d012200 | ||
|
|
b6aa3ede56 | ||
|
|
8a533a0670 | ||
|
|
1cc403eea6 | ||
|
|
4b4cc47e67 | ||
|
|
24de6c7f06 | ||
|
|
5fbf096a04 | ||
|
|
8f4fa4e186 | ||
|
|
14757f2e57 | ||
|
|
fed08aa6dd | ||
|
|
98e2c6fc42 | ||
|
|
ea88343ae7 | ||
|
|
04bbd0b0af | ||
|
|
8e25bf8871 | ||
|
|
2973178cb8 | ||
|
|
827830b043 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,3 +52,6 @@ data/rf-mapper.started
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local inventory (site-specific, not tracked)
|
||||||
|
INVENTORY.md
|
||||||
|
|||||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -6,6 +6,72 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-02-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Node Control API** - Master node can start/stop/restart peers via SSH
|
||||||
|
- `POST /api/nodes/<id>/start` - Start rf-mapper on peer
|
||||||
|
- `POST /api/nodes/<id>/stop` - Stop rf-mapper on peer
|
||||||
|
- `POST /api/nodes/<id>/restart` - Restart rf-mapper on peer
|
||||||
|
- `GET /api/nodes/<id>/status` - Check peer status
|
||||||
|
- **Home Assistant Node Control** - rest_command integration for peer control
|
||||||
|
- `rest_command.rf_mapper_jellystar_start/stop/restart`
|
||||||
|
- `sensor.rf_mapper_jellystar_status` (running/stopped/unreachable)
|
||||||
|
- **Termux/Android Support** - Graceful handling of Android limitations
|
||||||
|
- Skip BT scanning on Termux (bleak requires D-Bus, not available on Android)
|
||||||
|
- Location check now optional (uses config.yaml coordinates)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Filter checkboxes now control display only, not scanning
|
||||||
|
- WiFi and Bluetooth always scanned regardless of filter state
|
||||||
|
- Filters only affect what's shown on map/radar
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- WiFi devices not appearing when filter unchecked at scan time
|
||||||
|
- rf-mapper failing to start on Termux due to GPS timeout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-02-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-Scanner Peer Sync** - Multiple scanner instances share device metadata
|
||||||
|
- Peer registration API (`/api/peers/register`)
|
||||||
|
- Bidirectional sync (`/api/sync/devices`)
|
||||||
|
- Background sync thread (30s default interval)
|
||||||
|
- Automatic mutual registration between peers
|
||||||
|
- **Source scanner tracking** - Synced devices retain original detector info
|
||||||
|
- Devices positioned relative to detecting scanner, not local scanner
|
||||||
|
- Moving local scanner doesn't affect synced device positions
|
||||||
|
- **Peer scanner markers** - Show peer scanners on 3D map (cyan icons)
|
||||||
|
- Scanner identity configuration (id, name, floor, position)
|
||||||
|
- Timestamp-based conflict resolution for sync
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Device positions now use peer's current position (live lookup)
|
||||||
|
- Popup shows "Source: <scanner>" for remotely-synced devices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-02-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Home Assistant Integration** - Webhook-based presence tracking
|
||||||
|
- Scan results webhook (`rf_mapper_scan`)
|
||||||
|
- New device alerts webhook (`rf_mapper_new_device`)
|
||||||
|
- Device departure webhook (`rf_mapper_device_gone`)
|
||||||
|
- Configurable timeout for departure detection
|
||||||
|
- Scanner identity (id, name, floor) in webhooks
|
||||||
|
- Absence checker background thread
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.3.0] - 2026-02-01
|
## [0.3.0] - 2026-02-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
46
CLAUDE.md
46
CLAUDE.md
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
RF Environment Scanner for WiFi and Bluetooth signal mapping on Linux.
|
RF Environment Scanner for WiFi and Bluetooth signal mapping on Linux.
|
||||||
|
|
||||||
## Key Documentation
|
## Key Documentation (Maintain These!)
|
||||||
|
|
||||||
|
| File | Purpose | When to Update |
|
||||||
|
|------|---------|----------------|
|
||||||
|
| **[TASKS.md](TASKS.md)** | Current sprint tasks, priorities (P0-P3), status | Start/end of each work session |
|
||||||
|
| **[TODO.md](TODO.md)** | Backlog by category, completed items | When adding/completing features |
|
||||||
|
| **[ROADMAP.md](ROADMAP.md)** | Version milestones, long-term vision | When milestones change |
|
||||||
|
| **[CHANGELOG.md](CHANGELOG.md)** | Version history, notable changes | Each release |
|
||||||
|
| **[PROJECT.md](PROJECT.md)** | Goals, architecture, dependencies | Major architectural changes |
|
||||||
|
| **[USAGE.md](USAGE.md)** | User guide, CLI, web interface, API | When adding features |
|
||||||
|
| **[docs/CHEATSHEET.md](docs/CHEATSHEET.md)** | Quick reference commands | When adding features |
|
||||||
|
| **[INVENTORY.md](INVENTORY.md)** | Multi-node deployment info (gitignored) | When nodes change |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
- **[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)
|
- **[config.yaml](config.yaml)** - Current configuration (GPS, web server, scanner, building settings)
|
||||||
|
- **[docs/HOME_ASSISTANT.md](docs/HOME_ASSISTANT.md)** - Home Assistant webhook integration
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -22,6 +34,8 @@ src/rf_mapper/
|
|||||||
├── bluetooth_*.py # Bluetooth device identification and classification
|
├── bluetooth_*.py # Bluetooth device identification and classification
|
||||||
├── visualize.py # ASCII radar and chart generation
|
├── visualize.py # ASCII radar and chart generation
|
||||||
├── profiling.py # CPU/memory profiling utilities
|
├── profiling.py # CPU/memory profiling utilities
|
||||||
|
├── termux.py # Termux/Android environment detection
|
||||||
|
├── sync.py # Multi-scanner peer sync
|
||||||
└── web/
|
└── web/
|
||||||
├── app.py # Flask application and API endpoints
|
├── app.py # Flask application and API endpoints
|
||||||
├── templates/ # Jinja2 HTML templates (base.html, index.html)
|
├── templates/ # Jinja2 HTML templates (base.html, index.html)
|
||||||
@@ -38,21 +52,35 @@ src/rf_mapper/
|
|||||||
| Change web UI | `web/templates/index.html`, `static/js/app.js`, `static/css/style.css` |
|
| Change web UI | `web/templates/index.html`, `static/js/app.js`, `static/css/style.css` |
|
||||||
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
|
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
|
||||||
| Home Assistant integration | `src/rf_mapper/homeassistant.py`, `docs/HOME_ASSISTANT.md` |
|
| Home Assistant integration | `src/rf_mapper/homeassistant.py`, `docs/HOME_ASSISTANT.md` |
|
||||||
|
| Multi-scanner sync | `src/rf_mapper/sync.py`, `web/app.py` |
|
||||||
|
| Termux/Android support | `src/rf_mapper/termux.py` |
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
rf-mapper start # Start web server (background)
|
python -m rf_mapper start # Start web server (background)
|
||||||
rf-mapper status # Check if running
|
python -m rf_mapper status # Check if running
|
||||||
rf-mapper stop # Stop server
|
python -m rf_mapper stop # Stop server
|
||||||
rf-mapper scan -l room # CLI scan
|
python -m rf_mapper restart # Restart server
|
||||||
rf-mapper --help # All commands
|
python -m rf_mapper scan -l room # CLI scan
|
||||||
|
python -m rf_mapper --help # All commands
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See [INVENTORY.md](INVENTORY.md) for multi-node deployment details.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update and restart all nodes
|
||||||
|
cd ~/git/rf-mapper && source venv/bin/activate && git pull && python -m rf_mapper restart
|
||||||
|
ssh grokbox "cd ~/git/rf-mapper && source venv/bin/activate && git pull && python -m rf_mapper restart"
|
||||||
|
ssh jellystar "cd ~/git/rf-mapper && source venv/bin/activate && git pull && python -m rf_mapper restart"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Python 3.10+, Flask, PyYAML, requests
|
- Python 3.10+, Flask, PyYAML, requests, bleak
|
||||||
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
|
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
|
||||||
- Linux tools: `iw`, bleak (BLE via D-Bus)
|
- Linux tools: `iw`, bleak (BLE via D-Bus)
|
||||||
- SQLite for device history
|
- SQLite for device history
|
||||||
|
|||||||
26
PROJECT.md
26
PROJECT.md
@@ -32,6 +32,10 @@ Understanding the RF environment around you is useful for:
|
|||||||
- **Auto-scan** - Scheduled background scanning
|
- **Auto-scan** - Scheduled background scanning
|
||||||
- **Data Export** - JSON scan history with timestamps
|
- **Data Export** - JSON scan history with timestamps
|
||||||
- **Home Assistant Integration** - Webhook-based presence tracking, new device alerts, departure notifications
|
- **Home Assistant Integration** - Webhook-based presence tracking, new device alerts, departure notifications
|
||||||
|
- **Multi-Scanner Peer Sync** - Multiple scanner instances share device metadata automatically
|
||||||
|
- Bidirectional sync with timestamp-based conflict resolution
|
||||||
|
- Source scanner tracking (devices positioned relative to detecting scanner)
|
||||||
|
- Peer scanner markers on 3D map
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -74,6 +78,15 @@ Understanding the RF environment around you is useful for:
|
|||||||
│ │ Webhook │ │ Webhook │ │ Webhook │ │
|
│ │ Webhook │ │ Webhook │ │ Webhook │ │
|
||||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Peer Scanner Sync │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Scanner A │◄── sync ──►│ Scanner B │ │
|
||||||
|
│ │ (rpios) │ │ (grokbox) │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ │
|
||||||
|
│ Shared: floors, labels, favorites, notes │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
@@ -109,14 +122,17 @@ pip install -e .
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Activate virtual environment
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
# Start web server (background)
|
# Start web server (background)
|
||||||
rf-mapper start
|
python -m rf_mapper start
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
rf-mapper status
|
python -m rf_mapper status
|
||||||
|
|
||||||
# CLI scan
|
# CLI scan
|
||||||
rf-mapper scan
|
python -m rf_mapper scan
|
||||||
|
|
||||||
# Open http://localhost:5000
|
# Open http://localhost:5000
|
||||||
```
|
```
|
||||||
@@ -131,9 +147,13 @@ gps:
|
|||||||
longitude: 4.3978
|
longitude: 4.3978
|
||||||
|
|
||||||
scanner:
|
scanner:
|
||||||
|
id: rpios
|
||||||
|
name: "RPi OS Scanner"
|
||||||
wifi_interface: wlan0
|
wifi_interface: wlan0
|
||||||
bt_scan_timeout: 10
|
bt_scan_timeout: 10
|
||||||
path_loss_exponent: 2.5
|
path_loss_exponent: 2.5
|
||||||
|
sync_interval_seconds: 30
|
||||||
|
accept_registrations: true
|
||||||
|
|
||||||
building:
|
building:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
28
ROADMAP.md
28
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# RF Mapper Roadmap
|
# RF Mapper Roadmap
|
||||||
|
|
||||||
## Current Version: v0.3.0-dev
|
## Current Version: v1.0.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.3.0 - 3D Visualization (IN PROGRESS)
|
## v0.3.0 - 3D Visualization (COMPLETED)
|
||||||
|
|
||||||
- [x] MapLibre GL JS integration
|
- [x] MapLibre GL JS integration
|
||||||
- [x] 3D building extrusion
|
- [x] 3D building extrusion
|
||||||
@@ -45,7 +45,21 @@
|
|||||||
- [x] Position smoothing/averaging (statistical, 5-sample + stddev)
|
- [x] Position smoothing/averaging (statistical, 5-sample + stddev)
|
||||||
- [x] Floor persistence in SQLite database
|
- [x] Floor persistence in SQLite database
|
||||||
- [x] Popup persistence during live updates
|
- [x] Popup persistence during live updates
|
||||||
- [ ] Device trails/history visualization
|
- [x] Device trails/history visualization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 - Multi-Scanner Peer Sync (COMPLETED)
|
||||||
|
|
||||||
|
- [x] Scanner identity configuration (id, name, floor, position)
|
||||||
|
- [x] Peer registration API (`/api/peers/register`)
|
||||||
|
- [x] Bidirectional device sync (`/api/sync/devices`)
|
||||||
|
- [x] Timestamp-based conflict resolution
|
||||||
|
- [x] Source scanner tracking for synced devices
|
||||||
|
- [x] Device positions relative to detecting scanner
|
||||||
|
- [x] Peer scanner markers on 3D map
|
||||||
|
- [x] Background sync thread with configurable interval
|
||||||
|
- [x] Automatic mutual registration between peers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +109,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v1.0.0 - Production Ready
|
## v1.1.0 - Production Hardening
|
||||||
|
|
||||||
- [ ] Comprehensive test suite
|
- [ ] Comprehensive test suite
|
||||||
- [ ] Performance optimization
|
- [ ] Performance optimization
|
||||||
@@ -110,7 +124,7 @@
|
|||||||
|
|
||||||
## Future Ideas (v2.0+)
|
## Future Ideas (v2.0+)
|
||||||
|
|
||||||
- [ ] Multiple scanner nodes (distributed scanning)
|
- [x] Multiple scanner nodes (distributed scanning) - Done in v1.0.0
|
||||||
- [ ] Mesh network visualization
|
- [ ] Mesh network visualization
|
||||||
- [ ] Spectrum analyzer integration
|
- [ ] Spectrum analyzer integration
|
||||||
- [ ] RTL-SDR support for wider RF
|
- [ ] RTL-SDR support for wider RF
|
||||||
@@ -128,4 +142,6 @@
|
|||||||
|---------|------|------------|
|
|---------|------|------------|
|
||||||
| v0.1.0 | 2026-01 | Initial CLI scanner |
|
| v0.1.0 | 2026-01 | Initial CLI scanner |
|
||||||
| v0.2.0 | 2026-01 | Web dashboard |
|
| v0.2.0 | 2026-01 | Web dashboard |
|
||||||
| v0.3.0 | TBD | 3D visualization |
|
| v0.3.0 | 2026-02 | 3D visualization, floor positioning |
|
||||||
|
| v0.4.0 | 2026-02 | Home Assistant integration |
|
||||||
|
| v1.0.0 | 2026-02 | Multi-scanner peer sync |
|
||||||
|
|||||||
39
TASKS.md
39
TASKS.md
@@ -1,7 +1,8 @@
|
|||||||
# RF Mapper - Active Tasks
|
# RF Mapper - Active Tasks
|
||||||
|
|
||||||
**Sprint:** v0.3.0 - 3D Visualization
|
**Sprint:** v1.1.0 - Production Hardening
|
||||||
**Updated:** 2026-02-01
|
**Updated:** 2026-02-01
|
||||||
|
**Current Version:** v1.0.1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@
|
|||||||
| [x] | Document API endpoints | docs/API.md |
|
| [x] | Document API endpoints | docs/API.md |
|
||||||
| [x] | Create CHEATSHEET.md | Quick reference guide |
|
| [x] | Create CHEATSHEET.md | Quick reference guide |
|
||||||
| [x] | Home Assistant webhook integration | Scan results, new device, departure alerts |
|
| [x] | Home Assistant webhook integration | Scan results, new device, departure alerts |
|
||||||
|
| [x] | Multi-scanner peer sync | Bidirectional sync between scanner instances |
|
||||||
|
| [x] | Source scanner tracking | Synced devices positioned relative to source |
|
||||||
|
| [x] | Peer scanner markers | Show peer scanners on 3D map |
|
||||||
|
| [x] | Multi-node master dashboard | View peer node data without page redirect |
|
||||||
|
| [ ] | Unit test coverage | pytest for core modules |
|
||||||
|
| [ ] | Docker container | Containerized deployment |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,6 +99,19 @@
|
|||||||
| Device trails for moving devices | 2026-02-01 |
|
| Device trails for moving devices | 2026-02-01 |
|
||||||
| Manual position override (drag-drop) | 2026-02-01 |
|
| Manual position override (drag-drop) | 2026-02-01 |
|
||||||
| Home Assistant webhook integration | 2026-02-01 |
|
| Home Assistant webhook integration | 2026-02-01 |
|
||||||
|
| Multi-scanner peer sync | 2026-02-01 |
|
||||||
|
| Peer registration API | 2026-02-01 |
|
||||||
|
| Bidirectional device sync | 2026-02-01 |
|
||||||
|
| Source scanner tracking | 2026-02-01 |
|
||||||
|
| Peer scanner markers on 3D map | 2026-02-01 |
|
||||||
|
| v1.0.0 release | 2026-02-01 |
|
||||||
|
| Multi-node master dashboard | 2026-02-01 |
|
||||||
|
| Replace hcitool with bleak for BLE scanning | 2026-02-01 |
|
||||||
|
| Skip BT scanning on Termux/Android | 2026-02-01 |
|
||||||
|
| Node control API (start/stop/restart peers via SSH) | 2026-02-01 |
|
||||||
|
| Home Assistant node control integration | 2026-02-01 |
|
||||||
|
| Make Termux location check optional | 2026-02-01 |
|
||||||
|
| Fix filter to control display only (always scan both) | 2026-02-01 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,3 +138,20 @@
|
|||||||
- Floor assignments persist in database across page reloads
|
- Floor assignments persist in database across page reloads
|
||||||
- Popups stay open during live tracking updates
|
- Popups stay open during live tracking updates
|
||||||
- Manual position override: drag floor-assigned device markers to set custom position
|
- Manual position override: drag floor-assigned device markers to set custom position
|
||||||
|
- **Peer Sync**: Multiple scanner instances can share device metadata
|
||||||
|
- Devices synced from peers retain source scanner info
|
||||||
|
- Positions calculated relative to detecting scanner (not local scanner)
|
||||||
|
- Peer scanners shown on 3D map as cyan markers
|
||||||
|
- Background sync every 30 seconds (configurable)
|
||||||
|
- **Master Dashboard**: Designated master node can view any peer's data
|
||||||
|
- Set `is_master: true` in config.yaml to enable
|
||||||
|
- Node selector dropdown appears in header
|
||||||
|
- Switch between local/peer views without page redirect
|
||||||
|
- Live tracking runs on selected peer node
|
||||||
|
- WebSocket connects to peer for real-time updates
|
||||||
|
- **Node Control API**: Master can start/stop/restart peers via SSH
|
||||||
|
- `POST /api/nodes/<id>/start|stop|restart`
|
||||||
|
- `GET /api/nodes/<id>/status`
|
||||||
|
- Integrated with Home Assistant via rest_command
|
||||||
|
- **Termux/Android Support**: BT scanning skipped (no D-Bus), location check optional
|
||||||
|
- **Filter Behavior**: WiFi/BT always scanned; filter controls display only
|
||||||
|
|||||||
39
TODO.md
39
TODO.md
@@ -1,6 +1,7 @@
|
|||||||
# RF Mapper - TODO / Backlog
|
# RF Mapper - TODO / Backlog
|
||||||
|
|
||||||
**Last Updated:** 2026-02-01
|
**Last Updated:** 2026-02-01
|
||||||
|
**Current Version:** v1.0.1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,6 +25,26 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Multi-Scanner / Peer Sync
|
||||||
|
|
||||||
|
- [x] Scanner identity configuration
|
||||||
|
- [x] Peer registration API
|
||||||
|
- [x] Bidirectional device sync
|
||||||
|
- [x] Timestamp-based conflict resolution
|
||||||
|
- [x] Source scanner tracking for synced devices
|
||||||
|
- [x] Peer scanner markers on map
|
||||||
|
- [x] Background sync thread
|
||||||
|
- [x] WebSocket real-time sync (instead of polling)
|
||||||
|
- [ ] Automatic peer discovery via mDNS/Bonjour
|
||||||
|
- [x] Sync RSSI history for trilateration
|
||||||
|
- [x] Master dashboard: view peer node data without redirect
|
||||||
|
- [x] Node control API (start/stop/restart peers via SSH)
|
||||||
|
- [x] Home Assistant integration for node control
|
||||||
|
- [ ] Web UI for peer management
|
||||||
|
- [ ] Sync conflict resolution UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Scanning
|
## Scanning
|
||||||
|
|
||||||
- [ ] Support multiple WiFi interfaces
|
- [ ] Support multiple WiFi interfaces
|
||||||
@@ -47,7 +68,7 @@
|
|||||||
- [ ] Environment presets (office, home, warehouse)
|
- [ ] Environment presets (office, home, warehouse)
|
||||||
- [ ] Wall attenuation factor
|
- [ ] Wall attenuation factor
|
||||||
- [ ] Multi-floor path loss adjustment
|
- [ ] Multi-floor path loss adjustment
|
||||||
- [ ] Trilateration from multiple scan points
|
- [x] Trilateration from multiple scan points
|
||||||
- [ ] Kalman filter for position smoothing
|
- [ ] Kalman filter for position smoothing
|
||||||
- [ ] Dead reckoning with IMU (if available)
|
- [ ] Dead reckoning with IMU (if available)
|
||||||
- [ ] Fingerprinting-based positioning
|
- [ ] Fingerprinting-based positioning
|
||||||
@@ -102,7 +123,7 @@
|
|||||||
## API & Integration
|
## API & Integration
|
||||||
|
|
||||||
- [ ] OpenAPI/Swagger documentation
|
- [ ] OpenAPI/Swagger documentation
|
||||||
- [ ] WebSocket for real-time updates
|
- [x] WebSocket for real-time updates
|
||||||
- [ ] GraphQL endpoint (optional)
|
- [ ] GraphQL endpoint (optional)
|
||||||
- [ ] MQTT publishing
|
- [ ] MQTT publishing
|
||||||
- [x] Home Assistant webhook integration (scan results, new device, departure)
|
- [x] Home Assistant webhook integration (scan results, new device, departure)
|
||||||
@@ -222,6 +243,20 @@
|
|||||||
- [x] Popup persistence during live updates
|
- [x] Popup persistence during live updates
|
||||||
- [x] Manual position override (drag-drop for floor-assigned devices)
|
- [x] Manual position override (drag-drop for floor-assigned devices)
|
||||||
- [x] Home Assistant webhook integration (scan, new device, departure)
|
- [x] Home Assistant webhook integration (scan, new device, departure)
|
||||||
|
- [x] Multi-scanner peer sync (v1.0.0)
|
||||||
|
- [x] Bidirectional device metadata sync
|
||||||
|
- [x] Source scanner tracking for synced devices
|
||||||
|
- [x] Peer scanner markers on 3D map
|
||||||
|
- [x] WebSocket real-time sync
|
||||||
|
- [x] Termux/Android environment detection with prerequisite checks
|
||||||
|
- [x] Multi-scanner trilateration for device positioning
|
||||||
|
- [x] Signal coverage heat map visualization
|
||||||
|
- [x] Multi-node master dashboard (view peer data in single UI)
|
||||||
|
- [x] Node control API (start/stop/restart peers via SSH)
|
||||||
|
- [x] Home Assistant node control integration
|
||||||
|
- [x] Termux/Android: skip BT scanning gracefully (no D-Bus)
|
||||||
|
- [x] Termux/Android: optional location check
|
||||||
|
- [x] Filter controls display only, always scan both WiFi/BT
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
286
USAGE.md
286
USAGE.md
@@ -4,11 +4,75 @@ RF Mapper is a WiFi and Bluetooth signal mapper for Linux. It scans nearby devic
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux with WiFi and Bluetooth hardware
|
- Linux with WiFi and Bluetooth hardware (or Android via Termux)
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- `sudo` access (required for scanning)
|
- `sudo` access (required for scanning on Linux)
|
||||||
- System tools: `iw`, `hcitool`, `bluetoothctl`
|
- System tools: `iw`, `hcitool`, `bluetoothctl`
|
||||||
|
|
||||||
|
### Android/Termux Requirements
|
||||||
|
|
||||||
|
For running RF Mapper on Android via Termux:
|
||||||
|
|
||||||
|
| Requirement | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| **Termux** | From F-Droid (NOT Play Store) |
|
||||||
|
| **Termux:API** | From F-Droid (same source as Termux) |
|
||||||
|
| **Termux:Boot** | Optional, for auto-start on boot |
|
||||||
|
| **termux-api package** | `pkg install termux-api` |
|
||||||
|
| **Location permission** | Grant to Termux:API app |
|
||||||
|
| **GPS enabled** | Enable Location in Android settings |
|
||||||
|
|
||||||
|
**Important:** All Termux apps must be from the same source (F-Droid recommended). Mixing Play Store and F-Droid versions causes API communication failures.
|
||||||
|
|
||||||
|
#### ADB Configuration for Android 12+
|
||||||
|
|
||||||
|
Android 12+ has a "phantom process killer" that terminates background processes. Disable it for stable Termux operation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect via ADB and run:
|
||||||
|
adb shell "settings put global settings_enable_monitor_phantom_procs false"
|
||||||
|
```
|
||||||
|
|
||||||
|
This setting persists across reboots but may reset after Android updates.
|
||||||
|
|
||||||
|
#### Verify Termux Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if all prerequisites are met
|
||||||
|
rf-mapper check-termux
|
||||||
|
```
|
||||||
|
|
||||||
|
Output shows status of each requirement:
|
||||||
|
```
|
||||||
|
==================================================
|
||||||
|
TERMUX ENVIRONMENT DETECTED
|
||||||
|
Checking prerequisites...
|
||||||
|
==================================================
|
||||||
|
✓ Termux:API package: OK
|
||||||
|
✓ Location services: OK (lat: 50.8585)
|
||||||
|
✓ Wake lock: OK (wake lock acquired)
|
||||||
|
✓ Termux:Boot: OK (boot directory exists)
|
||||||
|
==================================================
|
||||||
|
All prerequisites met. Starting RF Mapper...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Termux Boot Script
|
||||||
|
|
||||||
|
For auto-start on device boot, create `~/.termux/boot/start-rf-mapper.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/data/data/com.termux/files/usr/bin/bash
|
||||||
|
termux-wake-lock
|
||||||
|
cd ~/git/rf-mapper
|
||||||
|
source venv/bin/activate
|
||||||
|
python -m rf_mapper start
|
||||||
|
```
|
||||||
|
|
||||||
|
Make it executable:
|
||||||
|
```bash
|
||||||
|
chmod +x ~/.termux/boot/start-rf-mapper.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -27,92 +91,100 @@ pip install -e .
|
|||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
|
||||||
# Run interactive scan
|
# Run interactive scan
|
||||||
rf-mapper
|
python -m rf_mapper
|
||||||
|
|
||||||
# Start web server
|
# Start web server
|
||||||
rf-mapper start
|
python -m rf_mapper start
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
|
All commands require activating the virtual environment first:
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
### Scanning
|
### Scanning
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic scan (interactive mode)
|
# Basic scan (interactive mode)
|
||||||
rf-mapper
|
python -m rf_mapper
|
||||||
|
|
||||||
# Scan with location label
|
# Scan with location label
|
||||||
rf-mapper scan -l kitchen
|
python -m rf_mapper scan -l kitchen
|
||||||
|
|
||||||
# Scan WiFi only
|
# Scan WiFi only
|
||||||
rf-mapper scan --no-bt
|
python -m rf_mapper scan --no-bt
|
||||||
|
|
||||||
# Scan Bluetooth only
|
# Scan Bluetooth only
|
||||||
rf-mapper scan --no-wifi
|
python -m rf_mapper scan --no-wifi
|
||||||
|
|
||||||
# Use specific WiFi interface
|
# Use specific WiFi interface
|
||||||
rf-mapper scan -i wlan1
|
python -m rf_mapper scan -i wlan1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Visualization (CLI)
|
### Visualization (CLI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Visualize latest scan (ASCII radar + charts)
|
# Visualize latest scan (ASCII radar + charts)
|
||||||
rf-mapper visualize
|
python -m rf_mapper visualize
|
||||||
|
|
||||||
# Visualize specific scan file
|
# Visualize specific scan file
|
||||||
rf-mapper visualize -f data/scan_20240131_120000_kitchen.json
|
python -m rf_mapper visualize -f data/scan_20240131_120000_kitchen.json
|
||||||
|
|
||||||
# Analyze RF environment
|
# Analyze RF environment
|
||||||
rf-mapper analyze
|
python -m rf_mapper analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scan History
|
### Scan History
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List saved scans
|
# List saved scans
|
||||||
rf-mapper list
|
python -m rf_mapper list
|
||||||
```
|
```
|
||||||
|
|
||||||
### Web Server
|
### Web Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start web server (background daemon)
|
# Start web server (background daemon)
|
||||||
rf-mapper start
|
python -m rf_mapper start
|
||||||
|
|
||||||
# Start in foreground (for debugging)
|
# Start in foreground (for debugging)
|
||||||
rf-mapper start --foreground
|
python -m rf_mapper start --foreground
|
||||||
|
|
||||||
# Custom host/port
|
# Custom host/port
|
||||||
rf-mapper start -H 127.0.0.1 -p 8080
|
python -m rf_mapper start -H 127.0.0.1 -p 8080
|
||||||
|
|
||||||
# With debug mode
|
# With debug mode
|
||||||
rf-mapper start --foreground --debug
|
python -m rf_mapper start --foreground --debug
|
||||||
|
|
||||||
# With request profiling
|
# With request profiling
|
||||||
rf-mapper start --profile-requests
|
python -m rf_mapper start --profile-requests
|
||||||
|
|
||||||
# With request logging
|
# With request logging
|
||||||
rf-mapper start --log-requests
|
python -m rf_mapper start --log-requests
|
||||||
|
|
||||||
# Stop the server
|
# Stop the server
|
||||||
rf-mapper stop
|
python -m rf_mapper stop
|
||||||
|
|
||||||
# Restart the server
|
# Restart the server
|
||||||
rf-mapper restart
|
python -m rf_mapper restart
|
||||||
|
|
||||||
# Check server status
|
# Check server status
|
||||||
rf-mapper status
|
python -m rf_mapper status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Show current configuration
|
# Show current configuration
|
||||||
rf-mapper config
|
python -m rf_mapper config
|
||||||
|
|
||||||
# Set GPS coordinates
|
# Set GPS coordinates
|
||||||
rf-mapper config --set-gps 50.8585 4.3978 --save
|
python -m rf_mapper config --set-gps 50.8585 4.3978 --save
|
||||||
|
|
||||||
|
# Check Termux prerequisites (Android only)
|
||||||
|
rf-mapper check-termux
|
||||||
```
|
```
|
||||||
|
|
||||||
### Profiling
|
### Profiling
|
||||||
@@ -201,6 +273,7 @@ The web server exposes a REST API:
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/health` | Health check for monitoring |
|
||||||
| POST | `/api/scan` | Trigger new scan |
|
| POST | `/api/scan` | Trigger new scan |
|
||||||
| GET | `/api/latest` | Get most recent scan |
|
| GET | `/api/latest` | Get most recent scan |
|
||||||
| GET | `/api/scans` | List all scans |
|
| GET | `/api/scans` | List all scans |
|
||||||
@@ -214,6 +287,131 @@ The web server exposes a REST API:
|
|||||||
| POST | `/api/autoscan/stop` | Stop auto-scanning |
|
| POST | `/api/autoscan/stop` | Stop auto-scanning |
|
||||||
| GET | `/api/bluetooth/identify/<addr>` | Identify BT device |
|
| GET | `/api/bluetooth/identify/<addr>` | Identify BT device |
|
||||||
|
|
||||||
|
## Multi-Node Master Dashboard
|
||||||
|
|
||||||
|
RF Mapper supports a multi-node architecture where one designated "master" node can view and control scanning on peer nodes without page redirects.
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Master Node (rpios) │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Node Selector: [📍 rpios ▼] │ │
|
||||||
|
│ │ ├─ 📍 rpios (local) │ │
|
||||||
|
│ │ ├─ 📡 grokbox │ │
|
||||||
|
│ │ └─ 📡 jellystar │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ [Radar] [3D Map] [Device Lists] │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────┼────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
grokbox:5000 jellystar:5000 (local)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enabling Master Mode
|
||||||
|
|
||||||
|
Set `is_master: true` in `config.yaml` on the master node:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scanner:
|
||||||
|
id: 'rpios'
|
||||||
|
name: 'Master Scanner'
|
||||||
|
is_master: true # Enable master dashboard features
|
||||||
|
peers: [] # Peers auto-register via API
|
||||||
|
accept_registrations: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Peer nodes do NOT need this flag (defaults to `false`).
|
||||||
|
|
||||||
|
### Node Selector
|
||||||
|
|
||||||
|
When `is_master: true` and peers are registered, a node selector dropdown appears in the header:
|
||||||
|
|
||||||
|
| Indicator | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| 📍 | Local scanner (this node) |
|
||||||
|
| 📡 | Peer scanner (remote node) |
|
||||||
|
| ● (green) | Connected and responding |
|
||||||
|
| ● (yellow) | Connecting/loading |
|
||||||
|
| ○ (red) | Peer unreachable |
|
||||||
|
|
||||||
|
### Switching Between Nodes
|
||||||
|
|
||||||
|
1. Click the node selector dropdown
|
||||||
|
2. Select a peer node (e.g., "📡 grokbox")
|
||||||
|
3. Dashboard recenters on peer's GPS location
|
||||||
|
4. Device lists show peer's scanned devices
|
||||||
|
5. WebSocket connects to peer for real-time updates
|
||||||
|
|
||||||
|
When viewing a peer:
|
||||||
|
- Map centers on peer's coordinates
|
||||||
|
- Radar shows devices relative to peer
|
||||||
|
- "New Scan" triggers scan on peer node
|
||||||
|
- Live tracking runs scans on peer
|
||||||
|
|
||||||
|
### Live Tracking on Remote Nodes
|
||||||
|
|
||||||
|
Live tracking works across nodes:
|
||||||
|
|
||||||
|
1. Select a peer from node selector
|
||||||
|
2. Enable "Live BT Track" toggle
|
||||||
|
3. Scans run on the selected peer (not locally)
|
||||||
|
4. Results stream back via WebSocket
|
||||||
|
5. Map updates in real-time with peer's devices
|
||||||
|
|
||||||
|
### Proxy API Endpoints
|
||||||
|
|
||||||
|
The master node provides proxy endpoints to access peer data:
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/api/node/<id>/latest` | Get latest scan from peer |
|
||||||
|
| `/api/node/<id>/scan/bt` | Trigger BT scan on peer |
|
||||||
|
| `/api/node/<id>/device/floors` | Get device floor assignments |
|
||||||
|
| `/api/node/<id>/positions/trilaterated` | Get trilaterated positions |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
# Get grokbox's latest scan via master
|
||||||
|
curl http://rpios:5000/api/node/grokbox/latest | jq
|
||||||
|
|
||||||
|
# Trigger BT scan on grokbox
|
||||||
|
curl -X POST http://rpios:5000/api/node/grokbox/scan/bt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Peer Registration
|
||||||
|
|
||||||
|
Peers register with the master automatically or manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual registration from peer
|
||||||
|
curl -X POST http://rpios:5000/api/peers/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "grokbox",
|
||||||
|
"name": "Grokbox Scanner",
|
||||||
|
"url": "http://grokbox:5000",
|
||||||
|
"latitude": 50.858495,
|
||||||
|
"longitude": 4.397614,
|
||||||
|
"floor": 11
|
||||||
|
}'
|
||||||
|
|
||||||
|
# List registered peers
|
||||||
|
curl http://rpios:5000/api/peers | jq '.peers'
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Peer Connections
|
||||||
|
|
||||||
|
When viewing a peer node, the dashboard:
|
||||||
|
|
||||||
|
1. Disconnects from local WebSocket
|
||||||
|
2. Connects to peer's WebSocket at `peer_url/ws/scan`
|
||||||
|
3. Receives real-time scan updates from peer
|
||||||
|
4. Falls back to HTTP polling if WebSocket fails
|
||||||
|
|
||||||
## Data Storage
|
## Data Storage
|
||||||
|
|
||||||
Scan results are saved as JSON in the data directory:
|
Scan results are saved as JSON in the data directory:
|
||||||
@@ -244,3 +442,43 @@ sudo hciconfig hci0 up
|
|||||||
- Zoom in to level 15+ for buildings to appear
|
- Zoom in to level 15+ for buildings to appear
|
||||||
- Not all areas have building data in OpenStreetMap
|
- Not all areas have building data in OpenStreetMap
|
||||||
- The map style must support vector tiles with building data
|
- The map style must support vector tiles with building data
|
||||||
|
|
||||||
|
### Termux: "Termux:API app not responding"
|
||||||
|
1. Ensure Termux:API is from F-Droid (not Play Store)
|
||||||
|
2. Grant all permissions to Termux:API in Android settings
|
||||||
|
3. Restart Termux after installing Termux:API
|
||||||
|
|
||||||
|
### Termux: Process killed in background
|
||||||
|
Android's phantom process killer terminates background processes. Fix:
|
||||||
|
```bash
|
||||||
|
adb shell "settings put global settings_enable_monitor_phantom_procs false"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Termux: Location services failing
|
||||||
|
1. Enable GPS/Location in Android settings
|
||||||
|
2. Grant location permission to Termux:API
|
||||||
|
3. Test with: `termux-location -p passive`
|
||||||
|
4. Ensure you're outdoors or near windows for GPS signal
|
||||||
|
|
||||||
|
### Termux: Wake lock not working
|
||||||
|
1. Disable battery optimization for Termux in Android settings
|
||||||
|
2. Run `termux-wake-lock` before starting RF Mapper
|
||||||
|
3. The app acquires wake lock automatically on start
|
||||||
|
|
||||||
|
### Master Dashboard: Node selector not appearing
|
||||||
|
1. Verify `is_master: true` in config.yaml
|
||||||
|
2. Restart rf-mapper: `python -m rf_mapper restart`
|
||||||
|
3. Check peers are registered: `curl http://localhost:5000/api/peers | jq '.peers | length'`
|
||||||
|
4. At least one peer must be registered for selector to appear
|
||||||
|
|
||||||
|
### Master Dashboard: Peer shows red status
|
||||||
|
1. Check peer is running: `curl http://peer:5000/api/peers`
|
||||||
|
2. Check network connectivity: `ping peer`
|
||||||
|
3. Check firewall allows port 5000
|
||||||
|
4. Re-register peer if needed (see Peer Registration above)
|
||||||
|
|
||||||
|
### Master Dashboard: WebSocket to peer fails
|
||||||
|
1. Check peer WebSocket endpoint: `curl http://peer:5000/socket.io/`
|
||||||
|
2. Browser console shows connection errors
|
||||||
|
3. Falls back to HTTP polling automatically
|
||||||
|
4. Ensure peer is running with WebSocket support enabled
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
gps:
|
gps:
|
||||||
latitude: 50.858495376473314
|
latitude: 50.858532461583906
|
||||||
longitude: 4.397614016072339
|
longitude: 4.397587773133864
|
||||||
web:
|
web:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 5000
|
port: 5000
|
||||||
debug: false
|
debug: false
|
||||||
scanner:
|
scanner:
|
||||||
id: ''
|
id: rpios
|
||||||
name: ''
|
name: rpios
|
||||||
latitude: null
|
latitude: null
|
||||||
longitude: null
|
longitude: null
|
||||||
floor: null
|
floor: null
|
||||||
|
is_master: true
|
||||||
wifi_interface: wlan0
|
wifi_interface: wlan0
|
||||||
bt_scan_timeout: 10
|
bt_scan_timeout: 10
|
||||||
path_loss_exponent: 2.5
|
path_loss_exponent: 2.5
|
||||||
|
|||||||
59
docs/API.md
59
docs/API.md
@@ -6,6 +6,65 @@ REST API documentation for RF Mapper web interface.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## System
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Returns health status for monitoring and load balancers.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"uptime_seconds": 3600,
|
||||||
|
"uptime_human": "1h 0m",
|
||||||
|
"scanner_id": "rpios",
|
||||||
|
"components": {
|
||||||
|
"database": {
|
||||||
|
"status": "ok",
|
||||||
|
"device_count": 100
|
||||||
|
},
|
||||||
|
"peer_sync": {
|
||||||
|
"status": "ok",
|
||||||
|
"peer_count": 2
|
||||||
|
},
|
||||||
|
"auto_scanner": {
|
||||||
|
"status": "stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- `200` - Healthy
|
||||||
|
- `503` - Unhealthy (component error)
|
||||||
|
|
||||||
|
**Component Status Values:**
|
||||||
|
- `ok` - Component working normally
|
||||||
|
- `disabled` - Component not enabled in config
|
||||||
|
- `error` - Component has errors
|
||||||
|
- `running` / `stopped` - For auto_scanner
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simple health check
|
||||||
|
curl -s http://localhost:5000/api/health | jq '.status'
|
||||||
|
|
||||||
|
# Use in monitoring scripts
|
||||||
|
if curl -sf http://localhost:5000/api/health > /dev/null; then
|
||||||
|
echo "RF Mapper is healthy"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Scanning
|
## Scanning
|
||||||
|
|
||||||
### Trigger Scan
|
### Trigger Scan
|
||||||
|
|||||||
@@ -6,21 +6,26 @@ Quick reference for RF Mapper commands and configuration.
|
|||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
|
All commands require activating the virtual environment first:
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `rf-mapper` | Interactive scan mode |
|
| `python -m rf_mapper` | Interactive scan mode |
|
||||||
| `rf-mapper scan` | Run scan with defaults |
|
| `python -m rf_mapper scan` | Run scan with defaults |
|
||||||
| `rf-mapper scan -l kitchen` | Scan with location label |
|
| `python -m rf_mapper scan -l kitchen` | Scan with location label |
|
||||||
| `rf-mapper scan --no-bt` | WiFi only |
|
| `python -m rf_mapper scan --no-bt` | WiFi only |
|
||||||
| `rf-mapper scan --no-wifi` | Bluetooth only |
|
| `python -m rf_mapper scan --no-wifi` | Bluetooth only |
|
||||||
| `rf-mapper visualize` | ASCII radar display |
|
| `python -m rf_mapper visualize` | ASCII radar display |
|
||||||
| `rf-mapper analyze` | RF environment analysis |
|
| `python -m rf_mapper analyze` | RF environment analysis |
|
||||||
| `rf-mapper list` | List saved scans |
|
| `python -m rf_mapper list` | List saved scans |
|
||||||
| `rf-mapper start` | Start web server (background) |
|
| `python -m rf_mapper start` | Start web server (background) |
|
||||||
| `rf-mapper stop` | Stop web server |
|
| `python -m rf_mapper stop` | Stop web server |
|
||||||
| `rf-mapper restart` | Restart web server |
|
| `python -m rf_mapper restart` | Restart web server |
|
||||||
| `rf-mapper status` | Check if server is running |
|
| `python -m rf_mapper status` | Check if server is running |
|
||||||
| `rf-mapper config` | Show configuration |
|
| `python -m rf_mapper config` | Show configuration |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,18 +33,18 @@ Quick reference for RF Mapper commands and configuration.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
rf-mapper start # Start (background daemon)
|
python -m rf_mapper start # Start (background daemon)
|
||||||
rf-mapper stop # Stop
|
python -m rf_mapper stop # Stop
|
||||||
rf-mapper restart # Restart
|
python -m rf_mapper restart # Restart
|
||||||
rf-mapper status # Check if running
|
python -m rf_mapper status # Check if running
|
||||||
|
|
||||||
# Start options
|
# Start options
|
||||||
rf-mapper start -f # Foreground mode
|
python -m rf_mapper start -f # Foreground mode
|
||||||
rf-mapper start -H 127.0.0.1 # Bind to localhost only
|
python -m rf_mapper start -H 127.0.0.1 # Bind to localhost only
|
||||||
rf-mapper start -p 8080 # Custom port
|
python -m rf_mapper start -p 8080 # Custom port
|
||||||
rf-mapper start --debug # Debug mode (requires -f)
|
python -m rf_mapper start --debug # Debug mode (requires -f)
|
||||||
rf-mapper start --profile-requests # Per-request profiling
|
python -m rf_mapper start --profile-requests # Per-request profiling
|
||||||
rf-mapper start --log-requests # Request logging
|
python -m rf_mapper start --log-requests # Request logging
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -48,10 +53,10 @@ rf-mapper start --log-requests # Request logging
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Show current config
|
# Show current config
|
||||||
rf-mapper config
|
python -m rf_mapper config
|
||||||
|
|
||||||
# Set GPS coordinates
|
# Set GPS coordinates
|
||||||
rf-mapper config --set-gps 50.8585 4.3978 --save
|
python -m rf_mapper config --set-gps 50.8585 4.3978 --save
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ template:
|
|||||||
|
|
||||||
1. **Enable integration**: Set `home_assistant.enabled: true` in config.yaml
|
1. **Enable integration**: Set `home_assistant.enabled: true` in config.yaml
|
||||||
2. **Add HA automations**: Copy webhook automations to HA
|
2. **Add HA automations**: Copy webhook automations to HA
|
||||||
3. **Restart RF Mapper**: `rf-mapper restart`
|
3. **Restart RF Mapper**: `source venv/bin/activate && python -m rf_mapper restart`
|
||||||
4. **Run scan**: Trigger BT scan in RF Mapper web UI
|
4. **Run scan**: Trigger BT scan in RF Mapper web UI
|
||||||
5. **Check HA**: Verify `device_tracker.rf_*` entities appear
|
5. **Check HA**: Verify `device_tracker.rf_*` entities appear
|
||||||
6. **Test new device**: Clear device from DB, re-scan, verify notification
|
6. **Test new device**: Clear device from DB, re-scan, verify notification
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ classifiers = [
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.0.0",
|
"flask>=3.0.0",
|
||||||
|
"flask-socketio>=5.3.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"bleak>=0.21.0",
|
"bleak>=0.21.0",
|
||||||
"requests>=2.28.0",
|
"requests>=2.28.0",
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
|||||||
web_parser.add_argument('--profile-requests', action='store_true', help='Enable profiling')
|
web_parser.add_argument('--profile-requests', action='store_true', help='Enable profiling')
|
||||||
web_parser.add_argument('--log-requests', action='store_true', help='Log requests')
|
web_parser.add_argument('--log-requests', action='store_true', help='Log requests')
|
||||||
|
|
||||||
|
# Check-termux command
|
||||||
|
subparsers.add_parser('check-termux', help='Check Termux/Android prerequisites')
|
||||||
|
|
||||||
# Config command
|
# Config command
|
||||||
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||||
config_parser.add_argument(
|
config_parser.add_argument(
|
||||||
@@ -209,6 +212,8 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
|||||||
run_status(data_dir)
|
run_status(data_dir)
|
||||||
elif args.command == 'config':
|
elif args.command == 'config':
|
||||||
run_config(args, config)
|
run_config(args, config)
|
||||||
|
elif args.command == 'check-termux':
|
||||||
|
run_check_termux()
|
||||||
elif args.command == 'web':
|
elif args.command == 'web':
|
||||||
run_web_deprecated(args, config, data_dir)
|
run_web_deprecated(args, config, data_dir)
|
||||||
else:
|
else:
|
||||||
@@ -436,6 +441,19 @@ Home Assistant:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def run_check_termux():
|
||||||
|
"""Check Termux/Android prerequisites"""
|
||||||
|
from .termux import is_termux, check_termux_prerequisites
|
||||||
|
|
||||||
|
if not is_termux():
|
||||||
|
print("Not running in Termux/Android environment.")
|
||||||
|
print("This check is only relevant when running on Android via Termux.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
success = check_termux_prerequisites(verbose=True)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
def get_pid_file(data_dir: Path) -> Path:
|
def get_pid_file(data_dir: Path) -> Path:
|
||||||
"""Get path to PID file"""
|
"""Get path to PID file"""
|
||||||
return data_dir / "rf-mapper.pid"
|
return data_dir / "rf-mapper.pid"
|
||||||
@@ -534,6 +552,16 @@ def run_start(args, config: Config, data_dir: Path):
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
# Check Termux prerequisites if running on Android
|
||||||
|
from .termux import is_termux, check_termux_prerequisites, setup_termux_signal_handlers
|
||||||
|
|
||||||
|
if is_termux():
|
||||||
|
if not check_termux_prerequisites(verbose=True):
|
||||||
|
print("Exiting due to missing prerequisites.")
|
||||||
|
sys.exit(1)
|
||||||
|
# Set up signal handlers for graceful shutdown
|
||||||
|
setup_termux_signal_handlers()
|
||||||
|
|
||||||
host = args.host or config.web.host
|
host = args.host or config.web.host
|
||||||
port = args.port or config.web.port
|
port = args.port or config.web.port
|
||||||
debug = getattr(args, 'debug', False)
|
debug = getattr(args, 'debug', False)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class ScannerConfig:
|
|||||||
latitude: float | None = None # Scanner position (falls back to gps.latitude)
|
latitude: float | None = None # Scanner position (falls back to gps.latitude)
|
||||||
longitude: float | None = None # Scanner position (falls back to gps.longitude)
|
longitude: float | None = None # Scanner position (falls back to gps.longitude)
|
||||||
floor: int | None = None # Scanner's floor (falls back to building.current_floor)
|
floor: int | None = None # Scanner's floor (falls back to building.current_floor)
|
||||||
|
is_master: bool = False # Master node can view other nodes' data in dashboard
|
||||||
|
bt_mac: str = "" # Scanner's Bluetooth MAC address (for filtering from device lists)
|
||||||
|
|
||||||
# Scanning configuration
|
# Scanning configuration
|
||||||
wifi_interface: str = "wlan0"
|
wifi_interface: str = "wlan0"
|
||||||
@@ -179,6 +181,8 @@ class Config:
|
|||||||
latitude=data["scanner"].get("latitude", config.scanner.latitude),
|
latitude=data["scanner"].get("latitude", config.scanner.latitude),
|
||||||
longitude=data["scanner"].get("longitude", config.scanner.longitude),
|
longitude=data["scanner"].get("longitude", config.scanner.longitude),
|
||||||
floor=data["scanner"].get("floor", config.scanner.floor),
|
floor=data["scanner"].get("floor", config.scanner.floor),
|
||||||
|
is_master=data["scanner"].get("is_master", config.scanner.is_master),
|
||||||
|
bt_mac=data["scanner"].get("bt_mac", config.scanner.bt_mac),
|
||||||
# Scanning configuration
|
# Scanning configuration
|
||||||
wifi_interface=data["scanner"].get("wifi_interface", config.scanner.wifi_interface),
|
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),
|
bt_scan_timeout=data["scanner"].get("bt_scan_timeout", config.scanner.bt_scan_timeout),
|
||||||
@@ -295,6 +299,7 @@ class Config:
|
|||||||
- latitude: Scanner position (from scanner config or gps config)
|
- latitude: Scanner position (from scanner config or gps config)
|
||||||
- longitude: Scanner position (from scanner config or gps config)
|
- longitude: Scanner position (from scanner config or gps config)
|
||||||
- floor: Scanner's floor (from scanner config or building config)
|
- floor: Scanner's floor (from scanner config or building config)
|
||||||
|
- bt_mac: Bluetooth MAC address (for filtering from device lists)
|
||||||
"""
|
"""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
@@ -304,7 +309,8 @@ class Config:
|
|||||||
"name": self.scanner.name or scanner_id,
|
"name": self.scanner.name or scanner_id,
|
||||||
"latitude": self.scanner.latitude if self.scanner.latitude is not None else self.gps.latitude,
|
"latitude": self.scanner.latitude if self.scanner.latitude is not None else self.gps.latitude,
|
||||||
"longitude": self.scanner.longitude if self.scanner.longitude is not None else self.gps.longitude,
|
"longitude": self.scanner.longitude if self.scanner.longitude is not None else self.gps.longitude,
|
||||||
"floor": self.scanner.floor if self.scanner.floor is not None else self.building.current_floor
|
"floor": self.scanner.floor if self.scanner.floor is not None else self.building.current_floor,
|
||||||
|
"bt_mac": self.scanner.bt_mac or None
|
||||||
}
|
}
|
||||||
|
|
||||||
def save(self, path: Path | None = None):
|
def save(self, path: Path | None = None):
|
||||||
@@ -330,6 +336,7 @@ class Config:
|
|||||||
"latitude": self.scanner.latitude,
|
"latitude": self.scanner.latitude,
|
||||||
"longitude": self.scanner.longitude,
|
"longitude": self.scanner.longitude,
|
||||||
"floor": self.scanner.floor,
|
"floor": self.scanner.floor,
|
||||||
|
"is_master": self.scanner.is_master,
|
||||||
# Scanning configuration
|
# Scanning configuration
|
||||||
"wifi_interface": self.scanner.wifi_interface,
|
"wifi_interface": self.scanner.wifi_interface,
|
||||||
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
||||||
|
|||||||
@@ -210,12 +210,34 @@ class DeviceDatabase:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Add bt_mac column to peers table if missing (for scanner BT filtering)
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE peers ADD COLUMN bt_mac TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
# Add notes column to devices table if missing (for sync)
|
# Add notes column to devices table if missing (for sync)
|
||||||
try:
|
try:
|
||||||
cursor.execute("ALTER TABLE devices ADD COLUMN notes TEXT")
|
cursor.execute("ALTER TABLE devices ADD COLUMN notes TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass # Column already exists
|
pass # Column already exists
|
||||||
|
|
||||||
|
# Add source_scanner columns for peer sync (device positions relative to source scanner)
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE devices ADD COLUMN source_scanner_id TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE devices ADD COLUMN source_scanner_lat REAL")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE devices ADD COLUMN source_scanner_lon REAL")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def record_scan(self, scan_id: str, timestamp: str, location_label: str,
|
def record_scan(self, scan_id: str, timestamp: str, location_label: str,
|
||||||
@@ -318,6 +340,11 @@ class DeviceDatabase:
|
|||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
timestamp = datetime.now().isoformat()
|
timestamp = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Skip if this is a known scanner's BT MAC (don't record scanners as devices)
|
||||||
|
cursor.execute("SELECT 1 FROM peers WHERE UPPER(bt_mac) = UPPER(?)", (address,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
return # Skip scanner device
|
||||||
|
|
||||||
# Get previous observation for movement detection
|
# Get previous observation for movement detection
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT rssi, distance_m, timestamp FROM rssi_history
|
SELECT rssi, distance_m, timestamp FROM rssi_history
|
||||||
@@ -480,6 +507,56 @@ class DeviceDatabase:
|
|||||||
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
|
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_device_multi_scanner_rssi(self, device_id: str, seconds: int = 60) -> list[dict]:
|
||||||
|
"""Get recent RSSI readings per scanner for a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The device MAC address
|
||||||
|
seconds: Time window in seconds (default 60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with scanner_id, avg_rssi, sample_count, last_seen
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT scanner_id,
|
||||||
|
AVG(rssi) as avg_rssi,
|
||||||
|
COUNT(*) as sample_count,
|
||||||
|
MAX(timestamp) as last_seen
|
||||||
|
FROM rssi_history
|
||||||
|
WHERE device_id = ?
|
||||||
|
AND scanner_id IS NOT NULL
|
||||||
|
AND timestamp >= datetime('now', '-' || ? || ' seconds')
|
||||||
|
GROUP BY scanner_id
|
||||||
|
"""
|
||||||
|
cursor.execute(query, (device_id, seconds))
|
||||||
|
return [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
def get_devices_seen_by_multiple_scanners(self, seconds: int = 60) -> list[str]:
|
||||||
|
"""Get device IDs seen by 2+ scanners recently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Time window in seconds (default 60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device IDs seen by multiple scanners
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT device_id
|
||||||
|
FROM rssi_history
|
||||||
|
WHERE scanner_id IS NOT NULL
|
||||||
|
AND timestamp >= datetime('now', '-' || ? || ' seconds')
|
||||||
|
GROUP BY device_id
|
||||||
|
HAVING COUNT(DISTINCT scanner_id) >= 2
|
||||||
|
"""
|
||||||
|
cursor.execute(query, (seconds,))
|
||||||
|
return [r['device_id'] for r in cursor.fetchall()]
|
||||||
|
|
||||||
def get_movement_events(self, device_id: Optional[str] = None,
|
def get_movement_events(self, device_id: Optional[str] = None,
|
||||||
since: Optional[str] = None,
|
since: Optional[str] = None,
|
||||||
limit: int = 100) -> list[dict]:
|
limit: int = 100) -> list[dict]:
|
||||||
@@ -586,6 +663,42 @@ class DeviceDatabase:
|
|||||||
cursor.execute("SELECT device_id, assigned_floor FROM devices WHERE assigned_floor IS NOT NULL")
|
cursor.execute("SELECT device_id, assigned_floor FROM devices WHERE assigned_floor IS NOT NULL")
|
||||||
return {row['device_id']: row['assigned_floor'] for row in cursor.fetchall()}
|
return {row['device_id']: row['assigned_floor'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
def get_all_device_sources(self) -> dict:
|
||||||
|
"""Get all device source scanner info as a dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping device_id to {scanner_id, lat, lon} or None if local
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT device_id, source_scanner_id, source_scanner_lat, source_scanner_lon
|
||||||
|
FROM devices
|
||||||
|
WHERE source_scanner_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
return {
|
||||||
|
row['device_id']: {
|
||||||
|
'scanner_id': row['source_scanner_id'],
|
||||||
|
'lat': row['source_scanner_lat'],
|
||||||
|
'lon': row['source_scanner_lon']
|
||||||
|
}
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_device_source(self, device_id: str, scanner_id: str,
|
||||||
|
scanner_lat: float, scanner_lon: float):
|
||||||
|
"""Set source scanner info for a device (where it was detected/positioned)."""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE devices
|
||||||
|
SET source_scanner_id = ?, source_scanner_lat = ?, source_scanner_lon = ?
|
||||||
|
WHERE device_id = ?
|
||||||
|
""", (scanner_id, scanner_lat, scanner_lon, device_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def set_device_position(self, device_id: str, lat_offset: float, lon_offset: float):
|
def set_device_position(self, device_id: str, lat_offset: float, lon_offset: float):
|
||||||
"""Set custom position offset for a device (relative to scanner position)"""
|
"""Set custom position offset for a device (relative to scanner position)"""
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
@@ -839,7 +952,7 @@ class DeviceDatabase:
|
|||||||
|
|
||||||
def register_peer(self, scanner_id: str, name: str, url: str,
|
def register_peer(self, scanner_id: str, name: str, url: str,
|
||||||
floor: Optional[int] = None, latitude: Optional[float] = None,
|
floor: Optional[int] = None, latitude: Optional[float] = None,
|
||||||
longitude: Optional[float] = None) -> bool:
|
longitude: Optional[float] = None, bt_mac: Optional[str] = None) -> bool:
|
||||||
"""Register a peer scanner.
|
"""Register a peer scanner.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -849,6 +962,7 @@ class DeviceDatabase:
|
|||||||
floor: Floor where peer scanner is located
|
floor: Floor where peer scanner is located
|
||||||
latitude: GPS latitude of peer
|
latitude: GPS latitude of peer
|
||||||
longitude: GPS longitude of peer
|
longitude: GPS longitude of peer
|
||||||
|
bt_mac: Bluetooth MAC address of the scanner (for filtering from device lists)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if newly registered, False if updated existing
|
True if newly registered, False if updated existing
|
||||||
@@ -862,16 +976,17 @@ class DeviceDatabase:
|
|||||||
exists = cursor.fetchone() is not None
|
exists = cursor.fetchone() is not None
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO peers (scanner_id, name, url, floor, latitude, longitude, last_seen, registered_at)
|
INSERT INTO peers (scanner_id, name, url, floor, latitude, longitude, bt_mac, last_seen, registered_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(scanner_id) DO UPDATE SET
|
ON CONFLICT(scanner_id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
floor = excluded.floor,
|
floor = excluded.floor,
|
||||||
latitude = excluded.latitude,
|
latitude = excluded.latitude,
|
||||||
longitude = excluded.longitude,
|
longitude = excluded.longitude,
|
||||||
|
bt_mac = COALESCE(excluded.bt_mac, peers.bt_mac),
|
||||||
last_seen = excluded.last_seen
|
last_seen = excluded.last_seen
|
||||||
""", (scanner_id, name, url, floor, latitude, longitude, timestamp, timestamp))
|
""", (scanner_id, name, url, floor, latitude, longitude, bt_mac, timestamp, timestamp))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return not exists
|
return not exists
|
||||||
@@ -931,14 +1046,17 @@ class DeviceDatabase:
|
|||||||
Returns:
|
Returns:
|
||||||
List of device dicts with sync-relevant fields.
|
List of device dicts with sync-relevant fields.
|
||||||
Note: Position offsets are NOT synced as they are relative to each scanner's location.
|
Note: Position offsets are NOT synced as they are relative to each scanner's location.
|
||||||
|
Source scanner info IS synced so receiving scanners can calculate positions correctly.
|
||||||
"""
|
"""
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Include source scanner info so receiving scanners know where device was detected
|
||||||
# Don't sync position offsets - they're relative to each scanner's location
|
# Don't sync position offsets - they're relative to each scanner's location
|
||||||
query = """
|
query = """
|
||||||
SELECT device_id, device_type, name, ssid, manufacturer,
|
SELECT device_id, device_type, name, ssid, manufacturer,
|
||||||
custom_label, assigned_floor, is_favorite, notes, updated_at
|
custom_label, assigned_floor, is_favorite, notes, updated_at,
|
||||||
|
source_scanner_id, source_scanner_lat, source_scanner_lon
|
||||||
FROM devices
|
FROM devices
|
||||||
WHERE (custom_label IS NOT NULL OR assigned_floor IS NOT NULL
|
WHERE (custom_label IS NOT NULL OR assigned_floor IS NOT NULL
|
||||||
OR is_favorite = 1 OR notes IS NOT NULL)
|
OR is_favorite = 1 OR notes IS NOT NULL)
|
||||||
@@ -954,15 +1072,21 @@ class DeviceDatabase:
|
|||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
return [dict(row) for row in cursor.fetchall()]
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
def bulk_update_devices(self, devices: list[dict], source_scanner: str) -> int:
|
def bulk_update_devices(self, devices: list[dict], source_scanner: str,
|
||||||
|
source_scanner_lat: Optional[float] = None,
|
||||||
|
source_scanner_lon: Optional[float] = None) -> int:
|
||||||
"""Bulk update device metadata from peer sync.
|
"""Bulk update device metadata from peer sync.
|
||||||
|
|
||||||
Uses timestamp-based conflict resolution: newer wins.
|
Uses timestamp-based conflict resolution: newer wins.
|
||||||
Only updates non-null fields from peer.
|
Only updates non-null fields from peer.
|
||||||
|
Preserves source scanner info so device positions are calculated relative to
|
||||||
|
the scanner that originally detected them.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
devices: List of device dicts from peer
|
devices: List of device dicts from peer
|
||||||
source_scanner: Scanner ID that sent the update
|
source_scanner: Scanner ID that sent the update
|
||||||
|
source_scanner_lat: Latitude of source scanner (for position calculation)
|
||||||
|
source_scanner_lon: Longitude of source scanner (for position calculation)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of devices updated
|
Number of devices updated
|
||||||
@@ -978,11 +1102,17 @@ class DeviceDatabase:
|
|||||||
|
|
||||||
# Get existing device
|
# Get existing device
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT updated_at FROM devices WHERE device_id = ?",
|
"SELECT updated_at, source_scanner_id FROM devices WHERE device_id = ?",
|
||||||
(device_id,)
|
(device_id,)
|
||||||
)
|
)
|
||||||
existing = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
# Determine source scanner info for this device
|
||||||
|
# Use device's original source if present, otherwise use the peer sending the data
|
||||||
|
dev_source_id = dev.get("source_scanner_id") or source_scanner
|
||||||
|
dev_source_lat = dev.get("source_scanner_lat") or source_scanner_lat
|
||||||
|
dev_source_lon = dev.get("source_scanner_lon") or source_scanner_lon
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# Check timestamp - skip if local is newer
|
# Check timestamp - skip if local is newer
|
||||||
local_updated = existing["updated_at"] or ""
|
local_updated = existing["updated_at"] or ""
|
||||||
@@ -1012,6 +1142,18 @@ class DeviceDatabase:
|
|||||||
updates.append("notes = ?")
|
updates.append("notes = ?")
|
||||||
params.append(dev["notes"])
|
params.append(dev["notes"])
|
||||||
|
|
||||||
|
# Update source scanner info if not already set locally
|
||||||
|
# (preserve original source, don't overwrite with intermediate peer)
|
||||||
|
if not existing["source_scanner_id"] and dev_source_id:
|
||||||
|
updates.append("source_scanner_id = ?")
|
||||||
|
params.append(dev_source_id)
|
||||||
|
if dev_source_lat is not None:
|
||||||
|
updates.append("source_scanner_lat = ?")
|
||||||
|
params.append(dev_source_lat)
|
||||||
|
if dev_source_lon is not None:
|
||||||
|
updates.append("source_scanner_lon = ?")
|
||||||
|
params.append(dev_source_lon)
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
# Keep the peer's updated_at to preserve timeline
|
# Keep the peer's updated_at to preserve timeline
|
||||||
updates.append("updated_at = ?")
|
updates.append("updated_at = ?")
|
||||||
@@ -1025,9 +1167,26 @@ class DeviceDatabase:
|
|||||||
if cursor.rowcount > 0:
|
if cursor.rowcount > 0:
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
else:
|
else:
|
||||||
# Device doesn't exist locally - we can only sync metadata for
|
# Device doesn't exist locally - create it if it has useful metadata
|
||||||
# devices we've seen, so skip unknown devices
|
if dev.get("assigned_floor") is not None or dev.get("custom_label") or dev.get("is_favorite"):
|
||||||
pass
|
device_type = dev.get("device_type", "bluetooth")
|
||||||
|
name = dev.get("name") or dev.get("ssid") or device_id
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO devices (device_id, device_type, name, ssid, manufacturer,
|
||||||
|
custom_label, assigned_floor, is_favorite, notes,
|
||||||
|
first_seen, last_seen, total_observations, updated_at,
|
||||||
|
source_scanner_id, source_scanner_lat, source_scanner_lon)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
device_id, device_type, name, dev.get("ssid"), dev.get("manufacturer"),
|
||||||
|
dev.get("custom_label"), dev.get("assigned_floor"),
|
||||||
|
1 if dev.get("is_favorite") else 0, dev.get("notes"),
|
||||||
|
now, now, dev.get("updated_at", now),
|
||||||
|
dev_source_id, dev_source_lat, dev_source_lon
|
||||||
|
))
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return updated_count
|
return updated_count
|
||||||
|
|||||||
@@ -118,3 +118,85 @@ def trilaterate_2d(
|
|||||||
y = (A * F - D * C) / denom
|
y = (A * F - D * C) / denom
|
||||||
|
|
||||||
return (x, y)
|
return (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def trilaterate_weighted_latlon(
|
||||||
|
scanner_data: list[dict]
|
||||||
|
) -> tuple[float, float, float] | None:
|
||||||
|
"""
|
||||||
|
Weighted trilateration using lat/lon coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanner_data: List of dicts with:
|
||||||
|
- lat: Scanner latitude
|
||||||
|
- lon: Scanner longitude
|
||||||
|
- distance: Estimated distance in meters
|
||||||
|
- rssi: Signal strength (for weighting)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(lat, lon, confidence) or None if insufficient data
|
||||||
|
"""
|
||||||
|
if len(scanner_data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate weights from RSSI (higher = better)
|
||||||
|
for s in scanner_data:
|
||||||
|
s['weight'] = 10 ** ((s['rssi'] + 100) / 40) # Normalize -100 to 0 range
|
||||||
|
|
||||||
|
total_weight = sum(s['weight'] for s in scanner_data)
|
||||||
|
|
||||||
|
if len(scanner_data) == 2:
|
||||||
|
# Two scanners: weighted average along line between them
|
||||||
|
s1, s2 = scanner_data[0], scanner_data[1]
|
||||||
|
|
||||||
|
# Calculate position along line based on distance ratio
|
||||||
|
total_dist = s1['distance'] + s2['distance']
|
||||||
|
if total_dist == 0:
|
||||||
|
ratio = 0.5
|
||||||
|
else:
|
||||||
|
ratio = s1['distance'] / total_dist
|
||||||
|
|
||||||
|
# Weighted interpolation
|
||||||
|
lat = s1['lat'] + ratio * (s2['lat'] - s1['lat'])
|
||||||
|
lon = s1['lon'] + ratio * (s2['lon'] - s1['lon'])
|
||||||
|
|
||||||
|
# Lower confidence for 2-scanner solution
|
||||||
|
confidence = 0.5
|
||||||
|
return (lat, lon, confidence)
|
||||||
|
|
||||||
|
# 3+ scanners: convert to local XY and trilaterate
|
||||||
|
# Use centroid as origin
|
||||||
|
center_lat = sum(s['lat'] for s in scanner_data) / len(scanner_data)
|
||||||
|
center_lon = sum(s['lon'] for s in scanner_data) / len(scanner_data)
|
||||||
|
|
||||||
|
# Convert to meters from centroid
|
||||||
|
positions = []
|
||||||
|
distances = []
|
||||||
|
for s in scanner_data:
|
||||||
|
x = (s['lon'] - center_lon) * 111000 * math.cos(math.radians(center_lat))
|
||||||
|
y = (s['lat'] - center_lat) * 111000
|
||||||
|
positions.append((x, y))
|
||||||
|
distances.append(s['distance'])
|
||||||
|
|
||||||
|
# Use existing trilaterate_2d
|
||||||
|
result = trilaterate_2d(positions, distances)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
x, y = result
|
||||||
|
|
||||||
|
# Convert back to lat/lon
|
||||||
|
lat = center_lat + (y / 111000)
|
||||||
|
lon = center_lon + (x / (111000 * math.cos(math.radians(center_lat))))
|
||||||
|
|
||||||
|
# Calculate confidence based on residuals
|
||||||
|
residuals = []
|
||||||
|
for i, s in enumerate(scanner_data):
|
||||||
|
calc_dist = math.sqrt((x - positions[i][0])**2 + (y - positions[i][1])**2)
|
||||||
|
residuals.append(abs(calc_dist - distances[i]))
|
||||||
|
|
||||||
|
avg_residual = sum(residuals) / len(residuals)
|
||||||
|
avg_distance = sum(distances) / len(distances)
|
||||||
|
confidence = max(0, min(1, 1 - (avg_residual / max(avg_distance, 1))))
|
||||||
|
|
||||||
|
return (lat, lon, confidence)
|
||||||
|
|||||||
212
src/rf_mapper/import_ble_radar.py
Normal file
212
src/rf_mapper/import_ble_radar.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Import BLE Radar database into RF Mapper.
|
||||||
|
|
||||||
|
BLE Radar is an Android app that scans for BLE devices.
|
||||||
|
This script imports its exported SQLite database into rf-mapper's database.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m rf_mapper.import_ble_radar /path/to/ble_radar.sqlite
|
||||||
|
|
||||||
|
Or from Python:
|
||||||
|
from rf_mapper.import_ble_radar import import_ble_radar_db
|
||||||
|
import_ble_radar_db('/path/to/ble_radar.sqlite')
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .database import DeviceDatabase, get_database
|
||||||
|
from .distance import estimate_distance
|
||||||
|
|
||||||
|
|
||||||
|
def import_ble_radar_db(
|
||||||
|
ble_radar_path: str,
|
||||||
|
rf_mapper_db: Optional[DeviceDatabase] = None,
|
||||||
|
scanner_id: str = "ble_radar",
|
||||||
|
verbose: bool = True
|
||||||
|
) -> dict:
|
||||||
|
"""Import BLE Radar database into RF Mapper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ble_radar_path: Path to BLE Radar exported SQLite database
|
||||||
|
rf_mapper_db: RF Mapper database instance (creates default if None)
|
||||||
|
scanner_id: Source scanner ID for imported devices
|
||||||
|
verbose: Print progress messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with import statistics
|
||||||
|
"""
|
||||||
|
ble_radar_path = Path(ble_radar_path)
|
||||||
|
if not ble_radar_path.exists():
|
||||||
|
raise FileNotFoundError(f"BLE Radar database not found: {ble_radar_path}")
|
||||||
|
|
||||||
|
# Connect to BLE Radar database
|
||||||
|
ble_conn = sqlite3.connect(ble_radar_path)
|
||||||
|
ble_conn.row_factory = sqlite3.Row
|
||||||
|
ble_cursor = ble_conn.cursor()
|
||||||
|
|
||||||
|
# Use global RF Mapper database if not provided
|
||||||
|
if rf_mapper_db is None:
|
||||||
|
rf_mapper_db = get_database()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"devices_imported": 0,
|
||||||
|
"devices_updated": 0,
|
||||||
|
"rssi_records": 0,
|
||||||
|
"locations_used": 0,
|
||||||
|
"errors": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"Importing from: {ble_radar_path}")
|
||||||
|
|
||||||
|
# Get scanner BT MACs to filter out (don't import scanners as devices)
|
||||||
|
scanner_bt_macs = set()
|
||||||
|
peers = rf_mapper_db.get_peers()
|
||||||
|
for peer in peers:
|
||||||
|
if peer.get("bt_mac"):
|
||||||
|
scanner_bt_macs.add(peer["bt_mac"].upper())
|
||||||
|
|
||||||
|
if verbose and scanner_bt_macs:
|
||||||
|
print(f"Filtering {len(scanner_bt_macs)} scanner BT MAC(s)")
|
||||||
|
|
||||||
|
stats["scanners_filtered"] = 0
|
||||||
|
|
||||||
|
# Get location data for device-location mapping
|
||||||
|
ble_cursor.execute("SELECT time, lat, lng FROM location ORDER BY time")
|
||||||
|
locations = {row["time"]: (row["lat"], row["lng"]) for row in ble_cursor.fetchall()}
|
||||||
|
stats["locations_used"] = len(locations)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"Found {len(locations)} location records")
|
||||||
|
|
||||||
|
# Import devices
|
||||||
|
ble_cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
address, name, manufacturer_name, manufacturer_id,
|
||||||
|
first_detect_time_ms, last_detect_time_ms, detect_count,
|
||||||
|
last_seen_rssi, favorite, custom_name, service_uuids,
|
||||||
|
device_class, is_connectable
|
||||||
|
FROM device
|
||||||
|
""")
|
||||||
|
|
||||||
|
scan_id = f"ble_radar_import_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
|
||||||
|
for row in ble_cursor.fetchall():
|
||||||
|
try:
|
||||||
|
address = row["address"]
|
||||||
|
|
||||||
|
# Skip scanner devices (don't import scanners as regular devices)
|
||||||
|
if address.upper() in scanner_bt_macs:
|
||||||
|
stats["scanners_filtered"] += 1
|
||||||
|
if verbose:
|
||||||
|
print(f"Skipping scanner: {address}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = row["custom_name"] or row["name"] or ""
|
||||||
|
manufacturer = row["manufacturer_name"] or ""
|
||||||
|
rssi = row["last_seen_rssi"] or -70
|
||||||
|
|
||||||
|
# Determine device type from BLE Radar's device_class
|
||||||
|
device_class_num = row["device_class"] or 0
|
||||||
|
if device_class_num == 0:
|
||||||
|
bt_device_type = "Low Energy Device"
|
||||||
|
else:
|
||||||
|
bt_device_type = f"BLE Class {device_class_num}"
|
||||||
|
|
||||||
|
# Check if device exists
|
||||||
|
existing = rf_mapper_db.get_device(address)
|
||||||
|
|
||||||
|
# Calculate distance from RSSI
|
||||||
|
distance = estimate_distance(rssi, tx_power=-65)
|
||||||
|
|
||||||
|
# Record the observation (inserts or updates device)
|
||||||
|
rf_mapper_db.record_bluetooth_observation(
|
||||||
|
address=address,
|
||||||
|
name=name,
|
||||||
|
rssi=rssi,
|
||||||
|
distance_m=distance,
|
||||||
|
device_class="BLE",
|
||||||
|
device_type=bt_device_type,
|
||||||
|
manufacturer=manufacturer,
|
||||||
|
floor=None,
|
||||||
|
scan_id=scan_id,
|
||||||
|
scanner_id=scanner_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
stats["devices_updated"] += 1
|
||||||
|
else:
|
||||||
|
stats["devices_imported"] += 1
|
||||||
|
|
||||||
|
stats["rssi_records"] += 1
|
||||||
|
|
||||||
|
# Set favorite if marked in BLE Radar
|
||||||
|
if row["favorite"]:
|
||||||
|
rf_mapper_db.set_device_favorite(address, True)
|
||||||
|
|
||||||
|
# Set custom label if set in BLE Radar
|
||||||
|
if row["custom_name"]:
|
||||||
|
rf_mapper_db.set_device_label(address, row["custom_name"])
|
||||||
|
|
||||||
|
# Set source scanner
|
||||||
|
rf_mapper_db.set_device_source(address, scanner_id, None, None)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if verbose:
|
||||||
|
print(f"Error importing {row['address']}: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
ble_conn.close()
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"\nImport complete:")
|
||||||
|
print(f" Devices imported: {stats['devices_imported']}")
|
||||||
|
print(f" Devices updated: {stats['devices_updated']}")
|
||||||
|
print(f" RSSI records: {stats['rssi_records']}")
|
||||||
|
print(f" Locations used: {stats['locations_used']}")
|
||||||
|
if stats.get("scanners_filtered"):
|
||||||
|
print(f" Scanners skipped: {stats['scanners_filtered']}")
|
||||||
|
if stats["errors"]:
|
||||||
|
print(f" Errors: {stats['errors']}")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Import BLE Radar database into RF Mapper"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"ble_radar_db",
|
||||||
|
help="Path to BLE Radar exported SQLite database"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scanner-id",
|
||||||
|
default="ble_radar",
|
||||||
|
help="Source scanner ID for imported devices (default: ble_radar)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-q", "--quiet",
|
||||||
|
action="store_true",
|
||||||
|
help="Suppress output"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = import_ble_radar_db(
|
||||||
|
args.ble_radar_db,
|
||||||
|
scanner_id=args.scanner_id,
|
||||||
|
verbose=not args.quiet
|
||||||
|
)
|
||||||
|
return 0 if stats["errors"] == 0 else 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
@@ -3,10 +3,20 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
from bleak import BleakScanner
|
||||||
|
|
||||||
|
|
||||||
|
def is_termux() -> bool:
|
||||||
|
"""Detect if running in Termux (Android)."""
|
||||||
|
return os.environ.get('TERMUX_VERSION') is not None or \
|
||||||
|
os.path.exists('/data/data/com.termux')
|
||||||
|
|
||||||
from .oui import OUILookup
|
from .oui import OUILookup
|
||||||
from .bluetooth_class import BluetoothClassDecoder
|
from .bluetooth_class import BluetoothClassDecoder
|
||||||
from .distance import estimate_distance, rssi_to_quality, rssi_bar
|
from .distance import estimate_distance, rssi_to_quality, rssi_bar
|
||||||
@@ -181,66 +191,79 @@ class RFScanner:
|
|||||||
"""
|
"""
|
||||||
devices = []
|
devices = []
|
||||||
|
|
||||||
# Classic Bluetooth scan
|
# Classic Bluetooth scan (not supported on Termux/Android)
|
||||||
try:
|
if is_termux():
|
||||||
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
print("Skipping Classic BT scan (not supported on Termux)")
|
||||||
result = subprocess.run(
|
else:
|
||||||
['sudo', 'hcitool', 'inq', '--flush'],
|
try:
|
||||||
capture_output=True,
|
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
||||||
text=True,
|
result = subprocess.run(
|
||||||
timeout=timeout + 10
|
['sudo', 'hcitool', 'inq', '--flush'],
|
||||||
)
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
for line in result.stdout.split('\n'):
|
timeout=timeout + 10
|
||||||
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(
|
for line in result.stdout.split('\n'):
|
||||||
address=addr,
|
match = re.match(
|
||||||
name=name,
|
r'\s*([0-9A-Fa-f:]+)\s+clock offset:\s*\S+\s+class:\s*(\S+)',
|
||||||
rssi=rssi,
|
line
|
||||||
device_class=device_class,
|
)
|
||||||
device_type=f"{dev_type}" + (f" ({dev_subtype})" if dev_subtype else ""),
|
if match:
|
||||||
manufacturer=self.oui_lookup.lookup(addr)
|
addr = match.group(1)
|
||||||
))
|
device_class = match.group(2)
|
||||||
except Exception as e:
|
name = self._get_bt_name(addr)
|
||||||
print(f"Classic BT scan error: {e}")
|
rssi = self._get_bt_rssi(addr)
|
||||||
|
dev_type, dev_subtype = self.bt_decoder.decode(device_class)
|
||||||
|
|
||||||
# BLE scan
|
devices.append(BluetoothDevice(
|
||||||
try:
|
address=addr,
|
||||||
print(f"Scanning BLE devices ({timeout} seconds)...")
|
name=name,
|
||||||
result = subprocess.run(
|
rssi=rssi,
|
||||||
['sudo', 'timeout', str(timeout), 'hcitool', 'lescan', '--duplicates'],
|
device_class=device_class,
|
||||||
capture_output=True,
|
device_type=f"{dev_type}" + (f" ({dev_subtype})" if dev_subtype else ""),
|
||||||
text=True,
|
manufacturer=self.oui_lookup.lookup(addr)
|
||||||
timeout=timeout + 5
|
))
|
||||||
)
|
except Exception as e:
|
||||||
|
print(f"Classic BT scan error: {e}")
|
||||||
|
|
||||||
seen_addrs = {d.address for d in devices}
|
# BLE scan using bleak (not supported on Termux/Android)
|
||||||
for line in result.stdout.split('\n'):
|
if is_termux():
|
||||||
match = re.match(r'([0-9A-Fa-f:]+)\s*(.*)', line)
|
print("Skipping BLE scan (not supported on Termux)")
|
||||||
if match:
|
else:
|
||||||
addr = match.group(1)
|
try:
|
||||||
name = match.group(2).strip() or '<unknown>'
|
print(f"Scanning BLE devices ({timeout} seconds)...")
|
||||||
|
|
||||||
|
async def _ble_scan():
|
||||||
|
return await BleakScanner.discover(
|
||||||
|
timeout=timeout,
|
||||||
|
return_adv=True
|
||||||
|
)
|
||||||
|
|
||||||
|
ble_results = asyncio.run(_ble_scan())
|
||||||
|
|
||||||
|
seen_addrs = {d.address for d in devices}
|
||||||
|
for device, adv_data in ble_results.values():
|
||||||
|
addr = device.address
|
||||||
if addr not in seen_addrs and addr != 'LE':
|
if addr not in seen_addrs and addr != 'LE':
|
||||||
seen_addrs.add(addr)
|
seen_addrs.add(addr)
|
||||||
|
|
||||||
|
name = device.name or adv_data.local_name or '<unknown>'
|
||||||
|
rssi = adv_data.rssi # Real RSSI from advertisement
|
||||||
manufacturer = self.oui_lookup.lookup(addr)
|
manufacturer = self.oui_lookup.lookup(addr)
|
||||||
|
|
||||||
# Try to infer device type from name first, then manufacturer
|
# Extract manufacturer data if available
|
||||||
|
if adv_data.manufacturer_data and not manufacturer:
|
||||||
|
# First 2 bytes are company ID
|
||||||
|
for company_id in adv_data.manufacturer_data.keys():
|
||||||
|
manufacturer = f"Company ID: {company_id}"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Infer device type
|
||||||
inferred_type = infer_device_type_from_name(name)
|
inferred_type = infer_device_type_from_name(name)
|
||||||
if not inferred_type:
|
if not inferred_type:
|
||||||
inferred_type = infer_device_type_from_manufacturer(manufacturer)
|
inferred_type = infer_device_type_from_manufacturer(manufacturer)
|
||||||
|
|
||||||
# Mark randomized MAC devices if still unknown
|
|
||||||
if not inferred_type:
|
if not inferred_type:
|
||||||
if is_random_mac(addr):
|
if is_random_mac(addr):
|
||||||
device_type = "BLE Device (Random MAC)"
|
device_type = "BLE Device (Random MAC)"
|
||||||
@@ -252,13 +275,13 @@ class RFScanner:
|
|||||||
devices.append(BluetoothDevice(
|
devices.append(BluetoothDevice(
|
||||||
address=addr,
|
address=addr,
|
||||||
name=name,
|
name=name,
|
||||||
rssi=-70, # Default estimate for BLE
|
rssi=rssi, # Real RSSI instead of -70
|
||||||
device_class="BLE",
|
device_class="BLE",
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
manufacturer=manufacturer
|
manufacturer=manufacturer
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"BLE scan error: {e}")
|
print(f"BLE scan error: {e}")
|
||||||
|
|
||||||
# Auto-identify unknown devices
|
# Auto-identify unknown devices
|
||||||
if auto_identify and devices:
|
if auto_identify and devices:
|
||||||
|
|||||||
@@ -124,7 +124,15 @@ class PeerSync:
|
|||||||
devices = data.get("devices", [])
|
devices = data.get("devices", [])
|
||||||
source_scanner = data.get("scanner_id", "unknown")
|
source_scanner = data.get("scanner_id", "unknown")
|
||||||
|
|
||||||
updated = self.db.bulk_update_devices(devices, source_scanner)
|
# Get source scanner position for correct device positioning
|
||||||
|
source_lat = data.get("scanner_lat")
|
||||||
|
source_lon = data.get("scanner_lon")
|
||||||
|
|
||||||
|
updated = self.db.bulk_update_devices(
|
||||||
|
devices, source_scanner,
|
||||||
|
source_scanner_lat=source_lat,
|
||||||
|
source_scanner_lon=source_lon
|
||||||
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def push_devices_to_peer(self, peer_url: str, since: Optional[str] = None) -> dict:
|
def push_devices_to_peer(self, peer_url: str, since: Optional[str] = None) -> dict:
|
||||||
@@ -141,6 +149,8 @@ class PeerSync:
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"source_scanner": self.scanner_identity["id"],
|
"source_scanner": self.scanner_identity["id"],
|
||||||
|
"source_scanner_lat": self.scanner_identity["latitude"],
|
||||||
|
"source_scanner_lon": self.scanner_identity["longitude"],
|
||||||
"devices": devices
|
"devices": devices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
218
src/rf_mapper/termux.py
Normal file
218
src/rf_mapper/termux.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""Termux/Android environment detection and prerequisite checks."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def is_termux() -> bool:
|
||||||
|
"""Detect if running in Termux on Android."""
|
||||||
|
# Check for Termux environment variable
|
||||||
|
if os.environ.get("TERMUX_VERSION"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for Termux-specific paths
|
||||||
|
termux_paths = [
|
||||||
|
"/data/data/com.termux",
|
||||||
|
Path.home() / ".termux",
|
||||||
|
Path("/data/data/com.termux/files/usr"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in termux_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check PREFIX environment variable (Termux sets this)
|
||||||
|
prefix = os.environ.get("PREFIX", "")
|
||||||
|
if "/com.termux/" in prefix:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_termux_api_installed() -> tuple[bool, str]:
|
||||||
|
"""Check if termux-api package and Termux:API app are installed."""
|
||||||
|
# Test if Termux:API app is working (quick test with battery status)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["termux-battery-status"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, "Termux:API app not installed or not granted permissions"
|
||||||
|
if "error" in result.stderr.lower():
|
||||||
|
return False, "Termux:API app error. Reinstall from F-Droid"
|
||||||
|
# Verify we got valid JSON output
|
||||||
|
if not result.stdout.strip().startswith("{"):
|
||||||
|
return False, "Termux:API app not responding correctly"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Termux:API app not responding. Install from F-Droid and grant permissions"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "termux-api package not installed. Run: pkg install termux-api"
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def check_location_enabled() -> tuple[bool, str]:
|
||||||
|
"""Check if location services are accessible via termux-api."""
|
||||||
|
try:
|
||||||
|
# Use termux-location with a short timeout
|
||||||
|
result = subprocess.run(
|
||||||
|
["termux-location", "-p", "passive", "-r", "once"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
output = result.stdout.strip()
|
||||||
|
|
||||||
|
# Check for common error patterns
|
||||||
|
if not output:
|
||||||
|
return False, "Location services not responding. Enable GPS/Location in Android settings"
|
||||||
|
|
||||||
|
if "null" in output.lower() and "latitude" not in output.lower():
|
||||||
|
return False, "Location unavailable. Enable GPS and grant Termux:API location permission"
|
||||||
|
|
||||||
|
# Try to parse as JSON to verify it's valid location data
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
data = json.loads(output)
|
||||||
|
if data.get("latitude") is not None:
|
||||||
|
return True, f"OK (lat: {data.get('latitude'):.4f})"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Location might still be initializing
|
||||||
|
return True, "OK (location services accessible)"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Location request timed out. Enable GPS in Android settings"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "termux-location not found. Run: pkg install termux-api"
|
||||||
|
|
||||||
|
|
||||||
|
def check_wake_lock() -> tuple[bool, str]:
|
||||||
|
"""Check if wake lock can be acquired."""
|
||||||
|
try:
|
||||||
|
# Try to acquire wake lock
|
||||||
|
result = subprocess.run(
|
||||||
|
["termux-wake-lock"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False, f"Failed to acquire wake lock: {result.stderr.strip()}"
|
||||||
|
|
||||||
|
return True, "OK (wake lock acquired)"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Wake lock request timed out"
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "termux-wake-lock not found. Run: pkg install termux-api"
|
||||||
|
|
||||||
|
|
||||||
|
def check_termux_boot() -> tuple[bool, str]:
|
||||||
|
"""Check if Termux:Boot is available for auto-start capability."""
|
||||||
|
boot_dir = Path.home() / ".termux" / "boot"
|
||||||
|
|
||||||
|
if boot_dir.exists():
|
||||||
|
return True, "OK (boot directory exists)"
|
||||||
|
|
||||||
|
# Not critical, just informational
|
||||||
|
return True, "Termux:Boot not configured (optional for auto-start)"
|
||||||
|
|
||||||
|
|
||||||
|
def check_termux_prerequisites(verbose: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Check all Termux prerequisites for RF Mapper.
|
||||||
|
|
||||||
|
Returns True if all required checks pass, False otherwise.
|
||||||
|
Prints status messages if verbose=True.
|
||||||
|
"""
|
||||||
|
if not is_termux():
|
||||||
|
# Not running in Termux, skip checks
|
||||||
|
return True
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("TERMUX ENVIRONMENT DETECTED")
|
||||||
|
print("Checking prerequisites...")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
checks = [
|
||||||
|
("Termux:API package", check_termux_api_installed, True), # Required
|
||||||
|
("Location services", check_location_enabled, False), # Optional (uses config.yaml coords)
|
||||||
|
("Wake lock", check_wake_lock, True), # Required
|
||||||
|
("Termux:Boot", check_termux_boot, False), # Optional
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, check_func, required in checks:
|
||||||
|
try:
|
||||||
|
ok, message = check_func()
|
||||||
|
status = "✓" if ok else ("✗" if required else "⚠")
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" {status} {name}: {message}")
|
||||||
|
|
||||||
|
if not ok and required:
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if verbose:
|
||||||
|
print(f" ✗ {name}: Error - {e}")
|
||||||
|
if required:
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if all_ok:
|
||||||
|
print("All prerequisites met. Starting RF Mapper...")
|
||||||
|
else:
|
||||||
|
print("\nREQUIRED PREREQUISITES NOT MET")
|
||||||
|
print("\nTo fix:")
|
||||||
|
print(" 1. Install Termux:API from F-Droid")
|
||||||
|
print(" (NOT from Play Store - versions must match)")
|
||||||
|
print(" 2. Run: pkg install termux-api")
|
||||||
|
print(" 3. Enable Location in Android Settings")
|
||||||
|
print(" 4. Grant Termux:API location permission")
|
||||||
|
print(" 5. Run: termux-wake-lock")
|
||||||
|
print("\nFor auto-start on boot:")
|
||||||
|
print(" 1. Install Termux:Boot from F-Droid")
|
||||||
|
print(" 2. mkdir -p ~/.termux/boot")
|
||||||
|
print(" 3. Create boot script: ~/.termux/boot/start-rf-mapper.sh")
|
||||||
|
print("")
|
||||||
|
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def setup_termux_signal_handlers():
|
||||||
|
"""Set up signal handlers for graceful shutdown in Termux."""
|
||||||
|
import signal
|
||||||
|
|
||||||
|
def handle_signal(signum, frame):
|
||||||
|
"""Release wake lock and exit gracefully."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["termux-wake-unlock"], capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Allow running as standalone check
|
||||||
|
if is_termux():
|
||||||
|
success = check_termux_prerequisites(verbose=True)
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
else:
|
||||||
|
print("Not running in Termux environment")
|
||||||
|
sys.exit(0)
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, render_template, request
|
import requests
|
||||||
|
from flask import Flask, current_app, jsonify, render_template, request
|
||||||
|
from flask_socketio import SocketIO, emit
|
||||||
|
|
||||||
from ..scanner import RFScanner
|
from ..scanner import RFScanner
|
||||||
from ..distance import estimate_distance
|
from ..distance import estimate_distance
|
||||||
@@ -15,6 +18,30 @@ from ..config import Config, get_config
|
|||||||
from ..bluetooth_identify import identify_single_device, identify_device
|
from ..bluetooth_identify import identify_single_device, identify_device
|
||||||
from ..database import DeviceDatabase, init_database, get_database
|
from ..database import DeviceDatabase, init_database, get_database
|
||||||
from ..homeassistant import HAWebhooks, HAWebhookConfig
|
from ..homeassistant import HAWebhooks, HAWebhookConfig
|
||||||
|
from .. import __version__
|
||||||
|
|
||||||
|
# Module-level SocketIO instance
|
||||||
|
socketio = SocketIO()
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_scan_update(app: Flask, devices: list[dict], scan_type: str = "bluetooth"):
|
||||||
|
"""Broadcast scan results to all connected WebSocket clients."""
|
||||||
|
sio = app.config.get("SOCKETIO")
|
||||||
|
if not sio:
|
||||||
|
return
|
||||||
|
|
||||||
|
scanner_identity = app.config.get("SCANNER_IDENTITY", {})
|
||||||
|
|
||||||
|
sio.emit(
|
||||||
|
"scan_update",
|
||||||
|
{
|
||||||
|
"type": scan_type,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"scanner_id": scanner_identity.get("id", "unknown"),
|
||||||
|
"devices": devices,
|
||||||
|
},
|
||||||
|
namespace="/ws/scan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AutoScanner:
|
class AutoScanner:
|
||||||
@@ -212,6 +239,11 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
|
|
||||||
# Store config reference
|
# Store config reference
|
||||||
app.config["RF_CONFIG"] = config
|
app.config["RF_CONFIG"] = config
|
||||||
|
app.config["START_TIME"] = datetime.now()
|
||||||
|
|
||||||
|
# Initialize SocketIO with threading mode (compatible with existing threads)
|
||||||
|
socketio.init_app(app, cors_allowed_origins="*", async_mode="threading")
|
||||||
|
app.config["SOCKETIO"] = socketio
|
||||||
|
|
||||||
# Data directory from config
|
# Data directory from config
|
||||||
app.config["DATA_DIR"] = config.get_data_dir()
|
app.config["DATA_DIR"] = config.get_data_dir()
|
||||||
@@ -320,10 +352,36 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
location_label=config.auto_scan.location_label
|
location_label=config.auto_scan.location_label
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==================== WebSocket Event Handlers ====================
|
||||||
|
|
||||||
|
@socketio.on("connect", namespace="/ws/scan")
|
||||||
|
def ws_connect():
|
||||||
|
"""Handle client connection."""
|
||||||
|
scanner_id = current_app.config.get("SCANNER_IDENTITY", {}).get("id", "unknown")
|
||||||
|
emit("connected", {"scanner_id": scanner_id})
|
||||||
|
print(f"[WS] Client connected: {request.sid}")
|
||||||
|
|
||||||
|
@socketio.on("disconnect", namespace="/ws/scan")
|
||||||
|
def ws_disconnect():
|
||||||
|
"""Handle client disconnection."""
|
||||||
|
print(f"[WS] Client disconnected: {request.sid}")
|
||||||
|
|
||||||
|
@socketio.on("subscribe_floor", namespace="/ws/scan")
|
||||||
|
def ws_subscribe_floor(data):
|
||||||
|
"""Subscribe to floor-specific updates."""
|
||||||
|
from flask_socketio import join_room
|
||||||
|
|
||||||
|
floor = data.get("floor", "all")
|
||||||
|
join_room(f"floor_{floor}")
|
||||||
|
emit("subscribed", {"floor": floor})
|
||||||
|
|
||||||
|
# ==================== HTTP Routes ====================
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
"""Main dashboard page"""
|
"""Main dashboard page"""
|
||||||
rf_config = app.config["RF_CONFIG"]
|
rf_config = app.config["RF_CONFIG"]
|
||||||
|
scanner_identity = app.config["SCANNER_IDENTITY"]
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
lat=app.config["CURRENT_LAT"],
|
lat=app.config["CURRENT_LAT"],
|
||||||
@@ -335,9 +393,77 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
"floor_height_m": rf_config.building.floor_height_m,
|
"floor_height_m": rf_config.building.floor_height_m,
|
||||||
"ground_floor_number": rf_config.building.ground_floor_number,
|
"ground_floor_number": rf_config.building.ground_floor_number,
|
||||||
"current_floor": rf_config.building.current_floor
|
"current_floor": rf_config.building.current_floor
|
||||||
|
},
|
||||||
|
scanner={
|
||||||
|
"id": scanner_identity["id"],
|
||||||
|
"name": scanner_identity["name"]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route("/api/health")
|
||||||
|
def api_health():
|
||||||
|
"""Health check endpoint for monitoring"""
|
||||||
|
start_time = app.config.get("START_TIME", datetime.now())
|
||||||
|
uptime_seconds = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
# Check component status
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
peer_sync = app.config.get("PEER_SYNC")
|
||||||
|
auto_scanner = app.config.get("AUTO_SCANNER")
|
||||||
|
|
||||||
|
# Database status
|
||||||
|
db_status = "disabled"
|
||||||
|
db_device_count = 0
|
||||||
|
if db:
|
||||||
|
try:
|
||||||
|
db_device_count = len(db.get_all_devices())
|
||||||
|
db_status = "ok"
|
||||||
|
except Exception:
|
||||||
|
db_status = "error"
|
||||||
|
|
||||||
|
# Peer sync status
|
||||||
|
peers_status = "disabled"
|
||||||
|
peers_count = 0
|
||||||
|
if peer_sync:
|
||||||
|
try:
|
||||||
|
sync_status = peer_sync.get_status()
|
||||||
|
peers_count = len(sync_status.get("peers", []))
|
||||||
|
peers_status = "ok"
|
||||||
|
except Exception:
|
||||||
|
peers_status = "error"
|
||||||
|
|
||||||
|
# Auto scanner status
|
||||||
|
autoscan_status = "stopped"
|
||||||
|
if auto_scanner and auto_scanner._enabled:
|
||||||
|
autoscan_status = "running"
|
||||||
|
|
||||||
|
# Overall health
|
||||||
|
is_healthy = db_status != "error" and peers_status != "error"
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"status": "healthy" if is_healthy else "unhealthy",
|
||||||
|
"version": __version__,
|
||||||
|
"uptime_seconds": int(uptime_seconds),
|
||||||
|
"uptime_human": f"{int(uptime_seconds // 3600)}h {int((uptime_seconds % 3600) // 60)}m",
|
||||||
|
"scanner_id": app.config.get("SCANNER_IDENTITY", {}).get("id", ""),
|
||||||
|
"components": {
|
||||||
|
"database": {
|
||||||
|
"status": db_status,
|
||||||
|
"device_count": db_device_count
|
||||||
|
},
|
||||||
|
"peer_sync": {
|
||||||
|
"status": peers_status,
|
||||||
|
"peer_count": peers_count
|
||||||
|
},
|
||||||
|
"auto_scanner": {
|
||||||
|
"status": autoscan_status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status_code = 200 if is_healthy else 503
|
||||||
|
return jsonify(response), status_code
|
||||||
|
|
||||||
@app.route("/api/scan", methods=["POST"])
|
@app.route("/api/scan", methods=["POST"])
|
||||||
def api_scan():
|
def api_scan():
|
||||||
"""Trigger a new RF scan"""
|
"""Trigger a new RF scan"""
|
||||||
@@ -540,10 +666,17 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
with open(scan_files[0]) as f:
|
with open(scan_files[0]) as f:
|
||||||
scan = json.load(f)
|
scan = json.load(f)
|
||||||
|
|
||||||
# Get saved floor assignments from database
|
# Get saved floor assignments and scanner BT MACs from database
|
||||||
db = app.config.get("DATABASE")
|
db = app.config.get("DATABASE")
|
||||||
saved_floors = db.get_all_device_floors() if db else {}
|
saved_floors = db.get_all_device_floors() if db else {}
|
||||||
|
|
||||||
|
# Get scanner BT MACs to filter out
|
||||||
|
scanner_bt_macs = set()
|
||||||
|
if db:
|
||||||
|
for peer in db.get_peers():
|
||||||
|
if peer.get("bt_mac"):
|
||||||
|
scanner_bt_macs.add(peer["bt_mac"].upper())
|
||||||
|
|
||||||
# Enrich with distance estimates and saved floor assignments
|
# Enrich with distance estimates and saved floor assignments
|
||||||
for net in scan.get("wifi_networks", []):
|
for net in scan.get("wifi_networks", []):
|
||||||
net["estimated_distance_m"] = round(estimate_distance(net["rssi"]), 2)
|
net["estimated_distance_m"] = round(estimate_distance(net["rssi"]), 2)
|
||||||
@@ -552,12 +685,18 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
if "height_m" not in net:
|
if "height_m" not in net:
|
||||||
net["height_m"] = None
|
net["height_m"] = None
|
||||||
|
|
||||||
|
# Filter out scanner devices and enrich BT devices
|
||||||
|
filtered_bt = []
|
||||||
for dev in scan.get("bluetooth_devices", []):
|
for dev in scan.get("bluetooth_devices", []):
|
||||||
|
address = dev.get("address", "").upper()
|
||||||
|
if address in scanner_bt_macs:
|
||||||
|
continue # Skip scanner devices
|
||||||
dev["estimated_distance_m"] = round(estimate_distance(dev["rssi"], tx_power=-65), 2)
|
dev["estimated_distance_m"] = round(estimate_distance(dev["rssi"], tx_power=-65), 2)
|
||||||
address = dev.get("address")
|
dev["floor"] = saved_floors.get(dev.get("address")) if dev.get("address") else dev.get("floor")
|
||||||
dev["floor"] = saved_floors.get(address) if address else dev.get("floor")
|
|
||||||
if "height_m" not in dev:
|
if "height_m" not in dev:
|
||||||
dev["height_m"] = None
|
dev["height_m"] = None
|
||||||
|
filtered_bt.append(dev)
|
||||||
|
scan["bluetooth_devices"] = filtered_bt
|
||||||
|
|
||||||
scan["gps"] = {
|
scan["gps"] = {
|
||||||
"lat": app.config["CURRENT_LAT"],
|
"lat": app.config["CURRENT_LAT"],
|
||||||
@@ -880,17 +1019,19 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.route("/api/device/floors", methods=["GET"])
|
@app.route("/api/device/floors", methods=["GET"])
|
||||||
def api_device_floors():
|
def api_device_floors():
|
||||||
"""Get all saved floor assignments and position offsets"""
|
"""Get all saved floor assignments, position offsets, and source scanner info"""
|
||||||
db = app.config.get("DATABASE")
|
db = app.config.get("DATABASE")
|
||||||
if not db:
|
if not db:
|
||||||
return jsonify({"floors": {}, "positions": {}})
|
return jsonify({"floors": {}, "positions": {}, "sources": {}})
|
||||||
|
|
||||||
floors = db.get_all_device_floors()
|
floors = db.get_all_device_floors()
|
||||||
positions = db.get_all_device_positions()
|
positions = db.get_all_device_positions()
|
||||||
|
sources = db.get_all_device_sources()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"floors": floors,
|
"floors": floors,
|
||||||
"positions": positions
|
"positions": positions,
|
||||||
|
"sources": sources
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route("/api/device/<device_id>/position", methods=["POST"])
|
@app.route("/api/device/<device_id>/position", methods=["POST"])
|
||||||
@@ -1070,6 +1211,9 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
scan_type="bluetooth"
|
scan_type="bluetooth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Broadcast to WebSocket clients
|
||||||
|
broadcast_scan_update(current_app, response_data["bluetooth_devices"], "bluetooth")
|
||||||
|
|
||||||
return jsonify(response_data)
|
return jsonify(response_data)
|
||||||
|
|
||||||
# ==================== Historical Data API ====================
|
# ==================== Historical Data API ====================
|
||||||
@@ -1236,6 +1380,132 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
|
|
||||||
return jsonify(activity)
|
return jsonify(activity)
|
||||||
|
|
||||||
|
# ==================== Trilateration API ====================
|
||||||
|
|
||||||
|
@app.route("/api/positions/trilaterated")
|
||||||
|
def api_trilaterated_positions():
|
||||||
|
"""Get trilaterated positions for devices seen by multiple scanners."""
|
||||||
|
from ..distance import trilaterate_weighted_latlon, estimate_distance
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if not db:
|
||||||
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
seconds = int(request.args.get("seconds", 60))
|
||||||
|
|
||||||
|
# Get devices seen by multiple scanners
|
||||||
|
device_ids = db.get_devices_seen_by_multiple_scanners(seconds=seconds)
|
||||||
|
|
||||||
|
# Get all scanner positions (local + peers)
|
||||||
|
scanner_positions = {}
|
||||||
|
|
||||||
|
# Local scanner
|
||||||
|
local = app.config.get("SCANNER_IDENTITY", {})
|
||||||
|
if local.get("id"):
|
||||||
|
scanner_positions[local["id"]] = {
|
||||||
|
"lat": local.get("latitude"),
|
||||||
|
"lon": local.get("longitude")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Peer scanners
|
||||||
|
peers = db.get_peers()
|
||||||
|
for peer in peers:
|
||||||
|
scanner_positions[peer["scanner_id"]] = {
|
||||||
|
"lat": peer.get("latitude"),
|
||||||
|
"lon": peer.get("longitude")
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for device_id in device_ids:
|
||||||
|
scanner_rssi = db.get_device_multi_scanner_rssi(device_id, seconds=seconds)
|
||||||
|
|
||||||
|
# Build scanner data for trilateration
|
||||||
|
scanner_data = []
|
||||||
|
for sr in scanner_rssi:
|
||||||
|
sid = sr["scanner_id"]
|
||||||
|
if sid in scanner_positions and scanner_positions[sid].get("lat"):
|
||||||
|
pos = scanner_positions[sid]
|
||||||
|
dist = estimate_distance(int(sr["avg_rssi"]), tx_power=-65)
|
||||||
|
scanner_data.append({
|
||||||
|
"scanner_id": sid,
|
||||||
|
"lat": pos["lat"],
|
||||||
|
"lon": pos["lon"],
|
||||||
|
"distance": dist,
|
||||||
|
"rssi": sr["avg_rssi"]
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(scanner_data) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate trilaterated position
|
||||||
|
result = trilaterate_weighted_latlon(scanner_data)
|
||||||
|
if result:
|
||||||
|
lat, lon, confidence = result
|
||||||
|
results.append({
|
||||||
|
"device_id": device_id,
|
||||||
|
"position": {"lat": lat, "lon": lon},
|
||||||
|
"confidence": round(confidence, 2),
|
||||||
|
"scanners": [s["scanner_id"] for s in scanner_data],
|
||||||
|
"method": "trilateration" if len(scanner_data) >= 3 else "weighted_average"
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"devices": results, "count": len(results)})
|
||||||
|
|
||||||
|
@app.route("/api/heatmap/signal")
|
||||||
|
def api_signal_heatmap():
|
||||||
|
"""Get signal strength data for heat map visualization."""
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if not db:
|
||||||
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
floor = request.args.get("floor")
|
||||||
|
|
||||||
|
# Get scanner positions as anchor points (max signal)
|
||||||
|
points = []
|
||||||
|
|
||||||
|
# Local scanner
|
||||||
|
local = app.config.get("SCANNER_IDENTITY", {})
|
||||||
|
if local.get("latitude"):
|
||||||
|
points.append({
|
||||||
|
"lat": local["latitude"],
|
||||||
|
"lon": local["longitude"],
|
||||||
|
"signal": -30, # Scanner location = max signal
|
||||||
|
"weight": 1.0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Peer scanners
|
||||||
|
peers = db.get_peers()
|
||||||
|
for peer in peers:
|
||||||
|
if peer.get("latitude"):
|
||||||
|
points.append({
|
||||||
|
"lat": peer["latitude"],
|
||||||
|
"lon": peer["longitude"],
|
||||||
|
"signal": -30,
|
||||||
|
"weight": 1.0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get recent device observations with positions
|
||||||
|
# Use trilaterated positions if available, otherwise source scanner positions
|
||||||
|
devices = db.get_all_devices(device_type="bluetooth", limit=100)
|
||||||
|
for dev in devices:
|
||||||
|
# Get most recent RSSI for this device
|
||||||
|
rssi_history = db.get_device_rssi_history(dev["device_id"], limit=1)
|
||||||
|
if rssi_history:
|
||||||
|
last_rssi = rssi_history[0].rssi
|
||||||
|
# Get device source info
|
||||||
|
if dev.get("source_scanner_lat") and dev.get("source_scanner_lon"):
|
||||||
|
# Calculate rough position from source scanner
|
||||||
|
from ..distance import estimate_distance
|
||||||
|
dist = estimate_distance(last_rssi, tx_power=-65)
|
||||||
|
points.append({
|
||||||
|
"lat": dev["source_scanner_lat"],
|
||||||
|
"lon": dev["source_scanner_lon"],
|
||||||
|
"signal": last_rssi,
|
||||||
|
"weight": max(0.1, min(0.8, 1 - (dist / 50))) # Weight by distance
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"points": points, "count": len(points)})
|
||||||
|
|
||||||
@app.route("/api/history/stats")
|
@app.route("/api/history/stats")
|
||||||
def api_history_stats():
|
def api_history_stats():
|
||||||
"""Get database statistics"""
|
"""Get database statistics"""
|
||||||
@@ -1260,6 +1530,41 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
result = db.cleanup_old_data(retention_days)
|
result = db.cleanup_old_data(retention_days)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
@app.route("/api/import/ble-radar", methods=["POST"])
|
||||||
|
def api_import_ble_radar():
|
||||||
|
"""Import BLE Radar database from uploaded file"""
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if not db:
|
||||||
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
if "file" not in request.files:
|
||||||
|
return jsonify({"error": "No file uploaded"}), 400
|
||||||
|
|
||||||
|
file = request.files["file"]
|
||||||
|
if file.filename == "":
|
||||||
|
return jsonify({"error": "No file selected"}), 400
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".sqlite") as tmp:
|
||||||
|
file.save(tmp.name)
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..import_ble_radar import import_ble_radar_db
|
||||||
|
scanner_id = request.form.get("scanner_id", "ble_radar")
|
||||||
|
stats = import_ble_radar_db(tmp_path, db, scanner_id, verbose=False)
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Imported {stats['devices_imported']} new devices, updated {stats['devices_updated']}",
|
||||||
|
**stats
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
# ==================== Peer Sync API ====================
|
# ==================== Peer Sync API ====================
|
||||||
|
|
||||||
@app.route("/api/peers", methods=["GET"])
|
@app.route("/api/peers", methods=["GET"])
|
||||||
@@ -1269,11 +1574,13 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
if not db:
|
if not db:
|
||||||
return jsonify({"error": "Database not enabled"}), 503
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
rf_config = app.config["RF_CONFIG"]
|
||||||
peers = db.get_peers()
|
peers = db.get_peers()
|
||||||
peer_sync = app.config.get("PEER_SYNC")
|
peer_sync = app.config.get("PEER_SYNC")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"this_scanner": app.config["SCANNER_IDENTITY"],
|
"this_scanner": app.config["SCANNER_IDENTITY"],
|
||||||
|
"is_master": rf_config.scanner.is_master,
|
||||||
"peers": peers,
|
"peers": peers,
|
||||||
"sync_status": peer_sync.get_status() if peer_sync else None
|
"sync_status": peer_sync.get_status() if peer_sync else None
|
||||||
})
|
})
|
||||||
@@ -1304,7 +1611,8 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
url=peer_url,
|
url=peer_url,
|
||||||
floor=data.get("floor"),
|
floor=data.get("floor"),
|
||||||
latitude=data.get("latitude"),
|
latitude=data.get("latitude"),
|
||||||
longitude=data.get("longitude")
|
longitude=data.get("longitude"),
|
||||||
|
bt_mac=data.get("bt_mac")
|
||||||
)
|
)
|
||||||
|
|
||||||
action = "registered" if is_new else "updated"
|
action = "registered" if is_new else "updated"
|
||||||
@@ -1339,6 +1647,130 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
else:
|
else:
|
||||||
return jsonify({"error": "Peer not found"}), 404
|
return jsonify({"error": "Peer not found"}), 404
|
||||||
|
|
||||||
|
# ==================== Node Control API ====================
|
||||||
|
|
||||||
|
def _ssh_node_command(scanner_id: str, command: str, timeout: int = 30) -> tuple[bool, str]:
|
||||||
|
"""Execute a command on a peer node via SSH.
|
||||||
|
|
||||||
|
Uses scanner_id as SSH hostname (relies on ~/.ssh/config for ports).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, output/error message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes",
|
||||||
|
scanner_id, command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
output = result.stdout.strip() or result.stderr.strip()
|
||||||
|
return result.returncode == 0, output
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "SSH command timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
@app.route("/api/nodes/<scanner_id>/start", methods=["POST"])
|
||||||
|
def api_node_start(scanner_id: str):
|
||||||
|
"""Start rf-mapper on a peer node via SSH"""
|
||||||
|
rf_config = app.config["RF_CONFIG"]
|
||||||
|
if not rf_config.scanner.is_master:
|
||||||
|
return jsonify({"error": "Only master node can control peers"}), 403
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if db:
|
||||||
|
peer = db.get_peer(scanner_id)
|
||||||
|
if not peer:
|
||||||
|
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
|
||||||
|
|
||||||
|
print(f"[Node] Starting rf-mapper on {scanner_id}...")
|
||||||
|
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper start"
|
||||||
|
success, output = _ssh_node_command(scanner_id, cmd, timeout=60)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"[Node] {scanner_id} started successfully")
|
||||||
|
return jsonify({"status": "started", "scanner_id": scanner_id, "output": output})
|
||||||
|
else:
|
||||||
|
print(f"[Node] Failed to start {scanner_id}: {output}")
|
||||||
|
return jsonify({"error": "Failed to start", "details": output}), 500
|
||||||
|
|
||||||
|
@app.route("/api/nodes/<scanner_id>/stop", methods=["POST"])
|
||||||
|
def api_node_stop(scanner_id: str):
|
||||||
|
"""Stop rf-mapper on a peer node via SSH"""
|
||||||
|
rf_config = app.config["RF_CONFIG"]
|
||||||
|
if not rf_config.scanner.is_master:
|
||||||
|
return jsonify({"error": "Only master node can control peers"}), 403
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if db:
|
||||||
|
peer = db.get_peer(scanner_id)
|
||||||
|
if not peer:
|
||||||
|
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
|
||||||
|
|
||||||
|
print(f"[Node] Stopping rf-mapper on {scanner_id}...")
|
||||||
|
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper stop"
|
||||||
|
success, output = _ssh_node_command(scanner_id, cmd, timeout=30)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"[Node] {scanner_id} stopped successfully")
|
||||||
|
return jsonify({"status": "stopped", "scanner_id": scanner_id, "output": output})
|
||||||
|
else:
|
||||||
|
print(f"[Node] Failed to stop {scanner_id}: {output}")
|
||||||
|
return jsonify({"error": "Failed to stop", "details": output}), 500
|
||||||
|
|
||||||
|
@app.route("/api/nodes/<scanner_id>/restart", methods=["POST"])
|
||||||
|
def api_node_restart(scanner_id: str):
|
||||||
|
"""Restart rf-mapper on a peer node via SSH"""
|
||||||
|
rf_config = app.config["RF_CONFIG"]
|
||||||
|
if not rf_config.scanner.is_master:
|
||||||
|
return jsonify({"error": "Only master node can control peers"}), 403
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if db:
|
||||||
|
peer = db.get_peer(scanner_id)
|
||||||
|
if not peer:
|
||||||
|
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
|
||||||
|
|
||||||
|
print(f"[Node] Restarting rf-mapper on {scanner_id}...")
|
||||||
|
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper restart"
|
||||||
|
success, output = _ssh_node_command(scanner_id, cmd, timeout=60)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"[Node] {scanner_id} restarted successfully")
|
||||||
|
return jsonify({"status": "restarted", "scanner_id": scanner_id, "output": output})
|
||||||
|
else:
|
||||||
|
print(f"[Node] Failed to restart {scanner_id}: {output}")
|
||||||
|
return jsonify({"error": "Failed to restart", "details": output}), 500
|
||||||
|
|
||||||
|
@app.route("/api/nodes/<scanner_id>/status", methods=["GET"])
|
||||||
|
def api_node_status(scanner_id: str):
|
||||||
|
"""Get rf-mapper status on a peer node via SSH"""
|
||||||
|
rf_config = app.config["RF_CONFIG"]
|
||||||
|
if not rf_config.scanner.is_master:
|
||||||
|
return jsonify({"error": "Only master node can control peers"}), 403
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if db:
|
||||||
|
peer = db.get_peer(scanner_id)
|
||||||
|
if not peer:
|
||||||
|
return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404
|
||||||
|
|
||||||
|
cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper status"
|
||||||
|
success, output = _ssh_node_command(scanner_id, cmd, timeout=15)
|
||||||
|
|
||||||
|
# Parse status from output
|
||||||
|
reachable = success or "not running" in output.lower()
|
||||||
|
running = success and "running" in output.lower() and "not running" not in output.lower()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"scanner_id": scanner_id,
|
||||||
|
"reachable": reachable,
|
||||||
|
"running": running,
|
||||||
|
"output": output
|
||||||
|
})
|
||||||
|
|
||||||
@app.route("/api/sync/devices", methods=["GET"])
|
@app.route("/api/sync/devices", methods=["GET"])
|
||||||
def api_sync_devices_get():
|
def api_sync_devices_get():
|
||||||
"""Get devices for sync (called by peers)"""
|
"""Get devices for sync (called by peers)"""
|
||||||
@@ -1348,9 +1780,12 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
|
|
||||||
since = request.args.get("since")
|
since = request.args.get("since")
|
||||||
devices = db.get_devices_since(since)
|
devices = db.get_devices_since(since)
|
||||||
|
scanner_identity = app.config["SCANNER_IDENTITY"]
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"scanner_id": app.config["SCANNER_IDENTITY"]["id"],
|
"scanner_id": scanner_identity["id"],
|
||||||
|
"scanner_lat": scanner_identity["latitude"],
|
||||||
|
"scanner_lon": scanner_identity["longitude"],
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"devices": devices
|
"devices": devices
|
||||||
})
|
})
|
||||||
@@ -1365,8 +1800,14 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
devices = data.get("devices", [])
|
devices = data.get("devices", [])
|
||||||
source_scanner = data.get("source_scanner", "unknown")
|
source_scanner = data.get("source_scanner", "unknown")
|
||||||
|
source_lat = data.get("source_scanner_lat")
|
||||||
|
source_lon = data.get("source_scanner_lon")
|
||||||
|
|
||||||
updated = db.bulk_update_devices(devices, source_scanner)
|
updated = db.bulk_update_devices(
|
||||||
|
devices, source_scanner,
|
||||||
|
source_scanner_lat=source_lat,
|
||||||
|
source_scanner_lon=source_lon
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "synced",
|
"status": "synced",
|
||||||
@@ -1389,12 +1830,14 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
peer_id = peer["scanner_id"]
|
peer_id = peer["scanner_id"]
|
||||||
peer_url = peer["url"]
|
peer_url = peer["url"]
|
||||||
try:
|
try:
|
||||||
updated = peer_sync.sync_devices_from_peer(peer_url)
|
pulled = peer_sync.sync_devices_from_peer(peer_url)
|
||||||
peer_sync.push_devices_to_peer(peer_url)
|
push_result = peer_sync.push_devices_to_peer(peer_url)
|
||||||
|
pushed = push_result.get("updated", 0) if push_result else 0
|
||||||
results.append({
|
results.append({
|
||||||
"peer_id": peer_id,
|
"peer_id": peer_id,
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"devices_updated": updated
|
"devices_pulled": pulled,
|
||||||
|
"devices_pushed": pushed
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append({
|
results.append({
|
||||||
@@ -1408,6 +1851,121 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
"results": results
|
"results": results
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ==================== Multi-Node Master Dashboard Proxy API ====================
|
||||||
|
|
||||||
|
# Cache for peer responses (scanner_id -> path -> {data, timestamp})
|
||||||
|
_peer_cache: dict[str, dict[str, dict]] = {}
|
||||||
|
_peer_cache_ttl = 300 # 5 minutes cache TTL
|
||||||
|
|
||||||
|
def proxy_peer_request(scanner_id: str, path: str, method: str = "GET", **kwargs):
|
||||||
|
"""Proxy a request to a peer node with caching.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- Response tuple (jsonify(...), status_code) on error or peer response
|
||||||
|
- None if scanner_id matches local scanner (caller should use local handler)
|
||||||
|
|
||||||
|
Caching behavior:
|
||||||
|
- GET requests are cached on success
|
||||||
|
- On peer unreachable, returns cached data if available (with _cached flag)
|
||||||
|
- POST requests are not cached but will return cached GET data on failure
|
||||||
|
"""
|
||||||
|
local_id = app.config.get("SCANNER_IDENTITY", {}).get("id")
|
||||||
|
if scanner_id == local_id:
|
||||||
|
return None # Signal to use local handler
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
peer = db.get_peer(scanner_id) if db else None
|
||||||
|
if not peer or not peer.get("url"):
|
||||||
|
return jsonify({"error": "Peer not found"}), 404
|
||||||
|
|
||||||
|
cache_key = f"{scanner_id}:{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{peer['url']}{path}"
|
||||||
|
resp = requests.request(method, url, timeout=10, **kwargs)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Enrich response with source scanner info
|
||||||
|
data["_source_scanner"] = scanner_id
|
||||||
|
data["_source_position"] = {
|
||||||
|
"lat": peer.get("latitude"),
|
||||||
|
"lon": peer.get("longitude"),
|
||||||
|
"floor": peer.get("floor")
|
||||||
|
}
|
||||||
|
data["_cached"] = False
|
||||||
|
|
||||||
|
# Cache successful GET responses
|
||||||
|
if method == "GET":
|
||||||
|
if scanner_id not in _peer_cache:
|
||||||
|
_peer_cache[scanner_id] = {}
|
||||||
|
_peer_cache[scanner_id][path] = {
|
||||||
|
"data": data,
|
||||||
|
"timestamp": datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
# Try to return cached data
|
||||||
|
if scanner_id in _peer_cache and path in _peer_cache[scanner_id]:
|
||||||
|
cached = _peer_cache[scanner_id][path]
|
||||||
|
cache_age = (datetime.now() - cached["timestamp"]).total_seconds()
|
||||||
|
|
||||||
|
# Return cached data if within TTL (or always for display purposes)
|
||||||
|
cached_data = cached["data"].copy()
|
||||||
|
cached_data["_cached"] = True
|
||||||
|
cached_data["_cached_at"] = cached["timestamp"].isoformat()
|
||||||
|
cached_data["_cache_age_seconds"] = int(cache_age)
|
||||||
|
cached_data["_peer_error"] = str(e)
|
||||||
|
|
||||||
|
return jsonify(cached_data)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"error": f"Peer unreachable: {e}",
|
||||||
|
"_source_scanner": scanner_id,
|
||||||
|
"_cached": False
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
@app.route("/api/node/<scanner_id>/latest")
|
||||||
|
def api_node_latest(scanner_id: str):
|
||||||
|
"""Proxy: Get latest scan from a peer node."""
|
||||||
|
result = proxy_peer_request(scanner_id, "/api/latest")
|
||||||
|
if result is None:
|
||||||
|
return api_latest_scan()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.route("/api/node/<scanner_id>/scan/bt", methods=["POST"])
|
||||||
|
def api_node_scan_bt(scanner_id: str):
|
||||||
|
"""Proxy: Trigger BT scan on a peer node."""
|
||||||
|
result = proxy_peer_request(scanner_id, "/api/scan/bt", method="POST")
|
||||||
|
if result is None:
|
||||||
|
return api_scan_bt()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.route("/api/node/<scanner_id>/device/floors")
|
||||||
|
def api_node_device_floors(scanner_id: str):
|
||||||
|
"""Proxy: Get device floors from a peer node."""
|
||||||
|
result = proxy_peer_request(scanner_id, "/api/device/floors")
|
||||||
|
if result is None:
|
||||||
|
return api_device_floors()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.route("/api/node/<scanner_id>/positions/trilaterated")
|
||||||
|
def api_node_trilaterated(scanner_id: str):
|
||||||
|
"""Proxy: Get trilaterated positions from a peer node."""
|
||||||
|
result = proxy_peer_request(scanner_id, "/api/positions/trilaterated")
|
||||||
|
if result is None:
|
||||||
|
return api_trilaterated_positions()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.route("/api/node/<scanner_id>/health")
|
||||||
|
def api_node_health(scanner_id: str):
|
||||||
|
"""Proxy: Get health status from a peer node."""
|
||||||
|
result = proxy_peer_request(scanner_id, "/api/health")
|
||||||
|
if result is None:
|
||||||
|
return api_health()
|
||||||
|
return result
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -1463,10 +2021,12 @@ def run_server(
|
|||||||
if log_requests:
|
if log_requests:
|
||||||
print(f"Request logging: ENABLED")
|
print(f"Request logging: ENABLED")
|
||||||
print(f"Log output: {config.get_data_dir() / 'logs'}")
|
print(f"Log output: {config.get_data_dir() / 'logs'}")
|
||||||
|
print(f"WebSocket: ENABLED (namespace /ws/scan)")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
print(f"Server running at: http://{host}:{port}")
|
print(f"Server running at: http://{host}:{port}")
|
||||||
print(f"Local access: http://localhost:{port}")
|
print(f"Local access: http://localhost:{port}")
|
||||||
print(f"Network access: http://<your-ip>:{port}")
|
print(f"Network access: http://<your-ip>:{port}")
|
||||||
print(f"{'='*60}\n")
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
app.run(host=host, port=port, debug=debug)
|
# Use socketio.run() for WebSocket support
|
||||||
|
socketio.run(app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True)
|
||||||
|
|||||||
@@ -56,6 +56,44 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Node selector for master dashboard */
|
||||||
|
#node-selector-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#node-selector-container.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#node-select {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#node-select:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#node-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
@@ -577,6 +615,37 @@ body {
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Import Controls */
|
||||||
|
.import-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-row label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-row input[type="file"] {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#import-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Device Detail Panel */
|
/* Device Detail Panel */
|
||||||
.device-detail-panel {
|
.device-detail-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -714,6 +783,11 @@ body {
|
|||||||
border-radius: var(--border-radius) !important;
|
border-radius: var(--border-radius) !important;
|
||||||
padding: 0.75rem !important;
|
padding: 0.75rem !important;
|
||||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2) !important;
|
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2) !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-popup {
|
||||||
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
||||||
@@ -832,6 +906,20 @@ body {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-value.trilaterated {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-source-info {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(0, 200, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 200, 255, 0.3);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #00c8ff;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-reset-btn {
|
.popup-reset-btn {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@@ -951,6 +1039,79 @@ body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.marker-3d.peer-scanner .marker-icon {
|
||||||
|
background: #00c8ff;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 200, 255, 0.6), 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-3d.peer-scanner .marker-floor {
|
||||||
|
background: rgba(0, 200, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scanner coverage rings (shown when heatmap enabled) */
|
||||||
|
.heatmap-enabled .marker-3d.center .marker-icon::before,
|
||||||
|
.heatmap-enabled .marker-3d.peer-scanner .marker-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle,
|
||||||
|
rgba(100, 255, 100, 0.3) 0%,
|
||||||
|
rgba(100, 255, 100, 0.2) 25%,
|
||||||
|
rgba(255, 255, 100, 0.15) 50%,
|
||||||
|
rgba(255, 100, 100, 0.1) 75%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-enabled .marker-3d.center .marker-icon,
|
||||||
|
.heatmap-enabled .marker-3d.peer-scanner .marker-icon {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trilaterated device markers - gold border */
|
||||||
|
.marker-3d.trilaterated .marker-icon {
|
||||||
|
border: 2px dashed #ffd700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-3d.trilaterated.high-confidence .marker-icon {
|
||||||
|
border-style: solid !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 2px 8px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trilat-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
background: #ffd700;
|
||||||
|
color: #000;
|
||||||
|
font-size: 7px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heat map toggle button */
|
||||||
|
.filter-btn.heatmap {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.heatmap.active {
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
/* Floor Controls */
|
/* Floor Controls */
|
||||||
.floor-section {
|
.floor-section {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
7
src/rf_mapper/web/static/js/vendor/socket.io.min.js
generated
vendored
Normal file
7
src/rf_mapper/web/static/js/vendor/socket.io.min.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
158
src/rf_mapper/web/static/js/websocket.js
Normal file
158
src/rf_mapper/web/static/js/websocket.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* RF Mapper WebSocket client with automatic reconnection and HTTP fallback
|
||||||
|
*/
|
||||||
|
class RFMapperWS {
|
||||||
|
constructor() {
|
||||||
|
this.socket = null;
|
||||||
|
this.peerSocket = null; // For peer node connections (master dashboard)
|
||||||
|
this.connected = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 5;
|
||||||
|
this.listeners = {
|
||||||
|
scanUpdate: [],
|
||||||
|
connected: [],
|
||||||
|
disconnected: [],
|
||||||
|
peerConnected: [],
|
||||||
|
peerDisconnected: [],
|
||||||
|
peerScanUpdate: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Check if socket.io is loaded
|
||||||
|
if (typeof io === 'undefined') {
|
||||||
|
console.warn('[WS] socket.io not loaded, using HTTP polling');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.socket = io('/ws/scan', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
reconnectionAttempts: this.maxReconnectAttempts
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('[WS] Connected');
|
||||||
|
this.connected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this._emit('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason) => {
|
||||||
|
console.log('[WS] Disconnected:', reason);
|
||||||
|
this.connected = false;
|
||||||
|
this._emit('disconnected', { reason });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('scan_update', (data) => {
|
||||||
|
this._emit('scanUpdate', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
console.warn('[WS] Connection error:', error.message);
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.log('[WS] Max reconnect attempts, falling back to HTTP');
|
||||||
|
this.connected = false;
|
||||||
|
this._emit('disconnected', { reason: 'max_reconnect' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] Failed to initialize:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeFloor(floor) {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit('subscribe_floor', { floor });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to a peer node's WebSocket (for master dashboard)
|
||||||
|
connectToPeer(peerUrl) {
|
||||||
|
this.disconnectFromPeer();
|
||||||
|
|
||||||
|
// Check if socket.io is loaded
|
||||||
|
if (typeof io === 'undefined') {
|
||||||
|
console.warn('[WS] socket.io not loaded, cannot connect to peer');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.peerSocket = io(peerUrl + '/ws/scan', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 3,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
this.peerSocket.on('connect', () => {
|
||||||
|
console.log('[WS] Connected to peer:', peerUrl);
|
||||||
|
this._emit('peerConnected', peerUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.peerSocket.on('scan_update', (data) => {
|
||||||
|
this._emit('peerScanUpdate', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.peerSocket.on('disconnect', (reason) => {
|
||||||
|
console.log('[WS] Peer disconnected:', reason);
|
||||||
|
this._emit('peerDisconnected', { url: peerUrl, reason });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.peerSocket.on('connect_error', (error) => {
|
||||||
|
console.warn('[WS] Peer connection error:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] Failed to connect to peer:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from peer node
|
||||||
|
disconnectFromPeer() {
|
||||||
|
if (this.peerSocket) {
|
||||||
|
this.peerSocket.disconnect();
|
||||||
|
this.peerSocket = null;
|
||||||
|
console.log('[WS] Disconnected from peer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, callback) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(event, data) {
|
||||||
|
if (this.listeners[event]) {
|
||||||
|
this.listeners[event].forEach(cb => cb(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
const rfMapperWS = new RFMapperWS();
|
||||||
@@ -65,6 +65,10 @@
|
|||||||
groundFloorNumber: {{ building.ground_floor_number | default(0) }},
|
groundFloorNumber: {{ building.ground_floor_number | default(0) }},
|
||||||
currentFloor: {{ building.current_floor | default(0) }}
|
currentFloor: {{ building.current_floor | default(0) }}
|
||||||
},
|
},
|
||||||
|
scanner: {
|
||||||
|
id: {{ scanner.id | default('') | tojson }},
|
||||||
|
name: {{ scanner.name | default('') | tojson }}
|
||||||
|
},
|
||||||
maplibre: {
|
maplibre: {
|
||||||
style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
|
style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
||||||
|
|
||||||
{% block header_controls %}
|
{% block header_controls %}
|
||||||
|
<div id="node-selector-container" class="hidden">
|
||||||
|
<select id="node-select" onchange="switchNode(this.value)">
|
||||||
|
<option value="local" selected>📍 This Scanner</option>
|
||||||
|
</select>
|
||||||
|
<span id="node-status" class="node-status">●</span>
|
||||||
|
</div>
|
||||||
<span id="scan-status" class="scan-info">Ready</span>
|
<span id="scan-status" class="scan-info">Ready</span>
|
||||||
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
||||||
🔍 New Scan
|
🔍 New Scan
|
||||||
@@ -33,6 +39,9 @@
|
|||||||
<span class="filter-indicator"></span>
|
<span class="filter-indicator"></span>
|
||||||
<span>Bluetooth</span>
|
<span>Bluetooth</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="btn-heatmap" class="filter-btn heatmap" onclick="toggleHeatMap()">
|
||||||
|
<span>🌡️ Heat Map</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
||||||
@@ -118,6 +127,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">📥 Import Data</span>
|
||||||
|
</div>
|
||||||
|
<div class="import-controls">
|
||||||
|
<div class="import-row">
|
||||||
|
<label>BLE Radar Database:</label>
|
||||||
|
<input type="file" id="ble-radar-file" accept=".sqlite,.db">
|
||||||
|
</div>
|
||||||
|
<div class="import-row">
|
||||||
|
<button class="btn btn-small" onclick="importBleRadar()">Import</button>
|
||||||
|
<span id="import-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title">📊 Statistics</span>
|
<span class="section-title">📊 Statistics</span>
|
||||||
@@ -166,5 +191,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<!-- Socket.IO client -->
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/socket.io.min.js') }}"></script>
|
||||||
|
<!-- WebSocket client module -->
|
||||||
|
<script src="{{ url_for('static', filename='js/websocket.js') }}"></script>
|
||||||
|
<!-- Main application -->
|
||||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user