Compare commits

36 Commits

Author SHA1 Message Date
User
9fc7c65454 fix: filter scanner BT MACs in API responses
- /api/latest now filters out devices matching peer bt_mac
- Prevents scanner devices from appearing in scan results

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:09:06 +01:00
User
579cea57dc fix: filter scanner BT MACs in database recording
- Skip recording BT observations for addresses matching peer bt_mac
- Prevents scanners from being stored as regular devices
- Filters at database level, not just frontend display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:07:11 +01:00
User
5def3e2214 fix: filter scanner BT MACs during BLE Radar import
- Skip devices whose address matches a registered peer's bt_mac
- Prevents scanners from appearing as regular devices
- Shows count of filtered scanners in import stats

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:01:58 +01:00
User
cc6d9ee58d feat: add BLE Radar database import
- Add import_ble_radar.py module for importing BLE Radar exports
- Add /api/import/ble-radar endpoint for file upload
- Add import section to dashboard with file picker
- CLI: python -m rf_mapper.import_ble_radar <file.sqlite>
- Imports devices, RSSI, favorites, and custom labels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:54:31 +01:00
User
b1efb4ae3c docs: enhance CLAUDE.md with maintenance table
- Add table of key docs with "When to Update" guidance
- Include TASKS.md, TODO.md, ROADMAP.md, CHANGELOG.md
- Add deployment section with node update commands
- Add Multi-scanner sync and Termux support to key files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:39:15 +01:00
User
91536860ad docs: update CLI commands to use python -m rf_mapper
- Replace rf-mapper with python -m rf_mapper throughout docs
- Add note about activating venv first
- Updated: CLAUDE.md, PROJECT.md, USAGE.md, CHEATSHEET.md, HOME_ASSISTANT.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:38:44 +01:00
User
322c53d513 feat: auto-remove stale devices after 60s timeout
- Add deviceLastSeen tracking for all WiFi and BT devices
- Add cleanupStaleDevices() that runs every 10 seconds
- Remove devices not seen for 60 seconds (STALE_DEVICE_TIMEOUT_MS)
- Works regardless of live tracking state
- Updates lastSeen in all scan paths: manual, WS, polling, node switch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:32:05 +01:00
User
9c9f27e55f docs: update TASKS, TODO, CHANGELOG for v1.0.1
- Node control API (start/stop/restart peers via SSH)
- Home Assistant node control integration
- Termux/Android: skip BT scanning, optional location check
- Filter controls display only, always scan both WiFi/BT

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:06:00 +01:00
User
bc2e23c3ca fix: always scan WiFi and BT, filter controls display only
The filter checkboxes now only control whether devices are displayed
on the map, not whether they are scanned. Both WiFi and Bluetooth
are always scanned regardless of filter state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:48:52 +01:00
User
e27200c5b5 fix: make Termux location check optional
Location services check now optional since scanner coordinates
are configured in config.yaml. Allows rf-mapper to start on
Termux even when GPS is unavailable (indoors).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:41:33 +01:00
User
cee36e2ce1 feat: add node control API for starting/stopping peers via SSH
New endpoints on master node:
- 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

Uses SSH with scanner_id as hostname (relies on ~/.ssh/config).
Enables Home Assistant integration to control peer nodes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:16:38 +01:00
User
3ff43de5ea fix: skip Bluetooth scanning on Termux/Android
Bleak requires D-Bus which isn't available on Android. Detect Termux
environment and skip both Classic BT and BLE scanning gracefully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:52:23 +01:00
User
b0e6d1107c feat: replace hcitool BLE scanning with bleak library
Use bleak's BleakScanner for BLE device discovery instead of
hcitool lescan subprocess. Provides real RSSI values from
advertisement packets instead of hardcoded -70dB estimate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:13:28 +01:00
User
4fef21c06f feat: filter scanner Bluetooth devices from display
- Add bt_mac field to scanner config for identifying scanner BT adapters
- Store bt_mac in peers table for peer scanners
- Filter out devices matching scanner BT MACs from all views
- Prevents scanners from appearing as devices in device lists/maps

Config: scanner.bt_mac = "XX:XX:XX:XX:XX:XX"
API: /api/peers/register accepts bt_mac field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:00:42 +01:00
User
446bec278d fix: prevent duplicate scanner markers when viewing peer nodes
- Use viewing scanner name (peer or local) for center marker
- Filter out the peer being viewed from peer markers list
- Fixes duplicate grokbox markers when viewing grokbox from master

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:44:31 +01:00
User
b695e19079 feat: add caching for peer proxy requests
- Cache successful GET responses from peers (5 min TTL)
- Return cached data when peer is unreachable
- Add _cached, _cached_at, _cache_age_seconds flags
- Add /api/node/<id>/health proxy endpoint

Enables master dashboard to show peer data even when
peers are temporarily unreachable (e.g., mobile devices,
VPN disconnections).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:38:15 +01:00
User
5b9612dfae feat: add /api/health endpoint for monitoring
- Returns status, version, uptime, scanner_id
- Component status: database, peer_sync, auto_scanner
- Returns 200 for healthy, 503 for unhealthy
- Tracks app start time for uptime calculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:24:04 +01:00
User
522174721d docs: add multi-node master dashboard documentation
- Architecture overview with diagram
- Enabling master mode (is_master config)
- Node selector UI indicators
- Switching between local and peer views
- Live tracking on remote nodes
- Proxy API endpoints for peer data
- Peer registration examples
- WebSocket peer connection details
- Troubleshooting section for common issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:11:14 +01:00
User
f787ccd426 chore: gitignore INVENTORY.md (site-specific)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:03:40 +01:00
User
7ccbf486c5 docs: update TASKS.md with multi-node master dashboard
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:59:40 +01:00
User
7e469d6a0a fix: consolidate scanner display with capabilities
Show each scanner as a single marker with WiFi/Bluetooth capabilities
listed in the popup, instead of separate entries per capability.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:54:53 +01:00
User
ae235ebef8 fix: display scanner name instead of "Your Position"
Show the scanner's configured name (or hostname if not set) in map
markers and popups instead of the generic "Your Position" label.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:51:01 +01:00
User
588102ddf4 feat: add multi-node master dashboard
Enable a designated "master" node to view device data from any peer node
within the same dashboard, without page redirects.

- Add is_master config option to ScannerConfig
- Add proxy endpoints /api/node/<id>/* for peer data
- Add node selector dropdown UI (shown only on master)
- Add peer WebSocket connection support for real-time updates
- Live tracking uses node-specific scan endpoint
- Map centers on selected peer's position

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:47:28 +01:00
User
f04ce5aed3 docs: update TODO with completed features
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:32:16 +01:00
User
9b275f4606 docs: add Termux/Android setup and troubleshooting
- Document Android/Termux requirements (Termux:API, permissions)
- Add ADB command to disable phantom process killer
- Document check-termux command for prerequisite verification
- Add Termux boot script example for auto-start
- Add troubleshooting section for common Termux issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:31:52 +01:00
User
320d012200 fix: use termux-battery-status directly instead of which
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:27:22 +01:00
User
b6aa3ede56 feat: add Termux/Android prerequisite detection
Detects when running in Termux on Android and checks for required
prerequisites before starting the server:
- Termux:API package installed
- Location services enabled and accessible
- Wake lock available

Exits with informative error message if prerequisites not met.
Adds `rf-mapper check-termux` command for manual verification.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:22:36 +01:00
User
8a533a0670 fix: allow popup overflow for floor dropdown visibility
Set overflow: visible on MapLibre popup to prevent clipping
of floor select dropdown when it expands.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:03:41 +01:00
User
1cc403eea6 fix: use CSS-based coverage rings on scanner markers
Coverage rings now render as CSS pseudo-elements on scanner markers,
following the same floor offset positioning. Toggle .heatmap-enabled
class on map container to show/hide coverage visualization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:42:30 +01:00
User
4b4cc47e67 fix: use coverage circles instead of heatmap layer
Replace heatmap layer with concentric circle layers around scanner
positions. This avoids the z-index issue where heatmap appeared at
floor 0 below all markers.

Shows three coverage rings:
- Green inner (good signal ~5m)
- Yellow middle (fair signal ~10m)
- Red outer (weak signal ~15m)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:39:20 +01:00
User
24de6c7f06 feat: add multi-scanner trilateration and signal heat map
- Add database methods for multi-scanner RSSI queries
- Add weighted trilateration function supporting 2+ scanners
- Add /api/positions/trilaterated endpoint
- Add /api/heatmap/signal endpoint for heat map data
- Update frontend to show trilaterated positions with gold markers
- Add heat map toggle button for signal coverage visualization

Trilateration uses RSSI from multiple scanners to calculate device
positions with confidence scores. Devices seen by 2+ scanners within
60 seconds get trilaterated positions shown with gold border markers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:33:57 +01:00
User
5fbf096a04 feat: integrate WebSocket with live tracking
- Add WebSocket state variables (wsEnabled, wsConnected)
- Add initWebSocket() function with connection and event handling
- Add handleWebSocketScanUpdate() for processing WS scan events
- Modify startLiveTracking() to use WS mode with HTTP fallback
- Update index.html to load socket.io and websocket.js scripts
- Show [WS] indicator in status when using WebSocket mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 04:56:10 +01:00
User
8f4fa4e186 feat: add WebSocket client with fallback
- Add Socket.IO client library (v4.7.5)
- Create RFMapperWS class with:
  - Automatic reconnection (5 attempts)
  - HTTP polling fallback
  - Event listener system for scanUpdate, connected, disconnected
  - Floor subscription support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 04:55:55 +01:00
User
14757f2e57 feat: add SocketIO server integration
- Initialize SocketIO with threading mode
- Add WebSocket event handlers (connect, disconnect, subscribe_floor)
- Add broadcast_scan_update() function for pushing scan results
- Integrate broadcast with BT scan endpoint
- Update run_server() to use socketio.run()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 04:55:44 +01:00
User
fed08aa6dd feat: add flask-socketio dependency
Add flask-socketio>=5.3.0 for WebSocket support in the web interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 04:55:28 +01:00
User
98e2c6fc42 docs: update project docs for v1.0.0 release
- PROJECT.md: Add peer sync feature description and architecture
- ROADMAP.md: Update to v1.0.0, mark peer sync complete
- TASKS.md: Add peer sync completed tasks, update sprint
- TODO.md: Add multi-scanner section, mark items done
- CHANGELOG.md: Add v0.4.0 (HA) and v1.0.0 (peer sync) entries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 04:41:24 +01:00
27 changed files with 2842 additions and 178 deletions

3
.gitignore vendored
View File

@@ -52,3 +52,6 @@ data/rf-mapper.started
# OS
.DS_Store
Thumbs.db
# Local inventory (site-specific, not tracked)
INVENTORY.md

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
```
---

View File

@@ -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

View File

@@ -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",

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View 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())

View File

@@ -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,7 +191,10 @@ class RFScanner:
"""
devices = []
# Classic Bluetooth scan
# Classic Bluetooth scan (not supported on Termux/Android)
if is_termux():
print("Skipping Classic BT scan (not supported on Termux)")
else:
try:
print(f"Scanning Classic Bluetooth ({timeout} seconds)...")
result = subprocess.run(
@@ -214,33 +227,43 @@ class RFScanner:
except Exception as e:
print(f"Classic BT scan error: {e}")
# BLE scan
# BLE scan using bleak (not supported on Termux/Android)
if is_termux():
print("Skipping BLE scan (not supported on Termux)")
else:
try:
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
async def _ble_scan():
return await BleakScanner.discover(
timeout=timeout,
return_adv=True
)
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_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,7 +275,7 @@ 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

218
src/rf_mapper/termux.py Normal file
View 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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -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
if (wsConnected) {
// WebSocket mode - updates come automatically via 'scanUpdate' events
// Still need to trigger initial scan
performLiveBTScan();
// Set up interval
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

File diff suppressed because one or more lines are too long

View 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();

View File

@@ -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'
}

View File

@@ -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 %}