From 7cc7c47805412e1a4787d6ace50168860ec07685 Mon Sep 17 00:00:00 2001 From: User Date: Sun, 1 Feb 2026 03:31:02 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 78 +++ CLAUDE.md | 13 +- PROJECT.md | 25 +- ROADMAP.md | 26 +- TASKS.md | 17 +- TODO.md | 11 +- USAGE.md | 32 +- docs/API.md | 750 +++++++++++++++++++++++++ docs/CHEATSHEET.md | 277 +++++++++ docs/HOME_ASSISTANT.md | 360 ++++++++++++ pyproject.toml | 1 + src/rf_mapper/__main__.py | 347 ++++++++++-- src/rf_mapper/homeassistant.py | 168 ++++++ src/rf_mapper/web/static/css/style.css | 86 +++ src/rf_mapper/web/static/js/app.js | 607 ++++++++++++++++++-- src/rf_mapper/web/templates/index.html | 14 +- 16 files changed, 2704 insertions(+), 108 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/API.md create mode 100644 docs/CHEATSHEET.md create mode 100644 docs/HOME_ASSISTANT.md create mode 100644 src/rf_mapper/homeassistant.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea1114b --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index e3aa5e9..78359c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,9 @@ src/rf_mapper/ ├── __main__.py # CLI entry point and argument parsing ├── scanner.py # WiFi/Bluetooth scanning (WifiNetwork, BluetoothDevice dataclasses) ├── config.py # Configuration management (Config, BuildingConfig, etc.) +├── database.py # SQLite database for device history and tracking ├── distance.py # RSSI to distance estimation +├── homeassistant.py # Home Assistant webhook integration ├── oui.py # MAC address manufacturer lookup ├── bluetooth_*.py # Bluetooth device identification and classification ├── visualize.py # ASCII radar and chart generation @@ -35,18 +37,23 @@ src/rf_mapper/ | 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` | | Add configuration | `src/rf_mapper/config.py`, `config.yaml` | +| Home Assistant integration | `src/rf_mapper/homeassistant.py`, `docs/HOME_ASSISTANT.md` | ## Running ```bash 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 --help # All commands ``` ## Tech Stack -- Python 3.10+, Flask, PyYAML +- Python 3.10+, Flask, PyYAML, requests - 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 diff --git a/PROJECT.md b/PROJECT.md index 16180fa..9f6eec8 100644 --- a/PROJECT.md +++ b/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 - **Auto-scan** - Scheduled background scanning - **Data Export** - JSON scan history with timestamps +- **Home Assistant Integration** - Webhook-based presence tracking, new device alerts, departure notifications ## Architecture @@ -64,6 +65,14 @@ Understanding the RF environment around you is useful for: │ │ iw │ │ bleak │ │ SQLite │ │ │ │ (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 - dataclasses - Data structures - bleak - BLE scanning with RSSI +- requests - HTTP client for webhooks ### Frontend - Leaflet.js - 2D maps @@ -99,8 +109,11 @@ pip install -e . ## Quick Start ```bash -# Start web interface -rf-mapper web +# Start web server (background) +rf-mapper start + +# Check status +rf-mapper status # CLI scan rf-mapper scan @@ -126,6 +139,14 @@ building: enabled: true floors: 12 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 diff --git a/ROADMAP.md b/ROADMAP.md index 2ced3a6..4346aef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ --- -## v0.4.0 - Multilateration & Positioning +## v0.6.0 - Multilateration & Positioning - [ ] Multi-point scanning (move scanner, record positions) - [ ] 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] 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 -- [ ] Webhook notifications -- [ ] Home Assistant integration (MQTT) -- [ ] Presence detection automation -- [ ] Device absence detection +- [x] Webhook notifications +- [ ] Home Assistant MQTT auto-discovery +- [x] Presence detection automation (via HA webhook) +- [x] Device absence detection (via HA webhook) - [ ] Scheduled reports --- diff --git a/TASKS.md b/TASKS.md index 6866f98..58ea4bd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -35,12 +35,14 @@ | [x] | Floor-based positioning | Devices assigned to floors | | [x] | Floor selector UI | Dropdown to filter by floor | | [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] | Moving device detection | Purple markers for RSSI changes | | [x] | Filter-aware scanning | Skip WiFi/BT based on toggle | | [x] | Improve BT discovery reliability | Using bleak library for BLE scanning | -| [ ] | Document API endpoints | docs/API.md | -| [ ] | Create CHEATSHEET.md | Quick reference guide | +| [x] | Document API endpoints | docs/API.md | +| [x] | Create CHEATSHEET.md | Quick reference guide | +| [x] | Home Assistant webhook integration | Scan results, new device, departure alerts | --- @@ -49,7 +51,7 @@ | Status | Task | Notes | |--------|------|-------| | [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 | | [ ] | Scan history browser | View past scans in UI | | [ ] | Export functionality | Download scan data as CSV | @@ -62,7 +64,7 @@ |--------|------|-------| | [x] | SQLite persistence | Historical device tracking enabled | | [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 | | [ ] | Unit tests | pytest coverage | @@ -84,6 +86,12 @@ | Statistical movement detection | 2026-02-01 | | Floor persistence in database | 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) - Floor assignments persist in database across page reloads - Popups stay open during live tracking updates +- Manual position override: drag floor-assigned device markers to set custom position diff --git a/TODO.md b/TODO.md index e034021..5d7c6e5 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,7 @@ - [ ] Historical playback mode (scrub through time) - [x] Device activity patterns (daily/weekly) - [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) --- @@ -41,7 +41,7 @@ ## 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 - [ ] Path loss exponent calibration wizard - [ ] Environment presets (office, home, warehouse) @@ -105,8 +105,9 @@ - [ ] WebSocket for real-time updates - [ ] GraphQL endpoint (optional) - [ ] MQTT publishing -- [ ] Home Assistant auto-discovery -- [ ] Webhook on device events +- [x] Home Assistant webhook integration (scan results, new device, departure) +- [ ] Home Assistant auto-discovery (MQTT) +- [x] Webhook on device events - [ ] Prometheus metrics endpoint - [ ] Grafana dashboard template - [ ] Node-RED integration nodes @@ -219,6 +220,8 @@ - [x] Statistical movement detection (reduces false positives) - [x] Floor persistence in database - [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) --- diff --git a/USAGE.md b/USAGE.md index 604917a..9c6cab9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -29,8 +29,8 @@ source venv/bin/activate # Run interactive scan rf-mapper -# Start web interface -rf-mapper web +# Start web server +rf-mapper start ``` ## CLI Commands @@ -74,23 +74,35 @@ rf-mapper analyze rf-mapper list ``` -### Web Interface +### Web Server ```bash -# Start web server (default: http://0.0.0.0:5000) -rf-mapper web +# Start web server (background daemon) +rf-mapper start + +# Start in foreground (for debugging) +rf-mapper start --foreground # 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 -rf-mapper web --debug +# With debug mode +rf-mapper start --foreground --debug # With request profiling -rf-mapper web --profile-requests +rf-mapper start --profile-requests # 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 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..29bbc37 --- /dev/null +++ b/docs/API.md @@ -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/ +``` + +**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//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//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/
+``` + +**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/ +``` + +**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//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//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//label +``` + +**Request Body:** + +```json +{"label": "Living Room TV"} +``` + +--- + +### Toggle Favorite + +``` +POST /api/history/devices//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//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 | diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md new file mode 100644 index 0000000..89ab60b --- /dev/null +++ b/docs/CHEATSHEET.md @@ -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 | diff --git a/docs/HOME_ASSISTANT.md b/docs/HOME_ASSISTANT.md new file mode 100644 index 0000000..b833fa8 --- /dev/null +++ b/docs/HOME_ASSISTANT.md @@ -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 +``` diff --git a/pyproject.toml b/pyproject.toml index d4b0d48..031c7b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "flask>=3.0.0", "pyyaml>=6.0", "bleak>=0.21.0", + "requests>=2.28.0", ] [project.optional-dependencies] diff --git a/src/rf_mapper/__main__.py b/src/rf_mapper/__main__.py index 49ae3ec..1aac27b 100644 --- a/src/rf_mapper/__main__.py +++ b/src/rf_mapper/__main__.py @@ -22,11 +22,12 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 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 visualize # Visualize latest scan - rf-mapper analyze # Analyze RF environment - rf-mapper web # Start web server rf-mapper config # Show current configuration Note: Requires sudo for WiFi/Bluetooth scanning. @@ -95,33 +96,79 @@ Note: Requires sudo for WiFi/Bluetooth scanning. # List command subparsers.add_parser('list', help='List saved scans') - # Web server command - web_parser = subparsers.add_parser('web', help='Start web server') - web_parser.add_argument( + # Start command + start_parser = subparsers.add_parser('start', help='Start web server') + start_parser.add_argument( '-H', '--host', help='Host to bind to (default from config)' ) - web_parser.add_argument( + start_parser.add_argument( '-p', '--port', type=int, 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', action='store_true', - help='Enable debug mode' + help='Enable Flask debug mode' ) - web_parser.add_argument( + start_parser.add_argument( '--profile-requests', action='store_true', help='Enable per-request profiling (saves profiles to data/profiles/)' ) - web_parser.add_argument( + start_parser.add_argument( '--log-requests', action='store_true', 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_parser = subparsers.add_parser('config', help='Show/edit configuration') config_parser.add_argument( @@ -152,10 +199,18 @@ Note: Requires sudo for WiFi/Bluetooth scanning. run_analyze(args, data_dir) elif args.command == 'list': run_list(data_dir) - elif args.command == 'web': - run_web(args, config) + elif args.command == 'start': + 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': run_config(args, config) + elif args.command == 'web': + run_web_deprecated(args, config, data_dir) else: # Default: run interactive scan 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}") -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): """Show or edit configuration""" 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__': main() diff --git a/src/rf_mapper/homeassistant.py b/src/rf_mapper/homeassistant.py new file mode 100644 index 0000000..1abe87e --- /dev/null +++ b/src/rf_mapper/homeassistant.py @@ -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) diff --git a/src/rf_mapper/web/static/css/style.css b/src/rf_mapper/web/static/css/style.css index 437cf7e..0a2aa35 100644 --- a/src/rf_mapper/web/static/css/style.css +++ b/src/rf_mapper/web/static/css/style.css @@ -183,6 +183,33 @@ body { 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 { transform: scale(1.05); } @@ -775,6 +802,50 @@ body { 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-btn.active { background: var(--color-accent); @@ -831,6 +902,21 @@ body { 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 { font-size: 10px; font-weight: bold; diff --git a/src/rf_mapper/web/static/js/app.js b/src/rf_mapper/web/static/js/app.js index 53f4725..597e130 100644 --- a/src/rf_mapper/web/static/js/app.js +++ b/src/rf_mapper/web/static/js/app.js @@ -8,10 +8,16 @@ let scanData = null; let map = null; let markers = []; let filters = { - wifi: true, + wifi: false, 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 let autoScanEnabled = false; 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: [] } } 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 function mean(arr) { if (arr.length === 0) return 0; @@ -110,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => { initFloorSelector(); loadLatestScan(); loadAutoScanStatus(); + loadDevicePositions(); // Load saved manual positions // Initialize 3D map as default view setTimeout(() => { @@ -483,6 +494,9 @@ function drawRadar() { const size = Math.max(3, 10 + (dev.rssi + 90) / 5); 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; // Store position for hit detection @@ -494,6 +508,10 @@ function drawRadar() { radius: Math.max(size / 2, 8) // Minimum clickable area }); + // Save context for opacity + ctx.save(); + ctx.globalAlpha = opacity; + // Draw glow const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2); if (isMoving) { @@ -522,6 +540,9 @@ function drawRadar() { ctx.beginPath(); ctx.arc(x, y, size / 2, 0, Math.PI * 2); ctx.fill(); + + // Restore context (for opacity) + ctx.restore(); }); // Legend @@ -609,11 +630,16 @@ function updateMapMarkers() { const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined; 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], { radius: Math.max(4, 8 + (dev.rssi + 90) / 10), color: APP_CONFIG.colors.bluetooth, fillColor: APP_CONFIG.colors.bluetooth, - fillOpacity: 0.6, + fillOpacity: opacity, + opacity: opacity + 0.2, weight: 2 }).addTo(map).bindPopup(` 🔵 ${escapeHtml(dev.name)}
@@ -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 ========== // Initialize 3D map with MapLibre GL @@ -1153,17 +1341,31 @@ function update3DMarkers() { const pixelsPerFloor = 18; 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'); - scannerEl.className = 'marker-3d center'; + scannerEl.className = 'marker-3d center draggable'; scannerEl.innerHTML = `
📍
F${scannerFloor}
`; - scannerEl.title = `Your Position - Floor ${scannerFloor}`; + scannerEl.title = `Your Position - Floor ${scannerFloor} (drag to reposition)`; 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]) - .setPopup(new maplibregl.Popup().setHTML(`📍 Your Position
Floor: ${scannerFloor}`)) + .setPopup(new maplibregl.Popup().setHTML(` + 📍 Your Position
+ Floor: ${scannerFloor}
+ Lat: ${lat.toFixed(6)}
+ Lon: ${lon.toFixed(6)}
+
Drag marker to reposition
+ `)) .addTo(map3d); + + // Handle scanner marker drag + scannerMarker.on('dragend', () => onScannerDragEnd(scannerMarker)); + scannerMarker._deviceId = '__scanner__'; map3dMarkers.push(scannerMarker); if (!scanData) return; @@ -1179,24 +1381,32 @@ function update3DMarkers() { // Add WiFi markers filteredWifi.forEach((net) => { - const effectiveDist = getEffectiveDistance(net); - 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 wifiDeviceId = net.bssid; const deviceFloor = net.floor !== null && net.floor !== undefined ? net.floor : null; 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 el = document.createElement('div'); el.className = 'marker-3d wifi'; + if (isDraggable) el.classList.add('draggable'); + if (hasManualPosition) el.classList.add('has-manual-position'); el.innerHTML = `
📶
${floorLabel}
`; - 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 positionStatus = hasManualPosition ? 'Manual' : 'Auto'; + const positionClass = hasManualPosition ? 'manual' : 'auto'; + const resetBtn = hasManualPosition ? `` : ''; + const dragHint = isDraggable && !hasManualPosition ? '
Drag marker to set position
' : ''; + const popup = new maplibregl.Popup({ offset: 25 }).setHTML(` 📶 ${escapeHtml(net.ssid)}
Signal: ${net.rssi} dBm
@@ -1216,14 +1426,30 @@ function update3DMarkers() { placeholder="${net.estimated_distance_m}" onchange="updateDeviceDistance('${wifiDeviceId}', this.value)"> + + ${resetBtn} + ${dragHint} `); // Offset marker up based on floor (unknown floors at ground level) const wifiOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0; - const marker = new maplibregl.Marker({ element: el, offset: [0, -wifiOffset] }) - .setLngLat([lon + lonOffset, lat + latOffset]) + const marker = new maplibregl.Marker({ + element: el, + offset: [0, -wifiOffset], + draggable: isDraggable + }) + .setLngLat([pos.lon, pos.lat]) .setPopup(popup) .addTo(map3d); + + // Handle drag end for floor-assigned devices + if (isDraggable) { + marker.on('dragend', () => onMarkerDragEnd(marker, wifiDeviceId, lat, lon)); + } + marker._deviceId = wifiDeviceId; popup.on('open', () => { openPopupDeviceId = wifiDeviceId; }); popup.on('close', () => { if (openPopupDeviceId === wifiDeviceId) openPopupDeviceId = null; }); @@ -1232,27 +1458,45 @@ function update3DMarkers() { // Add Bluetooth markers filteredBt.forEach((dev) => { - const effectiveDist = getEffectiveDistance(dev); - 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 btDeviceId = dev.address; const deviceFloor = dev.floor !== null && dev.floor !== undefined ? dev.floor : null; 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 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'); 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 = `
${isMoving ? '🟣' : '🔵'}
${btFloorLabel}
`; - 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 positionStatus = hasManualPosition ? 'Manual' : 'Auto'; + const positionClass = hasManualPosition ? 'manual' : 'auto'; + const resetBtn = hasManualPosition ? `` : ''; + const dragHint = isDraggable && !hasManualPosition ? '
Drag marker to set position
' : ''; + const trailBtnHtml = isMoving ? ` + ` : ''; + const popup = new maplibregl.Popup({ offset: 25 }).setHTML(` - 🔵 ${escapeHtml(dev.name)}
+ ${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}${isMoving ? ' (Moving)' : ''}
Signal: ${dev.rssi} dBm
Distance: ${btDistLabel}
Type: ${escapeHtml(dev.device_type)}
@@ -1270,14 +1514,31 @@ function update3DMarkers() { placeholder="${dev.estimated_distance_m}" onchange="updateDeviceDistance('${btDeviceId}', this.value)"> + + ${resetBtn} + ${dragHint} + ${trailBtnHtml} `); // Offset marker up based on floor (unknown floors at ground level) const btOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0; - const marker = new maplibregl.Marker({ element: el, offset: [0, -btOffset] }) - .setLngLat([lon + lonOffset, lat + latOffset]) + const marker = new maplibregl.Marker({ + element: el, + offset: [0, -btOffset], + draggable: isDraggable + }) + .setLngLat([pos.lon, pos.lat]) .setPopup(popup) .addTo(map3d); + + // Handle drag end for floor-assigned devices + if (isDraggable) { + marker.on('dragend', () => onMarkerDragEnd(marker, btDeviceId, lat, lon)); + } + marker._deviceId = btDeviceId; popup.on('open', () => { openPopupDeviceId = btDeviceId; }); popup.on('close', () => { if (openPopupDeviceId === btDeviceId) openPopupDeviceId = null; }); @@ -1560,11 +1821,14 @@ async function performLiveBTScan() { if (response.ok) { 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 if (scanData) { const existingBt = scanData.bluetooth_devices || []; - const newBt = data.bluetooth_devices || []; // Update existing devices with new RSSI, add new devices newBt.forEach(newDev => { @@ -1574,12 +1838,16 @@ async function performLiveBTScan() { // Check for movement using statistical analysis const moving = isDeviceMoving(newDev.address, newDist); + // Reset miss count - device was detected + deviceMissCount[newDev.address] = 0; + if (existing) { // Update RSSI and estimated distance, preserve custom values existing.rssi = newDev.rssi; existing.estimated_distance_m = newDev.estimated_distance_m; existing.signal_quality = newDev.signal_quality; existing.is_moving = moving; + existing.miss_count = 0; // Preserve floor and custom_distance_m if set } else { // New device, add it @@ -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 { // No existing scan data, use BT-only data data.bluetooth_devices.forEach(dev => { // Initialize history with first sample, not moving yet isDeviceMoving(dev.address, dev.estimated_distance_m); dev.is_moving = false; + deviceMissCount[dev.address] = 0; }); scanData = { wifi_networks: [], @@ -1607,7 +1901,7 @@ async function performLiveBTScan() { const status = document.getElementById('scan-status'); if (status) { 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 @@ -1623,3 +1917,250 @@ async function performLiveBTScan() { 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 = {}; +} diff --git a/src/rf_mapper/web/templates/index.html b/src/rf_mapper/web/templates/index.html index 924ff2d..24d7bee 100644 --- a/src/rf_mapper/web/templates/index.html +++ b/src/rf_mapper/web/templates/index.html @@ -25,7 +25,7 @@
- @@ -76,15 +76,9 @@