Compare commits
36 Commits
| 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 |
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.85846541332012
|
latitude: 50.858532461583906
|
||||||
longitude: 4.397570348817993
|
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,6 +210,12 @@ 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")
|
||||||
@@ -334,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
|
||||||
@@ -496,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]:
|
||||||
@@ -891,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:
|
||||||
@@ -901,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
|
||||||
@@ -914,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
|
||||||
|
|||||||
@@ -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,7 +191,10 @@ class RFScanner:
|
|||||||
"""
|
"""
|
||||||
devices = []
|
devices = []
|
||||||
|
|
||||||
# Classic Bluetooth scan
|
# Classic Bluetooth scan (not supported on Termux/Android)
|
||||||
|
if is_termux():
|
||||||
|
print("Skipping Classic BT scan (not supported on Termux)")
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -214,33 +227,43 @@ class RFScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Classic BT scan error: {e}")
|
print(f"Classic BT scan error: {e}")
|
||||||
|
|
||||||
# BLE scan
|
# BLE scan using bleak (not supported on Termux/Android)
|
||||||
|
if is_termux():
|
||||||
|
print("Skipping BLE scan (not supported on Termux)")
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
print(f"Scanning BLE devices ({timeout} seconds)...")
|
print(f"Scanning BLE devices ({timeout} seconds)...")
|
||||||
result = subprocess.run(
|
|
||||||
['sudo', 'timeout', str(timeout), 'hcitool', 'lescan', '--duplicates'],
|
async def _ble_scan():
|
||||||
capture_output=True,
|
return await BleakScanner.discover(
|
||||||
text=True,
|
timeout=timeout,
|
||||||
timeout=timeout + 5
|
return_adv=True
|
||||||
)
|
)
|
||||||
|
|
||||||
seen_addrs = {d.address for d in devices}
|
ble_results = asyncio.run(_ble_scan())
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
match = re.match(r'([0-9A-Fa-f:]+)\s*(.*)', line)
|
|
||||||
if match:
|
|
||||||
addr = match.group(1)
|
|
||||||
name = match.group(2).strip() or '<unknown>'
|
|
||||||
|
|
||||||
|
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,7 +275,7 @@ 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
|
||||||
|
|||||||
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"],
|
||||||
@@ -1072,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 ====================
|
||||||
@@ -1238,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"""
|
||||||
@@ -1262,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"])
|
||||||
@@ -1271,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
|
||||||
})
|
})
|
||||||
@@ -1306,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"
|
||||||
@@ -1341,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)"""
|
||||||
@@ -1421,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
|
||||||
|
|
||||||
|
|
||||||
@@ -1476,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,10 @@ body {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-value.trilaterated {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-source-info {
|
.popup-source-info {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -974,6 +1052,66 @@ body {
|
|||||||
background: rgba(0, 200, 255, 0.9);
|
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;
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } }
|
|||||||
// Peer scanner positions - loaded from /api/peers (live positions)
|
// Peer scanner positions - loaded from /api/peers (live positions)
|
||||||
let peerScanners = {}; // { scanner_id: { lat, lon, floor, name } }
|
let peerScanners = {}; // { scanner_id: { lat, lon, floor, name } }
|
||||||
|
|
||||||
|
// Scanner Bluetooth MACs - for filtering scanners from device lists
|
||||||
|
let scannerBtMacs = new Set(); // Set of BT MAC addresses belonging to scanners
|
||||||
|
|
||||||
|
// Trilateration state - positions calculated from multiple scanner RSSI
|
||||||
|
let trilateratedPositions = {}; // { deviceId: { lat, lon, confidence, scanners, method } }
|
||||||
|
let trilaterationEnabled = true;
|
||||||
|
|
||||||
|
// Heat map state
|
||||||
|
let heatMapEnabled = false;
|
||||||
|
|
||||||
|
// Multi-node master state
|
||||||
|
let isMasterNode = false;
|
||||||
|
let activeNode = 'local'; // 'local' or scanner_id
|
||||||
|
let activeNodeInfo = null; // { id, name, url, lat, lon, floor }
|
||||||
|
|
||||||
// Auto-scan state
|
// Auto-scan state
|
||||||
let autoScanEnabled = false;
|
let autoScanEnabled = false;
|
||||||
let autoScanPollInterval = null;
|
let autoScanPollInterval = null;
|
||||||
@@ -33,6 +48,10 @@ let liveTrackingEnabled = false;
|
|||||||
let liveTrackingInterval = null;
|
let liveTrackingInterval = null;
|
||||||
const LIVE_TRACKING_INTERVAL_MS = 4000; // 4 seconds
|
const LIVE_TRACKING_INTERVAL_MS = 4000; // 4 seconds
|
||||||
|
|
||||||
|
// WebSocket state
|
||||||
|
let wsEnabled = true; // Try WebSocket first
|
||||||
|
let wsConnected = false;
|
||||||
|
|
||||||
// Statistical movement detection
|
// Statistical movement detection
|
||||||
const SAMPLE_HISTORY_SIZE = 5; // Number of samples to keep for averaging
|
const SAMPLE_HISTORY_SIZE = 5; // Number of samples to keep for averaging
|
||||||
const MOVEMENT_THRESHOLD = 1.5; // meters - movement must exceed this + stddev margin
|
const MOVEMENT_THRESHOLD = 1.5; // meters - movement must exceed this + stddev margin
|
||||||
@@ -45,6 +64,11 @@ let deviceDistanceHistory = {};
|
|||||||
let deviceMissCount = {};
|
let deviceMissCount = {};
|
||||||
const MAX_MISSED_SCANS = 5; // Remove device after this many consecutive misses (~20s with 4s interval)
|
const MAX_MISSED_SCANS = 5; // Remove device after this many consecutive misses (~20s with 4s interval)
|
||||||
|
|
||||||
|
// Track last seen timestamp per device for timeout-based removal
|
||||||
|
let deviceLastSeen = {}; // { address: timestamp_ms }
|
||||||
|
const STALE_DEVICE_TIMEOUT_MS = 60000; // Remove devices not seen for 60 seconds
|
||||||
|
let staleDeviceCleanupInterval = null;
|
||||||
|
|
||||||
// Calculate mean of array
|
// Calculate mean of array
|
||||||
function mean(arr) {
|
function mean(arr) {
|
||||||
if (arr.length === 0) return 0;
|
if (arr.length === 0) return 0;
|
||||||
@@ -104,6 +128,100 @@ function isDeviceMoving(address, newDistance) {
|
|||||||
return isMoving;
|
return isMoving;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out scanner Bluetooth devices from scan data
|
||||||
|
// Removes devices whose address matches a known scanner's BT MAC
|
||||||
|
function filterScannerDevices(data) {
|
||||||
|
if (!data || scannerBtMacs.size === 0) return data;
|
||||||
|
|
||||||
|
if (data.bluetooth_devices) {
|
||||||
|
const before = data.bluetooth_devices.length;
|
||||||
|
data.bluetooth_devices = data.bluetooth_devices.filter(dev => {
|
||||||
|
const addr = (dev.address || '').toUpperCase();
|
||||||
|
return !scannerBtMacs.has(addr);
|
||||||
|
});
|
||||||
|
const filtered = before - data.bluetooth_devices.length;
|
||||||
|
if (filtered > 0) {
|
||||||
|
console.log(`[Filter] Removed ${filtered} scanner BT device(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last seen timestamp for a device
|
||||||
|
function updateDeviceLastSeen(address) {
|
||||||
|
deviceLastSeen[address] = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale devices that haven't been seen recently
|
||||||
|
function cleanupStaleDevices() {
|
||||||
|
if (!scanData) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
// Clean up Bluetooth devices
|
||||||
|
if (scanData.bluetooth_devices) {
|
||||||
|
const before = scanData.bluetooth_devices.length;
|
||||||
|
scanData.bluetooth_devices = scanData.bluetooth_devices.filter(dev => {
|
||||||
|
const lastSeen = deviceLastSeen[dev.address];
|
||||||
|
if (!lastSeen || (now - lastSeen) > STALE_DEVICE_TIMEOUT_MS) {
|
||||||
|
// Clean up tracking data
|
||||||
|
delete deviceMissCount[dev.address];
|
||||||
|
delete deviceDistanceHistory[dev.address];
|
||||||
|
delete deviceLastSeen[dev.address];
|
||||||
|
if (deviceTrails[dev.address]) {
|
||||||
|
clearDeviceTrail(dev.address);
|
||||||
|
}
|
||||||
|
console.log(`[Stale] Removed BT ${dev.name || dev.address} (timeout)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
removedCount += before - scanData.bluetooth_devices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up WiFi networks
|
||||||
|
if (scanData.wifi_networks) {
|
||||||
|
const before = scanData.wifi_networks.length;
|
||||||
|
scanData.wifi_networks = scanData.wifi_networks.filter(net => {
|
||||||
|
const id = net.bssid || net.ssid;
|
||||||
|
const lastSeen = deviceLastSeen[id];
|
||||||
|
if (!lastSeen || (now - lastSeen) > STALE_DEVICE_TIMEOUT_MS) {
|
||||||
|
delete deviceLastSeen[id];
|
||||||
|
console.log(`[Stale] Removed WiFi ${net.ssid || net.bssid} (timeout)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
removedCount += before - scanData.wifi_networks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI if devices were removed
|
||||||
|
if (removedCount > 0) {
|
||||||
|
document.getElementById('wifi-count').textContent = scanData.wifi_networks?.length || 0;
|
||||||
|
document.getElementById('bt-count').textContent = scanData.bluetooth_devices?.length || 0;
|
||||||
|
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices?.length || 0;
|
||||||
|
drawRadar();
|
||||||
|
update3DMarkers();
|
||||||
|
updateMapMarkers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start stale device cleanup interval
|
||||||
|
function startStaleDeviceCleanup() {
|
||||||
|
if (staleDeviceCleanupInterval) return;
|
||||||
|
staleDeviceCleanupInterval = setInterval(cleanupStaleDevices, 10000); // Check every 10 seconds
|
||||||
|
console.log('[Cleanup] Stale device cleanup started (timeout: ' + (STALE_DEVICE_TIMEOUT_MS / 1000) + 's)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stale device cleanup interval
|
||||||
|
function stopStaleDeviceCleanup() {
|
||||||
|
if (staleDeviceCleanupInterval) {
|
||||||
|
clearInterval(staleDeviceCleanupInterval);
|
||||||
|
staleDeviceCleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Device positions for hit detection (radar view)
|
// Device positions for hit detection (radar view)
|
||||||
let devicePositions = [];
|
let devicePositions = [];
|
||||||
|
|
||||||
@@ -121,12 +239,18 @@ let openPopupDeviceId = null; // Track which device popup is open
|
|||||||
|
|
||||||
// Initialize on DOM ready
|
// Initialize on DOM ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Store original position for node switching
|
||||||
|
APP_CONFIG.originalLat = APP_CONFIG.defaultLat;
|
||||||
|
APP_CONFIG.originalLon = APP_CONFIG.defaultLon;
|
||||||
|
|
||||||
initMap();
|
initMap();
|
||||||
initRadar();
|
initRadar();
|
||||||
initFloorSelector();
|
initFloorSelector();
|
||||||
loadLatestScan();
|
loadLatestScan();
|
||||||
loadAutoScanStatus();
|
loadAutoScanStatus();
|
||||||
loadDevicePositions(); // Load saved manual positions
|
loadDevicePositions(); // Load saved manual positions
|
||||||
|
loadTrilateratedPositions(); // Load multi-scanner trilaterated positions
|
||||||
|
startStaleDeviceCleanup(); // Auto-remove devices not seen for 60s
|
||||||
|
|
||||||
// Initialize 3D map as default view
|
// Initialize 3D map as default view
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -134,6 +258,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
map3dInitialized = true;
|
map3dInitialized = true;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// Initialize WebSocket connection
|
||||||
|
initWebSocket();
|
||||||
|
|
||||||
|
// Initialize master dashboard (checks if this node is master)
|
||||||
|
initMasterDashboard();
|
||||||
|
|
||||||
// Start BT live tracking by default after a short delay
|
// Start BT live tracking by default after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
startLiveTracking();
|
startLiveTracking();
|
||||||
@@ -141,6 +271,333 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket connection for real-time updates
|
||||||
|
function initWebSocket() {
|
||||||
|
if (!wsEnabled) return;
|
||||||
|
|
||||||
|
// Check if rfMapperWS is available (websocket.js loaded)
|
||||||
|
if (typeof rfMapperWS === 'undefined') {
|
||||||
|
console.log('[App] WebSocket module not loaded, using HTTP polling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = rfMapperWS.connect();
|
||||||
|
if (!connected) {
|
||||||
|
console.log('[App] WebSocket not available, using HTTP polling');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rfMapperWS.on('connected', () => {
|
||||||
|
wsConnected = true;
|
||||||
|
console.log('[App] WebSocket connected');
|
||||||
|
|
||||||
|
// Stop HTTP polling if running (WS will handle updates)
|
||||||
|
if (liveTrackingInterval) {
|
||||||
|
clearInterval(liveTrackingInterval);
|
||||||
|
liveTrackingInterval = null;
|
||||||
|
console.log('[App] Stopped HTTP polling (using WebSocket)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rfMapperWS.on('disconnected', (data) => {
|
||||||
|
wsConnected = false;
|
||||||
|
console.log('[App] WebSocket disconnected:', data?.reason);
|
||||||
|
|
||||||
|
// Resume HTTP polling if live tracking is enabled
|
||||||
|
if (liveTrackingEnabled && !liveTrackingInterval) {
|
||||||
|
liveTrackingInterval = setInterval(performLiveBTScan, LIVE_TRACKING_INTERVAL_MS);
|
||||||
|
console.log('[App] Resumed HTTP polling');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rfMapperWS.on('scanUpdate', (data) => {
|
||||||
|
handleWebSocketScanUpdate(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle peer scan updates (from master dashboard peer connections)
|
||||||
|
rfMapperWS.on('peerScanUpdate', (data) => {
|
||||||
|
if (activeNode !== 'local') {
|
||||||
|
handleWebSocketScanUpdate(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Multi-Node Master Dashboard ==========
|
||||||
|
|
||||||
|
// Initialize master dashboard (checks if this node is master and shows node selector)
|
||||||
|
async function initMasterDashboard() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/peers');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
isMasterNode = data.is_master === true;
|
||||||
|
|
||||||
|
if (isMasterNode && data.peers?.length > 0) {
|
||||||
|
document.getElementById('node-selector-container').classList.remove('hidden');
|
||||||
|
populateNodeSelector(data.this_scanner, data.peers);
|
||||||
|
console.log('[Master] Dashboard enabled with', data.peers.length, 'peers');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Master] Init failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate node selector dropdown with local and peer scanners
|
||||||
|
function populateNodeSelector(thisScanner, peers) {
|
||||||
|
const select = document.getElementById('node-select');
|
||||||
|
select.innerHTML = `<option value="local">📍 ${thisScanner.name || thisScanner.id}</option>`;
|
||||||
|
|
||||||
|
peers.forEach(peer => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = peer.scanner_id;
|
||||||
|
opt.textContent = `📡 ${peer.name || peer.scanner_id}`;
|
||||||
|
opt.dataset.url = peer.url;
|
||||||
|
opt.dataset.lat = peer.latitude;
|
||||||
|
opt.dataset.lon = peer.longitude;
|
||||||
|
opt.dataset.floor = peer.floor || 0;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to viewing a different node's data
|
||||||
|
async function switchNode(nodeId) {
|
||||||
|
const select = document.getElementById('node-select');
|
||||||
|
const statusEl = document.getElementById('node-status');
|
||||||
|
const wasLiveTracking = liveTrackingEnabled;
|
||||||
|
|
||||||
|
// Stop current tracking and peer connection
|
||||||
|
if (wasLiveTracking) stopLiveTracking();
|
||||||
|
if (activeNode !== 'local' && typeof rfMapperWS !== 'undefined') {
|
||||||
|
rfMapperWS.disconnectFromPeer();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeNode = nodeId;
|
||||||
|
statusEl.textContent = '⏳';
|
||||||
|
statusEl.style.color = '#fbbf24';
|
||||||
|
|
||||||
|
if (nodeId === 'local') {
|
||||||
|
activeNodeInfo = null;
|
||||||
|
// Restore local position
|
||||||
|
const localLat = APP_CONFIG.originalLat || APP_CONFIG.defaultLat;
|
||||||
|
const localLon = APP_CONFIG.originalLon || APP_CONFIG.defaultLon;
|
||||||
|
updateMapCenter(localLat, localLon);
|
||||||
|
|
||||||
|
await loadLatestScan();
|
||||||
|
await loadDevicePositions();
|
||||||
|
await loadTrilateratedPositions();
|
||||||
|
|
||||||
|
// Reconnect local WebSocket
|
||||||
|
if (typeof rfMapperWS !== 'undefined') {
|
||||||
|
rfMapperWS.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = '●';
|
||||||
|
statusEl.style.color = '#4ade80';
|
||||||
|
console.log('[Node] Switched to local');
|
||||||
|
} else {
|
||||||
|
const opt = select.options[select.selectedIndex];
|
||||||
|
activeNodeInfo = {
|
||||||
|
id: nodeId,
|
||||||
|
name: opt.textContent,
|
||||||
|
url: opt.dataset.url,
|
||||||
|
lat: parseFloat(opt.dataset.lat),
|
||||||
|
lon: parseFloat(opt.dataset.lon),
|
||||||
|
floor: parseInt(opt.dataset.floor) || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Center map on peer's position
|
||||||
|
updateMapCenter(activeNodeInfo.lat, activeNodeInfo.lon);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load peer's data via proxy endpoints
|
||||||
|
const [latestResp, floorsResp] = await Promise.all([
|
||||||
|
fetch(`/api/node/${nodeId}/latest`),
|
||||||
|
fetch(`/api/node/${nodeId}/device/floors`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (latestResp.ok) {
|
||||||
|
scanData = filterScannerDevices(await latestResp.json());
|
||||||
|
markAllDevicesSeen();
|
||||||
|
if (floorsResp.ok) {
|
||||||
|
const floorsData = await floorsResp.json();
|
||||||
|
updateDeviceFloors(floorsData);
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
// Connect to peer's WebSocket for real-time updates
|
||||||
|
if (typeof rfMapperWS !== 'undefined' && activeNodeInfo.url) {
|
||||||
|
rfMapperWS.connectToPeer(activeNodeInfo.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = '●';
|
||||||
|
statusEl.style.color = '#4ade80';
|
||||||
|
console.log('[Node] Switched to', nodeId);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to load peer data');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Node] Load failed:`, e);
|
||||||
|
statusEl.textContent = '○';
|
||||||
|
statusEl.style.color = '#ef4444';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasLiveTracking) startLiveTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update device floors data from API response
|
||||||
|
function updateDeviceFloors(floorsData) {
|
||||||
|
if (floorsData.floors) {
|
||||||
|
// Update scanData devices with floor info
|
||||||
|
if (scanData) {
|
||||||
|
const floors = floorsData.floors;
|
||||||
|
(scanData.wifi_networks || []).forEach(net => {
|
||||||
|
if (floors[net.bssid] !== undefined) {
|
||||||
|
net.floor = floors[net.bssid];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(scanData.bluetooth_devices || []).forEach(dev => {
|
||||||
|
if (floors[dev.address] !== undefined) {
|
||||||
|
dev.floor = floors[dev.address];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (floorsData.positions) {
|
||||||
|
manualPositions = floorsData.positions;
|
||||||
|
}
|
||||||
|
if (floorsData.sources) {
|
||||||
|
deviceSources = floorsData.sources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map center position (for node switching)
|
||||||
|
function updateMapCenter(lat, lon) {
|
||||||
|
APP_CONFIG.defaultLat = lat;
|
||||||
|
APP_CONFIG.defaultLon = lon;
|
||||||
|
document.getElementById('lat-input').value = lat.toFixed(6);
|
||||||
|
document.getElementById('lon-input').value = lon.toFixed(6);
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.setView([lat, lon], map.getZoom());
|
||||||
|
}
|
||||||
|
if (map3d) {
|
||||||
|
map3d.flyTo({ center: [lon, lat], duration: 1000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scan updates received via WebSocket
|
||||||
|
function handleWebSocketScanUpdate(data) {
|
||||||
|
if (!liveTrackingEnabled) return;
|
||||||
|
|
||||||
|
console.log('[WS] Scan update:', data.type, data.devices?.length, 'devices');
|
||||||
|
|
||||||
|
// Handle Bluetooth scan results
|
||||||
|
if (data.type === 'bluetooth' && data.devices) {
|
||||||
|
// Filter out scanner Bluetooth devices
|
||||||
|
const newBt = data.devices.filter(dev => {
|
||||||
|
const addr = (dev.address || '').toUpperCase();
|
||||||
|
return !scannerBtMacs.has(addr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track which devices were detected in this scan
|
||||||
|
const detectedAddresses = new Set(newBt.map(d => d.address));
|
||||||
|
|
||||||
|
if (scanData) {
|
||||||
|
const existingBt = scanData.bluetooth_devices || [];
|
||||||
|
|
||||||
|
// Update existing devices with new RSSI, add new devices
|
||||||
|
newBt.forEach(newDev => {
|
||||||
|
const existing = existingBt.find(d => d.address === newDev.address);
|
||||||
|
const newDist = newDev.estimated_distance_m;
|
||||||
|
|
||||||
|
// Check for movement using statistical analysis
|
||||||
|
const moving = isDeviceMoving(newDev.address, newDist);
|
||||||
|
|
||||||
|
// Reset miss count - device was detected
|
||||||
|
deviceMissCount[newDev.address] = 0;
|
||||||
|
updateDeviceLastSeen(newDev.address);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update RSSI and estimated distance, preserve custom values
|
||||||
|
existing.rssi = newDev.rssi;
|
||||||
|
existing.estimated_distance_m = newDev.estimated_distance_m;
|
||||||
|
existing.signal_quality = newDev.signal_quality;
|
||||||
|
existing.is_moving = moving;
|
||||||
|
existing.miss_count = 0;
|
||||||
|
// Preserve floor and custom_distance_m if set
|
||||||
|
} else {
|
||||||
|
// New device, add it
|
||||||
|
newDev.is_moving = moving;
|
||||||
|
existingBt.push(newDev);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment miss count for devices not detected in this scan
|
||||||
|
existingBt.forEach(dev => {
|
||||||
|
if (!detectedAddresses.has(dev.address)) {
|
||||||
|
deviceMissCount[dev.address] = (deviceMissCount[dev.address] || 0) + 1;
|
||||||
|
dev.miss_count = deviceMissCount[dev.address];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out devices that have been missed too many times
|
||||||
|
const filteredBt = existingBt.filter(dev => {
|
||||||
|
const missCount = deviceMissCount[dev.address] || 0;
|
||||||
|
if (missCount >= MAX_MISSED_SCANS) {
|
||||||
|
// Clean up tracking data for removed device
|
||||||
|
delete deviceMissCount[dev.address];
|
||||||
|
delete deviceDistanceHistory[dev.address];
|
||||||
|
delete deviceLastSeen[dev.address];
|
||||||
|
// Clear trail if showing
|
||||||
|
if (deviceTrails[dev.address]) {
|
||||||
|
clearDeviceTrail(dev.address);
|
||||||
|
}
|
||||||
|
console.log(`[WS] Removed ${dev.name} (missed ${missCount} scans)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
scanData.bluetooth_devices = filteredBt;
|
||||||
|
} else {
|
||||||
|
// No existing scan data, use BT-only data
|
||||||
|
newBt.forEach(dev => {
|
||||||
|
// Initialize history with first sample, not moving yet
|
||||||
|
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
||||||
|
dev.is_moving = false;
|
||||||
|
deviceMissCount[dev.address] = 0;
|
||||||
|
updateDeviceLastSeen(dev.address);
|
||||||
|
});
|
||||||
|
scanData = {
|
||||||
|
wifi_networks: [],
|
||||||
|
bluetooth_devices: newBt,
|
||||||
|
timestamp: data.timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visualizations
|
||||||
|
const status = document.getElementById('scan-status');
|
||||||
|
if (status) {
|
||||||
|
const movingCount = scanData.bluetooth_devices.filter(d => d.is_moving).length;
|
||||||
|
const wsIndicator = wsConnected ? '[WS]' : '';
|
||||||
|
status.textContent = `Live${wsIndicator}: ${scanData.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update BT count
|
||||||
|
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
||||||
|
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
||||||
|
|
||||||
|
// Refresh trilaterated positions (non-blocking)
|
||||||
|
loadTrilateratedPositions();
|
||||||
|
|
||||||
|
// Refresh views
|
||||||
|
drawRadar();
|
||||||
|
update3DMarkers();
|
||||||
|
updateMapMarkers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle filter
|
// Toggle filter
|
||||||
function toggleFilter(type) {
|
function toggleFilter(type) {
|
||||||
filters[type] = !filters[type];
|
filters[type] = !filters[type];
|
||||||
@@ -237,12 +694,25 @@ function setView(view) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark all devices in scanData as seen now
|
||||||
|
function markAllDevicesSeen() {
|
||||||
|
if (!scanData) return;
|
||||||
|
const now = Date.now();
|
||||||
|
(scanData.wifi_networks || []).forEach(net => {
|
||||||
|
deviceLastSeen[net.bssid || net.ssid] = now;
|
||||||
|
});
|
||||||
|
(scanData.bluetooth_devices || []).forEach(dev => {
|
||||||
|
deviceLastSeen[dev.address] = now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load latest scan
|
// Load latest scan
|
||||||
async function loadLatestScan() {
|
async function loadLatestScan() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/latest');
|
const response = await fetch('/api/latest');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
scanData = await response.json();
|
scanData = filterScannerDevices(await response.json());
|
||||||
|
markAllDevicesSeen();
|
||||||
updateUI();
|
updateUI();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('wifi-list').innerHTML = '<div style="color:#888;padding:1rem;">No scans yet. Click "New Scan" to start.</div>';
|
document.getElementById('wifi-list').innerHTML = '<div style="color:#888;padding:1rem;">No scans yet. Click "New Scan" to start.</div>';
|
||||||
@@ -272,13 +742,14 @@ async function triggerScan() {
|
|||||||
location: 'web_scan',
|
location: 'web_scan',
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lon: lon,
|
lon: lon,
|
||||||
scan_wifi: filters.wifi,
|
scan_wifi: true, // Always scan WiFi (filter controls display only)
|
||||||
scan_bluetooth: filters.bluetooth
|
scan_bluetooth: true // Always scan BT (filter controls display only)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
scanData = await response.json();
|
scanData = filterScannerDevices(await response.json());
|
||||||
|
markAllDevicesSeen();
|
||||||
updateUI();
|
updateUI();
|
||||||
status.textContent = `Scanned at ${new Date().toLocaleTimeString()}`;
|
status.textContent = `Scanned at ${new Date().toLocaleTimeString()}`;
|
||||||
} else {
|
} else {
|
||||||
@@ -592,7 +1063,9 @@ function updateMapMarkers() {
|
|||||||
const lat = parseFloat(document.getElementById('lat-input').value);
|
const lat = parseFloat(document.getElementById('lat-input').value);
|
||||||
const lon = parseFloat(document.getElementById('lon-input').value);
|
const lon = parseFloat(document.getElementById('lon-input').value);
|
||||||
|
|
||||||
// Add center marker (this scanner) - distinct star shape
|
// Add center marker (viewing scanner) - distinct star shape
|
||||||
|
// When viewing a peer, show that peer's name; otherwise show local scanner name
|
||||||
|
const viewingScannerName = activeNodeInfo?.name || APP_CONFIG.scanner?.name || APP_CONFIG.scanner?.id || 'Scanner';
|
||||||
const scannerIcon = `
|
const scannerIcon = `
|
||||||
<div style="position:relative;width:24px;height:24px;">
|
<div style="position:relative;width:24px;height:24px;">
|
||||||
<svg viewBox="0 0 24 24" style="width:24px;height:24px;filter:drop-shadow(0 0 4px rgba(255,0,128,0.8));">
|
<svg viewBox="0 0 24 24" style="width:24px;height:24px;filter:drop-shadow(0 0 4px rgba(255,0,128,0.8));">
|
||||||
@@ -606,15 +1079,19 @@ function updateMapMarkers() {
|
|||||||
iconSize: [24, 24],
|
iconSize: [24, 24],
|
||||||
iconAnchor: [12, 12]
|
iconAnchor: [12, 12]
|
||||||
})
|
})
|
||||||
}).addTo(map).bindPopup('📍 This Scanner');
|
}).addTo(map).bindPopup(`<strong>📍 ${escapeHtml(viewingScannerName)}</strong><br><span style="color:#4dabf7;">WiFi</span> · <span style="color:#9b59b6;">Bluetooth</span>`);
|
||||||
markers.push(centerMarker);
|
markers.push(centerMarker);
|
||||||
|
|
||||||
// Add peer scanner markers (async, won't block)
|
// Add peer scanner markers (async, won't block)
|
||||||
|
// Skip the peer we're currently viewing (it's shown as center marker)
|
||||||
fetch('/api/peers')
|
fetch('/api/peers')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
(data.peers || []).forEach(peer => {
|
(data.peers || []).forEach(peer => {
|
||||||
|
// Skip if this is the peer we're currently viewing
|
||||||
|
if (activeNodeInfo && peer.scanner_id === activeNodeInfo.id) return;
|
||||||
if (peer.latitude && peer.longitude) {
|
if (peer.latitude && peer.longitude) {
|
||||||
|
const peerName = peer.name || peer.scanner_id;
|
||||||
const peerIcon = `
|
const peerIcon = `
|
||||||
<div style="position:relative;width:20px;height:20px;">
|
<div style="position:relative;width:20px;height:20px;">
|
||||||
<svg viewBox="0 0 24 24" style="width:20px;height:20px;filter:drop-shadow(0 0 3px rgba(0,200,255,0.8));">
|
<svg viewBox="0 0 24 24" style="width:20px;height:20px;filter:drop-shadow(0 0 3px rgba(0,200,255,0.8));">
|
||||||
@@ -628,7 +1105,7 @@ function updateMapMarkers() {
|
|||||||
iconSize: [20, 20],
|
iconSize: [20, 20],
|
||||||
iconAnchor: [10, 10]
|
iconAnchor: [10, 10]
|
||||||
})
|
})
|
||||||
}).addTo(map).bindPopup(`📡 ${peer.name || peer.scanner_id}<br>Floor: ${peer.floor ?? '?'}`);
|
}).addTo(map).bindPopup(`<strong>📡 ${escapeHtml(peerName)}</strong><br><span style="color:#4dabf7;">WiFi</span> · <span style="color:#9b59b6;">Bluetooth</span><br>Floor: ${peer.floor ?? '?'}`);
|
||||||
markers.push(peerMarker);
|
markers.push(peerMarker);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1025,8 +1502,8 @@ async function startAutoScan() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interval_minutes: interval,
|
interval_minutes: interval,
|
||||||
location_label: label,
|
location_label: label,
|
||||||
scan_wifi: filters.wifi,
|
scan_wifi: true, // Always scan both (filter controls display only)
|
||||||
scan_bluetooth: filters.bluetooth,
|
scan_bluetooth: true,
|
||||||
save: false
|
save: false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -1034,10 +1511,7 @@ async function startAutoScan() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
updateAutoScanUI(data);
|
updateAutoScanUI(data);
|
||||||
const services = [];
|
document.getElementById('scan-status').textContent = `Auto-scan: WiFi+BT every ${interval} min`;
|
||||||
if (filters.wifi) services.push('WiFi');
|
|
||||||
if (filters.bluetooth) services.push('BT');
|
|
||||||
document.getElementById('scan-status').textContent = `Auto-scan: ${services.join('+')} every ${interval} min`;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting auto-scan:', error);
|
console.error('Error starting auto-scan:', error);
|
||||||
@@ -1063,6 +1537,52 @@ async function stopAutoScan() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Import Functions ==========
|
||||||
|
|
||||||
|
// Import BLE Radar database
|
||||||
|
async function importBleRadar() {
|
||||||
|
const fileInput = document.getElementById('ble-radar-file');
|
||||||
|
const statusEl = document.getElementById('import-status');
|
||||||
|
|
||||||
|
if (!fileInput.files || !fileInput.files[0]) {
|
||||||
|
statusEl.textContent = 'No file selected';
|
||||||
|
statusEl.style.color = '#ef4444';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
statusEl.textContent = 'Importing...';
|
||||||
|
statusEl.style.color = '#fbbf24';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('scanner_id', activeNode === 'local' ? 'ble_radar' : activeNode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/import/ble-radar', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
statusEl.textContent = `✓ ${data.devices_imported} new, ${data.devices_updated} updated`;
|
||||||
|
statusEl.style.color = '#4ade80';
|
||||||
|
fileInput.value = '';
|
||||||
|
// Refresh the scan to show imported devices
|
||||||
|
loadLatestScan();
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = `Error: ${data.error}`;
|
||||||
|
statusEl.style.color = '#ef4444';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.textContent = `Error: ${error.message}`;
|
||||||
|
statusEl.style.color = '#ef4444';
|
||||||
|
console.error('Import error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Device Position Functions ==========
|
// ========== Device Position Functions ==========
|
||||||
|
|
||||||
// Load saved device positions, source scanner info, and peer positions
|
// Load saved device positions, source scanner info, and peer positions
|
||||||
@@ -1083,11 +1603,18 @@ async function loadDevicePositions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load peer scanner positions (live/current positions)
|
// Load peer scanner positions (live/current positions) and BT MACs
|
||||||
const peersResponse = await fetch('/api/peers');
|
const peersResponse = await fetch('/api/peers');
|
||||||
if (peersResponse.ok) {
|
if (peersResponse.ok) {
|
||||||
const peersData = await peersResponse.json();
|
const peersData = await peersResponse.json();
|
||||||
peerScanners = {};
|
peerScanners = {};
|
||||||
|
scannerBtMacs = new Set();
|
||||||
|
|
||||||
|
// Add local scanner's BT MAC if available
|
||||||
|
if (peersData.this_scanner?.bt_mac) {
|
||||||
|
scannerBtMacs.add(peersData.this_scanner.bt_mac.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
(peersData.peers || []).forEach(peer => {
|
(peersData.peers || []).forEach(peer => {
|
||||||
peerScanners[peer.scanner_id] = {
|
peerScanners[peer.scanner_id] = {
|
||||||
lat: peer.latitude,
|
lat: peer.latitude,
|
||||||
@@ -1095,21 +1622,65 @@ async function loadDevicePositions() {
|
|||||||
floor: peer.floor,
|
floor: peer.floor,
|
||||||
name: peer.name
|
name: peer.name
|
||||||
};
|
};
|
||||||
|
// Collect peer BT MACs for filtering
|
||||||
|
if (peer.bt_mac) {
|
||||||
|
scannerBtMacs.add(peer.bt_mac.toUpperCase());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
console.log('[Peers] Loaded', Object.keys(peerScanners).length, 'peer positions');
|
console.log('[Peers] Loaded', Object.keys(peerScanners).length, 'peer positions,', scannerBtMacs.size, 'scanner BT MACs');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading device positions:', error);
|
console.error('Error loading device positions:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get device position (manual or RSSI-based)
|
// Load trilaterated positions from multi-scanner RSSI data
|
||||||
|
async function loadTrilateratedPositions() {
|
||||||
|
if (!trilaterationEnabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/positions/trilaterated');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
trilateratedPositions = {};
|
||||||
|
(data.devices || []).forEach(d => {
|
||||||
|
trilateratedPositions[d.device_id] = {
|
||||||
|
lat: d.position.lat,
|
||||||
|
lon: d.position.lon,
|
||||||
|
confidence: d.confidence,
|
||||||
|
scanners: d.scanners,
|
||||||
|
method: d.method
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log(`[Trilateration] Loaded ${data.count} positions`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Trilateration] Error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device position (manual, trilaterated, or RSSI-based)
|
||||||
|
// Priority: 1. Manual position, 2. Trilaterated (if confidence > 0.3), 3. RSSI-based
|
||||||
// Uses source scanner position for synced devices so they don't move when local scanner moves
|
// Uses source scanner position for synced devices so they don't move when local scanner moves
|
||||||
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
||||||
const deviceId = device.bssid || device.address;
|
const deviceId = device.bssid || device.address;
|
||||||
const customPos = manualPositions[deviceId];
|
const customPos = manualPositions[deviceId];
|
||||||
const sourceInfo = deviceSources[deviceId];
|
const sourceInfo = deviceSources[deviceId];
|
||||||
|
|
||||||
|
// Check for trilaterated position first (if enabled and confident)
|
||||||
|
const triData = trilateratedPositions[deviceId];
|
||||||
|
if (trilaterationEnabled && triData && triData.confidence > 0.3) {
|
||||||
|
return {
|
||||||
|
lat: triData.lat,
|
||||||
|
lon: triData.lon,
|
||||||
|
isManual: false,
|
||||||
|
isTrilaterated: true,
|
||||||
|
confidence: triData.confidence,
|
||||||
|
scannerCount: triData.scanners.length,
|
||||||
|
method: triData.method
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Determine which scanner position to use for this device
|
// Determine which scanner position to use for this device
|
||||||
// If device was synced from another scanner, use that scanner's CURRENT position
|
// If device was synced from another scanner, use that scanner's CURRENT position
|
||||||
let baseLat = scannerLat;
|
let baseLat = scannerLat;
|
||||||
@@ -1457,10 +2028,12 @@ function update3DMarkers() {
|
|||||||
const groundFloor = buildingConfig.groundFloorNumber || 0;
|
const groundFloor = buildingConfig.groundFloorNumber || 0;
|
||||||
|
|
||||||
// Add scanner position marker at center (draggable for fine-grained positioning)
|
// Add scanner position marker at center (draggable for fine-grained positioning)
|
||||||
|
// When viewing a peer, show that peer's name; otherwise show local scanner name
|
||||||
|
const viewingScannerName = activeNodeInfo?.name || APP_CONFIG.scanner?.name || APP_CONFIG.scanner?.id || 'Scanner';
|
||||||
const scannerEl = document.createElement('div');
|
const scannerEl = document.createElement('div');
|
||||||
scannerEl.className = 'marker-3d center draggable';
|
scannerEl.className = 'marker-3d center draggable';
|
||||||
scannerEl.innerHTML = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
|
scannerEl.innerHTML = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
|
||||||
scannerEl.title = `Your Position - Floor ${scannerFloor} (drag to reposition)`;
|
scannerEl.title = `${viewingScannerName} - Floor ${scannerFloor} (drag to reposition)`;
|
||||||
|
|
||||||
const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor;
|
const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor;
|
||||||
const scannerMarker = new maplibregl.Marker({
|
const scannerMarker = new maplibregl.Marker({
|
||||||
@@ -1470,11 +2043,11 @@ function update3DMarkers() {
|
|||||||
})
|
})
|
||||||
.setLngLat([lon, lat])
|
.setLngLat([lon, lat])
|
||||||
.setPopup(new maplibregl.Popup().setHTML(`
|
.setPopup(new maplibregl.Popup().setHTML(`
|
||||||
<strong>📍 Your Position</strong><br>
|
<strong>📍 ${escapeHtml(viewingScannerName)}</strong><br>
|
||||||
|
<span style="color:#4dabf7;">WiFi</span> · <span style="color:#9b59b6;">Bluetooth</span><br>
|
||||||
Floor: ${scannerFloor}<br>
|
Floor: ${scannerFloor}<br>
|
||||||
Lat: ${lat.toFixed(6)}<br>
|
<span style="font-size:0.8rem;color:#888;">${lat.toFixed(6)}, ${lon.toFixed(6)}</span>
|
||||||
Lon: ${lon.toFixed(6)}<br>
|
<div style="font-size:0.7rem;color:#666;margin-top:4px;">Drag to reposition</div>
|
||||||
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to reposition</div>
|
|
||||||
`))
|
`))
|
||||||
.addTo(map3d);
|
.addTo(map3d);
|
||||||
|
|
||||||
@@ -1484,18 +2057,22 @@ function update3DMarkers() {
|
|||||||
map3dMarkers.push(scannerMarker);
|
map3dMarkers.push(scannerMarker);
|
||||||
|
|
||||||
// Add peer scanner markers (absolute positions)
|
// Add peer scanner markers (absolute positions)
|
||||||
|
// Skip the peer we're currently viewing (it's shown as center marker)
|
||||||
fetch('/api/peers')
|
fetch('/api/peers')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
(data.peers || []).forEach(peer => {
|
(data.peers || []).forEach(peer => {
|
||||||
|
// Skip if this is the peer we're currently viewing
|
||||||
|
if (activeNodeInfo && peer.scanner_id === activeNodeInfo.id) return;
|
||||||
if (peer.latitude && peer.longitude) {
|
if (peer.latitude && peer.longitude) {
|
||||||
const peerFloor = peer.floor ?? 0;
|
const peerFloor = peer.floor ?? 0;
|
||||||
const peerOffset = (peerFloor - groundFloor) * pixelsPerFloor;
|
const peerOffset = (peerFloor - groundFloor) * pixelsPerFloor;
|
||||||
|
const peerName = peer.name || peer.scanner_id;
|
||||||
|
|
||||||
const peerEl = document.createElement('div');
|
const peerEl = document.createElement('div');
|
||||||
peerEl.className = 'marker-3d peer-scanner';
|
peerEl.className = 'marker-3d peer-scanner';
|
||||||
peerEl.innerHTML = `<div class="marker-icon">📡</div><div class="marker-floor">F${peerFloor}</div>`;
|
peerEl.innerHTML = `<div class="marker-icon">📡</div><div class="marker-floor">F${peerFloor}</div>`;
|
||||||
peerEl.title = `${peer.name || peer.scanner_id} - Floor ${peerFloor}`;
|
peerEl.title = `${peerName} - Floor ${peerFloor}`;
|
||||||
|
|
||||||
const peerMarker = new maplibregl.Marker({
|
const peerMarker = new maplibregl.Marker({
|
||||||
element: peerEl,
|
element: peerEl,
|
||||||
@@ -1503,11 +2080,10 @@ function update3DMarkers() {
|
|||||||
})
|
})
|
||||||
.setLngLat([peer.longitude, peer.latitude])
|
.setLngLat([peer.longitude, peer.latitude])
|
||||||
.setPopup(new maplibregl.Popup().setHTML(`
|
.setPopup(new maplibregl.Popup().setHTML(`
|
||||||
<strong>📡 ${peer.name || peer.scanner_id}</strong><br>
|
<strong>📡 ${escapeHtml(peerName)}</strong><br>
|
||||||
|
<span style="color:#4dabf7;">WiFi</span> · <span style="color:#9b59b6;">Bluetooth</span><br>
|
||||||
Floor: ${peerFloor}<br>
|
Floor: ${peerFloor}<br>
|
||||||
Lat: ${peer.latitude.toFixed(6)}<br>
|
<span style="font-size:0.8rem;color:#888;">${peer.latitude.toFixed(6)}, ${peer.longitude.toFixed(6)}</span>
|
||||||
Lon: ${peer.longitude.toFixed(6)}<br>
|
|
||||||
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Peer scanner (read-only)</div>
|
|
||||||
`))
|
`))
|
||||||
.addTo(map3d);
|
.addTo(map3d);
|
||||||
|
|
||||||
@@ -1627,26 +2203,33 @@ function update3DMarkers() {
|
|||||||
const missCount = dev.miss_count || 0;
|
const missCount = dev.miss_count || 0;
|
||||||
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
|
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
|
||||||
const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
|
const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
|
||||||
|
const isTrilaterated = pos.isTrilaterated === true;
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
||||||
if (isDraggable) el.classList.add('draggable');
|
if (isDraggable) el.classList.add('draggable');
|
||||||
if (hasManualPosition) el.classList.add('has-manual-position');
|
if (hasManualPosition) el.classList.add('has-manual-position');
|
||||||
|
if (isTrilaterated) {
|
||||||
|
el.classList.add('trilaterated');
|
||||||
|
if (pos.confidence >= 0.7) el.classList.add('high-confidence');
|
||||||
|
}
|
||||||
el.style.opacity = opacity;
|
el.style.opacity = opacity;
|
||||||
el.style.transition = 'opacity 0.5s ease';
|
el.style.transition = 'opacity 0.5s ease';
|
||||||
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
const trilatBadge = isTrilaterated ? `<span class="trilat-badge">${pos.scannerCount}📡</span>` : '';
|
||||||
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
|
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}${trilatBadge}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
||||||
|
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${isTrilaterated ? ` (Trilaterated: ${pos.scannerCount} scanners, ${Math.round(pos.confidence*100)}% conf)` : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
|
||||||
|
|
||||||
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
||||||
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
const positionStatus = isTrilaterated ? `Trilaterated (${pos.scannerCount} scanners)` : (hasManualPosition ? 'Manual' : 'Auto');
|
||||||
const positionClass = hasManualPosition ? 'manual' : 'auto';
|
const positionClass = isTrilaterated ? 'trilaterated' : (hasManualPosition ? 'manual' : 'auto');
|
||||||
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${btDeviceId}')">Reset to Auto</button>` : '';
|
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${btDeviceId}')">Reset to Auto</button>` : '';
|
||||||
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
|
const dragHint = isDraggable && !hasManualPosition && !isTrilaterated ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
|
||||||
const trailBtnHtml = isMoving ? `
|
const trailBtnHtml = isMoving ? `
|
||||||
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
||||||
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
||||||
Show Trail
|
Show Trail
|
||||||
</button>` : '';
|
</button>` : '';
|
||||||
const btSourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
|
const btSourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
|
||||||
|
const trilatInfo = isTrilaterated ? `<div class="popup-source-info" style="background:rgba(255,215,0,0.1);border-color:rgba(255,215,0,0.3);color:#ffd700;">📐 ${pos.method}: ${Math.round(pos.confidence*100)}% confidence</div>` : '';
|
||||||
|
|
||||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
||||||
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
|
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
|
||||||
@@ -1655,6 +2238,7 @@ function update3DMarkers() {
|
|||||||
Type: ${escapeHtml(dev.device_type)}<br>
|
Type: ${escapeHtml(dev.device_type)}<br>
|
||||||
${escapeHtml(dev.manufacturer)}<br>
|
${escapeHtml(dev.manufacturer)}<br>
|
||||||
${btSourceInfo}
|
${btSourceInfo}
|
||||||
|
${trilatInfo}
|
||||||
<div class="popup-floor-control">
|
<div class="popup-floor-control">
|
||||||
<label>Floor:</label>
|
<label>Floor:</label>
|
||||||
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
|
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
|
||||||
@@ -1925,17 +2509,23 @@ function toggleLiveTracking() {
|
|||||||
function startLiveTracking() {
|
function startLiveTracking() {
|
||||||
if (liveTrackingInterval) {
|
if (liveTrackingInterval) {
|
||||||
clearInterval(liveTrackingInterval);
|
clearInterval(liveTrackingInterval);
|
||||||
|
liveTrackingInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
liveTrackingEnabled = true;
|
liveTrackingEnabled = true;
|
||||||
updateLiveTrackingUI();
|
updateLiveTrackingUI();
|
||||||
console.log('Live BT tracking started');
|
|
||||||
|
|
||||||
// Do initial scan
|
if (wsConnected) {
|
||||||
|
// WebSocket mode - updates come automatically via 'scanUpdate' events
|
||||||
|
// Still need to trigger initial scan
|
||||||
performLiveBTScan();
|
performLiveBTScan();
|
||||||
|
console.log('[Live] Started (WebSocket mode)');
|
||||||
// Set up interval
|
} else {
|
||||||
|
// HTTP polling fallback
|
||||||
liveTrackingInterval = setInterval(performLiveBTScan, LIVE_TRACKING_INTERVAL_MS);
|
liveTrackingInterval = setInterval(performLiveBTScan, LIVE_TRACKING_INTERVAL_MS);
|
||||||
|
performLiveBTScan();
|
||||||
|
console.log('[Live] Started (HTTP polling mode)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop live BT tracking
|
// Stop live BT tracking
|
||||||
@@ -1968,14 +2558,23 @@ async function performLiveBTScan() {
|
|||||||
if (!liveTrackingEnabled) return;
|
if (!liveTrackingEnabled) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/scan/bt', {
|
// Use node-specific endpoint if viewing a peer
|
||||||
|
const endpoint = activeNode === 'local'
|
||||||
|
? '/api/scan/bt'
|
||||||
|
: `/api/node/${activeNode}/scan/bt`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const newBt = data.bluetooth_devices || [];
|
// Filter out scanner Bluetooth devices
|
||||||
|
const newBt = (data.bluetooth_devices || []).filter(dev => {
|
||||||
|
const addr = (dev.address || '').toUpperCase();
|
||||||
|
return !scannerBtMacs.has(addr);
|
||||||
|
});
|
||||||
|
|
||||||
// Track which devices were detected in this scan
|
// Track which devices were detected in this scan
|
||||||
const detectedAddresses = new Set(newBt.map(d => d.address));
|
const detectedAddresses = new Set(newBt.map(d => d.address));
|
||||||
@@ -1994,6 +2593,7 @@ async function performLiveBTScan() {
|
|||||||
|
|
||||||
// Reset miss count - device was detected
|
// Reset miss count - device was detected
|
||||||
deviceMissCount[newDev.address] = 0;
|
deviceMissCount[newDev.address] = 0;
|
||||||
|
updateDeviceLastSeen(newDev.address);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update RSSI and estimated distance, preserve custom values
|
// Update RSSI and estimated distance, preserve custom values
|
||||||
@@ -2025,6 +2625,7 @@ async function performLiveBTScan() {
|
|||||||
// Clean up tracking data for removed device
|
// Clean up tracking data for removed device
|
||||||
delete deviceMissCount[dev.address];
|
delete deviceMissCount[dev.address];
|
||||||
delete deviceDistanceHistory[dev.address];
|
delete deviceDistanceHistory[dev.address];
|
||||||
|
delete deviceLastSeen[dev.address];
|
||||||
// Clear trail if showing
|
// Clear trail if showing
|
||||||
if (deviceTrails[dev.address]) {
|
if (deviceTrails[dev.address]) {
|
||||||
clearDeviceTrail(dev.address);
|
clearDeviceTrail(dev.address);
|
||||||
@@ -2037,16 +2638,17 @@ async function performLiveBTScan() {
|
|||||||
|
|
||||||
scanData.bluetooth_devices = filteredBt;
|
scanData.bluetooth_devices = filteredBt;
|
||||||
} else {
|
} else {
|
||||||
// No existing scan data, use BT-only data
|
// No existing scan data, use BT-only data (already filtered above)
|
||||||
data.bluetooth_devices.forEach(dev => {
|
newBt.forEach(dev => {
|
||||||
// Initialize history with first sample, not moving yet
|
// Initialize history with first sample, not moving yet
|
||||||
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
||||||
dev.is_moving = false;
|
dev.is_moving = false;
|
||||||
deviceMissCount[dev.address] = 0;
|
deviceMissCount[dev.address] = 0;
|
||||||
|
updateDeviceLastSeen(dev.address);
|
||||||
});
|
});
|
||||||
scanData = {
|
scanData = {
|
||||||
wifi_networks: [],
|
wifi_networks: [],
|
||||||
bluetooth_devices: data.bluetooth_devices,
|
bluetooth_devices: newBt,
|
||||||
timestamp: data.timestamp
|
timestamp: data.timestamp
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2062,6 +2664,9 @@ async function performLiveBTScan() {
|
|||||||
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
||||||
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
||||||
|
|
||||||
|
// Refresh trilaterated positions (non-blocking)
|
||||||
|
loadTrilateratedPositions();
|
||||||
|
|
||||||
// Refresh views
|
// Refresh views
|
||||||
drawRadar();
|
drawRadar();
|
||||||
update3DMarkers();
|
update3DMarkers();
|
||||||
@@ -2318,3 +2923,36 @@ function clearAllTrails() {
|
|||||||
|
|
||||||
deviceTrails = {};
|
deviceTrails = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Heat Map Functions ==========
|
||||||
|
|
||||||
|
// Toggle heat map layer
|
||||||
|
function toggleHeatMap() {
|
||||||
|
heatMapEnabled = !heatMapEnabled;
|
||||||
|
const btn = document.getElementById('btn-heatmap');
|
||||||
|
if (btn) btn.classList.toggle('active', heatMapEnabled);
|
||||||
|
|
||||||
|
// Toggle CSS class on map container for scanner coverage rings
|
||||||
|
const mapContainer = document.getElementById('map-3d');
|
||||||
|
if (mapContainer) {
|
||||||
|
mapContainer.classList.toggle('heatmap-enabled', heatMapEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also render/remove MapLibre layers for additional visualization
|
||||||
|
if (heatMapEnabled) {
|
||||||
|
renderHeatMap();
|
||||||
|
} else {
|
||||||
|
removeHeatMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render heat map visualization
|
||||||
|
// Coverage rings are CSS-based on scanner markers (via .heatmap-enabled class)
|
||||||
|
async function renderHeatMap() {
|
||||||
|
console.log('[HeatMap] Enabled - coverage rings shown around scanners');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove heat map visualization
|
||||||
|
function removeHeatMap() {
|
||||||
|
console.log('[HeatMap] Disabled');
|
||||||
|
}
|
||||||
|
|||||||
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