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
|
||||
.DS_Store
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -2,11 +2,23 @@
|
||||
|
||||
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)
|
||||
- **[docs/HOME_ASSISTANT.md](docs/HOME_ASSISTANT.md)** - Home Assistant webhook integration
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -22,6 +34,8 @@ src/rf_mapper/
|
||||
├── bluetooth_*.py # Bluetooth device identification and classification
|
||||
├── visualize.py # ASCII radar and chart generation
|
||||
├── profiling.py # CPU/memory profiling utilities
|
||||
├── termux.py # Termux/Android environment detection
|
||||
├── sync.py # Multi-scanner peer sync
|
||||
└── web/
|
||||
├── app.py # Flask application and API endpoints
|
||||
├── 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` |
|
||||
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
|
||||
| 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
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
rf-mapper start # Start web server (background)
|
||||
rf-mapper status # Check if running
|
||||
rf-mapper stop # Stop server
|
||||
rf-mapper scan -l room # CLI scan
|
||||
rf-mapper --help # All commands
|
||||
python -m rf_mapper start # Start web server (background)
|
||||
python -m rf_mapper status # Check if running
|
||||
python -m rf_mapper stop # Stop server
|
||||
python -m rf_mapper restart # Restart server
|
||||
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
|
||||
|
||||
- Python 3.10+, Flask, PyYAML, requests
|
||||
- Python 3.10+, Flask, PyYAML, requests, bleak
|
||||
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
|
||||
- Linux tools: `iw`, bleak (BLE via D-Bus)
|
||||
- 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
|
||||
- **Data Export** - JSON scan history with timestamps
|
||||
- **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
|
||||
|
||||
@@ -74,6 +78,15 @@ Understanding the RF environment around you is useful for:
|
||||
│ │ Webhook │ │ Webhook │ │ Webhook │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Peer Scanner Sync │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Scanner A │◄── sync ──►│ Scanner B │ │
|
||||
│ │ (rpios) │ │ (grokbox) │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ Shared: floors, labels, favorites, notes │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
@@ -109,14 +122,17 @@ pip install -e .
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Start web server (background)
|
||||
rf-mapper start
|
||||
python -m rf_mapper start
|
||||
|
||||
# Check status
|
||||
rf-mapper status
|
||||
python -m rf_mapper status
|
||||
|
||||
# CLI scan
|
||||
rf-mapper scan
|
||||
python -m rf_mapper scan
|
||||
|
||||
# Open http://localhost:5000
|
||||
```
|
||||
@@ -131,9 +147,13 @@ gps:
|
||||
longitude: 4.3978
|
||||
|
||||
scanner:
|
||||
id: rpios
|
||||
name: "RPi OS Scanner"
|
||||
wifi_interface: wlan0
|
||||
bt_scan_timeout: 10
|
||||
path_loss_exponent: 2.5
|
||||
sync_interval_seconds: 30
|
||||
accept_registrations: true
|
||||
|
||||
building:
|
||||
enabled: true
|
||||
|
||||
28
ROADMAP.md
28
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# 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] 3D building extrusion
|
||||
@@ -45,7 +45,21 @@
|
||||
- [x] Position smoothing/averaging (statistical, 5-sample + stddev)
|
||||
- [x] Floor persistence in SQLite database
|
||||
- [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
|
||||
- [ ] Performance optimization
|
||||
@@ -110,7 +124,7 @@
|
||||
|
||||
## Future Ideas (v2.0+)
|
||||
|
||||
- [ ] Multiple scanner nodes (distributed scanning)
|
||||
- [x] Multiple scanner nodes (distributed scanning) - Done in v1.0.0
|
||||
- [ ] Mesh network visualization
|
||||
- [ ] Spectrum analyzer integration
|
||||
- [ ] RTL-SDR support for wider RF
|
||||
@@ -128,4 +142,6 @@
|
||||
|---------|------|------------|
|
||||
| v0.1.0 | 2026-01 | Initial CLI scanner |
|
||||
| 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
|
||||
|
||||
**Sprint:** v0.3.0 - 3D Visualization
|
||||
**Sprint:** v1.1.0 - Production Hardening
|
||||
**Updated:** 2026-02-01
|
||||
**Current Version:** v1.0.1
|
||||
|
||||
---
|
||||
|
||||
@@ -43,6 +44,12 @@
|
||||
| [x] | Document API endpoints | docs/API.md |
|
||||
| [x] | Create CHEATSHEET.md | Quick reference guide |
|
||||
| [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 |
|
||||
| Manual position override (drag-drop) | 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
|
||||
- Popups stay open during live tracking updates
|
||||
- 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
|
||||
|
||||
**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
|
||||
|
||||
- [ ] Support multiple WiFi interfaces
|
||||
@@ -47,7 +68,7 @@
|
||||
- [ ] Environment presets (office, home, warehouse)
|
||||
- [ ] Wall attenuation factor
|
||||
- [ ] Multi-floor path loss adjustment
|
||||
- [ ] Trilateration from multiple scan points
|
||||
- [x] Trilateration from multiple scan points
|
||||
- [ ] Kalman filter for position smoothing
|
||||
- [ ] Dead reckoning with IMU (if available)
|
||||
- [ ] Fingerprinting-based positioning
|
||||
@@ -102,7 +123,7 @@
|
||||
## API & Integration
|
||||
|
||||
- [ ] OpenAPI/Swagger documentation
|
||||
- [ ] WebSocket for real-time updates
|
||||
- [x] WebSocket for real-time updates
|
||||
- [ ] GraphQL endpoint (optional)
|
||||
- [ ] MQTT publishing
|
||||
- [x] Home Assistant webhook integration (scan results, new device, departure)
|
||||
@@ -222,6 +243,20 @@
|
||||
- [x] Popup persistence during live updates
|
||||
- [x] Manual position override (drag-drop for floor-assigned devices)
|
||||
- [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
|
||||
|
||||
- Linux with WiFi and Bluetooth hardware
|
||||
- Linux with WiFi and Bluetooth hardware (or Android via Termux)
|
||||
- Python 3.10+
|
||||
- `sudo` access (required for scanning)
|
||||
- `sudo` access (required for scanning on Linux)
|
||||
- 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
|
||||
|
||||
```bash
|
||||
@@ -27,92 +91,100 @@ pip install -e .
|
||||
source venv/bin/activate
|
||||
|
||||
# Run interactive scan
|
||||
rf-mapper
|
||||
python -m rf_mapper
|
||||
|
||||
# Start web server
|
||||
rf-mapper start
|
||||
python -m rf_mapper start
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
All commands require activating the virtual environment first:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Scanning
|
||||
|
||||
```bash
|
||||
# Basic scan (interactive mode)
|
||||
rf-mapper
|
||||
python -m rf_mapper
|
||||
|
||||
# Scan with location label
|
||||
rf-mapper scan -l kitchen
|
||||
python -m rf_mapper scan -l kitchen
|
||||
|
||||
# Scan WiFi only
|
||||
rf-mapper scan --no-bt
|
||||
python -m rf_mapper scan --no-bt
|
||||
|
||||
# Scan Bluetooth only
|
||||
rf-mapper scan --no-wifi
|
||||
python -m rf_mapper scan --no-wifi
|
||||
|
||||
# Use specific WiFi interface
|
||||
rf-mapper scan -i wlan1
|
||||
python -m rf_mapper scan -i wlan1
|
||||
```
|
||||
|
||||
### Visualization (CLI)
|
||||
|
||||
```bash
|
||||
# Visualize latest scan (ASCII radar + charts)
|
||||
rf-mapper visualize
|
||||
python -m rf_mapper visualize
|
||||
|
||||
# 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
|
||||
rf-mapper analyze
|
||||
python -m rf_mapper analyze
|
||||
```
|
||||
|
||||
### Scan History
|
||||
|
||||
```bash
|
||||
# List saved scans
|
||||
rf-mapper list
|
||||
python -m rf_mapper list
|
||||
```
|
||||
|
||||
### Web Server
|
||||
|
||||
```bash
|
||||
# Start web server (background daemon)
|
||||
rf-mapper start
|
||||
python -m rf_mapper start
|
||||
|
||||
# Start in foreground (for debugging)
|
||||
rf-mapper start --foreground
|
||||
python -m rf_mapper start --foreground
|
||||
|
||||
# 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
|
||||
rf-mapper start --foreground --debug
|
||||
python -m rf_mapper start --foreground --debug
|
||||
|
||||
# With request profiling
|
||||
rf-mapper start --profile-requests
|
||||
python -m rf_mapper start --profile-requests
|
||||
|
||||
# With request logging
|
||||
rf-mapper start --log-requests
|
||||
python -m rf_mapper start --log-requests
|
||||
|
||||
# Stop the server
|
||||
rf-mapper stop
|
||||
python -m rf_mapper stop
|
||||
|
||||
# Restart the server
|
||||
rf-mapper restart
|
||||
python -m rf_mapper restart
|
||||
|
||||
# Check server status
|
||||
rf-mapper status
|
||||
python -m rf_mapper status
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Show current configuration
|
||||
rf-mapper config
|
||||
python -m rf_mapper config
|
||||
|
||||
# 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
|
||||
@@ -201,6 +273,7 @@ The web server exposes a REST API:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Health check for monitoring |
|
||||
| POST | `/api/scan` | Trigger new scan |
|
||||
| GET | `/api/latest` | Get most recent scan |
|
||||
| GET | `/api/scans` | List all scans |
|
||||
@@ -214,6 +287,131 @@ The web server exposes a REST API:
|
||||
| POST | `/api/autoscan/stop` | Stop auto-scanning |
|
||||
| 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
|
||||
|
||||
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
|
||||
- Not all areas have building data in OpenStreetMap
|
||||
- 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:
|
||||
latitude: 50.85846541332012
|
||||
longitude: 4.397570348817993
|
||||
latitude: 50.858532461583906
|
||||
longitude: 4.397587773133864
|
||||
web:
|
||||
host: 0.0.0.0
|
||||
port: 5000
|
||||
debug: false
|
||||
scanner:
|
||||
id: ''
|
||||
name: ''
|
||||
id: rpios
|
||||
name: rpios
|
||||
latitude: null
|
||||
longitude: null
|
||||
floor: null
|
||||
is_master: true
|
||||
wifi_interface: wlan0
|
||||
bt_scan_timeout: 10
|
||||
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
|
||||
|
||||
### Trigger Scan
|
||||
|
||||
@@ -6,21 +6,26 @@ Quick reference for RF Mapper commands and configuration.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
All commands require activating the virtual environment first:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `rf-mapper` | Interactive scan mode |
|
||||
| `rf-mapper scan` | Run scan with defaults |
|
||||
| `rf-mapper scan -l kitchen` | Scan with location label |
|
||||
| `rf-mapper scan --no-bt` | WiFi only |
|
||||
| `rf-mapper scan --no-wifi` | Bluetooth only |
|
||||
| `rf-mapper visualize` | ASCII radar display |
|
||||
| `rf-mapper analyze` | RF environment analysis |
|
||||
| `rf-mapper list` | List saved scans |
|
||||
| `rf-mapper start` | Start web server (background) |
|
||||
| `rf-mapper stop` | Stop web server |
|
||||
| `rf-mapper restart` | Restart web server |
|
||||
| `rf-mapper status` | Check if server is running |
|
||||
| `rf-mapper config` | Show configuration |
|
||||
| `python -m rf_mapper` | Interactive scan mode |
|
||||
| `python -m rf_mapper scan` | Run scan with defaults |
|
||||
| `python -m rf_mapper scan -l kitchen` | Scan with location label |
|
||||
| `python -m rf_mapper scan --no-bt` | WiFi only |
|
||||
| `python -m rf_mapper scan --no-wifi` | Bluetooth only |
|
||||
| `python -m rf_mapper visualize` | ASCII radar display |
|
||||
| `python -m rf_mapper analyze` | RF environment analysis |
|
||||
| `python -m rf_mapper list` | List saved scans |
|
||||
| `python -m rf_mapper start` | Start web server (background) |
|
||||
| `python -m rf_mapper stop` | Stop web server |
|
||||
| `python -m rf_mapper restart` | Restart web server |
|
||||
| `python -m rf_mapper status` | Check if server is running |
|
||||
| `python -m rf_mapper config` | Show configuration |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,18 +33,18 @@ Quick reference for RF Mapper commands and configuration.
|
||||
|
||||
```bash
|
||||
# Lifecycle
|
||||
rf-mapper start # Start (background daemon)
|
||||
rf-mapper stop # Stop
|
||||
rf-mapper restart # Restart
|
||||
rf-mapper status # Check if running
|
||||
python -m rf_mapper start # Start (background daemon)
|
||||
python -m rf_mapper stop # Stop
|
||||
python -m rf_mapper restart # Restart
|
||||
python -m rf_mapper status # Check if running
|
||||
|
||||
# Start options
|
||||
rf-mapper start -f # Foreground mode
|
||||
rf-mapper start -H 127.0.0.1 # Bind to localhost only
|
||||
rf-mapper start -p 8080 # Custom port
|
||||
rf-mapper start --debug # Debug mode (requires -f)
|
||||
rf-mapper start --profile-requests # Per-request profiling
|
||||
rf-mapper start --log-requests # Request logging
|
||||
python -m rf_mapper start -f # Foreground mode
|
||||
python -m rf_mapper start -H 127.0.0.1 # Bind to localhost only
|
||||
python -m rf_mapper start -p 8080 # Custom port
|
||||
python -m rf_mapper start --debug # Debug mode (requires -f)
|
||||
python -m rf_mapper start --profile-requests # Per-request profiling
|
||||
python -m rf_mapper start --log-requests # Request logging
|
||||
```
|
||||
|
||||
---
|
||||
@@ -48,10 +53,10 @@ rf-mapper start --log-requests # Request logging
|
||||
|
||||
```bash
|
||||
# Show current config
|
||||
rf-mapper config
|
||||
python -m rf_mapper config
|
||||
|
||||
# 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
|
||||
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
|
||||
5. **Check HA**: Verify `device_tracker.rf_*` entities appear
|
||||
6. **Test new device**: Clear device from DB, re-scan, verify notification
|
||||
|
||||
@@ -31,6 +31,7 @@ classifiers = [
|
||||
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"flask-socketio>=5.3.0",
|
||||
"pyyaml>=6.0",
|
||||
"bleak>=0.21.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('--log-requests', action='store_true', help='Log requests')
|
||||
|
||||
# Check-termux command
|
||||
subparsers.add_parser('check-termux', help='Check Termux/Android prerequisites')
|
||||
|
||||
# Config command
|
||||
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||
config_parser.add_argument(
|
||||
@@ -209,6 +212,8 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||
run_status(data_dir)
|
||||
elif args.command == 'config':
|
||||
run_config(args, config)
|
||||
elif args.command == 'check-termux':
|
||||
run_check_termux()
|
||||
elif args.command == 'web':
|
||||
run_web_deprecated(args, config, data_dir)
|
||||
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:
|
||||
"""Get path to PID file"""
|
||||
return data_dir / "rf-mapper.pid"
|
||||
@@ -534,6 +552,16 @@ def run_start(args, config: Config, data_dir: Path):
|
||||
import subprocess
|
||||
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
|
||||
port = args.port or config.web.port
|
||||
debug = getattr(args, 'debug', False)
|
||||
|
||||
@@ -28,6 +28,8 @@ class ScannerConfig:
|
||||
latitude: float | None = None # Scanner position (falls back to gps.latitude)
|
||||
longitude: float | None = None # Scanner position (falls back to gps.longitude)
|
||||
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
|
||||
wifi_interface: str = "wlan0"
|
||||
@@ -179,6 +181,8 @@ class Config:
|
||||
latitude=data["scanner"].get("latitude", config.scanner.latitude),
|
||||
longitude=data["scanner"].get("longitude", config.scanner.longitude),
|
||||
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
|
||||
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),
|
||||
@@ -295,6 +299,7 @@ class Config:
|
||||
- latitude: 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)
|
||||
- bt_mac: Bluetooth MAC address (for filtering from device lists)
|
||||
"""
|
||||
import socket
|
||||
|
||||
@@ -304,7 +309,8 @@ class Config:
|
||||
"name": self.scanner.name or scanner_id,
|
||||
"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,
|
||||
"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):
|
||||
@@ -330,6 +336,7 @@ class Config:
|
||||
"latitude": self.scanner.latitude,
|
||||
"longitude": self.scanner.longitude,
|
||||
"floor": self.scanner.floor,
|
||||
"is_master": self.scanner.is_master,
|
||||
# Scanning configuration
|
||||
"wifi_interface": self.scanner.wifi_interface,
|
||||
"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)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE devices ADD COLUMN notes TEXT")
|
||||
@@ -334,6 +340,11 @@ class DeviceDatabase:
|
||||
cursor = conn.cursor()
|
||||
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
|
||||
cursor.execute("""
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 100) -> list[dict]:
|
||||
@@ -891,7 +952,7 @@ class DeviceDatabase:
|
||||
|
||||
def register_peer(self, scanner_id: str, name: str, url: str,
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -901,6 +962,7 @@ class DeviceDatabase:
|
||||
floor: Floor where peer scanner is located
|
||||
latitude: GPS latitude of peer
|
||||
longitude: GPS longitude of peer
|
||||
bt_mac: Bluetooth MAC address of the scanner (for filtering from device lists)
|
||||
|
||||
Returns:
|
||||
True if newly registered, False if updated existing
|
||||
@@ -914,16 +976,17 @@ class DeviceDatabase:
|
||||
exists = cursor.fetchone() is not None
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO peers (scanner_id, name, url, floor, latitude, longitude, last_seen, registered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO peers (scanner_id, name, url, floor, latitude, longitude, bt_mac, last_seen, registered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scanner_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
url = excluded.url,
|
||||
floor = excluded.floor,
|
||||
latitude = excluded.latitude,
|
||||
longitude = excluded.longitude,
|
||||
bt_mac = COALESCE(excluded.bt_mac, peers.bt_mac),
|
||||
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()
|
||||
return not exists
|
||||
|
||||
@@ -118,3 +118,85 @@ def trilaterate_2d(
|
||||
y = (A * F - D * C) / denom
|
||||
|
||||
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 re
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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 .bluetooth_class import BluetoothClassDecoder
|
||||
from .distance import estimate_distance, rssi_to_quality, rssi_bar
|
||||
@@ -181,66 +191,79 @@ class RFScanner:
|
||||
"""
|
||||
devices = []
|
||||
|
||||
# Classic Bluetooth scan
|
||||
try:
|
||||
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'inq', '--flush'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 10
|
||||
)
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(
|
||||
r'\s*([0-9A-Fa-f:]+)\s+clock offset:\s*\S+\s+class:\s*(\S+)',
|
||||
line
|
||||
# Classic Bluetooth scan (not supported on Termux/Android)
|
||||
if is_termux():
|
||||
print("Skipping Classic BT scan (not supported on Termux)")
|
||||
else:
|
||||
try:
|
||||
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'hcitool', 'inq', '--flush'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 10
|
||||
)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
device_class = match.group(2)
|
||||
name = self._get_bt_name(addr)
|
||||
rssi = self._get_bt_rssi(addr)
|
||||
dev_type, dev_subtype = self.bt_decoder.decode(device_class)
|
||||
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=rssi,
|
||||
device_class=device_class,
|
||||
device_type=f"{dev_type}" + (f" ({dev_subtype})" if dev_subtype else ""),
|
||||
manufacturer=self.oui_lookup.lookup(addr)
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Classic BT scan error: {e}")
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(
|
||||
r'\s*([0-9A-Fa-f:]+)\s+clock offset:\s*\S+\s+class:\s*(\S+)',
|
||||
line
|
||||
)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
device_class = match.group(2)
|
||||
name = self._get_bt_name(addr)
|
||||
rssi = self._get_bt_rssi(addr)
|
||||
dev_type, dev_subtype = self.bt_decoder.decode(device_class)
|
||||
|
||||
# BLE scan
|
||||
try:
|
||||
print(f"Scanning BLE devices ({timeout} seconds)...")
|
||||
result = subprocess.run(
|
||||
['sudo', 'timeout', str(timeout), 'hcitool', 'lescan', '--duplicates'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5
|
||||
)
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=rssi,
|
||||
device_class=device_class,
|
||||
device_type=f"{dev_type}" + (f" ({dev_subtype})" if dev_subtype else ""),
|
||||
manufacturer=self.oui_lookup.lookup(addr)
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Classic BT scan error: {e}")
|
||||
|
||||
seen_addrs = {d.address for d in devices}
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.match(r'([0-9A-Fa-f:]+)\s*(.*)', line)
|
||||
if match:
|
||||
addr = match.group(1)
|
||||
name = match.group(2).strip() or '<unknown>'
|
||||
# BLE scan using bleak (not supported on Termux/Android)
|
||||
if is_termux():
|
||||
print("Skipping BLE scan (not supported on Termux)")
|
||||
else:
|
||||
try:
|
||||
print(f"Scanning BLE devices ({timeout} seconds)...")
|
||||
|
||||
async def _ble_scan():
|
||||
return await BleakScanner.discover(
|
||||
timeout=timeout,
|
||||
return_adv=True
|
||||
)
|
||||
|
||||
ble_results = asyncio.run(_ble_scan())
|
||||
|
||||
seen_addrs = {d.address for d in devices}
|
||||
for device, adv_data in ble_results.values():
|
||||
addr = device.address
|
||||
if addr not in seen_addrs and addr != 'LE':
|
||||
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)
|
||||
|
||||
# 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)
|
||||
if not inferred_type:
|
||||
inferred_type = infer_device_type_from_manufacturer(manufacturer)
|
||||
|
||||
# Mark randomized MAC devices if still unknown
|
||||
if not inferred_type:
|
||||
if is_random_mac(addr):
|
||||
device_type = "BLE Device (Random MAC)"
|
||||
@@ -252,13 +275,13 @@ class RFScanner:
|
||||
devices.append(BluetoothDevice(
|
||||
address=addr,
|
||||
name=name,
|
||||
rssi=-70, # Default estimate for BLE
|
||||
rssi=rssi, # Real RSSI instead of -70
|
||||
device_class="BLE",
|
||||
device_type=device_type,
|
||||
manufacturer=manufacturer
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"BLE scan error: {e}")
|
||||
except Exception as e:
|
||||
print(f"BLE scan error: {e}")
|
||||
|
||||
# Auto-identify unknown devices
|
||||
if auto_identify and devices:
|
||||
|
||||
218
src/rf_mapper/termux.py
Normal file
218
src/rf_mapper/termux.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Termux/Android environment detection and prerequisite checks."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_termux() -> bool:
|
||||
"""Detect if running in Termux on Android."""
|
||||
# Check for Termux environment variable
|
||||
if os.environ.get("TERMUX_VERSION"):
|
||||
return True
|
||||
|
||||
# Check for Termux-specific paths
|
||||
termux_paths = [
|
||||
"/data/data/com.termux",
|
||||
Path.home() / ".termux",
|
||||
Path("/data/data/com.termux/files/usr"),
|
||||
]
|
||||
|
||||
for path in termux_paths:
|
||||
if Path(path).exists():
|
||||
return True
|
||||
|
||||
# Check PREFIX environment variable (Termux sets this)
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
if "/com.termux/" in prefix:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_termux_api_installed() -> tuple[bool, str]:
|
||||
"""Check if termux-api package and Termux:API app are installed."""
|
||||
# Test if Termux:API app is working (quick test with battery status)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["termux-battery-status"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, "Termux:API app not installed or not granted permissions"
|
||||
if "error" in result.stderr.lower():
|
||||
return False, "Termux:API app error. Reinstall from F-Droid"
|
||||
# Verify we got valid JSON output
|
||||
if not result.stdout.strip().startswith("{"):
|
||||
return False, "Termux:API app not responding correctly"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Termux:API app not responding. Install from F-Droid and grant permissions"
|
||||
except FileNotFoundError:
|
||||
return False, "termux-api package not installed. Run: pkg install termux-api"
|
||||
|
||||
return True, "OK"
|
||||
|
||||
|
||||
def check_location_enabled() -> tuple[bool, str]:
|
||||
"""Check if location services are accessible via termux-api."""
|
||||
try:
|
||||
# Use termux-location with a short timeout
|
||||
result = subprocess.run(
|
||||
["termux-location", "-p", "passive", "-r", "once"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
output = result.stdout.strip()
|
||||
|
||||
# Check for common error patterns
|
||||
if not output:
|
||||
return False, "Location services not responding. Enable GPS/Location in Android settings"
|
||||
|
||||
if "null" in output.lower() and "latitude" not in output.lower():
|
||||
return False, "Location unavailable. Enable GPS and grant Termux:API location permission"
|
||||
|
||||
# Try to parse as JSON to verify it's valid location data
|
||||
import json
|
||||
try:
|
||||
data = json.loads(output)
|
||||
if data.get("latitude") is not None:
|
||||
return True, f"OK (lat: {data.get('latitude'):.4f})"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Location might still be initializing
|
||||
return True, "OK (location services accessible)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Location request timed out. Enable GPS in Android settings"
|
||||
except FileNotFoundError:
|
||||
return False, "termux-location not found. Run: pkg install termux-api"
|
||||
|
||||
|
||||
def check_wake_lock() -> tuple[bool, str]:
|
||||
"""Check if wake lock can be acquired."""
|
||||
try:
|
||||
# Try to acquire wake lock
|
||||
result = subprocess.run(
|
||||
["termux-wake-lock"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return False, f"Failed to acquire wake lock: {result.stderr.strip()}"
|
||||
|
||||
return True, "OK (wake lock acquired)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Wake lock request timed out"
|
||||
except FileNotFoundError:
|
||||
return False, "termux-wake-lock not found. Run: pkg install termux-api"
|
||||
|
||||
|
||||
def check_termux_boot() -> tuple[bool, str]:
|
||||
"""Check if Termux:Boot is available for auto-start capability."""
|
||||
boot_dir = Path.home() / ".termux" / "boot"
|
||||
|
||||
if boot_dir.exists():
|
||||
return True, "OK (boot directory exists)"
|
||||
|
||||
# Not critical, just informational
|
||||
return True, "Termux:Boot not configured (optional for auto-start)"
|
||||
|
||||
|
||||
def check_termux_prerequisites(verbose: bool = True) -> bool:
|
||||
"""
|
||||
Check all Termux prerequisites for RF Mapper.
|
||||
|
||||
Returns True if all required checks pass, False otherwise.
|
||||
Prints status messages if verbose=True.
|
||||
"""
|
||||
if not is_termux():
|
||||
# Not running in Termux, skip checks
|
||||
return True
|
||||
|
||||
if verbose:
|
||||
print("\n" + "=" * 50)
|
||||
print("TERMUX ENVIRONMENT DETECTED")
|
||||
print("Checking prerequisites...")
|
||||
print("=" * 50)
|
||||
|
||||
all_ok = True
|
||||
checks = [
|
||||
("Termux:API package", check_termux_api_installed, True), # Required
|
||||
("Location services", check_location_enabled, False), # Optional (uses config.yaml coords)
|
||||
("Wake lock", check_wake_lock, True), # Required
|
||||
("Termux:Boot", check_termux_boot, False), # Optional
|
||||
]
|
||||
|
||||
for name, check_func, required in checks:
|
||||
try:
|
||||
ok, message = check_func()
|
||||
status = "✓" if ok else ("✗" if required else "⚠")
|
||||
|
||||
if verbose:
|
||||
print(f" {status} {name}: {message}")
|
||||
|
||||
if not ok and required:
|
||||
all_ok = False
|
||||
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f" ✗ {name}: Error - {e}")
|
||||
if required:
|
||||
all_ok = False
|
||||
|
||||
if verbose:
|
||||
print("=" * 50)
|
||||
|
||||
if all_ok:
|
||||
print("All prerequisites met. Starting RF Mapper...")
|
||||
else:
|
||||
print("\nREQUIRED PREREQUISITES NOT MET")
|
||||
print("\nTo fix:")
|
||||
print(" 1. Install Termux:API from F-Droid")
|
||||
print(" (NOT from Play Store - versions must match)")
|
||||
print(" 2. Run: pkg install termux-api")
|
||||
print(" 3. Enable Location in Android Settings")
|
||||
print(" 4. Grant Termux:API location permission")
|
||||
print(" 5. Run: termux-wake-lock")
|
||||
print("\nFor auto-start on boot:")
|
||||
print(" 1. Install Termux:Boot from F-Droid")
|
||||
print(" 2. mkdir -p ~/.termux/boot")
|
||||
print(" 3. Create boot script: ~/.termux/boot/start-rf-mapper.sh")
|
||||
print("")
|
||||
|
||||
return all_ok
|
||||
|
||||
|
||||
def setup_termux_signal_handlers():
|
||||
"""Set up signal handlers for graceful shutdown in Termux."""
|
||||
import signal
|
||||
|
||||
def handle_signal(signum, frame):
|
||||
"""Release wake lock and exit gracefully."""
|
||||
try:
|
||||
subprocess.run(["termux-wake-unlock"], capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Allow running as standalone check
|
||||
if is_termux():
|
||||
success = check_termux_prerequisites(verbose=True)
|
||||
sys.exit(0 if success else 1)
|
||||
else:
|
||||
print("Not running in Termux environment")
|
||||
sys.exit(0)
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
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 ..distance import estimate_distance
|
||||
@@ -15,6 +18,30 @@ from ..config import Config, get_config
|
||||
from ..bluetooth_identify import identify_single_device, identify_device
|
||||
from ..database import DeviceDatabase, init_database, get_database
|
||||
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:
|
||||
@@ -212,6 +239,11 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
|
||||
# Store config reference
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
# ==================== 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("/")
|
||||
def index():
|
||||
"""Main dashboard page"""
|
||||
rf_config = app.config["RF_CONFIG"]
|
||||
scanner_identity = app.config["SCANNER_IDENTITY"]
|
||||
return render_template(
|
||||
"index.html",
|
||||
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,
|
||||
"ground_floor_number": rf_config.building.ground_floor_number,
|
||||
"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"])
|
||||
def api_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:
|
||||
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")
|
||||
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
|
||||
for net in scan.get("wifi_networks", []):
|
||||
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:
|
||||
net["height_m"] = None
|
||||
|
||||
# Filter out scanner devices and enrich BT devices
|
||||
filtered_bt = []
|
||||
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)
|
||||
address = dev.get("address")
|
||||
dev["floor"] = saved_floors.get(address) if address else dev.get("floor")
|
||||
dev["floor"] = saved_floors.get(dev.get("address")) if dev.get("address") else dev.get("floor")
|
||||
if "height_m" not in dev:
|
||||
dev["height_m"] = None
|
||||
filtered_bt.append(dev)
|
||||
scan["bluetooth_devices"] = filtered_bt
|
||||
|
||||
scan["gps"] = {
|
||||
"lat": app.config["CURRENT_LAT"],
|
||||
@@ -1072,6 +1211,9 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
scan_type="bluetooth"
|
||||
)
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
broadcast_scan_update(current_app, response_data["bluetooth_devices"], "bluetooth")
|
||||
|
||||
return jsonify(response_data)
|
||||
|
||||
# ==================== Historical Data API ====================
|
||||
@@ -1238,6 +1380,132 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
|
||||
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")
|
||||
def api_history_stats():
|
||||
"""Get database statistics"""
|
||||
@@ -1262,6 +1530,41 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
result = db.cleanup_old_data(retention_days)
|
||||
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 ====================
|
||||
|
||||
@app.route("/api/peers", methods=["GET"])
|
||||
@@ -1271,11 +1574,13 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
if not db:
|
||||
return jsonify({"error": "Database not enabled"}), 503
|
||||
|
||||
rf_config = app.config["RF_CONFIG"]
|
||||
peers = db.get_peers()
|
||||
peer_sync = app.config.get("PEER_SYNC")
|
||||
|
||||
return jsonify({
|
||||
"this_scanner": app.config["SCANNER_IDENTITY"],
|
||||
"is_master": rf_config.scanner.is_master,
|
||||
"peers": peers,
|
||||
"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,
|
||||
floor=data.get("floor"),
|
||||
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"
|
||||
@@ -1341,6 +1647,130 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
else:
|
||||
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"])
|
||||
def api_sync_devices_get():
|
||||
"""Get devices for sync (called by peers)"""
|
||||
@@ -1421,6 +1851,121 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
"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
|
||||
|
||||
|
||||
@@ -1476,10 +2021,12 @@ def run_server(
|
||||
if log_requests:
|
||||
print(f"Request logging: ENABLED")
|
||||
print(f"Log output: {config.get_data_dir() / 'logs'}")
|
||||
print(f"WebSocket: ENABLED (namespace /ws/scan)")
|
||||
print(f"{'='*60}")
|
||||
print(f"Server running at: http://{host}:{port}")
|
||||
print(f"Local access: http://localhost:{port}")
|
||||
print(f"Network access: http://<your-ip>:{port}")
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.btn {
|
||||
background: var(--bg-tertiary);
|
||||
@@ -577,6 +615,37 @@ body {
|
||||
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 {
|
||||
position: absolute;
|
||||
@@ -714,6 +783,11 @@ body {
|
||||
border-radius: var(--border-radius) !important;
|
||||
padding: 0.75rem !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 {
|
||||
@@ -832,6 +906,10 @@ body {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-position-status .status-value.trilaterated {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.popup-source-info {
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
@@ -974,6 +1052,66 @@ body {
|
||||
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-section {
|
||||
display: block;
|
||||
|
||||
@@ -24,6 +24,21 @@ let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } }
|
||||
// Peer scanner positions - loaded from /api/peers (live positions)
|
||||
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
|
||||
let autoScanEnabled = false;
|
||||
let autoScanPollInterval = null;
|
||||
@@ -33,6 +48,10 @@ let liveTrackingEnabled = false;
|
||||
let liveTrackingInterval = null;
|
||||
const LIVE_TRACKING_INTERVAL_MS = 4000; // 4 seconds
|
||||
|
||||
// WebSocket state
|
||||
let wsEnabled = true; // Try WebSocket first
|
||||
let wsConnected = false;
|
||||
|
||||
// Statistical movement detection
|
||||
const SAMPLE_HISTORY_SIZE = 5; // Number of samples to keep for averaging
|
||||
const MOVEMENT_THRESHOLD = 1.5; // meters - movement must exceed this + stddev margin
|
||||
@@ -45,6 +64,11 @@ let deviceDistanceHistory = {};
|
||||
let deviceMissCount = {};
|
||||
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
|
||||
function mean(arr) {
|
||||
if (arr.length === 0) return 0;
|
||||
@@ -104,6 +128,100 @@ function isDeviceMoving(address, newDistance) {
|
||||
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)
|
||||
let devicePositions = [];
|
||||
|
||||
@@ -121,12 +239,18 @@ let openPopupDeviceId = null; // Track which device popup is open
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Store original position for node switching
|
||||
APP_CONFIG.originalLat = APP_CONFIG.defaultLat;
|
||||
APP_CONFIG.originalLon = APP_CONFIG.defaultLon;
|
||||
|
||||
initMap();
|
||||
initRadar();
|
||||
initFloorSelector();
|
||||
loadLatestScan();
|
||||
loadAutoScanStatus();
|
||||
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
|
||||
setTimeout(() => {
|
||||
@@ -134,6 +258,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
map3dInitialized = true;
|
||||
}, 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
|
||||
setTimeout(() => {
|
||||
startLiveTracking();
|
||||
@@ -141,6 +271,333 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}, 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
|
||||
function toggleFilter(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
|
||||
async function loadLatestScan() {
|
||||
try {
|
||||
const response = await fetch('/api/latest');
|
||||
if (response.ok) {
|
||||
scanData = await response.json();
|
||||
scanData = filterScannerDevices(await response.json());
|
||||
markAllDevicesSeen();
|
||||
updateUI();
|
||||
} else {
|
||||
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',
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
scan_wifi: filters.wifi,
|
||||
scan_bluetooth: filters.bluetooth
|
||||
scan_wifi: true, // Always scan WiFi (filter controls display only)
|
||||
scan_bluetooth: true // Always scan BT (filter controls display only)
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
scanData = await response.json();
|
||||
scanData = filterScannerDevices(await response.json());
|
||||
markAllDevicesSeen();
|
||||
updateUI();
|
||||
status.textContent = `Scanned at ${new Date().toLocaleTimeString()}`;
|
||||
} else {
|
||||
@@ -592,7 +1063,9 @@ function updateMapMarkers() {
|
||||
const lat = parseFloat(document.getElementById('lat-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 = `
|
||||
<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));">
|
||||
@@ -606,15 +1079,19 @@ function updateMapMarkers() {
|
||||
iconSize: [24, 24],
|
||||
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);
|
||||
|
||||
// Add peer scanner markers (async, won't block)
|
||||
// Skip the peer we're currently viewing (it's shown as center marker)
|
||||
fetch('/api/peers')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
(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) {
|
||||
const peerName = peer.name || peer.scanner_id;
|
||||
const peerIcon = `
|
||||
<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));">
|
||||
@@ -628,7 +1105,7 @@ function updateMapMarkers() {
|
||||
iconSize: [20, 20],
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1025,8 +1502,8 @@ async function startAutoScan() {
|
||||
body: JSON.stringify({
|
||||
interval_minutes: interval,
|
||||
location_label: label,
|
||||
scan_wifi: filters.wifi,
|
||||
scan_bluetooth: filters.bluetooth,
|
||||
scan_wifi: true, // Always scan both (filter controls display only)
|
||||
scan_bluetooth: true,
|
||||
save: false
|
||||
})
|
||||
});
|
||||
@@ -1034,10 +1511,7 @@ async function startAutoScan() {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
updateAutoScanUI(data);
|
||||
const services = [];
|
||||
if (filters.wifi) services.push('WiFi');
|
||||
if (filters.bluetooth) services.push('BT');
|
||||
document.getElementById('scan-status').textContent = `Auto-scan: ${services.join('+')} every ${interval} min`;
|
||||
document.getElementById('scan-status').textContent = `Auto-scan: WiFi+BT every ${interval} min`;
|
||||
}
|
||||
} catch (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 ==========
|
||||
|
||||
// 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');
|
||||
if (peersResponse.ok) {
|
||||
const peersData = await peersResponse.json();
|
||||
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 => {
|
||||
peerScanners[peer.scanner_id] = {
|
||||
lat: peer.latitude,
|
||||
@@ -1095,21 +1622,65 @@ async function loadDevicePositions() {
|
||||
floor: peer.floor,
|
||||
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) {
|
||||
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
|
||||
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
||||
const deviceId = device.bssid || device.address;
|
||||
const customPos = manualPositions[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
|
||||
// If device was synced from another scanner, use that scanner's CURRENT position
|
||||
let baseLat = scannerLat;
|
||||
@@ -1457,10 +2028,12 @@ function update3DMarkers() {
|
||||
const groundFloor = buildingConfig.groundFloorNumber || 0;
|
||||
|
||||
// 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');
|
||||
scannerEl.className = 'marker-3d center draggable';
|
||||
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 scannerMarker = new maplibregl.Marker({
|
||||
@@ -1470,11 +2043,11 @@ function update3DMarkers() {
|
||||
})
|
||||
.setLngLat([lon, lat])
|
||||
.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>
|
||||
Lat: ${lat.toFixed(6)}<br>
|
||||
Lon: ${lon.toFixed(6)}<br>
|
||||
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to reposition</div>
|
||||
<span style="font-size:0.8rem;color:#888;">${lat.toFixed(6)}, ${lon.toFixed(6)}</span>
|
||||
<div style="font-size:0.7rem;color:#666;margin-top:4px;">Drag to reposition</div>
|
||||
`))
|
||||
.addTo(map3d);
|
||||
|
||||
@@ -1484,18 +2057,22 @@ function update3DMarkers() {
|
||||
map3dMarkers.push(scannerMarker);
|
||||
|
||||
// Add peer scanner markers (absolute positions)
|
||||
// Skip the peer we're currently viewing (it's shown as center marker)
|
||||
fetch('/api/peers')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
(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) {
|
||||
const peerFloor = peer.floor ?? 0;
|
||||
const peerOffset = (peerFloor - groundFloor) * pixelsPerFloor;
|
||||
const peerName = peer.name || peer.scanner_id;
|
||||
|
||||
const peerEl = document.createElement('div');
|
||||
peerEl.className = 'marker-3d peer-scanner';
|
||||
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({
|
||||
element: peerEl,
|
||||
@@ -1503,11 +2080,10 @@ function update3DMarkers() {
|
||||
})
|
||||
.setLngLat([peer.longitude, peer.latitude])
|
||||
.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>
|
||||
Lat: ${peer.latitude.toFixed(6)}<br>
|
||||
Lon: ${peer.longitude.toFixed(6)}<br>
|
||||
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Peer scanner (read-only)</div>
|
||||
<span style="font-size:0.8rem;color:#888;">${peer.latitude.toFixed(6)}, ${peer.longitude.toFixed(6)}</span>
|
||||
`))
|
||||
.addTo(map3d);
|
||||
|
||||
@@ -1627,26 +2203,33 @@ function update3DMarkers() {
|
||||
const missCount = dev.miss_count || 0;
|
||||
// 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 isTrilaterated = pos.isTrilaterated === true;
|
||||
const el = document.createElement('div');
|
||||
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
||||
if (isDraggable) el.classList.add('draggable');
|
||||
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.transition = 'opacity 0.5s ease';
|
||||
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
||||
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
|
||||
const trilatBadge = isTrilaterated ? `<span class="trilat-badge">${pos.scannerCount}📡</span>` : '';
|
||||
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 positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
||||
const positionClass = hasManualPosition ? 'manual' : 'auto';
|
||||
const positionStatus = isTrilaterated ? `Trilaterated (${pos.scannerCount} scanners)` : (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 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 ? `
|
||||
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
||||
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
||||
Show Trail
|
||||
</button>` : '';
|
||||
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(`
|
||||
<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>
|
||||
${escapeHtml(dev.manufacturer)}<br>
|
||||
${btSourceInfo}
|
||||
${trilatInfo}
|
||||
<div class="popup-floor-control">
|
||||
<label>Floor:</label>
|
||||
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
|
||||
@@ -1925,17 +2509,23 @@ function toggleLiveTracking() {
|
||||
function startLiveTracking() {
|
||||
if (liveTrackingInterval) {
|
||||
clearInterval(liveTrackingInterval);
|
||||
liveTrackingInterval = null;
|
||||
}
|
||||
|
||||
liveTrackingEnabled = true;
|
||||
updateLiveTrackingUI();
|
||||
console.log('Live BT tracking started');
|
||||
|
||||
// Do initial scan
|
||||
performLiveBTScan();
|
||||
|
||||
// Set up interval
|
||||
liveTrackingInterval = setInterval(performLiveBTScan, LIVE_TRACKING_INTERVAL_MS);
|
||||
if (wsConnected) {
|
||||
// WebSocket mode - updates come automatically via 'scanUpdate' events
|
||||
// Still need to trigger initial scan
|
||||
performLiveBTScan();
|
||||
console.log('[Live] Started (WebSocket mode)');
|
||||
} else {
|
||||
// HTTP polling fallback
|
||||
liveTrackingInterval = setInterval(performLiveBTScan, LIVE_TRACKING_INTERVAL_MS);
|
||||
performLiveBTScan();
|
||||
console.log('[Live] Started (HTTP polling mode)');
|
||||
}
|
||||
}
|
||||
|
||||
// Stop live BT tracking
|
||||
@@ -1968,14 +2558,23 @@ async function performLiveBTScan() {
|
||||
if (!liveTrackingEnabled) return;
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
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
|
||||
const detectedAddresses = new Set(newBt.map(d => d.address));
|
||||
@@ -1994,6 +2593,7 @@ async function performLiveBTScan() {
|
||||
|
||||
// Reset miss count - device was detected
|
||||
deviceMissCount[newDev.address] = 0;
|
||||
updateDeviceLastSeen(newDev.address);
|
||||
|
||||
if (existing) {
|
||||
// Update RSSI and estimated distance, preserve custom values
|
||||
@@ -2025,6 +2625,7 @@ async function performLiveBTScan() {
|
||||
// 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);
|
||||
@@ -2037,16 +2638,17 @@ async function performLiveBTScan() {
|
||||
|
||||
scanData.bluetooth_devices = filteredBt;
|
||||
} else {
|
||||
// No existing scan data, use BT-only data
|
||||
data.bluetooth_devices.forEach(dev => {
|
||||
// No existing scan data, use BT-only data (already filtered above)
|
||||
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: data.bluetooth_devices,
|
||||
bluetooth_devices: newBt,
|
||||
timestamp: data.timestamp
|
||||
};
|
||||
}
|
||||
@@ -2062,6 +2664,9 @@ async function performLiveBTScan() {
|
||||
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();
|
||||
@@ -2318,3 +2923,36 @@ function clearAllTrails() {
|
||||
|
||||
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) }},
|
||||
currentFloor: {{ building.current_floor | default(0) }}
|
||||
},
|
||||
scanner: {
|
||||
id: {{ scanner.id | default('') | tojson }},
|
||||
name: {{ scanner.name | default('') | tojson }}
|
||||
},
|
||||
maplibre: {
|
||||
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 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>
|
||||
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
||||
🔍 New Scan
|
||||
@@ -33,6 +39,9 @@
|
||||
<span class="filter-indicator"></span>
|
||||
<span>Bluetooth</span>
|
||||
</button>
|
||||
<button id="btn-heatmap" class="filter-btn heatmap" onclick="toggleHeatMap()">
|
||||
<span>🌡️ Heat Map</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
||||
@@ -118,6 +127,22 @@
|
||||
</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-header">
|
||||
<span class="section-title">📊 Statistics</span>
|
||||
@@ -166,5 +191,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user