feat: add Home Assistant integration and improve CLI/UI
Home Assistant Integration: - New homeassistant.py module with webhook support - Webhooks for scan results, new devices, and device departures - Absence detection with configurable timeout - Documentation in docs/HOME_ASSISTANT.md CLI Improvements: - Replace 'web' command with start/stop/restart/status - Background daemon mode with PID file management - Foreground mode for debugging (--foreground) Web UI Enhancements: - Improved device list styling and layout - Better floor assignment UI - Enhanced map visualization Documentation: - Add CHANGELOG.md - Add docs/API.md with full endpoint reference - Add docs/CHEATSHEET.md for quick reference - Update project documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
78
CHANGELOG.md
Normal file
78
CHANGELOG.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to RF Mapper are documented here.
|
||||||
|
|
||||||
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-02-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **3D Map Visualization** - MapLibre GL JS integration with building extrusion
|
||||||
|
- **Floor-based positioning** - Assign devices to floors, filter by floor
|
||||||
|
- **SQLite database** - Historical device tracking with 7 tables
|
||||||
|
- **Historical data API** - 15+ new endpoints for device history, stats, alerts
|
||||||
|
- **Live BT tracking mode** - 4-second scan interval with auto-start
|
||||||
|
- **Movement detection** - Statistical analysis (5-sample avg + 2σ threshold)
|
||||||
|
- **bleak BLE scanning** - Reliable RSSI via D-Bus/BlueZ (replaced hcitool)
|
||||||
|
- **Floor persistence** - Floor assignments saved in database
|
||||||
|
- **Popup persistence** - Device popups stay open during live updates
|
||||||
|
- **Custom distance override** - Manual distance setting via popup
|
||||||
|
- **Position smoothing** - Statistical averaging for stable positions
|
||||||
|
- **Filter-aware scanning** - Skip WiFi/BT based on toggle state
|
||||||
|
- **Auto-scan feature** - Background scanning with configurable interval
|
||||||
|
- **Device labeling** - Custom names via API
|
||||||
|
- **Favorite devices** - Mark important devices
|
||||||
|
- **Alert system** - New device detection, activity alerts
|
||||||
|
- **Activity patterns** - Hourly/daily usage analysis
|
||||||
|
- **Database cleanup** - Automatic and manual data retention
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Switched from `hcitool` to `bleak` Python library for BLE scanning
|
||||||
|
- Improved distance estimation algorithm
|
||||||
|
- Enhanced web UI with real-time updates
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- BT RSSI acquisition now reliable (bleak library)
|
||||||
|
- Floor assignments persist across page reloads
|
||||||
|
- Popups no longer close during live tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-01-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **World Map view** - Leaflet.js integration with device markers
|
||||||
|
- **2.5D visualization** - Height-based display
|
||||||
|
- **Auto-identify Bluetooth** - Device type inference from name/manufacturer
|
||||||
|
- **OUI lookup** - Manufacturer identification from MAC address
|
||||||
|
- **Signal quality indicators** - Visual strength representation
|
||||||
|
- **Request profiling** - Optional per-request performance analysis
|
||||||
|
- **Request logging** - Optional request logging to file
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved CLI output formatting
|
||||||
|
- Better error handling for scan failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
- **WiFi scanning** - Uses `iw` command
|
||||||
|
- **Bluetooth scanning** - Uses `hcitool` and `bluetoothctl`
|
||||||
|
- **CLI interface** - scan, visualize, analyze, list, config commands
|
||||||
|
- **Web interface** - Flask-based dashboard
|
||||||
|
- **Radar visualization** - ASCII and web-based radar view
|
||||||
|
- **Distance estimation** - RSSI-based path loss model
|
||||||
|
- **Configuration system** - YAML config with environment variable overrides
|
||||||
|
- **JSON data storage** - Scan results saved as JSON files
|
||||||
|
- **GPS support** - Reference position for mapping
|
||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -15,7 +15,9 @@ src/rf_mapper/
|
|||||||
├── __main__.py # CLI entry point and argument parsing
|
├── __main__.py # CLI entry point and argument parsing
|
||||||
├── scanner.py # WiFi/Bluetooth scanning (WifiNetwork, BluetoothDevice dataclasses)
|
├── scanner.py # WiFi/Bluetooth scanning (WifiNetwork, BluetoothDevice dataclasses)
|
||||||
├── config.py # Configuration management (Config, BuildingConfig, etc.)
|
├── config.py # Configuration management (Config, BuildingConfig, etc.)
|
||||||
|
├── database.py # SQLite database for device history and tracking
|
||||||
├── distance.py # RSSI to distance estimation
|
├── distance.py # RSSI to distance estimation
|
||||||
|
├── homeassistant.py # Home Assistant webhook integration
|
||||||
├── oui.py # MAC address manufacturer lookup
|
├── oui.py # MAC address manufacturer lookup
|
||||||
├── bluetooth_*.py # Bluetooth device identification and classification
|
├── bluetooth_*.py # Bluetooth device identification and classification
|
||||||
├── visualize.py # ASCII radar and chart generation
|
├── visualize.py # ASCII radar and chart generation
|
||||||
@@ -35,18 +37,23 @@ src/rf_mapper/
|
|||||||
| Modify data model | `src/rf_mapper/scanner.py`, `config.py` |
|
| Modify data model | `src/rf_mapper/scanner.py`, `config.py` |
|
||||||
| Change web UI | `web/templates/index.html`, `static/js/app.js`, `static/css/style.css` |
|
| Change web UI | `web/templates/index.html`, `static/js/app.js`, `static/css/style.css` |
|
||||||
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
|
| Add configuration | `src/rf_mapper/config.py`, `config.yaml` |
|
||||||
|
| Home Assistant integration | `src/rf_mapper/homeassistant.py`, `docs/HOME_ASSISTANT.md` |
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
rf-mapper web # Web interface at http://localhost:5000
|
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 scan -l room # CLI scan
|
||||||
rf-mapper --help # All commands
|
rf-mapper --help # All commands
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Python 3.10+, Flask, PyYAML
|
- Python 3.10+, Flask, PyYAML, requests
|
||||||
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
|
- Leaflet.js (2D maps), MapLibre GL JS (3D maps)
|
||||||
- Linux tools: `iw`, `hcitool`, `bluetoothctl`
|
- Linux tools: `iw`, bleak (BLE via D-Bus)
|
||||||
|
- SQLite for device history
|
||||||
|
- Home Assistant webhooks for integration
|
||||||
|
|||||||
25
PROJECT.md
25
PROJECT.md
@@ -31,6 +31,7 @@ Understanding the RF environment around you is useful for:
|
|||||||
- **Historical Database** - SQLite storage for device history, RSSI time-series, statistics
|
- **Historical Database** - SQLite storage for device history, RSSI time-series, statistics
|
||||||
- **Auto-scan** - Scheduled background scanning
|
- **Auto-scan** - Scheduled background scanning
|
||||||
- **Data Export** - JSON scan history with timestamps
|
- **Data Export** - JSON scan history with timestamps
|
||||||
|
- **Home Assistant Integration** - Webhook-based presence tracking, new device alerts, departure notifications
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -64,6 +65,14 @@ Understanding the RF environment around you is useful for:
|
|||||||
│ │ iw │ │ bleak │ │ SQLite │ │
|
│ │ iw │ │ bleak │ │ SQLite │ │
|
||||||
│ │ (WiFi) │ │ (BLE) │ │(History) │ │
|
│ │ (WiFi) │ │ (BLE) │ │(History) │ │
|
||||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
└─────────────────────────┬───────────────────────────────────┘
|
||||||
|
│ Webhooks
|
||||||
|
┌─────────────────────────┴───────────────────────────────────┐
|
||||||
|
│ Home Assistant │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Scan Results │ │ New Device │ │ Device Gone │ │
|
||||||
|
│ │ Webhook │ │ Webhook │ │ Webhook │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,6 +90,7 @@ Understanding the RF environment around you is useful for:
|
|||||||
- PyYAML - Configuration
|
- PyYAML - Configuration
|
||||||
- dataclasses - Data structures
|
- dataclasses - Data structures
|
||||||
- bleak - BLE scanning with RSSI
|
- bleak - BLE scanning with RSSI
|
||||||
|
- requests - HTTP client for webhooks
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- Leaflet.js - 2D maps
|
- Leaflet.js - 2D maps
|
||||||
@@ -99,8 +109,11 @@ pip install -e .
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start web interface
|
# Start web server (background)
|
||||||
rf-mapper web
|
rf-mapper start
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
rf-mapper status
|
||||||
|
|
||||||
# CLI scan
|
# CLI scan
|
||||||
rf-mapper scan
|
rf-mapper scan
|
||||||
@@ -126,6 +139,14 @@ building:
|
|||||||
enabled: true
|
enabled: true
|
||||||
floors: 12
|
floors: 12
|
||||||
current_floor: 11
|
current_floor: 11
|
||||||
|
|
||||||
|
home_assistant:
|
||||||
|
enabled: true
|
||||||
|
url: http://192.168.129.10:8123
|
||||||
|
webhook_scan: rf_mapper_scan
|
||||||
|
webhook_new_device: rf_mapper_new_device
|
||||||
|
webhook_device_gone: rf_mapper_device_gone
|
||||||
|
device_timeout_minutes: 5
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
26
ROADMAP.md
26
ROADMAP.md
@@ -49,7 +49,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.4.0 - Multilateration & Positioning
|
## v0.6.0 - Multilateration & Positioning
|
||||||
|
|
||||||
- [ ] Multi-point scanning (move scanner, record positions)
|
- [ ] Multi-point scanning (move scanner, record positions)
|
||||||
- [ ] Trilateration algorithm for device positioning
|
- [ ] Trilateration algorithm for device positioning
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.5.0 - Persistence & History
|
## v0.7.0 - Persistence & History
|
||||||
|
|
||||||
- [x] SQLite database for scan history
|
- [x] SQLite database for scan history
|
||||||
- [x] Device tracking over time
|
- [x] Device tracking over time
|
||||||
@@ -73,14 +73,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.6.0 - Alerts & Automation
|
## v0.4.0 - Home Assistant Integration (COMPLETED)
|
||||||
|
|
||||||
- [ ] New device alerts
|
- [x] Webhook-based HA communication
|
||||||
|
- [x] Scan results webhook (device_tracker.see)
|
||||||
|
- [x] New device alerts webhook
|
||||||
|
- [x] Device departure detection webhook
|
||||||
|
- [x] Configurable timeout for departure detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5.0 - Alerts & Automation
|
||||||
|
|
||||||
|
- [x] New device alerts (via HA webhook)
|
||||||
- [ ] Signal threshold alerts
|
- [ ] Signal threshold alerts
|
||||||
- [ ] Webhook notifications
|
- [x] Webhook notifications
|
||||||
- [ ] Home Assistant integration (MQTT)
|
- [ ] Home Assistant MQTT auto-discovery
|
||||||
- [ ] Presence detection automation
|
- [x] Presence detection automation (via HA webhook)
|
||||||
- [ ] Device absence detection
|
- [x] Device absence detection (via HA webhook)
|
||||||
- [ ] Scheduled reports
|
- [ ] Scheduled reports
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
17
TASKS.md
17
TASKS.md
@@ -35,12 +35,14 @@
|
|||||||
| [x] | Floor-based positioning | Devices assigned to floors |
|
| [x] | Floor-based positioning | Devices assigned to floors |
|
||||||
| [x] | Floor selector UI | Dropdown to filter by floor |
|
| [x] | Floor selector UI | Dropdown to filter by floor |
|
||||||
| [x] | Custom distance override | Set manual distance via popup |
|
| [x] | Custom distance override | Set manual distance via popup |
|
||||||
|
| [x] | Manual position override | Drag-and-drop for floor-assigned devices |
|
||||||
| [x] | Live BT tracking mode | 4-second scan interval |
|
| [x] | Live BT tracking mode | 4-second scan interval |
|
||||||
| [x] | Moving device detection | Purple markers for RSSI changes |
|
| [x] | Moving device detection | Purple markers for RSSI changes |
|
||||||
| [x] | Filter-aware scanning | Skip WiFi/BT based on toggle |
|
| [x] | Filter-aware scanning | Skip WiFi/BT based on toggle |
|
||||||
| [x] | Improve BT discovery reliability | Using bleak library for BLE scanning |
|
| [x] | Improve BT discovery reliability | Using bleak library for BLE scanning |
|
||||||
| [ ] | Document API endpoints | docs/API.md |
|
| [x] | Document API endpoints | docs/API.md |
|
||||||
| [ ] | Create CHEATSHEET.md | Quick reference guide |
|
| [x] | Create CHEATSHEET.md | Quick reference guide |
|
||||||
|
| [x] | Home Assistant webhook integration | Scan results, new device, departure alerts |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
| Status | Task | Notes |
|
| Status | Task | Notes |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| [x] | Position smoothing | Statistical averaging (5 samples + stddev) |
|
| [x] | Position smoothing | Statistical averaging (5 samples + stddev) |
|
||||||
| [ ] | Device trails | Show movement history on map |
|
| [x] | Device trails | Show movement history on map (for moving devices) |
|
||||||
| [ ] | Signal strength graph | Per-device RSSI over time |
|
| [ ] | Signal strength graph | Per-device RSSI over time |
|
||||||
| [ ] | Scan history browser | View past scans in UI |
|
| [ ] | Scan history browser | View past scans in UI |
|
||||||
| [ ] | Export functionality | Download scan data as CSV |
|
| [ ] | Export functionality | Download scan data as CSV |
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| [x] | SQLite persistence | Historical device tracking enabled |
|
| [x] | SQLite persistence | Historical device tracking enabled |
|
||||||
| [x] | Device labeling | Custom names via API |
|
| [x] | Device labeling | Custom names via API |
|
||||||
| [ ] | Home Assistant integration | MQTT/webhook |
|
| [x] | Home Assistant integration | Webhook-based (scan, new device, departure) |
|
||||||
| [ ] | Docker container | Containerized deployment |
|
| [ ] | Docker container | Containerized deployment |
|
||||||
| [ ] | Unit tests | pytest coverage |
|
| [ ] | Unit tests | pytest coverage |
|
||||||
|
|
||||||
@@ -84,6 +86,12 @@
|
|||||||
| Statistical movement detection | 2026-02-01 |
|
| Statistical movement detection | 2026-02-01 |
|
||||||
| Floor persistence in database | 2026-02-01 |
|
| Floor persistence in database | 2026-02-01 |
|
||||||
| Popup persistence during updates | 2026-02-01 |
|
| Popup persistence during updates | 2026-02-01 |
|
||||||
|
| API documentation (docs/API.md) | 2026-02-01 |
|
||||||
|
| CHEATSHEET.md quick reference | 2026-02-01 |
|
||||||
|
| CHANGELOG.md version history | 2026-02-01 |
|
||||||
|
| Device trails for moving devices | 2026-02-01 |
|
||||||
|
| Manual position override (drag-drop) | 2026-02-01 |
|
||||||
|
| Home Assistant webhook integration | 2026-02-01 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -109,3 +117,4 @@
|
|||||||
- Movement detection uses statistical analysis (5-sample avg + 2σ threshold)
|
- Movement detection uses statistical analysis (5-sample avg + 2σ threshold)
|
||||||
- Floor assignments persist in database across page reloads
|
- Floor assignments persist in database across page reloads
|
||||||
- Popups stay open during live tracking updates
|
- Popups stay open during live tracking updates
|
||||||
|
- Manual position override: drag floor-assigned device markers to set custom position
|
||||||
|
|||||||
11
TODO.md
11
TODO.md
@@ -19,7 +19,7 @@
|
|||||||
- [ ] Historical playback mode (scrub through time)
|
- [ ] Historical playback mode (scrub through time)
|
||||||
- [x] Device activity patterns (daily/weekly)
|
- [x] Device activity patterns (daily/weekly)
|
||||||
- [x] Alert on new device detection
|
- [x] Alert on new device detection
|
||||||
- [ ] Alert on device absence (left the area)
|
- [x] Alert on device absence (left the area) - via HA webhook
|
||||||
- [x] Data retention policies (auto-cleanup old data)
|
- [x] Data retention policies (auto-cleanup old data)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
## Distance & Positioning
|
## Distance & Positioning
|
||||||
|
|
||||||
- [ ] Manual position override for devices with assigned floor
|
- [x] Manual position override for devices with assigned floor
|
||||||
- [ ] Configurable TX power per device type
|
- [ ] Configurable TX power per device type
|
||||||
- [ ] Path loss exponent calibration wizard
|
- [ ] Path loss exponent calibration wizard
|
||||||
- [ ] Environment presets (office, home, warehouse)
|
- [ ] Environment presets (office, home, warehouse)
|
||||||
@@ -105,8 +105,9 @@
|
|||||||
- [ ] WebSocket for real-time updates
|
- [ ] WebSocket for real-time updates
|
||||||
- [ ] GraphQL endpoint (optional)
|
- [ ] GraphQL endpoint (optional)
|
||||||
- [ ] MQTT publishing
|
- [ ] MQTT publishing
|
||||||
- [ ] Home Assistant auto-discovery
|
- [x] Home Assistant webhook integration (scan results, new device, departure)
|
||||||
- [ ] Webhook on device events
|
- [ ] Home Assistant auto-discovery (MQTT)
|
||||||
|
- [x] Webhook on device events
|
||||||
- [ ] Prometheus metrics endpoint
|
- [ ] Prometheus metrics endpoint
|
||||||
- [ ] Grafana dashboard template
|
- [ ] Grafana dashboard template
|
||||||
- [ ] Node-RED integration nodes
|
- [ ] Node-RED integration nodes
|
||||||
@@ -219,6 +220,8 @@
|
|||||||
- [x] Statistical movement detection (reduces false positives)
|
- [x] Statistical movement detection (reduces false positives)
|
||||||
- [x] Floor persistence in database
|
- [x] Floor persistence in database
|
||||||
- [x] Popup persistence during live updates
|
- [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
32
USAGE.md
32
USAGE.md
@@ -29,8 +29,8 @@ source venv/bin/activate
|
|||||||
# Run interactive scan
|
# Run interactive scan
|
||||||
rf-mapper
|
rf-mapper
|
||||||
|
|
||||||
# Start web interface
|
# Start web server
|
||||||
rf-mapper web
|
rf-mapper start
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
@@ -74,23 +74,35 @@ rf-mapper analyze
|
|||||||
rf-mapper list
|
rf-mapper list
|
||||||
```
|
```
|
||||||
|
|
||||||
### Web Interface
|
### Web Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start web server (default: http://0.0.0.0:5000)
|
# Start web server (background daemon)
|
||||||
rf-mapper web
|
rf-mapper start
|
||||||
|
|
||||||
|
# Start in foreground (for debugging)
|
||||||
|
rf-mapper start --foreground
|
||||||
|
|
||||||
# Custom host/port
|
# Custom host/port
|
||||||
rf-mapper web -H 127.0.0.1 -p 8080
|
rf-mapper start -H 127.0.0.1 -p 8080
|
||||||
|
|
||||||
# Debug mode
|
# With debug mode
|
||||||
rf-mapper web --debug
|
rf-mapper start --foreground --debug
|
||||||
|
|
||||||
# With request profiling
|
# With request profiling
|
||||||
rf-mapper web --profile-requests
|
rf-mapper start --profile-requests
|
||||||
|
|
||||||
# With request logging
|
# With request logging
|
||||||
rf-mapper web --log-requests
|
rf-mapper start --log-requests
|
||||||
|
|
||||||
|
# Stop the server
|
||||||
|
rf-mapper stop
|
||||||
|
|
||||||
|
# Restart the server
|
||||||
|
rf-mapper restart
|
||||||
|
|
||||||
|
# Check server status
|
||||||
|
rf-mapper status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|||||||
750
docs/API.md
Normal file
750
docs/API.md
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
# RF Mapper API Reference
|
||||||
|
|
||||||
|
REST API documentation for RF Mapper web interface.
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:5000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scanning
|
||||||
|
|
||||||
|
### Trigger Scan
|
||||||
|
|
||||||
|
Performs WiFi and/or Bluetooth scan.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/scan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `location` | string | `"web_scan"` | Location label for the scan |
|
||||||
|
| `lat` | number | config value | GPS latitude |
|
||||||
|
| `lon` | number | config value | GPS longitude |
|
||||||
|
| `scan_wifi` | boolean | `true` | Include WiFi networks |
|
||||||
|
| `scan_bluetooth` | boolean | `true` | Include Bluetooth devices |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5000/api/scan \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"location": "kitchen", "scan_wifi": true, "scan_bluetooth": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-01T14:30:00.123456",
|
||||||
|
"location": "kitchen",
|
||||||
|
"gps": {"lat": 50.8585, "lon": 4.3978},
|
||||||
|
"wifi_networks": [
|
||||||
|
{
|
||||||
|
"ssid": "HomeNetwork",
|
||||||
|
"bssid": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"rssi": -45,
|
||||||
|
"channel": 6,
|
||||||
|
"frequency": 2437,
|
||||||
|
"encryption": "WPA2",
|
||||||
|
"manufacturer": "Cisco",
|
||||||
|
"estimated_distance_m": 3.5,
|
||||||
|
"signal_quality": "Excellent",
|
||||||
|
"floor": null,
|
||||||
|
"height_m": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bluetooth_devices": [
|
||||||
|
{
|
||||||
|
"address": "11:22:33:44:55:66",
|
||||||
|
"name": "iPhone",
|
||||||
|
"rssi": -60,
|
||||||
|
"device_class": "Phone",
|
||||||
|
"device_type": "Smartphone",
|
||||||
|
"manufacturer": "Apple",
|
||||||
|
"estimated_distance_m": 5.2,
|
||||||
|
"signal_quality": "Good",
|
||||||
|
"floor": null,
|
||||||
|
"height_m": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Quick Bluetooth Scan
|
||||||
|
|
||||||
|
Fast BLE-only scan for real-time tracking (uses bleak library).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/scan/bt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Same as `/api/scan` but only `bluetooth_devices` populated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Latest Scan
|
||||||
|
|
||||||
|
Retrieve the most recent scan results.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Same format as `/api/scan` response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Scans
|
||||||
|
|
||||||
|
List saved scan files (most recent 50).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/scans
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"filename": "scan_20260201_143000_kitchen.json",
|
||||||
|
"timestamp": "2026-02-01T14:30:00",
|
||||||
|
"location": "kitchen",
|
||||||
|
"wifi_count": 12,
|
||||||
|
"bt_count": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Specific Scan
|
||||||
|
|
||||||
|
Retrieve a specific scan by filename.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/scans/<filename>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5000/api/scans/scan_20260201_143000_kitchen.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Position & Configuration
|
||||||
|
|
||||||
|
### GPS Position
|
||||||
|
|
||||||
|
Get or set current GPS position.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/position
|
||||||
|
POST /api/position
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"lat": 50.8585, "lon": 4.3978}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"lat": 50.8585, "lon": 4.3978}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Get or update application configuration.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/config
|
||||||
|
POST /api/config
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST Body:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `gps.latitude` | number | GPS latitude |
|
||||||
|
| `gps.longitude` | number | GPS longitude |
|
||||||
|
| `scanner.path_loss_exponent` | number | Distance calculation parameter |
|
||||||
|
| `save` | boolean | Persist changes to config file |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5000/api/config \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"gps": {"latitude": 50.85, "longitude": 4.39}, "save": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gps": {"latitude": 50.85, "longitude": 4.39},
|
||||||
|
"web": {"host": "0.0.0.0", "port": 5000},
|
||||||
|
"scanner": {
|
||||||
|
"wifi_interface": "wlan0",
|
||||||
|
"bt_scan_timeout": 10,
|
||||||
|
"path_loss_exponent": 2.5
|
||||||
|
},
|
||||||
|
"config_file": "/home/user/git/rf-mapper/config.yaml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Building Configuration
|
||||||
|
|
||||||
|
Get or update building configuration for 3D visualization.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/building
|
||||||
|
POST /api/building
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST Body:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `enabled` | boolean | Enable 3D building view |
|
||||||
|
| `name` | string | Building name |
|
||||||
|
| `floors` | integer | Number of floors |
|
||||||
|
| `floor_height_m` | number | Height per floor (meters) |
|
||||||
|
| `ground_floor_number` | integer | Ground floor number (0 or 1) |
|
||||||
|
| `current_floor` | integer | Scanner's current floor |
|
||||||
|
| `save` | boolean | Persist changes |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"name": "Home",
|
||||||
|
"floors": 3,
|
||||||
|
"floor_height_m": 3.0,
|
||||||
|
"ground_floor_number": 0,
|
||||||
|
"current_floor": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device Management
|
||||||
|
|
||||||
|
### Set Device Floor
|
||||||
|
|
||||||
|
Assign a floor to a device (persists in database).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/device/<device_id>/floor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `device_id`: BSSID (WiFi) or address (Bluetooth)
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"floor": 2, "height_m": 6.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "updated",
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"device_type": "wifi",
|
||||||
|
"floor": 2,
|
||||||
|
"height_m": 6.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Set Device Distance
|
||||||
|
|
||||||
|
Override estimated distance for a device.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/device/<device_id>/distance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"distance": 5.5}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get All Floor Assignments
|
||||||
|
|
||||||
|
Retrieve all saved floor assignments.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/device/floors
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"AA:BB:CC:DD:EE:FF": 2,
|
||||||
|
"11:22:33:44:55:66": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bluetooth Identification
|
||||||
|
|
||||||
|
### Identify Single Device
|
||||||
|
|
||||||
|
Get detailed info about a Bluetooth device.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/bluetooth/identify/<address>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5000/api/bluetooth/identify/11:22:33:44:55:66
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Batch Identify
|
||||||
|
|
||||||
|
Identify multiple devices (max 10).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/bluetooth/identify
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"addresses": ["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Scan
|
||||||
|
|
||||||
|
### Get Status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/autoscan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"running": true,
|
||||||
|
"interval_minutes": 5,
|
||||||
|
"location_label": "auto_scan",
|
||||||
|
"scan_wifi": true,
|
||||||
|
"scan_bluetooth": true,
|
||||||
|
"last_scan_time": "2026-02-01T14:30:00",
|
||||||
|
"last_scan_result": {"timestamp": "...", "wifi_count": 12, "bt_count": 5},
|
||||||
|
"scan_count": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Start Auto-Scan
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/autoscan/start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `interval_minutes` | integer | 5 | Scan interval |
|
||||||
|
| `location_label` | string | `"auto_scan"` | Location label |
|
||||||
|
| `scan_wifi` | boolean | `true` | Include WiFi |
|
||||||
|
| `scan_bluetooth` | boolean | `true` | Include Bluetooth |
|
||||||
|
| `save` | boolean | `false` | Persist to config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stop Auto-Scan
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/autoscan/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"save": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/autoscan/settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"interval_minutes": 10, "location_label": "living_room", "save": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historical Data API
|
||||||
|
|
||||||
|
All history endpoints require database to be enabled in config.
|
||||||
|
|
||||||
|
### List Devices
|
||||||
|
|
||||||
|
Get all tracked devices with statistics.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/devices
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `type` | string | Filter by `wifi` or `bluetooth` |
|
||||||
|
| `since` | string | ISO timestamp filter |
|
||||||
|
| `limit` | integer | Max results (default: 100) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"device_type": "wifi",
|
||||||
|
"name": "HomeNetwork",
|
||||||
|
"ssid": "HomeNetwork",
|
||||||
|
"manufacturer": "Cisco",
|
||||||
|
"first_seen": "2026-01-15T10:00:00",
|
||||||
|
"last_seen": "2026-02-01T14:30:00",
|
||||||
|
"total_observations": 500,
|
||||||
|
"custom_label": "Main Router",
|
||||||
|
"is_favorite": 1,
|
||||||
|
"assigned_floor": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Device Details
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/devices/<device_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device": { ... },
|
||||||
|
"stats": {
|
||||||
|
"avg_rssi": -55.3,
|
||||||
|
"min_rssi": -75,
|
||||||
|
"max_rssi": -40,
|
||||||
|
"avg_distance_m": 4.2,
|
||||||
|
"min_distance_m": 1.5,
|
||||||
|
"max_distance_m": 12.0,
|
||||||
|
"total_observations": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get RSSI History
|
||||||
|
|
||||||
|
Time series RSSI data for a device.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/devices/<device_id>/rssi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `since` | string | ISO timestamp filter |
|
||||||
|
| `limit` | integer | Max results (default: 1000) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"observations": [
|
||||||
|
{"timestamp": "2026-02-01T14:30:00", "rssi": -55, "distance_m": 4.2, "floor": 1}
|
||||||
|
],
|
||||||
|
"count": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Activity Pattern
|
||||||
|
|
||||||
|
Hourly/daily activity pattern for a device.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/devices/<device_id>/activity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `days` | integer | 7 | Analysis period |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"period_days": 7,
|
||||||
|
"hourly_pattern": {
|
||||||
|
"8": {"count": 50, "avg_rssi": -55.0},
|
||||||
|
"9": {"count": 45, "avg_rssi": -52.3}
|
||||||
|
},
|
||||||
|
"daily_pattern": {
|
||||||
|
"0": 120,
|
||||||
|
"1": 115
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Set Device Label
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/history/devices/<device_id>/label
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"label": "Living Room TV"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Toggle Favorite
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/history/devices/<device_id>/favorite
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"favorite": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Movement Events
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/movement
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `device_id` | string | Filter by device |
|
||||||
|
| `since` | string | ISO timestamp filter |
|
||||||
|
| `limit` | integer | Max results (default: 100) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"device_id": "11:22:33:44:55:66",
|
||||||
|
"timestamp": "2026-02-01T14:30:00",
|
||||||
|
"rssi_delta": 10,
|
||||||
|
"distance_delta_m": -2.5,
|
||||||
|
"direction": "approaching",
|
||||||
|
"velocity_m_s": 0.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Alerts
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `acknowledged` | boolean | Filter by acknowledged status |
|
||||||
|
| `type` | string | Filter by type: `new_device`, `device_absent`, `rssi_threshold` |
|
||||||
|
| `limit` | integer | Max results (default: 50) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"alert_type": "new_device",
|
||||||
|
"device_id": "11:22:33:44:55:66",
|
||||||
|
"timestamp": "2026-02-01T14:30:00",
|
||||||
|
"message": "New Bluetooth device detected: iPhone (Apple)",
|
||||||
|
"acknowledged": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Acknowledge Alert
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/history/alerts/<alert_id>/acknowledge
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Activity Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/activity
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `hours` | integer | 24 | Period in hours |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"period_hours": 24,
|
||||||
|
"since": "2026-01-31T14:30:00",
|
||||||
|
"active_wifi_devices": 12,
|
||||||
|
"active_bt_devices": 8,
|
||||||
|
"total_observations": 2500,
|
||||||
|
"movement_events": 15,
|
||||||
|
"new_devices": 2,
|
||||||
|
"scan_count": 288
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Database Stats
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/history/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_devices": 45,
|
||||||
|
"total_observations": 50000,
|
||||||
|
"total_scans": 1200,
|
||||||
|
"total_movement_events": 300,
|
||||||
|
"unread_alerts": 5,
|
||||||
|
"database_size_bytes": 5242880,
|
||||||
|
"database_size_mb": 5.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Manual Cleanup
|
||||||
|
|
||||||
|
Trigger data cleanup (removes old records).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/history/cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"retention_days": 30}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retention_days": 30,
|
||||||
|
"cutoff": "2026-01-02T14:30:00",
|
||||||
|
"cleaned_at": "2026-02-01T14:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
All endpoints return errors in this format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "Error message here"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common HTTP Status Codes:**
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 200 | Success |
|
||||||
|
| 400 | Bad request (missing/invalid parameters) |
|
||||||
|
| 404 | Resource not found |
|
||||||
|
| 500 | Internal server error |
|
||||||
|
| 503 | Database not enabled |
|
||||||
277
docs/CHEATSHEET.md
Normal file
277
docs/CHEATSHEET.md
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# RF Mapper Cheatsheet
|
||||||
|
|
||||||
|
Quick reference for RF Mapper commands and configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lifecycle
|
||||||
|
rf-mapper start # Start (background daemon)
|
||||||
|
rf-mapper stop # Stop
|
||||||
|
rf-mapper restart # Restart
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show current config
|
||||||
|
rf-mapper config
|
||||||
|
|
||||||
|
# Set GPS coordinates
|
||||||
|
rf-mapper config --set-gps 50.8585 4.3978 --save
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profiling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rf-mapper --profile scan # CPU profiling
|
||||||
|
rf-mapper --profile-memory scan # Memory profiling
|
||||||
|
rf-mapper --profile --profile-output scan.prof scan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common API Calls
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trigger scan
|
||||||
|
curl -X POST http://localhost:5000/api/scan \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"location": "office"}'
|
||||||
|
|
||||||
|
# Get latest scan
|
||||||
|
curl http://localhost:5000/api/latest
|
||||||
|
|
||||||
|
# Quick BT scan (real-time tracking)
|
||||||
|
curl -X POST http://localhost:5000/api/scan/bt
|
||||||
|
|
||||||
|
# List scans
|
||||||
|
curl http://localhost:5000/api/scans
|
||||||
|
|
||||||
|
# Get auto-scan status
|
||||||
|
curl http://localhost:5000/api/autoscan
|
||||||
|
|
||||||
|
# Start auto-scan
|
||||||
|
curl -X POST http://localhost:5000/api/autoscan/start \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"interval_minutes": 5}'
|
||||||
|
|
||||||
|
# Stop auto-scan
|
||||||
|
curl -X POST http://localhost:5000/api/autoscan/stop
|
||||||
|
|
||||||
|
# Set device floor
|
||||||
|
curl -X POST http://localhost:5000/api/device/AA:BB:CC:DD:EE:FF/floor \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"floor": 2}'
|
||||||
|
|
||||||
|
# Get all floor assignments
|
||||||
|
curl http://localhost:5000/api/device/floors
|
||||||
|
|
||||||
|
# Get device history
|
||||||
|
curl "http://localhost:5000/api/history/devices?type=bluetooth&limit=20"
|
||||||
|
|
||||||
|
# Get RSSI history for device
|
||||||
|
curl http://localhost:5000/api/history/devices/AA:BB:CC:DD:EE:FF/rssi
|
||||||
|
|
||||||
|
# Get database stats
|
||||||
|
curl http://localhost:5000/api/history/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config File Locations
|
||||||
|
|
||||||
|
Checked in order:
|
||||||
|
|
||||||
|
1. `./config.yaml` (project directory)
|
||||||
|
2. `~/.config/rf-mapper/config.yaml`
|
||||||
|
3. `/etc/rf-mapper/config.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `RF_MAPPER_LAT` | Override GPS latitude |
|
||||||
|
| `RF_MAPPER_LON` | Override GPS longitude |
|
||||||
|
| `RF_MAPPER_HOST` | Override web server host |
|
||||||
|
| `RF_MAPPER_PORT` | Override web server port |
|
||||||
|
| `HA_TOKEN` | Home Assistant API token |
|
||||||
|
| `HA_URL` | Home Assistant URL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Config Options
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GPS position
|
||||||
|
gps:
|
||||||
|
latitude: 50.8585
|
||||||
|
longitude: 4.3978
|
||||||
|
|
||||||
|
# Web server
|
||||||
|
web:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 5000
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# Scanner settings
|
||||||
|
scanner:
|
||||||
|
wifi_interface: "wlan0"
|
||||||
|
bt_scan_timeout: 10
|
||||||
|
path_loss_exponent: 2.5 # 2.0=open, 2.5=indoor, 3.5=walls
|
||||||
|
auto_identify_bluetooth: true
|
||||||
|
|
||||||
|
# Data storage
|
||||||
|
data:
|
||||||
|
directory: "data"
|
||||||
|
max_scans: 100
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database:
|
||||||
|
enabled: true
|
||||||
|
filename: "devices.db"
|
||||||
|
retention_days: 30
|
||||||
|
auto_cleanup: true
|
||||||
|
|
||||||
|
# Building (3D view)
|
||||||
|
building:
|
||||||
|
enabled: true
|
||||||
|
floors: 3
|
||||||
|
floor_height_m: 3.0
|
||||||
|
current_floor: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Locations
|
||||||
|
|
||||||
|
| Path | Content |
|
||||||
|
|------|---------|
|
||||||
|
| `data/` | Scan JSON files |
|
||||||
|
| `data/devices.db` | SQLite database |
|
||||||
|
| `data/profiles/` | Request profiles |
|
||||||
|
| `data/logs/` | Request logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Permission denied (WiFi/BT)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rf-mapper scan
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Bluetooth adapter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start bluetooth
|
||||||
|
sudo hciconfig hci0 up
|
||||||
|
```
|
||||||
|
|
||||||
|
### No RSSI from Bluetooth
|
||||||
|
|
||||||
|
The app uses `bleak` library for BLE scanning. Ensure:
|
||||||
|
- BlueZ service running: `systemctl status bluetooth`
|
||||||
|
- D-Bus available
|
||||||
|
- Bluetooth adapter powered on
|
||||||
|
|
||||||
|
### Check WiFi interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
iw dev # List interfaces
|
||||||
|
sudo iw dev wlan0 scan # Test scan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database
|
||||||
|
sqlite3 data/devices.db ".tables"
|
||||||
|
sqlite3 data/devices.db "SELECT COUNT(*) FROM devices"
|
||||||
|
|
||||||
|
# Manual cleanup
|
||||||
|
curl -X POST http://localhost:5000/api/history/cleanup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"retention_days": 7}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Interface Views
|
||||||
|
|
||||||
|
| View | Description | Best For |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Radar | Distance rings | Quick overview |
|
||||||
|
| World Map | Leaflet 2D | Geographic context |
|
||||||
|
| 3D Map | MapLibre GL | Building/floor view |
|
||||||
|
|
||||||
|
### Keyboard Shortcuts (Web UI)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `R` | Refresh scan |
|
||||||
|
| `1` | Radar view |
|
||||||
|
| `2` | World map view |
|
||||||
|
| `3` | 3D map view |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signal Quality Reference
|
||||||
|
|
||||||
|
| RSSI Range | Quality | Est. Distance |
|
||||||
|
|------------|---------|---------------|
|
||||||
|
| -30 to -50 | Excellent | < 2m |
|
||||||
|
| -50 to -60 | Good | 2-5m |
|
||||||
|
| -60 to -70 | Fair | 5-10m |
|
||||||
|
| -70 to -80 | Weak | 10-20m |
|
||||||
|
| < -80 | Poor | > 20m |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path Loss Exponent Guide
|
||||||
|
|
||||||
|
| Value | Environment |
|
||||||
|
|-------|-------------|
|
||||||
|
| 2.0 | Free space (outdoor) |
|
||||||
|
| 2.5 | Light indoor (open plan) |
|
||||||
|
| 3.0 | Normal indoor |
|
||||||
|
| 3.5 | Dense indoor (walls) |
|
||||||
|
| 4.0+ | Heavy obstructions |
|
||||||
360
docs/HOME_ASSISTANT.md
Normal file
360
docs/HOME_ASSISTANT.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# Home Assistant Integration
|
||||||
|
|
||||||
|
RF Mapper integrates with Home Assistant using webhooks. RF Mapper sends events to HA, and HA automations handle the logic.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Description | HA Entity Type |
|
||||||
|
|---------|-------------|----------------|
|
||||||
|
| Device presence | Track WiFi/BT devices | `device_tracker.rf_*` |
|
||||||
|
| New device alerts | Notify on unknown device | `automation` trigger |
|
||||||
|
| Departure alerts | Notify when device leaves | `automation` trigger |
|
||||||
|
| Sensor entities | Device count, nearest distance | `sensor.rf_*` |
|
||||||
|
| Multi-scanner | Room-level presence detection | per-scanner sensors |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Single Scanner
|
||||||
|
|
||||||
|
```
|
||||||
|
RF Mapper Home Assistant
|
||||||
|
------------------------------------------------------
|
||||||
|
[Scan] ----webhook----> /api/webhook/rf_mapper_scan
|
||||||
|
|-- automation: update sensors
|
||||||
|
|-- automation: device_tracker.see
|
||||||
|
|
||||||
|
[New Device] --webhook--> /api/webhook/rf_mapper_new_device
|
||||||
|
|-- automation: notify
|
||||||
|
|
||||||
|
[Device Gone] --webhook-> /api/webhook/rf_mapper_device_gone
|
||||||
|
|-- automation: notify
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Scanner Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Pi #1 │ │ Pi #2 │ │ Pi #3 │
|
||||||
|
│ scanner: │ │ scanner: │ │ scanner: │
|
||||||
|
│ id: living│ │ id: kitchen│ │ id: bedroom│
|
||||||
|
│ floor: 0 │ │ floor: 0 │ │ floor: 1 │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
└───────────────────┼───────────────────┘
|
||||||
|
▼
|
||||||
|
┌───────────────┐
|
||||||
|
│ Home Assistant │
|
||||||
|
│ - Track nearest│
|
||||||
|
│ scanner/device│
|
||||||
|
│ - Room presence │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## RF Mapper Configuration
|
||||||
|
|
||||||
|
Enable webhooks in `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Scanner identity (for multi-scanner support)
|
||||||
|
scanner:
|
||||||
|
id: "living_room" # Unique scanner ID
|
||||||
|
name: "Living Room Scanner" # Human-readable name
|
||||||
|
latitude: 50.8584 # Scanner position (optional, falls back to gps.latitude)
|
||||||
|
longitude: 4.3976 # Scanner position (optional, falls back to gps.longitude)
|
||||||
|
floor: 0 # Scanner's floor (optional, falls back to building.current_floor)
|
||||||
|
|
||||||
|
home_assistant:
|
||||||
|
enabled: true
|
||||||
|
url: "http://192.168.129.10:8123"
|
||||||
|
webhook_scan: "rf_mapper_scan"
|
||||||
|
webhook_new_device: "rf_mapper_new_device"
|
||||||
|
webhook_device_gone: "rf_mapper_device_gone"
|
||||||
|
device_timeout_minutes: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scanner Identity Settings
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `scanner.id` | Unique scanner identifier (auto-generated from hostname if empty) |
|
||||||
|
| `scanner.name` | Human-readable display name (defaults to id) |
|
||||||
|
| `scanner.latitude` | Scanner latitude (falls back to `gps.latitude`) |
|
||||||
|
| `scanner.longitude` | Scanner longitude (falls back to `gps.longitude`) |
|
||||||
|
| `scanner.floor` | Scanner's floor number (falls back to `building.current_floor`) |
|
||||||
|
|
||||||
|
### Home Assistant Settings
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `enabled` | Enable/disable HA integration |
|
||||||
|
| `url` | Home Assistant URL (no trailing slash) |
|
||||||
|
| `webhook_scan` | Webhook ID for scan results |
|
||||||
|
| `webhook_new_device` | Webhook ID for new device alerts |
|
||||||
|
| `webhook_device_gone` | Webhook ID for departure alerts |
|
||||||
|
| `device_timeout_minutes` | Minutes before device is considered departed |
|
||||||
|
|
||||||
|
## Home Assistant Setup
|
||||||
|
|
||||||
|
### 1. Automations (`automations.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Process scan results - update device trackers with scanner info
|
||||||
|
- alias: "RF Mapper - Update Device Trackers"
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_scan
|
||||||
|
action:
|
||||||
|
- repeat:
|
||||||
|
for_each: "{{ trigger.json.devices }}"
|
||||||
|
sequence:
|
||||||
|
- service: device_tracker.see
|
||||||
|
data:
|
||||||
|
dev_id: "rf_{{ repeat.item.id | replace(':', '_') }}"
|
||||||
|
source_type: "{{ 'bluetooth' if ':' in repeat.item.id else 'router' }}"
|
||||||
|
attributes:
|
||||||
|
friendly_name: "{{ repeat.item.name }}"
|
||||||
|
rssi: "{{ repeat.item.rssi }}"
|
||||||
|
distance_m: "{{ repeat.item.distance }}"
|
||||||
|
floor: "{{ repeat.item.floor }}"
|
||||||
|
scanner_id: "{{ trigger.json.scanner.id }}"
|
||||||
|
scanner_name: "{{ trigger.json.scanner.name }}"
|
||||||
|
|
||||||
|
# New device notification (includes which scanner detected it)
|
||||||
|
- alias: "RF Mapper - New Device Alert"
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_new_device
|
||||||
|
action:
|
||||||
|
- service: notify.persistent_notification
|
||||||
|
data:
|
||||||
|
title: "New Device Detected"
|
||||||
|
message: >
|
||||||
|
{{ trigger.json.device_type }}: {{ trigger.json.name }}
|
||||||
|
({{ trigger.json.device_id }})
|
||||||
|
detected by {{ trigger.json.scanner.name | default('unknown scanner') }}
|
||||||
|
|
||||||
|
# Device departure notification (includes last scanner)
|
||||||
|
- alias: "RF Mapper - Device Left"
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_device_gone
|
||||||
|
action:
|
||||||
|
- service: notify.persistent_notification
|
||||||
|
data:
|
||||||
|
title: "Device Left"
|
||||||
|
message: >
|
||||||
|
{{ trigger.json.name }} last seen {{ trigger.json.last_seen }}
|
||||||
|
at {{ trigger.json.last_scanner.name | default('unknown location') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Sensor Templates (`configuration.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
template:
|
||||||
|
- trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_scan
|
||||||
|
sensor:
|
||||||
|
# Per-scanner device count sensor
|
||||||
|
- name: "RF Scanner {{ trigger.json.scanner.id }} Device Count"
|
||||||
|
unique_id: "rf_scanner_{{ trigger.json.scanner.id }}_count"
|
||||||
|
state: "{{ trigger.json.device_count }}"
|
||||||
|
icon: mdi:bluetooth
|
||||||
|
attributes:
|
||||||
|
scanner_id: "{{ trigger.json.scanner.id }}"
|
||||||
|
scanner_name: "{{ trigger.json.scanner.name }}"
|
||||||
|
scanner_floor: "{{ trigger.json.scanner.floor }}"
|
||||||
|
|
||||||
|
# Per-scanner nearest device sensor
|
||||||
|
- name: "RF Scanner {{ trigger.json.scanner.id }} Nearest"
|
||||||
|
unique_id: "rf_scanner_{{ trigger.json.scanner.id }}_nearest"
|
||||||
|
state: "{{ trigger.json.devices | map(attribute='distance') | min | round(1) if trigger.json.devices else 'none' }}"
|
||||||
|
unit_of_measurement: "m"
|
||||||
|
icon: mdi:map-marker-distance
|
||||||
|
attributes:
|
||||||
|
scanner_id: "{{ trigger.json.scanner.id }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhook Payload Reference
|
||||||
|
|
||||||
|
### Scan Results (`rf_mapper_scan`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-01T12:34:56.789",
|
||||||
|
"scan_type": "bluetooth",
|
||||||
|
"scanner": {
|
||||||
|
"id": "living_room",
|
||||||
|
"name": "Living Room Scanner",
|
||||||
|
"latitude": 50.8584,
|
||||||
|
"longitude": 4.3976,
|
||||||
|
"floor": 0
|
||||||
|
},
|
||||||
|
"scanner_floor": 0,
|
||||||
|
"device_count": 5,
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "iPhone",
|
||||||
|
"rssi": -65,
|
||||||
|
"distance": 3.2,
|
||||||
|
"floor": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `scanner` | Full scanner identity object |
|
||||||
|
| `scanner.id` | Unique scanner identifier |
|
||||||
|
| `scanner.name` | Human-readable scanner name |
|
||||||
|
| `scanner.latitude` | Scanner GPS latitude |
|
||||||
|
| `scanner.longitude` | Scanner GPS longitude |
|
||||||
|
| `scanner.floor` | Floor where scanner is located |
|
||||||
|
| `scanner_floor` | (Deprecated) Same as `scanner.floor`, for backward compatibility |
|
||||||
|
|
||||||
|
### New Device (`rf_mapper_new_device`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-01T12:34:56.789",
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "Unknown Device",
|
||||||
|
"device_type": "bluetooth",
|
||||||
|
"manufacturer": "Apple, Inc.",
|
||||||
|
"rssi": -70,
|
||||||
|
"distance_m": 5.5,
|
||||||
|
"scanner": {
|
||||||
|
"id": "living_room",
|
||||||
|
"name": "Living Room Scanner",
|
||||||
|
"latitude": 50.8584,
|
||||||
|
"longitude": 4.3976,
|
||||||
|
"floor": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Device Gone (`rf_mapper_device_gone`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-02-01T12:34:56.789",
|
||||||
|
"device_id": "AA:BB:CC:DD:EE:FF",
|
||||||
|
"name": "iPhone",
|
||||||
|
"device_type": "bluetooth",
|
||||||
|
"last_seen": "2026-02-01T12:29:00.000",
|
||||||
|
"last_scanner": {
|
||||||
|
"id": "living_room",
|
||||||
|
"name": "Living Room Scanner",
|
||||||
|
"latitude": 50.8584,
|
||||||
|
"longitude": 4.3976,
|
||||||
|
"floor": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
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`
|
||||||
|
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
|
||||||
|
7. **Test departure**: Wait for timeout, verify departure notification
|
||||||
|
8. **Check sensors**: Verify `sensor.rf_mapper_*` values update
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Webhooks not received | Check HA URL in config, ensure no firewall blocking |
|
||||||
|
| No device trackers | Verify automation is enabled in HA |
|
||||||
|
| Departure not triggered | Increase `device_timeout_minutes` |
|
||||||
|
| Connection timeout | Check network connectivity between RF Mapper and HA |
|
||||||
|
|
||||||
|
## Multi-Scanner Setup
|
||||||
|
|
||||||
|
Configure each scanner with a unique ID and position:
|
||||||
|
|
||||||
|
**Pi #1 (Living Room):**
|
||||||
|
```yaml
|
||||||
|
scanner:
|
||||||
|
id: "living_room"
|
||||||
|
name: "Living Room Scanner"
|
||||||
|
floor: 0
|
||||||
|
latitude: 50.8584
|
||||||
|
longitude: 4.3976
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pi #2 (Kitchen):**
|
||||||
|
```yaml
|
||||||
|
scanner:
|
||||||
|
id: "kitchen"
|
||||||
|
name: "Kitchen Scanner"
|
||||||
|
floor: 0
|
||||||
|
latitude: 50.8585
|
||||||
|
longitude: 4.3978
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pi #3 (Bedroom):**
|
||||||
|
```yaml
|
||||||
|
scanner:
|
||||||
|
id: "bedroom"
|
||||||
|
name: "Bedroom Scanner"
|
||||||
|
floor: 1
|
||||||
|
latitude: 50.8584
|
||||||
|
longitude: 4.3977
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room Presence Automation
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Track which room a device is in (nearest scanner)
|
||||||
|
- alias: "RF Mapper - Track Room Presence"
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_scan
|
||||||
|
action:
|
||||||
|
- repeat:
|
||||||
|
for_each: "{{ trigger.json.devices }}"
|
||||||
|
sequence:
|
||||||
|
- service: input_text.set_value
|
||||||
|
target:
|
||||||
|
entity_id: "input_text.rf_{{ repeat.item.id | replace(':', '_') }}_room"
|
||||||
|
data:
|
||||||
|
value: "{{ trigger.json.scanner.id }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced: Presence-based Automations
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Turn on lights when specific device arrives in living room
|
||||||
|
- alias: "Welcome Home - Living Room"
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_scan
|
||||||
|
condition:
|
||||||
|
- condition: template
|
||||||
|
value_template: >
|
||||||
|
{{ trigger.json.scanner.id == 'living_room' and
|
||||||
|
trigger.json.devices | selectattr('id', 'eq', 'AA:BB:CC:DD:EE:FF') | list | count > 0 }}
|
||||||
|
action:
|
||||||
|
- service: light.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: light.living_room
|
||||||
|
|
||||||
|
# Turn off lights when device leaves a room
|
||||||
|
- alias: "Room Empty Check"
|
||||||
|
trigger:
|
||||||
|
- platform: webhook
|
||||||
|
webhook_id: rf_mapper_scan
|
||||||
|
condition:
|
||||||
|
- condition: template
|
||||||
|
value_template: "{{ trigger.json.scanner.id == 'bedroom' and trigger.json.device_count == 0 }}"
|
||||||
|
action:
|
||||||
|
- service: light.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: light.bedroom
|
||||||
|
```
|
||||||
@@ -33,6 +33,7 @@ dependencies = [
|
|||||||
"flask>=3.0.0",
|
"flask>=3.0.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"bleak>=0.21.0",
|
"bleak>=0.21.0",
|
||||||
|
"requests>=2.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ def main():
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
rf-mapper scan # Scan and save results
|
rf-mapper start # Start web server (background)
|
||||||
|
rf-mapper start --foreground # Start in foreground (debug)
|
||||||
|
rf-mapper stop # Stop web server
|
||||||
|
rf-mapper restart # Restart web server
|
||||||
|
rf-mapper status # Check if running
|
||||||
rf-mapper scan -l kitchen # Scan with location label
|
rf-mapper scan -l kitchen # Scan with location label
|
||||||
rf-mapper visualize # Visualize latest scan
|
|
||||||
rf-mapper analyze # Analyze RF environment
|
|
||||||
rf-mapper web # Start web server
|
|
||||||
rf-mapper config # Show current configuration
|
rf-mapper config # Show current configuration
|
||||||
|
|
||||||
Note: Requires sudo for WiFi/Bluetooth scanning.
|
Note: Requires sudo for WiFi/Bluetooth scanning.
|
||||||
@@ -95,33 +96,79 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
|||||||
# List command
|
# List command
|
||||||
subparsers.add_parser('list', help='List saved scans')
|
subparsers.add_parser('list', help='List saved scans')
|
||||||
|
|
||||||
# Web server command
|
# Start command
|
||||||
web_parser = subparsers.add_parser('web', help='Start web server')
|
start_parser = subparsers.add_parser('start', help='Start web server')
|
||||||
web_parser.add_argument(
|
start_parser.add_argument(
|
||||||
'-H', '--host',
|
'-H', '--host',
|
||||||
help='Host to bind to (default from config)'
|
help='Host to bind to (default from config)'
|
||||||
)
|
)
|
||||||
web_parser.add_argument(
|
start_parser.add_argument(
|
||||||
'-p', '--port',
|
'-p', '--port',
|
||||||
type=int,
|
type=int,
|
||||||
help='Port to listen on (default from config)'
|
help='Port to listen on (default from config)'
|
||||||
)
|
)
|
||||||
web_parser.add_argument(
|
start_parser.add_argument(
|
||||||
|
'-f', '--foreground',
|
||||||
|
action='store_true',
|
||||||
|
help='Run in foreground (default: background daemon)'
|
||||||
|
)
|
||||||
|
start_parser.add_argument(
|
||||||
'--debug',
|
'--debug',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='Enable debug mode'
|
help='Enable Flask debug mode'
|
||||||
)
|
)
|
||||||
web_parser.add_argument(
|
start_parser.add_argument(
|
||||||
'--profile-requests',
|
'--profile-requests',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='Enable per-request profiling (saves profiles to data/profiles/)'
|
help='Enable per-request profiling (saves profiles to data/profiles/)'
|
||||||
)
|
)
|
||||||
web_parser.add_argument(
|
start_parser.add_argument(
|
||||||
'--log-requests',
|
'--log-requests',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help='Log all requests to data/logs/requests_YYYYMMDD.log'
|
help='Log all requests to data/logs/requests_YYYYMMDD.log'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stop command
|
||||||
|
subparsers.add_parser('stop', help='Stop background web server')
|
||||||
|
|
||||||
|
# Restart command
|
||||||
|
restart_parser = subparsers.add_parser('restart', help='Restart web server')
|
||||||
|
restart_parser.add_argument(
|
||||||
|
'-H', '--host',
|
||||||
|
help='Host to bind to (default from config)'
|
||||||
|
)
|
||||||
|
restart_parser.add_argument(
|
||||||
|
'-p', '--port',
|
||||||
|
type=int,
|
||||||
|
help='Port to listen on (default from config)'
|
||||||
|
)
|
||||||
|
restart_parser.add_argument(
|
||||||
|
'--debug',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable Flask debug mode'
|
||||||
|
)
|
||||||
|
restart_parser.add_argument(
|
||||||
|
'--profile-requests',
|
||||||
|
action='store_true',
|
||||||
|
help='Enable per-request profiling'
|
||||||
|
)
|
||||||
|
restart_parser.add_argument(
|
||||||
|
'--log-requests',
|
||||||
|
action='store_true',
|
||||||
|
help='Log all requests'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status command
|
||||||
|
subparsers.add_parser('status', help='Check if web server is running')
|
||||||
|
|
||||||
|
# Deprecated: web command (alias for start --foreground)
|
||||||
|
web_parser = subparsers.add_parser('web', help='[DEPRECATED] Use "start" instead')
|
||||||
|
web_parser.add_argument('-H', '--host', help='Host to bind to')
|
||||||
|
web_parser.add_argument('-p', '--port', type=int, help='Port to listen on')
|
||||||
|
web_parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
||||||
|
web_parser.add_argument('--profile-requests', action='store_true', help='Enable profiling')
|
||||||
|
web_parser.add_argument('--log-requests', action='store_true', help='Log requests')
|
||||||
|
|
||||||
# Config command
|
# Config command
|
||||||
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
|
||||||
config_parser.add_argument(
|
config_parser.add_argument(
|
||||||
@@ -152,10 +199,18 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
|
|||||||
run_analyze(args, data_dir)
|
run_analyze(args, data_dir)
|
||||||
elif args.command == 'list':
|
elif args.command == 'list':
|
||||||
run_list(data_dir)
|
run_list(data_dir)
|
||||||
elif args.command == 'web':
|
elif args.command == 'start':
|
||||||
run_web(args, config)
|
run_start(args, config, data_dir)
|
||||||
|
elif args.command == 'stop':
|
||||||
|
run_stop(data_dir)
|
||||||
|
elif args.command == 'restart':
|
||||||
|
run_restart(args, config, data_dir)
|
||||||
|
elif args.command == 'status':
|
||||||
|
run_status(data_dir)
|
||||||
elif args.command == 'config':
|
elif args.command == 'config':
|
||||||
run_config(args, config)
|
run_config(args, config)
|
||||||
|
elif args.command == 'web':
|
||||||
|
run_web_deprecated(args, config, data_dir)
|
||||||
else:
|
else:
|
||||||
# Default: run interactive scan
|
# Default: run interactive scan
|
||||||
run_interactive(config, data_dir)
|
run_interactive(config, data_dir)
|
||||||
@@ -339,26 +394,6 @@ def run_list(data_dir: Path):
|
|||||||
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
|
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
|
||||||
|
|
||||||
|
|
||||||
def run_web(args, config: Config):
|
|
||||||
"""Start the web server"""
|
|
||||||
from .web.app import run_server
|
|
||||||
|
|
||||||
host = args.host
|
|
||||||
port = args.port
|
|
||||||
debug = args.debug
|
|
||||||
profile_requests = getattr(args, 'profile_requests', False)
|
|
||||||
log_requests = getattr(args, 'log_requests', False)
|
|
||||||
|
|
||||||
run_server(
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
debug=debug,
|
|
||||||
config=config,
|
|
||||||
profile_requests=profile_requests,
|
|
||||||
log_requests=log_requests
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_config(args, config: Config):
|
def run_config(args, config: Config):
|
||||||
"""Show or edit configuration"""
|
"""Show or edit configuration"""
|
||||||
if args.set_gps:
|
if args.set_gps:
|
||||||
@@ -401,5 +436,249 @@ Home Assistant:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def get_pid_file(data_dir: Path) -> Path:
|
||||||
|
"""Get path to PID file"""
|
||||||
|
return data_dir / "rf-mapper.pid"
|
||||||
|
|
||||||
|
|
||||||
|
def get_start_time_file(data_dir: Path) -> Path:
|
||||||
|
"""Get path to start time file"""
|
||||||
|
return data_dir / "rf-mapper.started"
|
||||||
|
|
||||||
|
|
||||||
|
def read_pid(data_dir: Path) -> int | None:
|
||||||
|
"""Read PID from file, return None if not exists or invalid"""
|
||||||
|
pid_file = get_pid_file(data_dir)
|
||||||
|
if not pid_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pid = int(pid_file.read_text().strip())
|
||||||
|
return pid
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_start_time(data_dir: Path) -> float | None:
|
||||||
|
"""Read start timestamp from file"""
|
||||||
|
start_file = get_start_time_file(data_dir)
|
||||||
|
if not start_file.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(start_file.read_text().strip())
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_uptime(seconds: float) -> str:
|
||||||
|
"""Format uptime in human-readable form"""
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{int(seconds)}s"
|
||||||
|
elif seconds < 3600:
|
||||||
|
mins = int(seconds // 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
return f"{mins}m {secs}s"
|
||||||
|
elif seconds < 86400:
|
||||||
|
hours = int(seconds // 3600)
|
||||||
|
mins = int((seconds % 3600) // 60)
|
||||||
|
return f"{hours}h {mins}m"
|
||||||
|
else:
|
||||||
|
days = int(seconds // 86400)
|
||||||
|
hours = int((seconds % 86400) // 3600)
|
||||||
|
return f"{days}d {hours}h"
|
||||||
|
|
||||||
|
|
||||||
|
def is_process_running(pid: int) -> bool:
|
||||||
|
"""Check if process with given PID is running"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_web_deprecated(args, config: Config, data_dir: Path):
|
||||||
|
"""Deprecated: run web server (forwards to start --foreground)"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("\033[33m" + "=" * 60 + "\033[0m")
|
||||||
|
print("\033[33mWARNING: 'rf-mapper web' is deprecated.\033[0m")
|
||||||
|
print("\033[33mUse 'rf-mapper start' instead (runs in background).\033[0m")
|
||||||
|
print("\033[33mUse 'rf-mapper start -f' for foreground mode.\033[0m")
|
||||||
|
print("\033[33mThis command will be removed in a future version.\033[0m")
|
||||||
|
print("\033[33m" + "=" * 60 + "\033[0m\n")
|
||||||
|
|
||||||
|
# Run in foreground (old behavior)
|
||||||
|
from .web.app import run_server
|
||||||
|
|
||||||
|
host = args.host or config.web.host
|
||||||
|
port = args.port or config.web.port
|
||||||
|
debug = getattr(args, 'debug', False)
|
||||||
|
profile_requests = getattr(args, 'profile_requests', False)
|
||||||
|
log_requests = getattr(args, 'log_requests', False)
|
||||||
|
|
||||||
|
run_server(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
debug=debug,
|
||||||
|
config=config,
|
||||||
|
profile_requests=profile_requests,
|
||||||
|
log_requests=log_requests
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_start(args, config: Config, data_dir: Path):
|
||||||
|
"""Start web server (foreground or background)"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
host = args.host or config.web.host
|
||||||
|
port = args.port or config.web.port
|
||||||
|
debug = getattr(args, 'debug', False)
|
||||||
|
profile_requests = getattr(args, 'profile_requests', False)
|
||||||
|
log_requests = getattr(args, 'log_requests', False)
|
||||||
|
foreground = getattr(args, 'foreground', False)
|
||||||
|
|
||||||
|
pid_file = get_pid_file(data_dir)
|
||||||
|
start_file = get_start_time_file(data_dir)
|
||||||
|
|
||||||
|
# Check if already running (skip check if we're the spawned foreground process)
|
||||||
|
if not foreground:
|
||||||
|
pid = read_pid(data_dir)
|
||||||
|
if pid and is_process_running(pid):
|
||||||
|
print(f"RF Mapper is already running (PID: {pid})")
|
||||||
|
print(f"Use 'rf-mapper stop' to stop it, or 'rf-mapper restart' to restart")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if foreground:
|
||||||
|
# Run in foreground (blocking)
|
||||||
|
from .web.app import run_server
|
||||||
|
|
||||||
|
# Write PID file for status command
|
||||||
|
pid_file.write_text(str(os.getpid()))
|
||||||
|
start_file.write_text(str(time.time()))
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_server(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
debug=debug,
|
||||||
|
config=config,
|
||||||
|
profile_requests=profile_requests,
|
||||||
|
log_requests=log_requests
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Cleanup PID file on exit
|
||||||
|
pid_file.unlink(missing_ok=True)
|
||||||
|
start_file.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
# Run in background (daemon mode)
|
||||||
|
# Build command for subprocess
|
||||||
|
cmd = [sys.executable, "-m", "rf_mapper", "start", "-H", host, "-p", str(port), "--foreground"]
|
||||||
|
if debug:
|
||||||
|
cmd.append("--debug")
|
||||||
|
if profile_requests:
|
||||||
|
cmd.append("--profile-requests")
|
||||||
|
if log_requests:
|
||||||
|
cmd.append("--log-requests")
|
||||||
|
|
||||||
|
# Start process in background
|
||||||
|
log_file = data_dir / "rf-mapper.log"
|
||||||
|
with open(log_file, 'a') as log:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=log,
|
||||||
|
stderr=log,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait briefly for process to start and write PID file
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Verify it started successfully
|
||||||
|
if not is_process_running(process.pid):
|
||||||
|
print("RF Mapper failed to start. Check log:")
|
||||||
|
print(f" {log_file}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"RF Mapper started (PID: {process.pid})")
|
||||||
|
print(f" URL: http://{host}:{port}")
|
||||||
|
print(f" Log: {log_file}")
|
||||||
|
print(f"\nUse 'rf-mapper stop' to stop the server")
|
||||||
|
|
||||||
|
|
||||||
|
def run_stop(data_dir: Path):
|
||||||
|
"""Stop background web server"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
pid = read_pid(data_dir)
|
||||||
|
if not pid:
|
||||||
|
print("RF Mapper is not running (no PID file)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not is_process_running(pid):
|
||||||
|
print(f"RF Mapper is not running (stale PID: {pid})")
|
||||||
|
get_pid_file(data_dir).unlink(missing_ok=True)
|
||||||
|
get_start_time_file(data_dir).unlink(missing_ok=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Send SIGTERM
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
print(f"RF Mapper stopped (PID: {pid})")
|
||||||
|
get_pid_file(data_dir).unlink(missing_ok=True)
|
||||||
|
get_start_time_file(data_dir).unlink(missing_ok=True)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Failed to stop RF Mapper: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_restart(args, config: Config, data_dir: Path):
|
||||||
|
"""Restart background web server"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
pid = read_pid(data_dir)
|
||||||
|
if pid and is_process_running(pid):
|
||||||
|
print("Stopping RF Mapper...")
|
||||||
|
run_stop(data_dir)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("Starting RF Mapper...")
|
||||||
|
run_start(args, config, data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def run_status(data_dir: Path):
|
||||||
|
"""Check if web server is running"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
pid = read_pid(data_dir)
|
||||||
|
|
||||||
|
if not pid:
|
||||||
|
print("RF Mapper is not running (no PID file)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if is_process_running(pid):
|
||||||
|
print(f"RF Mapper is running (PID: {pid})")
|
||||||
|
|
||||||
|
# Show uptime
|
||||||
|
start_time = read_start_time(data_dir)
|
||||||
|
if start_time:
|
||||||
|
uptime_secs = time.time() - start_time
|
||||||
|
print(f" Uptime: {format_uptime(uptime_secs)}")
|
||||||
|
|
||||||
|
log_file = data_dir / "rf-mapper.log"
|
||||||
|
if log_file.exists():
|
||||||
|
print(f" Log: {log_file}")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print(f"RF Mapper is not running (stale PID: {pid})")
|
||||||
|
get_pid_file(data_dir).unlink(missing_ok=True)
|
||||||
|
get_start_time_file(data_dir).unlink(missing_ok=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
168
src/rf_mapper/homeassistant.py
Normal file
168
src/rf_mapper/homeassistant.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Home Assistant webhook integration for RF Mapper."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HAWebhookConfig:
|
||||||
|
"""Configuration for Home Assistant webhook integration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
url: str = "http://192.168.129.10:8123"
|
||||||
|
webhook_scan: str = "rf_mapper_scan"
|
||||||
|
webhook_new_device: str = "rf_mapper_new_device"
|
||||||
|
webhook_device_gone: str = "rf_mapper_device_gone"
|
||||||
|
device_timeout_minutes: int = 5
|
||||||
|
timeout_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class HAWebhooks:
|
||||||
|
"""Home Assistant webhook sender for RF Mapper events."""
|
||||||
|
|
||||||
|
def __init__(self, config: HAWebhookConfig):
|
||||||
|
"""Initialize webhook sender with configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: HAWebhookConfig with HA URL and webhook IDs
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def _send(self, webhook_id: str, data: dict) -> bool:
|
||||||
|
"""Send data to a Home Assistant webhook.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
webhook_id: The webhook ID configured in HA
|
||||||
|
data: JSON-serializable data to send
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if webhook was sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
if not self.config.enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = f"{self.config.url.rstrip('/')}/api/webhook/{webhook_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
timeout=self.config.timeout_seconds,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
logger.debug(f"[HA Webhook] Sent to {webhook_id}: {len(data)} items")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[HA Webhook] {webhook_id} returned status {resp.status_code}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning(f"[HA Webhook] Timeout sending to {webhook_id}")
|
||||||
|
return False
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
logger.warning(f"[HA Webhook] Connection error: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[HA Webhook] Error sending to {webhook_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_scan_results(
|
||||||
|
self,
|
||||||
|
devices: list[dict],
|
||||||
|
scanner: dict,
|
||||||
|
scan_type: str = "bluetooth"
|
||||||
|
) -> bool:
|
||||||
|
"""Send scan results to Home Assistant for presence tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: List of device dicts with id, name, rssi, distance, floor
|
||||||
|
scanner: Scanner identity dict with id, name, latitude, longitude, floor
|
||||||
|
scan_type: Type of scan ('bluetooth', 'wifi', or 'both')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if sent successfully
|
||||||
|
"""
|
||||||
|
return self._send(self.config.webhook_scan, {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"scan_type": scan_type,
|
||||||
|
"scanner": scanner,
|
||||||
|
"scanner_floor": scanner.get("floor"), # Backward compatibility
|
||||||
|
"device_count": len(devices),
|
||||||
|
"devices": devices
|
||||||
|
})
|
||||||
|
|
||||||
|
def send_new_device(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
name: str,
|
||||||
|
device_type: str,
|
||||||
|
scanner: Optional[dict] = None,
|
||||||
|
manufacturer: Optional[str] = None,
|
||||||
|
rssi: Optional[int] = None,
|
||||||
|
distance_m: Optional[float] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Alert Home Assistant about a new device detection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: MAC address or unique ID
|
||||||
|
name: Device name (SSID for WiFi, advertised name for BT)
|
||||||
|
device_type: 'wifi' or 'bluetooth'
|
||||||
|
scanner: Scanner identity dict that detected the device
|
||||||
|
manufacturer: Manufacturer from OUI lookup
|
||||||
|
rssi: Signal strength at detection
|
||||||
|
distance_m: Estimated distance at detection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if sent successfully
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"device_id": device_id,
|
||||||
|
"name": name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"manufacturer": manufacturer or "Unknown",
|
||||||
|
"rssi": rssi,
|
||||||
|
"distance_m": distance_m
|
||||||
|
}
|
||||||
|
if scanner:
|
||||||
|
payload["scanner"] = scanner
|
||||||
|
return self._send(self.config.webhook_new_device, payload)
|
||||||
|
|
||||||
|
def send_device_gone(
|
||||||
|
self,
|
||||||
|
device_id: str,
|
||||||
|
name: str,
|
||||||
|
last_seen: str,
|
||||||
|
device_type: str = "bluetooth",
|
||||||
|
last_scanner: Optional[dict] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Alert Home Assistant that a device has departed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: MAC address or unique ID
|
||||||
|
name: Device name
|
||||||
|
last_seen: ISO timestamp of last observation
|
||||||
|
device_type: 'wifi' or 'bluetooth'
|
||||||
|
last_scanner: Scanner identity dict that last saw the device
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if sent successfully
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"device_id": device_id,
|
||||||
|
"name": name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"last_seen": last_seen
|
||||||
|
}
|
||||||
|
if last_scanner:
|
||||||
|
payload["last_scanner"] = last_scanner
|
||||||
|
return self._send(self.config.webhook_device_gone, payload)
|
||||||
@@ -183,6 +183,33 @@ body {
|
|||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Show Trail button in popup */
|
||||||
|
.popup-trail-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #e67e22;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-trail-btn:hover {
|
||||||
|
background: #d35400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-trail-btn.active {
|
||||||
|
background: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-trail-btn.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-btn:hover {
|
.filter-btn:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
@@ -775,6 +802,50 @@ body {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Position status in popup */
|
||||||
|
.popup-position-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-value.manual {
|
||||||
|
color: #f39c12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-value.auto {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-reset-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-reset-btn:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
/* Live Track Button */
|
/* Live Track Button */
|
||||||
#live-track-btn.active {
|
#live-track-btn.active {
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
@@ -831,6 +902,21 @@ body {
|
|||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Draggable markers (floor-assigned devices) */
|
||||||
|
.marker-3d.draggable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-3d.draggable:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual position indicator */
|
||||||
|
.marker-3d.has-manual-position .marker-icon {
|
||||||
|
border: 3px solid #f39c12 !important;
|
||||||
|
box-shadow: 0 0 15px #f39c12, 0 2px 8px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.marker-3d .marker-floor {
|
.marker-3d .marker-floor {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ let scanData = null;
|
|||||||
let map = null;
|
let map = null;
|
||||||
let markers = [];
|
let markers = [];
|
||||||
let filters = {
|
let filters = {
|
||||||
wifi: true,
|
wifi: false,
|
||||||
bluetooth: true
|
bluetooth: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Device trails state - stores trail data per device
|
||||||
|
let deviceTrails = {}; // { deviceId: { type, name, points: [{ timestamp, distance, lat, lon }, ...] } }
|
||||||
|
|
||||||
|
// Device manual positions - loaded from database
|
||||||
|
let manualPositions = {}; // { deviceId: { lat_offset, lon_offset } }
|
||||||
|
|
||||||
// Auto-scan state
|
// Auto-scan state
|
||||||
let autoScanEnabled = false;
|
let autoScanEnabled = false;
|
||||||
let autoScanPollInterval = null;
|
let autoScanPollInterval = null;
|
||||||
@@ -29,6 +35,10 @@ const MIN_SAMPLES_FOR_MOVEMENT = 3; // Need at least this many samples before de
|
|||||||
// Store distance history per device: { address: { samples: [], timestamps: [] } }
|
// Store distance history per device: { address: { samples: [], timestamps: [] } }
|
||||||
let deviceDistanceHistory = {};
|
let deviceDistanceHistory = {};
|
||||||
|
|
||||||
|
// Track consecutive missed detections per device: { address: missCount }
|
||||||
|
let deviceMissCount = {};
|
||||||
|
const MAX_MISSED_SCANS = 5; // Remove device after this many consecutive misses (~20s with 4s interval)
|
||||||
|
|
||||||
// Calculate mean of array
|
// Calculate mean of array
|
||||||
function mean(arr) {
|
function mean(arr) {
|
||||||
if (arr.length === 0) return 0;
|
if (arr.length === 0) return 0;
|
||||||
@@ -110,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initFloorSelector();
|
initFloorSelector();
|
||||||
loadLatestScan();
|
loadLatestScan();
|
||||||
loadAutoScanStatus();
|
loadAutoScanStatus();
|
||||||
|
loadDevicePositions(); // Load saved manual positions
|
||||||
|
|
||||||
// Initialize 3D map as default view
|
// Initialize 3D map as default view
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -483,6 +494,9 @@ function drawRadar() {
|
|||||||
|
|
||||||
const size = Math.max(3, 10 + (dev.rssi + 90) / 5);
|
const size = Math.max(3, 10 + (dev.rssi + 90) / 5);
|
||||||
const isMoving = dev.is_moving === true;
|
const isMoving = dev.is_moving === true;
|
||||||
|
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 color = isMoving ? '#9b59b6' : APP_CONFIG.colors.bluetooth;
|
const color = isMoving ? '#9b59b6' : APP_CONFIG.colors.bluetooth;
|
||||||
|
|
||||||
// Store position for hit detection
|
// Store position for hit detection
|
||||||
@@ -494,6 +508,10 @@ function drawRadar() {
|
|||||||
radius: Math.max(size / 2, 8) // Minimum clickable area
|
radius: Math.max(size / 2, 8) // Minimum clickable area
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save context for opacity
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
|
||||||
// Draw glow
|
// Draw glow
|
||||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2);
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2);
|
||||||
if (isMoving) {
|
if (isMoving) {
|
||||||
@@ -522,6 +540,9 @@ function drawRadar() {
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
|
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
|
// Restore context (for opacity)
|
||||||
|
ctx.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legend
|
// Legend
|
||||||
@@ -609,11 +630,16 @@ function updateMapMarkers() {
|
|||||||
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
|
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
|
||||||
const distLabel = hasCustomDist ? `${dist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
const distLabel = hasCustomDist ? `${dist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
||||||
|
|
||||||
|
// Calculate opacity based on miss count
|
||||||
|
const missCount = dev.miss_count || 0;
|
||||||
|
const opacity = missCount === 0 ? 0.6 : (missCount === 1 ? 0.4 : 0.2);
|
||||||
|
|
||||||
const marker = L.circleMarker([lat + latOffset, lon + lonOffset], {
|
const marker = L.circleMarker([lat + latOffset, lon + lonOffset], {
|
||||||
radius: Math.max(4, 8 + (dev.rssi + 90) / 10),
|
radius: Math.max(4, 8 + (dev.rssi + 90) / 10),
|
||||||
color: APP_CONFIG.colors.bluetooth,
|
color: APP_CONFIG.colors.bluetooth,
|
||||||
fillColor: APP_CONFIG.colors.bluetooth,
|
fillColor: APP_CONFIG.colors.bluetooth,
|
||||||
fillOpacity: 0.6,
|
fillOpacity: opacity,
|
||||||
|
opacity: opacity + 0.2,
|
||||||
weight: 2
|
weight: 2
|
||||||
}).addTo(map).bindPopup(`
|
}).addTo(map).bindPopup(`
|
||||||
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
|
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
|
||||||
@@ -991,6 +1017,168 @@ async function stopAutoScan() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Device Position Functions ==========
|
||||||
|
|
||||||
|
// Load saved device positions from database
|
||||||
|
async function loadDevicePositions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/device/floors');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle both old format (just floors) and new format (floors + positions)
|
||||||
|
if (data.positions) {
|
||||||
|
manualPositions = data.positions;
|
||||||
|
console.log('[Positions] Loaded', Object.keys(manualPositions).length, 'manual positions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading device positions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device position (manual or RSSI-based)
|
||||||
|
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
||||||
|
const deviceId = device.bssid || device.address;
|
||||||
|
const customPos = manualPositions[deviceId];
|
||||||
|
|
||||||
|
// If device has manual position, use it
|
||||||
|
if (customPos && customPos.lat_offset != null && customPos.lon_offset != null) {
|
||||||
|
return {
|
||||||
|
lat: scannerLat + customPos.lat_offset,
|
||||||
|
lon: scannerLon + customPos.lon_offset,
|
||||||
|
isManual: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise calculate from RSSI/distance
|
||||||
|
const effectiveDist = getEffectiveDistance(device);
|
||||||
|
const dist = Math.max(effectiveDist, minDistanceM);
|
||||||
|
const angle = hashString(deviceId) % 360;
|
||||||
|
|
||||||
|
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
||||||
|
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(scannerLat * Math.PI / 180));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: scannerLat + latOffset,
|
||||||
|
lon: scannerLon + lonOffset,
|
||||||
|
isManual: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update device position via API (called after drag)
|
||||||
|
async function updateDevicePosition(deviceId, latOffset, lonOffset) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lat_offset: latOffset,
|
||||||
|
lon_offset: lonOffset
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('[Position] Updated', deviceId, 'to offset', latOffset.toFixed(6), lonOffset.toFixed(6));
|
||||||
|
// Update local cache
|
||||||
|
manualPositions[deviceId] = { lat_offset: latOffset, lon_offset: lonOffset };
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error('[Position] Failed to update:', error.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Position] Error updating position:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset device position to auto (RSSI-based)
|
||||||
|
async function resetDevicePosition(deviceId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lat_offset: null,
|
||||||
|
lon_offset: null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[Position] Reset', deviceId, 'to auto');
|
||||||
|
// Remove from local cache
|
||||||
|
delete manualPositions[deviceId];
|
||||||
|
// Refresh markers to show new position
|
||||||
|
update3DMarkers();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error('[Position] Failed to reset position');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Position] Error resetting position:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scanner (primary source) drag end
|
||||||
|
async function onScannerDragEnd(marker) {
|
||||||
|
const lngLat = marker.getLngLat();
|
||||||
|
const newLat = lngLat.lat;
|
||||||
|
const newLon = lngLat.lng;
|
||||||
|
|
||||||
|
console.log('[Scanner] Repositioned to', newLat.toFixed(6), newLon.toFixed(6));
|
||||||
|
|
||||||
|
// Update the input fields
|
||||||
|
document.getElementById('lat-input').value = newLat.toFixed(6);
|
||||||
|
document.getElementById('lon-input').value = newLon.toFixed(6);
|
||||||
|
|
||||||
|
// Persist to config file (survives restarts)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
gps: { latitude: newLat, longitude: newLon },
|
||||||
|
save: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('[Scanner] Position saved to config');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Scanner] Error saving position:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh all markers (device positions are relative to scanner)
|
||||||
|
update3DMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle marker drag end
|
||||||
|
async function onMarkerDragEnd(marker, deviceId, scannerLat, scannerLon) {
|
||||||
|
const lngLat = marker.getLngLat();
|
||||||
|
const latOffset = lngLat.lat - scannerLat;
|
||||||
|
const lonOffset = lngLat.lng - scannerLon;
|
||||||
|
|
||||||
|
const success = await updateDevicePosition(deviceId, latOffset, lonOffset);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Update marker element to show manual position indicator
|
||||||
|
const el = marker.getElement();
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('has-manual-position');
|
||||||
|
}
|
||||||
|
// Refresh markers to update popup content
|
||||||
|
update3DMarkers();
|
||||||
|
} else {
|
||||||
|
// Revert marker to original position on failure
|
||||||
|
update3DMarkers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 3D Map Functions ==========
|
// ========== 3D Map Functions ==========
|
||||||
|
|
||||||
// Initialize 3D map with MapLibre GL
|
// Initialize 3D map with MapLibre GL
|
||||||
@@ -1153,17 +1341,31 @@ function update3DMarkers() {
|
|||||||
const pixelsPerFloor = 18;
|
const pixelsPerFloor = 18;
|
||||||
const groundFloor = buildingConfig.groundFloorNumber || 0;
|
const groundFloor = buildingConfig.groundFloorNumber || 0;
|
||||||
|
|
||||||
// Add scanner position marker at center
|
// Add scanner position marker at center (draggable for fine-grained positioning)
|
||||||
const scannerEl = document.createElement('div');
|
const scannerEl = document.createElement('div');
|
||||||
scannerEl.className = 'marker-3d center';
|
scannerEl.className = 'marker-3d center draggable';
|
||||||
scannerEl.innerHTML = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
|
scannerEl.innerHTML = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
|
||||||
scannerEl.title = `Your Position - Floor ${scannerFloor}`;
|
scannerEl.title = `Your Position - Floor ${scannerFloor} (drag to reposition)`;
|
||||||
|
|
||||||
const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor;
|
const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor;
|
||||||
const scannerMarker = new maplibregl.Marker({ element: scannerEl, offset: [0, -scannerOffset] })
|
const scannerMarker = new maplibregl.Marker({
|
||||||
|
element: scannerEl,
|
||||||
|
offset: [0, -scannerOffset],
|
||||||
|
draggable: true
|
||||||
|
})
|
||||||
.setLngLat([lon, lat])
|
.setLngLat([lon, lat])
|
||||||
.setPopup(new maplibregl.Popup().setHTML(`<strong>📍 Your Position</strong><br>Floor: ${scannerFloor}`))
|
.setPopup(new maplibregl.Popup().setHTML(`
|
||||||
|
<strong>📍 Your Position</strong><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>
|
||||||
|
`))
|
||||||
.addTo(map3d);
|
.addTo(map3d);
|
||||||
|
|
||||||
|
// Handle scanner marker drag
|
||||||
|
scannerMarker.on('dragend', () => onScannerDragEnd(scannerMarker));
|
||||||
|
scannerMarker._deviceId = '__scanner__';
|
||||||
map3dMarkers.push(scannerMarker);
|
map3dMarkers.push(scannerMarker);
|
||||||
|
|
||||||
if (!scanData) return;
|
if (!scanData) return;
|
||||||
@@ -1179,24 +1381,32 @@ function update3DMarkers() {
|
|||||||
|
|
||||||
// Add WiFi markers
|
// Add WiFi markers
|
||||||
filteredWifi.forEach((net) => {
|
filteredWifi.forEach((net) => {
|
||||||
const effectiveDist = getEffectiveDistance(net);
|
const wifiDeviceId = net.bssid;
|
||||||
const dist = Math.max(effectiveDist, minDistanceM);
|
|
||||||
const angle = hashString(net.bssid) % 360;
|
|
||||||
|
|
||||||
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
|
||||||
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
|
|
||||||
|
|
||||||
const deviceFloor = net.floor !== null && net.floor !== undefined ? net.floor : null;
|
const deviceFloor = net.floor !== null && net.floor !== undefined ? net.floor : null;
|
||||||
const hasCustomDist = net.custom_distance_m !== null && net.custom_distance_m !== undefined;
|
const hasCustomDist = net.custom_distance_m !== null && net.custom_distance_m !== undefined;
|
||||||
|
const effectiveDist = getEffectiveDistance(net);
|
||||||
|
|
||||||
|
// Get position (manual or RSSI-based)
|
||||||
|
const pos = getDevicePosition(net, lat, lon, minDistanceM);
|
||||||
|
const hasManualPosition = pos.isManual;
|
||||||
|
|
||||||
|
// Determine if marker should be draggable (only floor-assigned devices)
|
||||||
|
const isDraggable = deviceFloor !== null;
|
||||||
|
|
||||||
const floorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
|
const floorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'marker-3d wifi';
|
el.className = 'marker-3d wifi';
|
||||||
|
if (isDraggable) el.classList.add('draggable');
|
||||||
|
if (hasManualPosition) el.classList.add('has-manual-position');
|
||||||
el.innerHTML = `<div class="marker-icon">📶</div><div class="marker-floor">${floorLabel}</div>`;
|
el.innerHTML = `<div class="marker-icon">📶</div><div class="marker-floor">${floorLabel}</div>`;
|
||||||
el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}`;
|
el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${hasManualPosition ? ' (Manual position)' : ''}`;
|
||||||
|
|
||||||
const wifiDeviceId = net.bssid;
|
|
||||||
const distLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${net.estimated_distance_m}m`;
|
const distLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${net.estimated_distance_m}m`;
|
||||||
|
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
||||||
|
const positionClass = hasManualPosition ? 'manual' : 'auto';
|
||||||
|
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${wifiDeviceId}')">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 popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
||||||
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
|
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
|
||||||
Signal: ${net.rssi} dBm<br>
|
Signal: ${net.rssi} dBm<br>
|
||||||
@@ -1216,14 +1426,30 @@ function update3DMarkers() {
|
|||||||
placeholder="${net.estimated_distance_m}"
|
placeholder="${net.estimated_distance_m}"
|
||||||
onchange="updateDeviceDistance('${wifiDeviceId}', this.value)">
|
onchange="updateDeviceDistance('${wifiDeviceId}', this.value)">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="popup-position-status">
|
||||||
|
<span class="status-label">Position:</span>
|
||||||
|
<span class="status-value ${positionClass}">${positionStatus}</span>
|
||||||
|
</div>
|
||||||
|
${resetBtn}
|
||||||
|
${dragHint}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Offset marker up based on floor (unknown floors at ground level)
|
// Offset marker up based on floor (unknown floors at ground level)
|
||||||
const wifiOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
|
const wifiOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
|
||||||
const marker = new maplibregl.Marker({ element: el, offset: [0, -wifiOffset] })
|
const marker = new maplibregl.Marker({
|
||||||
.setLngLat([lon + lonOffset, lat + latOffset])
|
element: el,
|
||||||
|
offset: [0, -wifiOffset],
|
||||||
|
draggable: isDraggable
|
||||||
|
})
|
||||||
|
.setLngLat([pos.lon, pos.lat])
|
||||||
.setPopup(popup)
|
.setPopup(popup)
|
||||||
.addTo(map3d);
|
.addTo(map3d);
|
||||||
|
|
||||||
|
// Handle drag end for floor-assigned devices
|
||||||
|
if (isDraggable) {
|
||||||
|
marker.on('dragend', () => onMarkerDragEnd(marker, wifiDeviceId, lat, lon));
|
||||||
|
}
|
||||||
|
|
||||||
marker._deviceId = wifiDeviceId;
|
marker._deviceId = wifiDeviceId;
|
||||||
popup.on('open', () => { openPopupDeviceId = wifiDeviceId; });
|
popup.on('open', () => { openPopupDeviceId = wifiDeviceId; });
|
||||||
popup.on('close', () => { if (openPopupDeviceId === wifiDeviceId) openPopupDeviceId = null; });
|
popup.on('close', () => { if (openPopupDeviceId === wifiDeviceId) openPopupDeviceId = null; });
|
||||||
@@ -1232,27 +1458,45 @@ function update3DMarkers() {
|
|||||||
|
|
||||||
// Add Bluetooth markers
|
// Add Bluetooth markers
|
||||||
filteredBt.forEach((dev) => {
|
filteredBt.forEach((dev) => {
|
||||||
const effectiveDist = getEffectiveDistance(dev);
|
const btDeviceId = dev.address;
|
||||||
const dist = Math.max(effectiveDist, minDistanceM);
|
|
||||||
const angle = hashString(dev.address) % 360;
|
|
||||||
|
|
||||||
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
|
||||||
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
|
|
||||||
|
|
||||||
const deviceFloor = dev.floor !== null && dev.floor !== undefined ? dev.floor : null;
|
const deviceFloor = dev.floor !== null && dev.floor !== undefined ? dev.floor : null;
|
||||||
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
|
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
|
||||||
|
const effectiveDist = getEffectiveDistance(dev);
|
||||||
|
|
||||||
|
// Get position (manual or RSSI-based)
|
||||||
|
const pos = getDevicePosition(dev, lat, lon, minDistanceM);
|
||||||
|
const hasManualPosition = pos.isManual;
|
||||||
|
|
||||||
|
// Determine if marker should be draggable (only floor-assigned devices)
|
||||||
|
const isDraggable = deviceFloor !== null;
|
||||||
|
|
||||||
const btFloorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
|
const btFloorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
|
||||||
const isMoving = dev.is_moving === true;
|
const isMoving = dev.is_moving === true;
|
||||||
|
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 el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
||||||
|
if (isDraggable) el.classList.add('draggable');
|
||||||
|
if (hasManualPosition) el.classList.add('has-manual-position');
|
||||||
|
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.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)' : ''}`;
|
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
|
||||||
|
|
||||||
const btDeviceId = dev.address;
|
|
||||||
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
||||||
|
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
||||||
|
const positionClass = 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 trailBtnHtml = isMoving ? `
|
||||||
|
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
||||||
|
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
||||||
|
Show Trail
|
||||||
|
</button>` : '';
|
||||||
|
|
||||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
||||||
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
|
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
|
||||||
Signal: ${dev.rssi} dBm<br>
|
Signal: ${dev.rssi} dBm<br>
|
||||||
Distance: ${btDistLabel}<br>
|
Distance: ${btDistLabel}<br>
|
||||||
Type: ${escapeHtml(dev.device_type)}<br>
|
Type: ${escapeHtml(dev.device_type)}<br>
|
||||||
@@ -1270,14 +1514,31 @@ function update3DMarkers() {
|
|||||||
placeholder="${dev.estimated_distance_m}"
|
placeholder="${dev.estimated_distance_m}"
|
||||||
onchange="updateDeviceDistance('${btDeviceId}', this.value)">
|
onchange="updateDeviceDistance('${btDeviceId}', this.value)">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="popup-position-status">
|
||||||
|
<span class="status-label">Position:</span>
|
||||||
|
<span class="status-value ${positionClass}">${positionStatus}</span>
|
||||||
|
</div>
|
||||||
|
${resetBtn}
|
||||||
|
${dragHint}
|
||||||
|
${trailBtnHtml}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Offset marker up based on floor (unknown floors at ground level)
|
// Offset marker up based on floor (unknown floors at ground level)
|
||||||
const btOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
|
const btOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
|
||||||
const marker = new maplibregl.Marker({ element: el, offset: [0, -btOffset] })
|
const marker = new maplibregl.Marker({
|
||||||
.setLngLat([lon + lonOffset, lat + latOffset])
|
element: el,
|
||||||
|
offset: [0, -btOffset],
|
||||||
|
draggable: isDraggable
|
||||||
|
})
|
||||||
|
.setLngLat([pos.lon, pos.lat])
|
||||||
.setPopup(popup)
|
.setPopup(popup)
|
||||||
.addTo(map3d);
|
.addTo(map3d);
|
||||||
|
|
||||||
|
// Handle drag end for floor-assigned devices
|
||||||
|
if (isDraggable) {
|
||||||
|
marker.on('dragend', () => onMarkerDragEnd(marker, btDeviceId, lat, lon));
|
||||||
|
}
|
||||||
|
|
||||||
marker._deviceId = btDeviceId;
|
marker._deviceId = btDeviceId;
|
||||||
popup.on('open', () => { openPopupDeviceId = btDeviceId; });
|
popup.on('open', () => { openPopupDeviceId = btDeviceId; });
|
||||||
popup.on('close', () => { if (openPopupDeviceId === btDeviceId) openPopupDeviceId = null; });
|
popup.on('close', () => { if (openPopupDeviceId === btDeviceId) openPopupDeviceId = null; });
|
||||||
@@ -1560,11 +1821,14 @@ async function performLiveBTScan() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
const newBt = data.bluetooth_devices || [];
|
||||||
|
|
||||||
|
// Track which devices were detected in this scan
|
||||||
|
const detectedAddresses = new Set(newBt.map(d => d.address));
|
||||||
|
|
||||||
// Merge BT data with existing scan data, preserving custom distances and floors
|
// Merge BT data with existing scan data, preserving custom distances and floors
|
||||||
if (scanData) {
|
if (scanData) {
|
||||||
const existingBt = scanData.bluetooth_devices || [];
|
const existingBt = scanData.bluetooth_devices || [];
|
||||||
const newBt = data.bluetooth_devices || [];
|
|
||||||
|
|
||||||
// Update existing devices with new RSSI, add new devices
|
// Update existing devices with new RSSI, add new devices
|
||||||
newBt.forEach(newDev => {
|
newBt.forEach(newDev => {
|
||||||
@@ -1574,12 +1838,16 @@ async function performLiveBTScan() {
|
|||||||
// Check for movement using statistical analysis
|
// Check for movement using statistical analysis
|
||||||
const moving = isDeviceMoving(newDev.address, newDist);
|
const moving = isDeviceMoving(newDev.address, newDist);
|
||||||
|
|
||||||
|
// Reset miss count - device was detected
|
||||||
|
deviceMissCount[newDev.address] = 0;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update RSSI and estimated distance, preserve custom values
|
// Update RSSI and estimated distance, preserve custom values
|
||||||
existing.rssi = newDev.rssi;
|
existing.rssi = newDev.rssi;
|
||||||
existing.estimated_distance_m = newDev.estimated_distance_m;
|
existing.estimated_distance_m = newDev.estimated_distance_m;
|
||||||
existing.signal_quality = newDev.signal_quality;
|
existing.signal_quality = newDev.signal_quality;
|
||||||
existing.is_moving = moving;
|
existing.is_moving = moving;
|
||||||
|
existing.miss_count = 0;
|
||||||
// Preserve floor and custom_distance_m if set
|
// Preserve floor and custom_distance_m if set
|
||||||
} else {
|
} else {
|
||||||
// New device, add it
|
// New device, add it
|
||||||
@@ -1588,13 +1856,39 @@ async function performLiveBTScan() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
scanData.bluetooth_devices = existingBt;
|
// 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];
|
||||||
|
// Clear trail if showing
|
||||||
|
if (deviceTrails[dev.address]) {
|
||||||
|
clearDeviceTrail(dev.address);
|
||||||
|
}
|
||||||
|
console.log(`[Live] Removed ${dev.name} (missed ${missCount} scans)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
scanData.bluetooth_devices = filteredBt;
|
||||||
} else {
|
} else {
|
||||||
// No existing scan data, use BT-only data
|
// No existing scan data, use BT-only data
|
||||||
data.bluetooth_devices.forEach(dev => {
|
data.bluetooth_devices.forEach(dev => {
|
||||||
// Initialize history with first sample, not moving yet
|
// Initialize history with first sample, not moving yet
|
||||||
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
||||||
dev.is_moving = false;
|
dev.is_moving = false;
|
||||||
|
deviceMissCount[dev.address] = 0;
|
||||||
});
|
});
|
||||||
scanData = {
|
scanData = {
|
||||||
wifi_networks: [],
|
wifi_networks: [],
|
||||||
@@ -1607,7 +1901,7 @@ async function performLiveBTScan() {
|
|||||||
const status = document.getElementById('scan-status');
|
const status = document.getElementById('scan-status');
|
||||||
if (status) {
|
if (status) {
|
||||||
const movingCount = scanData.bluetooth_devices.filter(d => d.is_moving).length;
|
const movingCount = scanData.bluetooth_devices.filter(d => d.is_moving).length;
|
||||||
status.textContent = `Live: ${data.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
|
status.textContent = `Live: ${scanData.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update BT count
|
// Update BT count
|
||||||
@@ -1623,3 +1917,250 @@ async function performLiveBTScan() {
|
|||||||
console.error('Live BT scan error:', error);
|
console.error('Live BT scan error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Device Trails Functions ==========
|
||||||
|
|
||||||
|
// Toggle trail for a specific device (called from popup button)
|
||||||
|
async function toggleDeviceTrail(deviceId, deviceName, deviceType) {
|
||||||
|
const btnId = `trail-btn-${deviceId.replace(/:/g, '')}`;
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
|
||||||
|
// Check if trail already exists for this device
|
||||||
|
if (deviceTrails[deviceId]) {
|
||||||
|
// Hide trail
|
||||||
|
clearDeviceTrail(deviceId);
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = 'Show Trail';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
console.log(`[Trails] Hidden trail for ${deviceName}`);
|
||||||
|
} else {
|
||||||
|
// Show trail - fetch and render
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = 'Loading...';
|
||||||
|
btn.classList.add('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchDeviceTrail(deviceId, deviceName, deviceType);
|
||||||
|
renderDeviceTrail(deviceId);
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = 'Hide Trail';
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
console.log(`[Trails] Showing trail for ${deviceName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch trail data for a single device
|
||||||
|
async function fetchDeviceTrail(deviceId, deviceName, deviceType) {
|
||||||
|
try {
|
||||||
|
// Get last 100 observations for trail
|
||||||
|
const response = await fetch(`/api/history/devices/${encodeURIComponent(deviceId)}/rssi?limit=100`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[Trails] Failed to fetch trail for ${deviceId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const observations = data.observations || [];
|
||||||
|
|
||||||
|
if (observations.length < 2) {
|
||||||
|
console.log(`[Trails] Not enough data points for ${deviceId} (${observations.length})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert observations to trail points with positions
|
||||||
|
const lat = parseFloat(document.getElementById('lat-input').value) || APP_CONFIG.defaultLat;
|
||||||
|
const lon = parseFloat(document.getElementById('lon-input').value) || APP_CONFIG.defaultLon;
|
||||||
|
const angle = hashString(deviceId) % 360;
|
||||||
|
|
||||||
|
const trailPoints = observations.map(obs => {
|
||||||
|
const dist = obs.distance_m || 5;
|
||||||
|
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
|
||||||
|
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: obs.timestamp,
|
||||||
|
distance: dist,
|
||||||
|
lat: lat + latOffset,
|
||||||
|
lon: lon + lonOffset,
|
||||||
|
rssi: obs.rssi,
|
||||||
|
floor: obs.floor
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store trail data
|
||||||
|
deviceTrails[deviceId] = {
|
||||||
|
type: deviceType,
|
||||||
|
name: deviceName,
|
||||||
|
points: trailPoints.reverse() // Oldest first for line drawing
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[Trails] Fetched ${trailPoints.length} points for ${deviceName}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Trails] Error fetching trail for ${deviceId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render trail for a single device on the 3D map
|
||||||
|
function renderDeviceTrail(deviceId) {
|
||||||
|
if (!map3d || !map3dLoaded) return;
|
||||||
|
|
||||||
|
const trail = deviceTrails[deviceId];
|
||||||
|
if (!trail || trail.points.length < 2) return;
|
||||||
|
|
||||||
|
const safeId = deviceId.replace(/:/g, '-');
|
||||||
|
const sourceId = `trail-source-${safeId}`;
|
||||||
|
const layerId = `trail-layer-${safeId}`;
|
||||||
|
const pointsLayerId = `trail-points-${safeId}`;
|
||||||
|
|
||||||
|
// Remove existing trail for this device if any
|
||||||
|
clearDeviceTrailLayers(safeId);
|
||||||
|
|
||||||
|
// Create GeoJSON line coordinates
|
||||||
|
const coordinates = trail.points.map(p => [p.lon, p.lat]);
|
||||||
|
|
||||||
|
// Purple color for moving devices
|
||||||
|
const color = '#9b59b6';
|
||||||
|
|
||||||
|
// Add source for trail line
|
||||||
|
map3d.addSource(sourceId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
deviceId: deviceId,
|
||||||
|
name: trail.name,
|
||||||
|
type: trail.type
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: coordinates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add trail line layer
|
||||||
|
map3d.addLayer({
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: sourceId,
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': color,
|
||||||
|
'line-width': 3,
|
||||||
|
'line-opacity': 0.8,
|
||||||
|
'line-dasharray': [2, 1]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add trail points (circles at each observation)
|
||||||
|
const pointFeatures = trail.points.map((p, i) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
index: i,
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
rssi: p.rssi,
|
||||||
|
distance: p.distance
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [p.lon, p.lat]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
map3d.addSource(`${sourceId}-points`, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: pointFeatures
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map3d.addLayer({
|
||||||
|
id: pointsLayerId,
|
||||||
|
type: 'circle',
|
||||||
|
source: `${sourceId}-points`,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 4,
|
||||||
|
'circle-color': color,
|
||||||
|
'circle-opacity': 0.6,
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
'circle-stroke-opacity': 0.5
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Trails] Rendered trail for ${trail.name} with ${trail.points.length} points`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear trail for a specific device
|
||||||
|
function clearDeviceTrail(deviceId) {
|
||||||
|
const safeId = deviceId.replace(/:/g, '-');
|
||||||
|
clearDeviceTrailLayers(safeId);
|
||||||
|
delete deviceTrails[deviceId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear trail layers for a device (by safe ID)
|
||||||
|
function clearDeviceTrailLayers(safeId) {
|
||||||
|
if (!map3d || !map3dLoaded) return;
|
||||||
|
|
||||||
|
const sourceId = `trail-source-${safeId}`;
|
||||||
|
const layerId = `trail-layer-${safeId}`;
|
||||||
|
const pointsLayerId = `trail-points-${safeId}`;
|
||||||
|
|
||||||
|
// Remove layers first
|
||||||
|
if (map3d.getLayer(layerId)) {
|
||||||
|
map3d.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
if (map3d.getLayer(pointsLayerId)) {
|
||||||
|
map3d.removeLayer(pointsLayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sources
|
||||||
|
if (map3d.getSource(sourceId)) {
|
||||||
|
map3d.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
if (map3d.getSource(`${sourceId}-points`)) {
|
||||||
|
map3d.removeSource(`${sourceId}-points`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all trails from the 3D map
|
||||||
|
function clearAllTrails() {
|
||||||
|
if (!map3d || !map3dLoaded) return;
|
||||||
|
|
||||||
|
// Remove all trail layers and sources
|
||||||
|
const style = map3d.getStyle();
|
||||||
|
if (!style || !style.layers) return;
|
||||||
|
|
||||||
|
// Find and remove trail layers
|
||||||
|
const layersToRemove = style.layers
|
||||||
|
.filter(layer => layer.id.startsWith('trail-'))
|
||||||
|
.map(layer => layer.id);
|
||||||
|
|
||||||
|
layersToRemove.forEach(layerId => {
|
||||||
|
if (map3d.getLayer(layerId)) {
|
||||||
|
map3d.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and remove trail sources
|
||||||
|
const sources = Object.keys(style.sources || {});
|
||||||
|
sources.forEach(sourceId => {
|
||||||
|
if (sourceId.startsWith('trail-')) {
|
||||||
|
if (map3d.getSource(sourceId)) {
|
||||||
|
map3d.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deviceTrails = {};
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-controls">
|
<div class="filter-controls">
|
||||||
<button id="filter-wifi" class="filter-btn wifi" onclick="toggleFilter('wifi')">
|
<button id="filter-wifi" class="filter-btn wifi inactive" onclick="toggleFilter('wifi')">
|
||||||
<span class="filter-indicator"></span>
|
<span class="filter-indicator"></span>
|
||||||
<span>WiFi</span>
|
<span>WiFi</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -76,15 +76,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="section">
|
<!-- Hidden inputs to preserve GPS values for JS -->
|
||||||
<div class="section-header">
|
<input type="hidden" id="lat-input" value="{{ lat }}">
|
||||||
<span class="section-title">📍 Position</span>
|
<input type="hidden" id="lon-input" value="{{ lon }}">
|
||||||
</div>
|
|
||||||
<div class="position-input">
|
|
||||||
<input type="number" id="lat-input" placeholder="Latitude" step="0.0001" value="{{ lat }}">
|
|
||||||
<input type="number" id="lon-input" placeholder="Longitude" step="0.0001" value="{{ lon }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section floor-section" id="floor-section">
|
<div class="section floor-section" id="floor-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
|
|||||||
Reference in New Issue
Block a user