Compare commits

...

333 Commits

Author SHA1 Message Date
Smittix db304631f8 feat: Add Meshtastic, Ubertooth, and Offline Mode support
New Features:
- Meshtastic LoRa mesh network integration
  - Real-time message streaming via SSE
  - Channel configuration with encryption
  - Node information with RSSI/SNR metrics
- Ubertooth One BLE scanner backend
  - Passive capture across all 40 BLE channels
  - Raw advertising payload access
- Offline mode with bundled assets
  - Local Leaflet, Chart.js, and fonts
  - Multiple map tile providers
  - Settings modal for configuration

Technical Changes:
- New routes: meshtastic.py, offline.py
- New utils: ubertooth_scanner.py, meshtastic.py
- New CSS/JS for meshtastic and settings
- Updated dashboard templates with conditional asset loading
- Added context processor for offline settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:14:51 +00:00
Smittix eae1820fda feat: Add spinning globe background to welcome and login pages
Add animated SVG globe with rotating meridians as a subtle background
element on the welcome overlay and login pages.

Also removes unused signal-cards-mockup.html.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:24:50 +00:00
Smittix f70deb32a2 feat: Add back button to navigation on dashboard pages
Add browser history back button alongside existing dashboard links on
vessels, aircraft, network monitor, and remote agents pages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:23:13 +00:00
Smittix 69eea1e895 docs: Add AIS vessel tracking screenshot to gallery
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:35:33 +00:00
Smittix bf4346b4ff docs: Add Remote Agents documentation and updated screenshots
- Update dashboard screenshot to v2.10.0
- Add Remote Agents screenshot to docs gallery
- Add Remote Agents feature card to GitHub Pages
- Add navigation links to DISTRIBUTED_AGENTS.md
- Add Remote Agents section to FEATURES.md and USAGE.md
- Link distributed agents docs from main README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:32:28 +00:00
Smittix 7cde6a2068 fix: Improve Remote Agents page layout
- Fix header logo and title alignment using flexbox
- Move Refresh All button next to Register Agent button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:21:20 +00:00
Smittix 84b424b02e feat: Add Meshtastic mesh network integration
Add support for connecting to Meshtastic LoRa mesh devices via USB/Serial.
Includes routes for device connection, channel configuration with encryption,
and SSE streaming of received messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:17:40 +00:00
Smittix 04b73596ea fix: Prevent sidebar section content from being cut off
Change .section overflow from hidden to visible so form elements
and buttons display fully within sidebar boxes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:16:21 +00:00
Smittix 3916276de8 Merge pull request #87 from alphafox02/feature/distributed-agents 2026-01-27 21:25:12 +00:00
cemaxecuter 077d46f319 Fix listen button not disabling when agent selected
- Add fallback direct DOM manipulation in agents.js selectAgent()
- Fix setListeningPostRunning to check agent mode before re-enabling button
- Add debug logging for button state changes
2026-01-27 11:44:30 -05:00
cemaxecuter a0fd6d9651 Disable listen button when any agent is selected 2026-01-27 11:31:20 -05:00
cemaxecuter 8d505eb848 Fix agent listening post step conversion from kHz to MHz 2026-01-27 11:27:58 -05:00
cemaxecuter 3f364f47e9 Fix listening post agent mode and UI sync
Agent scanner fixes:
- Use non-blocking I/O with select/fcntl to prevent blocking reads
- Pass dwell_time parameter through to scanner function
- Add freqs_scanned counter to status and data endpoints
- Improve SDR test process cleanup with kill() fallback

Frontend listening post fixes:
- Add setListeningPostRunning for UI sync when switching to agent
- Fix button ID (radioScanBtn not scannerStartBtn)
- Handle nested data structure from controller proxy
- Update freqs_scanned and signal_count from polling data
- Disable listen button for agent mode (audio can't stream over HTTP)

Add listening_post to agents.js uiSetters map for mode sync.

Live testing completed:
- Sensor mode: works via agent
- WiFi quick scan: works via agent
- Listening post: works via agent (AM airband, WFM broadcast tested)
- Signal detection: confirmed working via agent

Testing ongoing - modes not yet tested via agent:
- Pager, ADS-B, AIS, ACARS, APRS, DSC, RTL-AMR, TSCM, Bluetooth
2026-01-27 11:20:17 -05:00
cemaxecuter b92139f207 Add agent location selector to satellite dashboard
- Location dropdown in header to select observer position source
- Options: Local (browser GPS) or any registered agent with GPS
- Fetches agent GPS position via /controller/agents/{id}/status
- Satellite pass predictions calculated from agent's location
- Observer marker on map shows agent name in popup
- Status dot indicates GPS availability
2026-01-27 10:51:55 -05:00
cemaxecuter c7e9a0a493 Fix WiFi quick scan via agent and improve error messages
Agent fixes:
- Accept 'success' status for quick scans (not just 'started')
- WiFi quick scans return 'success' with results, not 'started'

Controller fixes:
- Pass through actual error messages from agent responses
- Previously showed generic "Agent returned error: 400"
- Now shows actual message like "Root privileges required for deep scan"
2026-01-27 10:42:29 -05:00
cemaxecuter 717dec4e54 Add agent ACARS f00b4r0 support and UI state sync
- Agent: Add _detect_acarsdec_fork() for f00b4r0/DragonOS support
- Agent: Use --output json:file, --rtlsdr, -m 256 for f00b4r0 fork
- UI: Add setAcarsRunning() to sync button state with agent
- UI: Add 'acars' to syncModeUI uiSetters map
2026-01-27 10:20:53 -05:00
cemaxecuter d3cb20cdae Support f00b4r0 acarsdec fork and fix ADS-B stop
ACARS (f00b4r0/DragonOS compatibility):
- Use --output json:file (not json:file:-) for stdout
- Use --rtlsdr instead of -r for device selection
- Use -m 256 for 3.2 MS/s sample rate (wider bandwidth for NA freqs)
- Properly detects fork by checking for --output in help

The f00b4r0 fork (used by DragonOS) has different CLI syntax than
TLeconte's original. Key differences:
  - TLeconte: -j -r <device>
  - f00b4r0:  --output json:file -m 256 --rtlsdr <device>

ADS-B stop fix:
- Add Content-Type header to stop fetch request
- Flask's request.json requires application/json content type
- Without this header, stop returns HTTP 415 and dump1090 keeps running
2026-01-27 10:10:32 -05:00
cemaxecuter 518da075de Support f00b4r0 acarsdec fork (DragonOS)
Add detection for f00b4r0/acarsdec which uses --output json:file:-
syntax instead of TLeconte's -j flag. Auto-detects fork by checking
for --output in help output.

Supports three acarsdec variants:
- TLeconte v4+: -j
- TLeconte v3.x: -o 4
- f00b4r0 (DragonOS): --output json:file:-
2026-01-27 09:55:57 -05:00
cemaxecuter fb31157fe9 Fix ADS-B dashboard for remote agents
- Fix device dropdown to use sdr_devices (same as agents.js fix)
- Keep dropdown/start button enabled in "All Agents" mode for control
- Disable airband controls for remote agents (audio not supported)
2026-01-27 09:54:08 -05:00
cemaxecuter a5f574062d Fix agent/local mode state sync and process cleanup
Agent fixes:
- Fix stop not killing secondary processes (pager_rtl, aprs_rtl, rtlamr_tcp)
- Modes using piped processes now properly terminate all child processes

UI state sync fixes:
- Add syncLocalModeStates() to check local status when switching to local
- Fix switchMode() to re-sync with agent/local when changing mode tabs
- Only stop local modes when actually in local mode
- UI now correctly reflects running state when switching agents or modes
2026-01-27 09:31:14 -05:00
cemaxecuter afccb6fe0a Fix agent mode UI state sync for pager, WiFi, and Bluetooth
- Fix device dropdown for agent mode by checking sdr_devices key
- Fix pager checkStatus() to use agent endpoint when in agent mode
- Fix WiFi checkScanStatus() to be agent-aware
- Fix Bluetooth checkScanStatus() to be agent-aware

These fixes prevent the UI from reverting to 'stopped' state when
the agent is actually running a mode.
2026-01-27 09:09:29 -05:00
cemaxecuter f916b9fa19 Add TSCM support to distributed agent with local mode parity
- Agent TSCM uses same ThreatDetector and CorrelationEngine as local mode
- Added baseline_id parameter support using get_tscm_baseline()
- Fixed RF scan stop_check to allow agent-specific stop events
- Fixed 'undefined MHz' display for WiFi devices (added essid fallback and null check)
- Fixed signal strength type conversion (string to int) for correlation engine
- Agent threat detection matches local mode behavior:
  - No baseline: detects anomaly/hidden_camera threats only
  - With baseline: also detects new_device threats
2026-01-27 08:47:02 -05:00
cemaxecuter d775ba5b3e Add real-time agent health monitoring and response utilities
Health Monitoring:
- Add /controller/agents/health endpoint for efficient bulk health checks
- Check all agents in one call with response time tracking
- Update agent status in real-time (30s interval)
- Show latency next to agent status in UI
- Add collapsible "All Agents Health" panel in sidebar
- Log console notifications when agents go online/offline

Response Utilities:
- Add unwrapAgentResponse() to consistently handle controller proxy format
- Add isAgentMode() and getCurrentAgentName() helpers
- Standardize error handling for agent responses

UI Improvements:
- Show response latency (ms) in agent selector dropdown
- Health panel shows status + running modes for each agent
- Better visual feedback for agent status changes
2026-01-26 12:19:20 -05:00
cemaxecuter 3372daca84 Add comprehensive agent mode tests and listening_post SDR check
- Add SDR availability check to listening_post mode startup
- Create tests/test_agent_modes.py with 29 comprehensive tests covering:
  - Mode lifecycle tests (start/stop for all modes)
  - SDR conflict detection (same device vs different device)
  - Process verification (immediate exit detection)
  - Data snapshot operations
  - Error handling (missing tools, invalid modes)
  - Cleanup verification (process termination, thread stopping)
  - Multi-mode simultaneous operation
  - GPS integration
2026-01-26 12:02:52 -05:00
cemaxecuter b72ddd7c19 Enhance distributed agent architecture with full mode support and reliability
Agent improvements:
- Add process verification (0.5s delay + poll check) for sensor, pager, APRS, DSC modes
- Prevents silent failures when SDR is busy or tools fail to start
- Returns clear error messages when subprocess exits immediately

Frontend agent integration:
- Add agent routing to all SDR modes (pager, sensor, RTLAMR, APRS, listening post, TSCM)
- Add agent routing to WiFi and Bluetooth modes with polling fallback
- Add agent routing to AIS and DSC dashboards
- Implement "Show All Agents" toggle for Bluetooth mode
- Add agent badges to device/network lists
- Handle controller proxy response format (nested 'result' field)

Controller enhancements:
- Add running_modes_detail endpoint showing device info per mode
- Support SDR conflict detection across modes

Documentation:
- Expand DISTRIBUTED_AGENTS.md with complete API reference
- Add troubleshooting guide and security considerations
- Document all supported modes with tools and data formats

UI/CSS:
- Add agent badge styling for remote vs local sources
- Add WiFi and Bluetooth table agent columns
2026-01-26 11:44:54 -05:00
cemaxecuter f980e2e76d Add distributed agent architecture for multi-node signal intelligence
Features:
- Standalone agent server (intercept_agent.py) for remote sensor nodes
- Controller API blueprint for agent management and data aggregation
- Push mechanism for agents to send data to controller
- Pull mechanism for controller to proxy requests to agents
- Multi-agent SSE stream for combined data view
- Agent management page at /controller/manage
- Agent selector dropdown in main UI
- GPS integration for location tagging
- API key authentication for secure agent communication
- Integration with Intercept's dependency checking system

New files:
- intercept_agent.py: Remote agent HTTP server
- intercept_agent.cfg: Agent configuration template
- routes/controller.py: Controller API endpoints
- utils/agent_client.py: HTTP client for agents
- utils/trilateration.py: Multi-agent position calculation
- static/js/core/agents.js: Frontend agent management
- templates/agents.html: Agent management page
- docs/DISTRIBUTED_AGENTS.md: System documentation

Modified:
- app.py: Register controller blueprint
- utils/database.py: Add agents and push_payloads tables
- templates/index.html: Add agent selector section
2026-01-26 06:14:42 -05:00
Smittix ada6d5f1f1 Merge pull request #86 from xdep/testing-branch 2026-01-25 19:44:31 +00:00
Marc 7c6416ac38 New svg style icons for the AIS vessel tracking map 2026-01-25 13:40:52 -06:00
Marc e833488425 JSON fix for AIS including latitude and longitude 2026-01-25 13:29:13 -06:00
Smittix 0b8863aaa9 Merge pull request #85 from xdep/main 2026-01-25 16:57:09 +00:00
Device 8d30c40fe2 Fixing the AIS-catcher parameter for data ingest
The -o 5 flag sets the console/stdout output format to JSON, but it does NOT configure the TCP server output format
2026-01-25 17:07:45 +01:00
Smittix d2f2c37531 docs: Update in-app help with new features
- Add Vessels/VHF DSC documentation to help modal
- Add Spy Stations mode to help modal
- Update Aircraft section to mention history feature
- Add Spy Stations icon to Mode Tab Icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:23:16 +00:00
Smittix b23a1636b0 Merge pull request #83 from JamesIOmete/local-fixes
Add ADS-B history persistence, session tracking, and reporting dashboard
2026-01-25 13:20:23 +00:00
Smittix a73a74d1fc docs: Simplify ADS-B history setup instructions
Update README to use Docker Compose profiles instead of environment variables.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:18:54 +00:00
Smittix d297f87115 fix: Make ADS-B history optional and add tests
- Use Docker Compose profiles to make Postgres optional
- Default `docker compose up` runs without history/Postgres
- Use `docker compose --profile history up` to enable history
- Add 11 unit tests for AdsbHistoryWriter and AdsbSnapshotWriter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:18:33 +00:00
Smittix 88537c1119 fix: Restore flask-limiter and Werkzeug version pins
- Restore flask-limiter>=2.5.4 version constraint
- Restore Werkzeug>=3.1.5 dependency
- Group psycopg2-binary under ADS-B history section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:12:26 +00:00
James Ward 141b34391d Document ADS-B history setup and reporting 2026-01-25 13:12:09 +00:00
James Ward 8b4b440b22 Add ADS-B history persistence and reporting UI 2026-01-25 13:11:43 +00:00
James Ward 0cccf3c9dd Document local-fixes rebase workflow 2026-01-25 13:11:43 +00:00
James Ward e532f67c85 Add flask-limiter dependency and allow /health without login 2026-01-25 13:11:43 +00:00
Smittix 7a2b90055a Merge pull request #84 from xdep/testing-branch
feat: Add VHF DSC Channel 70 monitoring and decoding for vessels page
2026-01-25 13:06:32 +00:00
Smittix ab2d7bfe50 docs: Update version to 2.10.0 and document DSC features
- Bump version to 2.10.0 in config.py and pyproject.toml
- Add scipy/numpy dependencies for DSC signal processing
- Add CHANGELOG entry for 2.10.0 release
- Update README.md features list with vessel tracking
- Add AIS Vessel Tracking and VHF DSC section to FEATURES.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:06:06 +00:00
Smittix 1e2810b85c fix: Correct octal literal in DSC position decoder
Change `00` to `0` in quadrant check to avoid confusion with octal syntax.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:05:14 +00:00
Smittix 164887f8a4 test: Add comprehensive tests for DSC functionality
- Add parser tests for MMSI country lookup, distress codes, format codes
- Add decoder tests for MMSI/position decoding, bit conversion
- Add database tests for DSC alerts CRUD operations
- Include constants validation tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:05:14 +00:00
Marc b4d3e65a3d feat: Add VHF DSC Channel 70 monitoring and decoding
- Implement DSC message decoding (Distress, Urgency, Safety, Routine)

- Add MMSI country identification via MID lookup

- Integrate position extraction and map markers for distress alerts

- Implement device conflict detection to prevent SDR collisions with AIS

- Add permanent storage for critical alerts and visual UI overlays
2026-01-25 13:05:14 +00:00
Smittix 3b238c3c8f Merge pull request #82 from d3mocide/main
Enhance Docker environment with missing SDR dependencies
2026-01-24 20:51:10 +00:00
William Shields 93111b93c5 Enhance Docker environment with missing SDR dependencies
Added build and runtime dependencies for AIS-catcher, readsb (SoapySDR enabled), direwolf, and hcxtools. Included rx_tools build from source. Updated dependency checker to potentialy verify SoapySDR modules.
2026-01-24 12:37:20 -08:00
Smittix 6a63c13cd8 Update docs for v2.10.0: AIS vessel tracking and Spy Stations
- Add AIS Vessel Tracking and Spy Stations to GitHub Pages site
- Update mode count from 10+ to 12+
- Add feature sections to FEATURES.md
- Update README.md features list and acknowledgments
- Add AIS-catcher and Priyom.org to acknowledgments
- Bump version to 2.10.0 in config.py
- Update CHANGELOG.md with new release notes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:33:05 +00:00
Smittix 3518f7fede Merge branch 'main' of https://github.com/smittix/intercept 2026-01-24 18:12:57 +00:00
Smittix 79fc2871c9 Improve UI labels and pager filter responsiveness
- Rename "Scanner" to "Listening Post" and "RTLAMR" to "Meters" for clarity
- Change pager filter input from onchange to oninput for real-time filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:12:32 +00:00
Smittix 2d21ce9303 Merge pull request #79 from xdep/testing-branch
Merge vessel-tracking: Add AIS-based vessel tracking support + Spy stations (Diplomatic + number stations)
2026-01-24 18:10:41 +00:00
Marc 28e63a1029 Couple small fixes like vessel icon and double stations text in spy page 2026-01-24 10:10:46 -06:00
Marc cbfe46201e Adding Spystations page and 2 small fixed for the vessel page 2026-01-24 07:37:51 -06:00
Marc 1b0d39c5b0 Adding spy stations aka the number stations including diplomatic stations 2026-01-24 04:22:32 -06:00
Device 446a8f14cb Merge branch 'smittix:main' into testing-branch 2026-01-24 00:41:56 +01:00
Marc 57d448c003 Adjustment to dashboard style and 500 error 2026-01-23 16:00:13 -06:00
Smittix eabc73ff49 Update GitHub Pages screenshots
Replace TSCM and Bluetooth screenshots with updated versions, add WiFi Scanner screenshot to gallery.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:55:01 +00:00
Marc f724421ce7 Adding Vessels 2026-01-23 06:02:54 -06:00
Smittix 9134195eb1 Delete CNAME 2026-01-22 06:32:30 +00:00
Smittix ee6971284c Merge bluetooth-overhaul: Fix Bluetooth/WiFi TSCM scanning issues
- Fix bytes conversion errors in multiple Bluetooth scanner modules
- Add monitor mode detection for WiFi interfaces
- Auto-use deep scan (airodump-ng) for monitor mode interfaces
- Fix is_known_tracker to handle hex string manufacturer data
- Add debug logging for TSCM Bluetooth scanning
2026-01-21 23:42:12 +00:00
Smittix 098fab6aca Fix is_known_tracker to handle hex string manufacturer data
The function now accepts both bytes and hex string formats for
manufacturer_data, converting hex strings to bytes before processing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:39:38 +00:00
Smittix bc2b2bf23b Add full traceback to Bluetooth scan error logging 2026-01-21 23:38:21 +00:00
Smittix eb5bf55aad Fix bytes conversion in TSCM BLE scanner
Handle various data types safely when converting manufacturer_data
in the TSCM-specific BLE scanner module.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:36:10 +00:00
Smittix 17a0dddf61 Fix bytes conversion in fallback Bluetooth scanner
Handle various data types safely when converting manufacturer_data
and service_data in the bleak fallback scanner.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:31:21 +00:00
Smittix f6bd38e3dc Add debug logging for TSCM Bluetooth scanning
Helps diagnose why Bluetooth devices appear in Bluetooth section
but not in TSCM section.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:28:45 +00:00
Smittix 12db4f5178 Auto-detect monitor mode and use deep scan in TSCM WiFi scanning
When a monitor mode interface (e.g., wlan0mon) is detected, automatically
use airodump-ng deep scan instead of quick scan which doesn't work with
monitor mode interfaces.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:27:05 +00:00
Smittix f01502ff32 Fix Bluetooth bytes conversion and WiFi monitor mode detection
- Fix "cannot convert 'str' object to bytes" error in BLE identity engine
  by adding robust _convert_to_bytes() helper that handles bytes, hex
  strings, bytearrays, and arrays
- Improve DBus scanner to safely handle various data types for
  manufacturer_data and service_data with proper error handling
- Add monitor mode interface detection in WiFi scanner to provide clear
  error message when quick scan is attempted on monitor mode interface

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:25:22 +00:00
Smittix 54a47b03c2 Make tracker detection panel scrollable
Add max-height and overflow-y to btTrackerList for better UX when
multiple trackers are detected.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:18:07 +00:00
Smittix 537171d788 Add comprehensive BLE tracker detection with signature engine
Implement reliable tracker detection for AirTag, Tile, Samsung SmartTag,
and other BLE trackers based on manufacturer data patterns, service UUIDs,
and advertising payload analysis.

Key changes:
- Add TrackerSignatureEngine with signatures for major tracker brands
- Device fingerprinting to track devices across MAC randomization
- Suspicious presence heuristics (persistence, following patterns)
- New API endpoints: /api/bluetooth/trackers, /diagnostics
- UI updates with tracker badges, confidence, and evidence display
- TSCM integration updated to use v2 tracker detection data
- Unit tests and smoke test scripts for validation

Detection is heuristic-based with confidence scoring (high/medium/low)
and evidence transparency. Backwards compatible with existing APIs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:16:18 +00:00
Smittix f665203543 Improve WiFi quick scan error handling and Linux tool fallback
- Add fallback mechanism to try multiple tools (nmcli -> iw -> iwlist)
- Improve error messages for iw/iwlist with root privilege detection
- Enhance nmcli scanner to try without interface if specific scan fails
- Better error reporting in frontend showing actual backend errors
- Add logging throughout scan process for debugging

This fixes quick scan immediately failing on Linux systems by trying
multiple tools and providing meaningful error messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:57:51 +00:00
Smittix dfd4b0e89e Add WiFi v2 API endpoints for dual-mode scanning
- Add v2 capabilities, quick scan, deep scan, and status endpoints
- Add v2 networks, clients, probes, and channels endpoints
- Add v2 SSE stream, export (CSV/JSON), and baseline management
- Add recommendation_rank field to ChannelRecommendation model

The frontend was already wired up to call these v2 endpoints but they
were missing from the backend. This completes the WiFi module v2 API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:53:02 +00:00
Smittix 45c10a8593 Improve quick scan error handling and user feedback
Frontend (wifi.js):
- Show helpful message when quick scan returns no networks
- Suggest using Deep Scan as fallback
- Better error messages with actionable suggestions

Backend (scanner.py):
- Add proper error messages from airport scan failures
- Add proper error messages from nmcli scan failures
- Handle timeouts and missing tools explicitly
- Raise RuntimeError with descriptive messages

These changes help users understand when quick scan tools (airport/nmcli)
aren't working and guide them to use Deep Scan instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:48:25 +00:00
Smittix d929c30882 Fix channel chart not showing utilization data
- Add calculateChannelStats() in wifi.js to compute stats from networks
- Add fallback to calculate stats when API doesn't provide them
- Add syncLegacyToChannelChart() to sync legacy WiFi data to v2 chart
- Call sync function every 2 seconds when in WiFi mode

The channel chart now updates from both v2 API data and legacy WiFi scans.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:42:22 +00:00
Smittix 0ca3066cfc Add null checks for legacy WiFi UI elements
- Add null checks in updateChannelRecommendation for removed elements
- Add null checks in updateProbeAnalysis for counter elements
- Prevents TypeError when legacy functions run with v2 layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:35:57 +00:00
Smittix 1d30ea2708 Fix WiFi table columns and channel chart overflow
Table fixes:
- Add BSSID column header to match data columns
- Remove vendor column from table rows (6 columns total)
- Update placeholder colspan to 6

Layout fixes:
- Use minmax() for right columns to allow shrinking
- Add overflow handling to layout container
- Add min-width: 0 to analysis panel for proper grid behavior
- Add overflow-x: auto to channel chart container

Channel chart fixes:
- Reduce bar width from 20px to 14px
- Reduce bar spacing from 4px to 2px
- Reduce padding for more compact display
- Use viewBox for responsive SVG scaling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:30:04 +00:00
Smittix 6ae21e9e24 Complete WiFi UI overhaul with 3-column layout
Frontend:
- Replace legacy WiFi panels with clean 3-column layout
- Add sortable networks table with filter buttons (All/2.4G/5G/Open/Hidden)
- Add proximity radar panel with zone summary (Near/Mid/Far)
- Add channel analysis panel with band tabs (2.4/5 GHz)
- Add security overview with color-coded counts
- Add slide-up detail drawer for selected networks
- Remove all legacy hidden elements

CSS:
- New wifi-layout-container with status bar
- Networks table with sticky header and row selection
- Responsive grid layout (3-col -> 2-col -> 1-col)
- Zone summary styling with color-coded counts
- Detail drawer with grid layout

JavaScript:
- Update cacheDOM with new element IDs
- Update updateDetailPanel to use drawer structure
- Update updateStats to populate security counts and zones
- Add closeDetail function for drawer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:24:02 +00:00
Smittix 5843b3dcc5 Replace legacy WiFi visualizations with v2 components
- Replace Network Radar canvas with v2 Proximity Radar component
- Replace verbose channel bar wrappers with v2 Channel Analysis panel
- Add filter buttons (All/Hidden/Open) and zone summary to radar
- Add band tabs (2.4/5 GHz) to channel chart
- Hide legacy elements for backwards compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:13:53 +00:00
Smittix 1cd367332b Add v2 WiFi visualization panels and initialization
- Add proximity radar panel with filter buttons (All/Hidden/Open/Strong)
- Add zone summary display (Immediate/Near/Far)
- Add channel analysis panel with 2.4/5 GHz band tabs
- Initialize WiFiMode when switching to WiFi mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:10:30 +00:00
Smittix 9515f5fd7a Add unified WiFi scanning module with dual-mode architecture
Backend:
- New utils/wifi/ package with models, scanner, parsers, channel analyzer
- Quick Scan mode using system tools (nmcli, iw, iwlist, airport)
- Deep Scan mode using airodump-ng with monitor mode
- Hidden SSID correlation engine
- Channel utilization analysis with recommendations
- v2 API endpoints at /wifi/v2/* with SSE streaming
- TSCM integration updated to use new scanner (backwards compatible)

Frontend:
- WiFi mode controller (wifi.js) with dual-mode support
- Channel utilization chart component (channel-chart.js)
- Updated wifi.html template with scan mode tabs and export

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:06:16 +00:00
Smittix e22f464300 Create CNAME 2026-01-21 21:53:19 +00:00
Smittix 3d0c505178 Fix TSCM section: timeline, RF scanning, and UI defaults
- Fix Signal Timeline not receiving events by using SignalTimeline.create()
  for TSCM mode to maintain backward compatibility with addEvent() calls
- Lower RF detection thresholds for RTL-SDR compatibility (6dB margin,
  -90dBm floor instead of 10dB/-70dBm)
- Reduce RF scan interval from 60s to 30s for quicker feedback
- Enable RF/SDR checkbox by default to match WiFi and Bluetooth
- Update status message when no signals detected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:04:16 +00:00
Smittix a1f8377dd4 Fix TSCM Bluetooth scanner function signature mismatch
The unified get_tscm_bluetooth_snapshot() no longer accepts a bt_interface
parameter as it handles interface selection internally.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:36:28 +00:00
Smittix 588556c2a6 Link device list and proximity radar selection
Clicking a device in the list or a dot on the radar now highlights
both - the list row gets selected styling and the radar dot shows
an animated pulsing cyan ring for clear visual feedback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:29:01 +00:00
Smittix af078aaae0 Remove redundant Bluetooth stats icons from header
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:24:33 +00:00
Smittix 9dccbb95e8 Move Tracker Detection and Signal Distribution to left of radar
- Restructured layout to put side panels (Tracker Detection, Signal
  Distribution) on the left side of the Proximity Radar
- Side panels now stack vertically with fixed 220px width
- Radar takes remaining horizontal space
- Fixes radar being cut off at bottom
- Fixes signal distribution content being cut off

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:19:33 +00:00
Smittix 226f08f62d Remove Baseline section, fix filters and radar layout
- Removed Baseline section from Bluetooth sidebar (no longer needed)
- Fixed device filter buttons not working (changed display to '' instead
  of 'block' to preserve flexbox layout)
- Fixed proximity radar being cut off by bottom panels:
  - Added overflow: hidden to radar panel
  - Constrained bottom panels to max-height: 120px
  - Made radar content respect parent boundaries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:15:47 +00:00
Smittix 85159cbc44 Make device detail panel static and compact
- Panel is now always visible with fixed 140px height
- Shows "Select a device to view details" placeholder when empty
- Clicking a device populates the panel without layout shifts
- More compact design:
  - Smaller fonts and padding throughout
  - Combined Min/Max RSSI into single field
  - 4x2 stats grid with minimal spacing
  - Services shown inline as comma-separated text
- Panel no longer pushes proximity radar when populated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:08:44 +00:00
Smittix 201fce0125 Replace modal with inline device detail panel above proximity radar
- Added detail panel that appears above the radar when a device is clicked
- Shows comprehensive device information:
  - Large RSSI display with visual bar and range indicator
  - Protocol, status, and flag badges
  - 8-column stats grid: Manufacturer, Mfr ID, Address Type, Seen count,
    Min/Max RSSI, First/Last seen timestamps
  - Service UUIDs list (when available)
  - Copy Address button
- Selected device is highlighted in the device list
- Close button (×) to dismiss the panel
- Cyan accent border and gradient header for visual distinction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:04:49 +00:00
Smittix 3b8d4f3f74 Redesign Bluetooth device list with compact row-based layout
- Reduced card height from ~130px to ~55px (2.5x more devices visible)
- Added left color strip indicating signal strength at a glance
- Added visual RSSI bar alongside the dBm value
- Condensed info into two lines:
  - Primary: Protocol badge, device name, RSSI bar+value, status dot
  - Secondary: MAC address, manufacturer, seen count
- Blue glowing dot for new devices, green dot for known
- Hover effect highlights the row
- Click still opens full device details modal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:58:18 +00:00
Smittix 852d109468 Fix signal distribution bars not filling container height
Added more specific CSS selectors (.bt-signal-dist .signal-bar) to
override conflicting styles from the WiFi signal icon bars.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:46:49 +00:00
Smittix c5eb63ae7f Improve Bluetooth panel layout, signal bars, and add device filtering
- Rearranged layout: Proximity Radar on top, Tracker Detection and
  Signal Distribution side-by-side below for better space usage
- Made signal distribution bars thicker (16px) with gradient styling
  for better visibility
- Added device filtering with buttons: All, New, Named, Strong signal
- Filter buttons show filtered count (e.g., "5/37") when active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:43:09 +00:00
Smittix b0ab361ead Remove Signal History, FindMy Network, and Device Activity from Bluetooth panel
These features were removed as they were not providing useful functionality:
- Signal History heatmap
- Apple FindMy Network detection
- Device Activity timeline

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:36:27 +00:00
Smittix 7b2e1caa47 Remove ineffective Device Types section from Bluetooth panel
The device type classification relied on pattern matching against device
names (e.g., looking for "iphone" or "macbook"), but most Bluetooth devices
don't advertise with human-readable names that match these patterns,
resulting in nearly all devices being categorized as "Other".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:31:33 +00:00
Smittix 7957176e59 Add proximity radar visualization and signal history heatmap
Backend:
- Add device_key.py for stable device identification (identity > public MAC > fingerprint)
- Add distance.py with DistanceEstimator class (path-loss formula, EMA smoothing, confidence scoring)
- Add ring_buffer.py for time-windowed RSSI observation storage
- Extend BTDeviceAggregate with proximity_band, estimated_distance_m, distance_confidence, rssi_ema
- Add new API endpoints: /proximity/snapshot, /heatmap/data, /devices/<key>/timeseries
- Update TSCM integration to include new proximity fields

Frontend:
- Add proximity-radar.js: SVG radar with concentric rings, device dots positioned by distance
- Add timeline-heatmap.js: RSSI history grid with time buckets and color-coded signal strength
- Update bluetooth.js to initialize and feed data to new components
- Replace zone counters with radar visualization and zone summary
- Add proximity-viz.css for component styling

Tests:
- Add test_bluetooth_proximity.py with unit tests for device key stability, EMA smoothing,
  distance estimation, band classification, and ring buffer functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:25:33 +00:00
Smittix bd7c83b18c Replace radar with zone counts and add device detail modal
- Remove problematic canvas-based radar visualization
- Add simple proximity zone counters (Very Close, Close, Nearby, Far)
- Remove Selected Device panel from HTML
- Add device detail modal with full info display
- Modal shows RSSI, badges, manufacturer, signal stats, timestamps
- Modal closes on overlay click, close button, or Escape key
- Add CSS for modal styling with blur backdrop
- Simplify card rendering (no selection highlighting needed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:02:55 +00:00
Smittix 27a0e095a3 Simplify proximity visualization to fix flashing
- Remove double buffering and timers (overcomplicated)
- Use requestAnimationFrame for smooth batched updates
- Simplify to single deviceAngles map for persistent positions
- Only redraw when device data actually changes
- Dots persist as long as device is in the devices map
- Much simpler code path reduces chance of bugs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:51:57 +00:00
Smittix e19315819d Fix proximity visualization flicker with double buffering
- Add offscreen canvas for double buffering
- Draw all elements to offscreen canvas first
- Copy to visible canvas in single operation
- Increase update intervals (150ms throttle, 2s refresh)
- Eliminates flashing when visualization redraws

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:44:03 +00:00
Smittix 002afe3690 Replace radar with clean zone-based proximity visualization
- Replace heatmap with concentric distance zones (Very Close, Close, Nearby, Far)
- Each zone has distinct color coding and shows device counts
- Device dots persist with smooth fading for stale devices (30s threshold)
- Random angle distribution prevents dot overlap
- Glow effect on dots with color based on signal strength
- Periodic refresh timer keeps visualization smooth during inactive periods
- Throttled updates prevent performance issues during rapid scanning
- Center "YOU" marker with subtle glow effect
- Shows instructional text when idle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:38:07 +00:00
Smittix 9e31bc65db Overhaul Bluetooth UI: heatmap, device selection, and detection panels
- Replace proximity radar with persistent heatmap visualization using radial gradients
- Change device card click to populate Selected Device panel instead of modal
- Fix Device Types panel with proper categorization (phones, computers, audio, wearables)
- Add tracker detection for AirTag, Tile, SmartTag, Chipolo patterns
- Add Apple FindMy Network detection using manufacturer ID 0x004C
- Fix Signal Distribution histogram with Close/Medium/Far/Weak bands
- Make Device Activity timeline collapsible and collapsed by default
- Add contextual "No data" messages for all empty panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:19:40 +00:00
Smittix 898410b225 Fix null check for manufacturer_id in modal
Changed !== null to != null to catch both null and undefined values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:11:13 +00:00
Smittix fe28a91d5c Add device modal, visualization panels, and radar updates
Features added:
- Click-to-open modal with comprehensive device details
  - Signal strength with min/max/median/confidence stats
  - Device info grid (address, type, manufacturer)
  - Observation stats (first/last seen, count)
  - Service UUIDs display
  - Copy address button

- Live visualization panel updates:
  - Device Types (phones, computers, audio, wearables, other)
  - Signal Distribution (strong/medium/weak with bars)
  - Tracker Detection list
  - FindMy devices list

- Proximity Radar canvas:
  - Plots devices by RSSI (closer = nearer center)
  - Color-coded by signal strength
  - Glow effect for visibility

- Improved device name display:
  - Shows broadcast name if available
  - Falls back to formatted address (AA:BB:...:EE:FF)

- Cards now clickable with hover effect
- Stats recalculated on each device update

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:08:49 +00:00
Smittix be58c00bc7 Fix device card rendering with pure inline styles
- Remove all CSS class dependencies from device cards
- Use data-bt-device-id attribute instead of class-based selectors
- Add comprehensive inline styles to each element
- Change container from grid to block layout
- Add detailed console logging for debugging
- Remove potential CSS conflicts from .signal-card class

This isolates the card rendering from any CSS that might be
hiding content (like overflow:hidden on .signal-card).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:01:05 +00:00
Smittix 91b07fe797 Fix card rendering - use string concat instead of template literals
- Change card HTML generation from template literals to string concatenation
- This avoids potential issues with special characters in device data
- Also disable legacy handleBtDeviceImmediate when BluetoothMode exists
- Use device_id as fallback name if name is missing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:52:32 +00:00
Smittix bac7f8d55c Fix device card rendering - disable legacy code and fix CSS
- Disable legacy addBtDeviceCard when BluetoothMode is active
- Clear device container when starting scan to remove legacy cards
- Fix grid CSS with explicit auto height and align-items: start
- Add visibility rules for all card body elements
- Reset devices map when clearing container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:47:09 +00:00
Smittix bb660d02f5 Fix device card rendering with robust defaults and CSS fixes
- Add explicit default values for all card template variables
- Add try/catch for JSON.stringify
- Add !important CSS rules to ensure card body visibility
- Use ID selector for btDeviceListContent grid layout
- Add console logging for debugging device data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:31:48 +00:00
Smittix e3d9349d4b Improve Bluetooth device card layout and modal
- Remove Details dropdown from device cards for cleaner look
- Add grid layout for device cards (responsive, auto-fill columns)
- Enhanced modal with full device details:
  - Large RSSI display with sparkline
  - Signal statistics (median, min, max, confidence)
  - Device info grid (address, type, protocol, manufacturer)
  - Observation timeline (first/last seen, count, rate)
  - Service UUIDs list
  - Behavioral analysis heuristics
- Copy JSON and Copy Address buttons in modal footer
- Escape key closes modal
- Responsive design for mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:12:48 +00:00
Smittix 78642bcbb2 Fix device card rendering - handle DOM element not HTML string
DeviceCard.createDeviceCard() returns a DOM element, not an HTML string.
Use replaceWith() and prepend() instead of outerHTML and insertAdjacentHTML.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:04:46 +00:00
Smittix 48e3bf210a Fix Bluetooth device container - use btDeviceListContent instead of output
The Bluetooth mode uses its own layout container (btLayoutContainer) which
contains btDeviceListContent for device cards. The output element is hidden
for Bluetooth mode. Also adds device count updates and clears placeholder
when scanning starts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:01:21 +00:00
Smittix e9d5fe35fb Add debug logging to Bluetooth frontend 2026-01-21 16:00:21 +00:00
Smittix 66f16d4a2d Fix SSE event names for frontend compatibility
The SSE stream was sending events without proper event names.
Frontend uses addEventListener('device_update', ...) which only
works with named events. Now maps internal event types to proper
SSE event names:
- device -> device_update
- status/started -> scan_started
- status/stopped -> scan_stopped

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:57:44 +00:00
Smittix 187347e64b Fix legacy code conflicts and Bleak deprecation warning
- Add null checks to legacy refreshBtInterfaces() function
- Redirect to BluetoothMode.checkCapabilities() when available
- Fix Bleak deprecation: use AdvertisementData.connectable instead of device.metadata

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:56:04 +00:00
Smittix 5016327bc2 Fix API/frontend field name mismatches
- Add 'available' alias for 'can_scan' in capabilities
- Add 'preferred_backend' alias for 'recommended_backend'
- Add 'id' field to adapter info for frontend compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:54:05 +00:00
Smittix ed460761ff Prioritize bleak over DBus for Flask compatibility
DBus/BlueZ requires a GLib main loop which Flask doesn't have.
Reordered backend priority: bleak > hcitool > bluetoothctl > dbus

Removed DBus option from UI since it won't work with Flask.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:50:03 +00:00
Smittix c49b1e03f2 Fix scanner fallback logic when DBus fails
The fallback wasn't being triggered because when mode='auto' was
replaced with the recommended backend ('dbus'), the fallback condition
failed. Now properly tracks original_mode to allow fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:49:09 +00:00
Smittix 28d15d0ed5 Fix missing constants and test import names
- Add SUBPROCESS_TIMEOUT_SHORT to bluetooth constants
- Fix test imports to use correct constant names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:44:53 +00:00
Smittix 54db023520 Overhaul Bluetooth scanning with DBus-based BlueZ integration
Major changes:
- Add utils/bluetooth/ package with DBus scanner, fallback scanners
  (bleak, hcitool, bluetoothctl), device aggregation, and heuristics
- New unified API at /api/bluetooth/ with REST endpoints and SSE streaming
- Device observation aggregation with RSSI statistics and range bands
- Behavioral heuristics: new, persistent, beacon-like, strong+stable
- Frontend components: DeviceCard, MessageCard, RSSISparkline
- TSCM integration via get_tscm_bluetooth_snapshot() helper
- Unit tests for aggregator, heuristics, and API endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:42:33 +00:00
Smittix 713c1a3470 Add supported platforms note to Quick Start section
Documents that iNTERCEPT is officially tested on Debian and Ubuntu,
with partial macOS support. Other distributions have not been fully tested.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:41:29 +00:00
Smittix 5bafb88377 Add meter reading mode and clickable screenshot lightbox 2026-01-21 12:19:39 +00:00
Smittix 95f3836edd Fix branding to iNTERCEPT 2026-01-21 12:12:19 +00:00
Smittix 0195553a62 Add feature screenshots to GitHub Pages 2026-01-21 12:10:59 +00:00
Smittix 5c7554d6cb Add GitHub Pages landing site 2026-01-21 11:50:05 +00:00
Smittix ec32b9237e Make pager and 433MHz cards clickable with details dialog
Replace the dropdown details panel with a clickable card that opens
a modal dialog showing all signal information including raw data.
Action buttons (Copy/Mute) now float on hover.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:58:18 +00:00
Smittix 3edd40de0d Fix 433MHz sensor cards not displaying
The updateCounts call was using pager-specific filter logic that
didn't match sensor card data attributes, causing cards to be hidden.
Now uses the sensor filter bar's own applyFilters method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:45:05 +00:00
Smittix 88418b0850 Add keyboard controls and finer tuning steps for frequency tuning
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:19:40 +00:00
Smittix 1e59cfd2ea Remove GPS debug endpoint
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:52:00 +00:00
Smittix 42f2a6ef62 Add clickable station badges and integrate signal guessing engine
- Add clickable APRS station badges that display raw packet data in a modal
- Integrate SignalGuess into sensor mode cards for frequency identification
- Standardize UI language across timeline and signal components
- Update frequency band naming for consistency (e.g., "Wi-Fi 2.4GHz" → "2.4 GHz wireless band")

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:59:08 +00:00
Smittix 3e3bc0e857 Hide status bar in TSCM mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:47:26 +00:00
Smittix 290c5ff896 Add signal guessing engine for frequency identification
Implements heuristic-based signal identification that provides
plain-English guesses for detected signals based on frequency,
modulation, bandwidth, and burst behavior.

Features:
- Python backend engine (utils/signal_guess.py)
- JavaScript client-side engine with UI components
- Hedged language output (never claims certainty)
- UK/EU and US region support
- Confidence levels (LOW/MEDIUM/HIGH)
- 50+ unit tests for deterministic verification

Supported signal types: FM broadcast, airband, cellular/LTE,
ISM bands (433/868/915/2.4GHz), TPMS, amateur radio, marine VHF,
DAB, pager networks, weather satellites, ADS-B, and more.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:38:02 +00:00
Smittix 4c0d44a99d Improve mode card icon spacing and centering
- Increase margin-bottom from 6px to 12px for better spacing
- Add flexbox centering to properly align icons
- Bump icon size to 28px for better visual balance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:19:15 +00:00
Smittix ef4adfe003 Add RTLAMR mode to menu and fix mode card icon visibility
- Add RTLAMR utility meter mode card to the mode selection grid
- Fix icons being nearly invisible by setting color to --text-secondary
- Add explicit 24x24px sizing for mode card SVG icons
- Add cyan highlight on hover for icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:16:52 +00:00
Smittix 30dfea57b9 Remove sensor-waterfall panels and default recon mode to off
- Remove waterfall UI panels from pager and 433MHz sections
- Remove associated JS functions (toggle, render, data tracking)
- Remove waterfall CSS styles
- Change recon mode to default to 'off' instead of 'on'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:13:28 +00:00
Smittix a0d7f221c0 Add null checks to prevent errors on missing elements
- Add null checks in syncHeaderStats for header stat elements
- Add optional chaining for classList.toggle calls in switchMode
- Add null checks for style.display assignments in switchMode
- Prevents errors when page is accessed with unsupported mode params

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:05:43 +00:00
Smittix ee916d0022 Fix ActivityTimeline config property names for pager/sensor
- Change timeWindows to availableWindows (correct property name)
- Change defaultTimeWindow to defaultWindow (correct property name)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 23:03:27 +00:00
Smittix 156d832d2d Add ActivityTimeline to Pager and 433MHz sensor modes
- Add timeline container divs for pager and sensor modes
- Add timeline configurations in initializeModeTimeline()
- Show/hide timeline containers based on active mode
- Feed pager and sensor messages to their respective timelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:58:57 +00:00
Smittix abe3d42004 Remove duplicate header stats and fix icon rendering
- Remove duplicated message counters from header (keeping output panel stats)
- Remove syncHeaderStats function and its 500ms polling interval
- Fix icon CSS override that caused stroke-based SVGs to render as solid squares

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:52:15 +00:00
Smittix 3f38742dbe Refactor timeline as reusable ActivityTimeline component
- Extract signal-timeline into configurable activity-timeline.js
- Add visual modes: compact, enriched, summary
- Create data adapters for RF, Bluetooth, WiFi normalization
- Integrate timeline into Listening Post, Bluetooth, WiFi modes
- Preserve backward compatibility for existing TSCM code
- Add mode-specific configuration presets via adapters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:46:16 +00:00
Smittix 2cb62d5f34 Standardize all icons to uniform inline SVG format
Replace emojis throughout the codebase with inline SVG icons using
the Icons utility. Remove decorative icons where text labels already
describe the content. Add classification dot CSS for risk indicators.

- Extend Icons utility with comprehensive SVG icon set
- Update navigation, header stats, and action buttons
- Update playback controls and volume icons
- Remove decorative device type and panel header emojis
- Clean up notifications and alert messages
- Add CSS for classification status dots

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:29:28 +00:00
Smittix 256c30e7cd Add minimal SVG icon system for signal types
Replace emoji icons with inline SVG for WiFi, Bluetooth, and RF/SDR
indicators. Icons are standard symbols (arc, rune, wave) designed for
screenshot legibility in reports.

- Add Icons utility object in utils.js with SVG generators
- Add icon CSS system with sizing variants and state animations
- Update TSCM scanner indicators and capabilities bar
- Remove decorative sensor type emojis (text labels suffice)
- Keep signal strength SVG bars (already implemented)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:05:58 +00:00
Smittix c92f60e0f3 Show signal indicator placeholder when no RSSI/SNR data available
Also check 'noise' field from rtl_433 output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:47:07 +00:00
Smittix 9461cc2121 Add signal strength classification with confidence-safe language
Introduces standardized RSSI-to-label mapping (minimal/weak/moderate/strong/very_strong)
and duration-based confidence modifiers for client-facing reports and dashboards.

- New signal_classification.py module with hedged language generation
- Updated detector.py to use standardized signal descriptions
- Enhanced reports.py with signal classification in findings
- Added JS SignalClassification and signal indicator components
- CSS styles for signal strength bars and assessment panels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:37:07 +00:00
Smittix 8a744eb55a Fix TSCM panels sizing - increase heights and add scroll
- Set panel height to 200px with overflow scroll
- Add padding-bottom for status bar clearance
- Make dashboard scrollable
- Remove flex constraints causing collapse

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:18:05 +00:00
Smittix 73188c2471 Fix TSCM panels being squashed by adding minimum heights
- Set min-height: 300px on main grid
- Set min-height: 120px on individual panels
- Set min-height: 80px on panel content
- Change dashboard from height: 100% to min-height: 100%

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:13:27 +00:00
Smittix 6e8de37135 Make timeline more compact to not hide other panels
- Reduce lanes max-height to 160px
- Reduce lane height to 36px
- Narrow label column to 130px
- Narrow stats column to 50px
- Smaller annotations (max 60px, 9px font)
- Hide legend completely (colors are self-explanatory)
- Reduce padding throughout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:09:26 +00:00
Smittix bb010664ca Fix timeline text being squashed and unreadable
- Increase lane min-height from 28px to 44px
- Widen label column from 100px to 140px
- Increase font sizes (freq: 11px, name: 10px)
- Add proper line-height and gap between lines
- Increase lanes container max-height to 240px
- Add more padding to label and track areas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:05:18 +00:00
Smittix ffc55efe1c Fix timeline overwhelming TSCM page with many signals
- Make timeline collapsible (starts collapsed by default)
- Add header stats showing signal counts when collapsed
- Limit displayed lanes to 15 (scroll for more)
- Constrain max-height to 180px with scrollbar
- Add automatic pruning of old signals (keeps max 100)
- Show "+N more signals" indicator when truncated
- Reduce annotations max-height to 80px
- Preserve flagged signals during pruning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:01:22 +00:00
Smittix 8b42f4ac28 Add signal activity timeline visualization for TSCM mode
New lightweight timeline component that shows RF signal presence
over time without heavy waterfall rendering:

- Horizontal swimlanes for each frequency/signal source
- Bars show transmission duration with height = signal strength
- Status colors: blue=new, gray=baseline, orange=burst, red=flagged
- Pattern detection for regular interval transmissions
- Click to expand and see individual transmission ticks
- Right-click to flag signals for investigation
- Auto-annotations for new signals, bursts, and patterns
- Tooltip with signal details on hover
- Time window selector (5m to 2h)
- Filter controls (hide baseline, show only new/burst)

Integrated into TSCM mode:
- Timeline created when TSCM mode is selected
- WiFi, Bluetooth, and RF signals feed into timeline
- Clears on new sweep start

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:54:07 +00:00
Smittix 4c71a3bb92 Fix filter bar counts not updating on new messages
Update applyAllFilters to look for filter bars in all possible
containers (main filterBarContainer and aprsFilterBarContainer)
so counts update automatically when new messages arrive.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:35:38 +00:00
Smittix d88d5c4921 Apply signal card system across all message-bearing modes
- Extend signal cards to APRS, Sensors, and utility meter modes
- Add address tracking for automatic new/repeated/burst detection
- Create mode-specific filter bars with status and type filtering
- Add compact card variant for constrained layouts like APRS station list
- Add meter card type with consumption display and type-specific icons
- Refactor filter bar container to be shared across modes
- Add CSS for meter data display and distance display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:29:30 +00:00
Smittix 5c62ae316a Add signal cards component system for pager UI
- Create reusable signal-cards.css with status variants, protocol badges,
  advanced panels, and filter bar styles
- Add signal-cards.js component for rendering pager message cards
- Integrate into pager mode with mute address, copy message, and
  expandable details functionality
- Include interactive mockup for design reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 20:03:38 +00:00
Smittix ed58681800 Fix setup.sh hanging on rtlamr prompt by using ask_yes_no helper
Replace raw read commands with ask_yes_no function for rtlamr
installation prompts on both macOS and Debian. The helper properly
handles non-interactive mode and missing TTY scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:16:05 +00:00
Smittix 90d2d42478 Merge branch 'main' of https://github.com/smittix/intercept 2026-01-20 18:07:40 +00:00
Smittix c88cf831fc Add verbose results option to TSCM sweeps and setup improvements
- Add verbose_results flag to store full device details in sweep results
- Add non-interactive mode (--non-interactive) to setup.sh
- Add ask_yes_no helper for interactive prompts with TTY detection
- Update reports.py to handle new results structure with fallbacks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:07:33 +00:00
Smittix f6aed7deda Merge pull request #73 from JonanOribe/main 2026-01-20 15:19:05 +00:00
James Smith ce204ce413 Make rtlamr optional with interactive install prompt
- Add check_optional() function for non-critical tools
- Change rtlamr from required to optional tool
- Add install_rtlamr_from_source() that auto-installs Go and compiles rtlamr
- Prompt user during setup whether to install rtlamr
- Fixes setup failure for users who don't need utility meter monitoring
2026-01-20 13:16:14 +00:00
Jon Ander Oribe 1ef3e367eb Add new dependencies and sync requirement files
Added 'bleak', 'flask-sock', and 'requests' to pyproject.toml and updated requirements.txt to include 'Werkzeug' and 'bleak'. Introduced tests/test_requirements.py to ensure consistency between requirements files and the installed environment.
2026-01-20 10:20:13 +01:00
Smittix 7cd988b777 Merge pull request #72 from JonanOribe/main 2026-01-20 07:08:51 +00:00
Smittix aac88cdd29 Merge pull request #71 from SarahRoseLives/feature/rtlamr-support 2026-01-20 07:06:45 +00:00
Jon Ander Oribe 664ae5b5ce Update requirements.txt
Added flask-limiter>=2.5.4 to the requirements
2026-01-20 07:59:42 +01:00
Jon Ander Oribe d268e581bd Enhance login UX with JS feedback and update docs
Added a new login.js script to provide visual feedback and prevent double submission on the login form. Updated login.html to include the script and wire up the login button. Clarified credential configuration instructions in README.md.
2026-01-20 07:07:47 +01:00
SarahRose ecc8dad2e2 Add rtlamr utility meter monitoring support
- Added rtlamr mode for decoding utility meters (water, gas, electric)
- Starts rtl_tcp server first, then connects rtlamr to it
- Supports multiple message types: SCM, SCM+, IDM, NetIDM, R900, R900 BCD
- Added frequency presets for 912 MHz (NA) and 868 MHz (EU)
- Includes meter ID filtering and unique message options
- Updated setup.sh to check and install rtlamr and rtl_tcp
- Added UI components: navigation button, mode template, JavaScript functions
- Integrated into SDR/RF dropdown menu with lightning bolt icon
- Updates mode indicator with frequency when listening
- Added help documentation and requirements section
2026-01-19 21:42:01 -05:00
James Smith df025f0409 Widen ACARS sidebar and fix controls visibility
Increase sidebar width from 250px to 300px to prevent region dropdown
from being cut off. Add flex layout to keep header and controls visible
while messages area scrolls.
2026-01-19 21:18:41 +00:00
James Smith 5e4412879d Fix ACARS sidebar expanding page height
Add height constraints and overflow handling to keep the sidebar
static within viewport while allowing internal scrolling.
2026-01-19 21:12:39 +00:00
James Smith ce232e0512 Add flask-limiter to setup.sh dependency verification 2026-01-19 17:25:24 +00:00
Smittix 5d54449b21 Merge pull request #69 from JonanOribe/main 2026-01-19 07:00:54 +00:00
Jon Ander Oribe 04f003c9f0 Add rate limiting to login endpoint
Introduced Flask-Limiter to restrict login attempts to 5 per minute per IP, enhancing security against brute-force attacks. Updated error handling to display a user-friendly message when the rate limit is exceeded. Minor improvements to the login page, including clearer error messages and display of the user's IP address.
2026-01-19 07:20:29 +01:00
Smittix 9b55632c86 Remove legacy absolute positioning from nav buttons
The #depsBtn and #helpBtn had old right positioning rules
that conflicted with the flex layout, causing them to appear
in wrong positions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:29:21 +00:00
Smittix bd65679572 Tighten nav-utilities spacing
- Reduce nav-utilities gap from 16px to 12px
- Reduce nav-tools gap from 12px to 6px

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:25:57 +00:00
Smittix f93877d723 Restructure nav layout to fix utilities overlap
- Remove margin-left: auto from mode-nav-actions
- Set nav-utilities to use margin-left: auto for right alignment
- Increase gaps: nav-utilities 16px, nav-tools 12px

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:23:56 +00:00
Smittix 2b8b499e79 Fix nav-tools button overlap with increased gap and containment
- Increased gap between tool buttons from 4px to 8px
- Added min-width to prevent button shrinking
- Added overflow: hidden to contain absolutely positioned icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:20:18 +00:00
Smittix 69410fd7c2 Fix nav-utilities overlapping by removing competing auto margin
Both .mode-nav-actions and .nav-utilities had margin-left: auto,
causing them to compete for space in the flexbox layout.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:17:24 +00:00
Smittix 176014b706 Add New Zealand APRS frequency and custom frequency input
- Add New Zealand (144.575 MHz) to APRS region dropdown
- Add Argentina, Brazil, and China regions
- Add custom frequency input option for user-specified frequencies
- Custom frequency field shows/hides dynamically when selected
- Properly disable/enable custom frequency control during operation
- CSS improvements for nav element flex behavior

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 18:49:04 +00:00
Smittix 92984a7bae Update README.md 2026-01-18 18:38:11 +00:00
Smittix a5d433b516 Merge pull request #54 from smittix/feature/login-system 2026-01-18 17:02:25 +00:00
Smittix e30094e8fc Merge pull request #67 from JonanOribe/feature/login-system 2026-01-18 16:20:54 +00:00
Jon Ander Oribe f1b416bba5 Fancy logout button 2026-01-18 17:08:39 +01:00
Smittix ec0b8dbcf7 Merge pull request #66 from JonanOribe/feature/login-system
Feature/login system
2026-01-18 12:44:04 +00:00
Smittix 5bfa7bf651 Fix acarsdec flag detection using version parsing
The previous detection logic incorrectly matched '-o' in help text for
version 4.x, causing startup failures. Now properly detects version:
- Version 4.0+: uses -j for JSON stdout
- Version 3.x: uses -o 4 for JSON stdout

Parses version from acarsdec output (e.g., "Acarsdec v4.3.1" or
"Acarsdec/acarsserv 3.7") to determine the correct flag.

Fixes: "invalid option -- 'o'" error on modern acarsdec builds

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 12:36:49 +00:00
Smittix e204901d18 Fix acarsdec JSON flag detection to prevent startup failures
The get_acarsdec_json_flag() function was defaulting to the obsolete '-o'
flag when detection failed, causing "invalid option -- 'o'" errors with
modern acarsdec builds from TLeconte repository.

Changes:
- Try both -h and --help flags for better compatibility
- Improve -j flag detection patterns
- Default to -j (modern standard) instead of -o
- Only use -o if explicitly documented in help text

This fixes ACARS decoder startup failures on systems where acarsdec was
built from source using setup.sh.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 10:58:38 +00:00
Smittix 482d778bca Clean up whitespace and remove commented code in setup.sh
Remove commented DEBIAN_FRONTEND line and fix indentation in dump1090
installation section.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 10:58:38 +00:00
Jon Ander Oribe c4ad8f6c12 Update .gitignore 2026-01-18 09:36:02 +01:00
Jon Ander Oribe aa763b0f81 Redesign login page with improved UI and error display
Revamps the login page layout and styles for a more modern, 'hacker' terminal look. Adds animated background effects, updates the login box and input styling, and enhances error messages with a new format. Also removes the tracked intercept.db file and ensures it is ignored in .gitignore.
2026-01-18 09:03:17 +01:00
Jon Ander Oribe 58a825976d Merge branch 'main' into feature/login-system 2026-01-18 08:56:06 +01:00
Smittix e4e9e89451 Merge pull request #62 from RoyRock413/main 2026-01-17 09:57:27 +00:00
RoyRock413 2f2e56ff2e temporarily commented out ./data bind mount so compose could run 2026-01-16 21:11:12 -05:00
Smittix 2b29b5c86f Use dpkg force options to resolve librtlsdr package conflicts
Aggressively handle broken rtl-sdr package states:
- Use dpkg --force-remove-reinstreq to remove broken rtl-sdr
- Use dpkg --force-all to force remove librtlsdr2
- Run apt-get --fix-broken install after cleanup
- Improved detection of broken package states

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:17:02 +00:00
Smittix af1cb7c17b Fix librtlsdr2 dependency chain conflict
Remove all packages that depend on librtlsdr2 before upgrading:
- dump1090-mutability (will be rebuilt from source later)
- libgnuradio-osmosdr0.2.0t64
- rtl-433 (will be reinstalled)
- librtlsdr2 and rtl-sdr

This resolves the file conflict between librtlsdr2 (2.0.1) and librtlsdr0 (2.0.2).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:14:54 +00:00
Smittix c5aa382527 Improve RTL-SDR package conflict handling
Fix broken package states by:
- Running apt --fix-broken install before attempting installation
- Removing both librtlsdr2 and rtl-sdr when conflict detected
- Cleaning up with autoremove
- Running dpkg --configure -a to fix partial installations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:13:35 +00:00
Smittix 78f81eeccd Fix RTL-SDR package conflict on Debian/Ubuntu
Remove conflicting librtlsdr2 package before installing rtl-sdr to prevent dpkg errors when librtlsdr0 tries to overwrite shared library files.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 17:12:29 +00:00
Smittix 096763ad40 Reorganize TSCM menu with logical groupings
- Consolidate sweep config and scan sources into one section
- Group baseline recording and meeting window under "Advanced"
- Create 2x2 grid layout for tool buttons
- Use visual dividers instead of separate sections
- Keep all functionality and IDs intact

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:08:42 +00:00
Smittix 6354911c54 Revert TSCM menu changes - restore original layout
The simplified layout was causing display issues. Reverting to
the original working version.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:05:38 +00:00
Smittix a8bb56a109 Fix TSCM menu - remove collapsible sections
Show all controls directly instead of hiding them in collapsed
sections which was causing confusion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:03:47 +00:00
Smittix 5047fee431 Simplify TSCM menu for better UX
Redesign the sidebar to be more minimal with collapsible sections
for Settings and Advanced options. Primary sweep action is now
prominently displayed, with tool buttons condensed to compact icons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 17:01:29 +00:00
Smittix b63c7ab0fe Fix WiFi detection on modern macOS
The airport utility path doesn't exist on newer macOS versions.
Added fallback methods using networksetup and ifconfig to detect
WiFi availability.
2026-01-16 16:51:53 +00:00
Smittix c0c86ef601 Fix type comparison errors in TSCM detector
Signal/RSSI values from WiFi scans can be strings. Added safe
int conversion with try/except to prevent type comparison errors.
2026-01-16 16:50:07 +00:00
Smittix 69c765d44a Change TSCM disclaimer text color to white 2026-01-16 16:44:31 +00:00
Smittix 617ba859fb Fix known devices API: send 'protocol' instead of 'device_type'
The endpoint expects 'protocol' field but JS was sending 'device_type'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:42:48 +00:00
Smittix 62db171ed6 Fix capabilities display and add 'Add to Known Devices' button
- Fix tscmShowCapabilities to parse nested API response structure
- Build can/cannot detect lists dynamically from actual capabilities
- Display system info, limitations, and disclaimer
- Add 'Add to Known Devices' button in device detail modal
- New tscmAddToKnownDevices function with custom name prompt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:31:19 +00:00
Smittix 66b2f59ca0 Fix playbooks endpoint to return array format
- Change /tscm/playbooks to return array instead of dict
- Add id, name, category fields to each playbook for JS compatibility
- Fix tscmViewPlaybook JS to use correct field names (action/details/safety_note)
- Display when_to_escalate and documentation_required sections

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:23:09 +00:00
Smittix 6dbf2fda01 Add TSCM advanced features UI
- Add meeting window controls (start/end tracked meetings)
- Add quick actions: capabilities, known devices, cases, playbooks
- Add export options for PDF and JSON/CSV reports
- Add JavaScript functions to connect UI to backend API endpoints
- Add CSS for capabilities grid, modal headers, playbook styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:20:47 +00:00
Smittix 234f254f4f Add comprehensive TSCM advanced features
Implement 9 major TSCM feature enhancements:

1. Capability & Coverage Reality Panel - Exposes what sweeps can/cannot
   detect based on OS, privileges, adapters, and SDR limits

2. Baseline Diff & Health - Shows changes vs baseline with health scoring
   (healthy/noisy/stale) based on age and device churn

3. Per-Device Timelines - Time-bucketed observations with RSSI stability,
   movement patterns, and meeting correlation

4. Whitelist/Known-Good Registry + Case Grouping - Global and per-location
   device registry with case management for sweeps/threats/notes

5. Meeting-Window Summary Enhancements - Tracks devices first seen during
   meetings with scoring modifiers

6. Client-Ready PDF Report + Technical Annex - Executive summary, findings
   by risk tier, JSON/CSV annex export

7. WiFi Advanced Indicators - Evil twin detection, probe request tracking,
   deauth burst detection (auto-disables without monitor mode)

8. Bluetooth Risk Explainability - Proximity estimates, tracker brand
   explanations, human-readable risk descriptions

9. Operator Playbooks - Procedural guidance by risk level with steps,
   safety notes, and documentation requirements

All features include mandatory disclaimers, preserve existing architecture,
and follow TSCM best practices (no packet capture, no surveillance claims).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:06:18 +00:00
Smittix 3210fc0d20 Move time, theme, deps, and help to nav bar
Relocate header utilities (UTC clock, theme toggle, dependencies
button, help button) to the navigation bar. Elements are grouped
logically with the clock on its own and tool buttons together,
all aligned to the far right of the nav bar.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:27:52 +00:00
Smittix ac68e26c70 Use OpenStreetMap tiles for APRS map
Switch from CartoDB dark tiles to standard OpenStreetMap tiles
which show roads and more detail for tracking APRS stations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:49:02 +00:00
Smittix ce0f581938 Add GPSD integration to APRS section
- Add GPS indicator to APRS function bar
- Add user location marker on APRS map (yellow dot)
- Calculate and display distance to APRS stations in miles
- Show distance in station list and marker popups
- Center map on GPS location when available
- Update distances dynamically as GPS position changes

Uses same gpsd auto-connect mechanism as ADS-B section.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 13:59:06 +00:00
Smittix fc48ff7d9f Add comprehensive APRS packet parsing support
- Add Mic-E position decoding (destination field encoding)
- Add compressed position format parsing (Base-91)
- Add complete telemetry parsing with analog/digital values
- Add telemetry definition messages (PARM, UNIT, EQNS, BITS)
- Add message ACK/REJ parsing and sequence numbers
- Add weather data parsing in position packets
- Add PHG (Power/Height/Gain/Directivity) parsing
- Add Direction Finding (DF) report parsing
- Add timestamp extraction from position packets
- Add third-party traffic, NMEA, user-defined format parsing
- Add bulletin, NWS alert, query, and capabilities parsing
- Expand weather fields (luminosity, snow, radiation, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 13:50:32 +00:00
Smittix af39d40847 Add APRS function bar similar to ADS-B stats strip
Move SDR configuration controls from sidebar to a horizontal function bar
above the map display for better visibility and accessibility. The bar
includes frequency/station/packet stats, region and gain controls, tool
status indicators, and start/stop buttons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:55:19 +00:00
Smittix fb23766ed3 Fix APRS object and item position decoding
Objects (;) and items ()) were identified but position data was never
extracted, causing them to appear without location on the map. Added
parse_object() and parse_item() functions to properly extract name,
status, and coordinates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:39:15 +00:00
Smittix bcb3147d1e Revert ISMS Listening Station implementation
Remove all ISMS (Intelligent Spectrum Monitoring Station) code including:
- GSM cell scanning with gr-gsm
- Spectrum monitoring via rtl_power
- OpenCelliD tower integration
- Baseline recording and comparison
- Setup script changes for gr-gsm/libosmocore

Reverts to pre-ISMS state (commit 4c1690d).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 12:24:16 +00:00
Smittix 940a43747b Use velichkov gr-gsm fork for GNU Radio 3.10+
The bkerler fork still uses SWIG. The velichkov fork has a
dedicated maint-3.10 branch with proper GNU Radio 3.10 support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:56:48 +00:00
Smittix 16c74d10db Fix gr-gsm build for GNU Radio 3.10+
Use bkerler fork with pybind11 support for GNU Radio 3.10+ since
the original gr-gsm repo uses GrSwig which was removed in 3.10.

- Detect GNU Radio version and select appropriate fork
- Add pybind11-dev and python3-pybind11 to build dependencies
- Add python3-numpy to build dependencies
- Set CMAKE_PREFIX_PATH to find source-built libosmocore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:55:23 +00:00
Smittix a99c3e3894 Force remove broken RTL-SDR packages, skip stock rtl-sdr
Use dpkg --force-remove-reinstreq to remove broken packages.
Skip stock rtl-sdr package entirely - RTL-SDR Blog drivers are
better and will be built from source instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:49:44 +00:00
Smittix e621647768 Fix broken packages before RTL-SDR installation
Run apt --fix-broken install and dpkg --configure -a to resolve
any lingering package conflicts before proceeding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:49:21 +00:00
Smittix 5992156356 Fix RTL-SDR package conflicts in setup.sh
Remove conflicting librtlsdr packages before installing to avoid
dpkg errors when RTL-SDR Blog drivers conflict with stock packages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:30:57 +00:00
Smittix bed0c5fb8d Build libosmocore from source if packages unavailable
Ubuntu 24.04 and newer don't have Osmocom packages in repos.
This builds libosmocore from source as a fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:27:28 +00:00
Smittix 0362a1b4ea Add Osmocom repository for libosmocore packages
libosmocore packages are not in standard Ubuntu repos.
This adds the Osmocom repository before installing gr-gsm deps.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:25:44 +00:00
Smittix cf7c94f9d8 Improve gr-gsm installation error reporting in setup.sh
- Remove output suppression so errors are visible
- Add clearer error messages at each step
- Fix subshell isolation that was swallowing errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:24:21 +00:00
Smittix c044ecfba2 Improve GSM scan error handling when gr-gsm not installed
- Return 503 instead of 500 when grgsm_scanner not found
- Show clearer error message in UI when gr-gsm unavailable
- Update status display to show "Not Available" state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:21:57 +00:00
Smittix 23a79a7ac5 Fix recursive showNotification call in ISMS module
Renamed local notification helper to ismsNotify to avoid
infinite recursion with global showNotification from audio.js.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:17:51 +00:00
Smittix 795dd3f235 Add ISMS mode card to landing page menu
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:14:36 +00:00
Smittix 35d138175e Add ISMS Listening Station with GSM cell detection
- Add spectrum monitoring via rtl_power with configurable presets
- Add OpenCelliD tower integration with Leaflet map display
- Add grgsm_scanner integration for passive GSM cell detection (alpha)
- Add rules engine for anomaly detection and findings
- Add baseline recording and comparison system
- Add setup.sh support for gr-gsm installation on Debian/Ubuntu

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:12:09 +00:00
Smittix 4c1690dd28 Fix false emergency alerts and ACARS compatibility
- Fix emergency alerts triggering for non-emergency squawk codes (VFR 1200/7000, etc.)
  by checking squawkInfo.type === 'emergency' before alerting
- Fix emergency filter to only show actual emergency squawk codes
- Add acarsdec version detection to support both -j (newer) and -o 4 (older) JSON flags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:57:21 +00:00
Smittix 407d5c1d25 Add 8.33 kHz step option to listening post scanner
Add European airband 8.33 kHz channel spacing to the step selector
in the main listening post interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:12:23 +00:00
Smittix f46681fdbc Fix duplicate log messages and suppress SBS reconnection spam
- Add propagate=False to prevent child loggers from duplicating
  messages through parent handler
- Only log SBS connection errors once until successful reconnect

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:11:47 +00:00
Smittix 95e0309c63 Add configurable 8.33 kHz channel spacing for airband
When using custom frequency, a spacing selector appears allowing
choice between 25 kHz (standard) and 8.33 kHz (European) channel
spacing. The frequency step adjusts accordingly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:02:52 +00:00
Smittix 819944cccf Change airband squelch default to 0 in ADS-B dashboard
Set squelch slider default from 20 to 0 for more sensitive airband
reception out of the box.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:59:21 +00:00
Smittix c595450310 Fix audio stream race condition with process reference
Capture local reference to audio_process at generator start to prevent
'NoneType' object has no attribute 'stdout' error when stop is called
concurrently from another request.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:29:15 +00:00
Smittix 4af61c8cb9 Add RTL-SDR Blog driver installation for V4 support
Build and install RTL-SDR Blog fork drivers during setup to provide
proper support for RTL-SDR Blog V4 devices (R828D tuner). These
drivers are backward compatible with V3 and other RTL-SDR devices.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:24:13 +00:00
Smittix 9f391527c2 Fix RTL-SDR device conflicts when running ADS-B and airband simultaneously
Problems fixed:
1. Added start_new_session=True to dump1090 Popen - creates proper process
   group for clean shutdown
2. Use os.killpg() to kill entire process group when stopping ADS-B -
   ensures child processes are terminated and device is released
3. Track active device index in adsb_active_device for debugging
4. Add device info to /adsb/status endpoint
5. Add logging when starting/stopping ADS-B with device info

These changes ensure the RTL-SDR device is properly released when ADS-B
stops, allowing another process (e.g., airband) to use a different device.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:13:30 +00:00
Smittix cd168da760 Add graphical signal meter for APRS decoding
Backend changes (routes/aprs.py):
- Remove -q h flag from direwolf to enable audio level output
- Add parse_audio_level() to extract levels from direwolf output
- Add rate-limiting (max 10 updates/sec, min 2-level change)
- Push meter events to SSE queue as type='meter'

Frontend changes:
- Add signal meter widget to APRS sidebar
- Horizontal bar gauge with gradient (green->cyan->yellow->red)
- Numeric level display (0-100)
- "BURST" indicator for levels >70
- Status text (weak/moderate/strong signal)
- "No RF activity" state after 5 seconds of silence
- CSS styles in static/css/modes/aprs.css

Also added UK region to dropdown (same freq as Europe: 144.800)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:03:08 +00:00
Smittix f4282cb608 Robust APRS decoder with deadlock fixes and spectrum scanning
Major improvements to APRS decoding reliability:

Process piping fixes (prevent deadlocks):
- rtl_fm stderr -> DEVNULL (was blocking on unbuffered stderr)
- decoder stderr -> STDOUT (merged, single stream to read)
- decoder uses text=True, bufsize=1 for line-buffered reading
- Proper EOF detection in stream thread

rtl_fm command improvements:
- Use -M nfm (narrowband FM) for APRS
- Add -E dc (DC blocking filter) for cleaner audio
- Add -A fast (fast AGC) for packet bursts
- Sample rate 22050 Hz matches direwolf -r 22050

Parsing robustness:
- Strip direwolf bracket prefixes like "[0.4] " before parsing
- Handle multimon-ng "AFSK1200:" prefix
- Better error handling for early process exit

New /aprs/spectrum endpoint:
- Runs rtl_power to scan around APRS frequency
- Returns peak detection, noise floor, signal analysis
- Provides advice for antenna/signal debugging
- Supports region selection and custom frequency

Also added UK to region list (same freq as Europe: 144.800 MHz)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:47:24 +00:00
Smittix 073134d6d3 Add direwolf config file for APRS decoding
Direwolf requires a config file to run. Create a minimal receive-only
config at startup that configures stdin input with AFSK1200 modem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:10:27 +00:00
Smittix 4baefa61ac Fix direwolf not outputting decoded APRS packets
Changed -q d to -q h flag. The -q d option was suppressing APRS packet
descriptions (the decoded output we need), while -q h only suppresses
the audio level heard line.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:04:49 +00:00
Smittix 0d6d81fb69 Add direwolf installation to setup.sh
Adds direwolf (APRS decoder) installation for both Debian and macOS
platforms to support the APRS mode functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:57:08 +00:00
Smittix c96a3ade6b Fix APRS direwolf command flags
- Change direwolf flags from -D 1 to correct flags for stdin input
- Add -n 1 (mono), -b 16 (16-bit), -t 0 (no PTT), -q d (quiet)
- Add -M fm for explicit FM demodulation in rtl_fm
- Add explicit stdout output (-) to rtl_fm command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 20:24:09 +00:00
Smittix 81c9dd84b2 Merge pull request #61 from nechry/patch-1 2026-01-15 19:02:41 +00:00
Smittix fe67461f88 Add selectable ACARS frequencies
- Add checkboxes for each ACARS frequency in the selected region
- Users can now select one or multiple frequencies instead of all
- Frequencies stay checked when switching regions if they exist in both
- Falls back to all region frequencies if none selected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:54:41 +00:00
Smittix aae60e2037 Add real-time squelch control and clean up diagnostic logging
- Add updateAirbandSquelch() to restart audio when squelch slider changes
- Remove verbose diagnostic logging from audio streaming
- Remove tee diagnostic for raw rtl_fm output
- Keep error logging for troubleshooting
- Simplify audio stream generator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:17:15 +00:00
Smittix 97d5ec6b33 Add DVB driver conflict detection and auto-fix feature
- Add /settings/rtlsdr/driver-status endpoint to check for loaded DVB modules
- Add /settings/rtlsdr/blacklist-drivers endpoint to unload modules and create blacklist
- Show warning banner on dashboard when DVB conflict detected
- Provide "Fix Now" button to automatically resolve the issue
- Warn users that their RTL-SDR devices may not work until drivers are blacklisted

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:06:18 +00:00
Smittix 459bf2d8cd Add explicit stdout output flag to rtl_fm command
- Add '-' flag to explicitly specify stdout output
- Some rtl_fm versions/devices require this explicitly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:56:05 +00:00
Smittix 43f0f1cbfc Add rtl_fm raw output capture for diagnostics
- Use tee to capture rtl_fm raw output to /tmp/rtl_fm_raw.bin
- Log raw file size during stream timeouts
- Helps determine if rtl_fm is producing any data at all

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:54:13 +00:00
Smittix a3fd6881df Add ffmpeg stderr logging to diagnose audio pipeline issues
- Capture both rtl_fm and ffmpeg stderr to separate log files
- Log ffmpeg errors at stream request and during timeouts
- Helps identify if ffmpeg is the source of zero-byte streaming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:51:16 +00:00
Smittix b27a532bce Add detailed audio stream generator logging
- Log when generator starts
- Track iterations and bytes sent
- Log select timeouts to diagnose data flow issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:48:51 +00:00
Smittix 52f85669f8 Add diagnostic logging for SDR device audio debugging
- Log rtl_fm stderr to /tmp/rtl_fm_stderr.log instead of /dev/null
- Add detailed logging for audio start requests and parameters
- Log audio stream status and bytes transferred
- Help diagnose SDR1 airband audio issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:47:04 +00:00
Smittix a891160f98 Improve SDR device naming and fix airband audio display
- Show descriptive device names: RTL-SDR #0 (serial) instead of SDR 0
- Include last 4 digits of serial number for identification
- Add tooltip with full device name and serial
- Hide audio player element (no visible playback bar)
- Add debug logging for airband device selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:10:45 +00:00
Smittix 130bc8a51c Fix airband audio element - remove crossorigin, add controls
- Remove crossorigin="anonymous" attribute that may cause CORS issues
- Add controls attribute so user can manually play if autoplay blocked
- Show/hide audio player element when listening starts/stops
- Hide visualizer container on stop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:03:51 +00:00
Smittix 4224418e6f Fix airband audio using async/await pattern from working code
- Convert startAirband to async function
- Add 300ms delay after backend start for stream readiness
- Properly reset audio element before connecting to stream
- Add both oncanplay and immediate play() for browser compatibility
- Add console logging for debugging
- Show visualizer container when audio starts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:54:28 +00:00
Smittix 4018f95723 Fix squawk button to use existing modal, fix airband audio playback
- Use existing showSquawkInfo() for squawk button instead of custom modal
- Fix airband audio by waiting for canplay event before calling play()
- Add proper audio state reset before starting new stream
- Remove unused showSquawkReference function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:35:02 +00:00
Smittix e6c7a3eae4 Add radar overlay on map, fix squawk button and airband status
- Add "Radar" toggle in display controls to overlay radar effect on map
- Radar overlay shows sweep line, range rings, compass rose, center point
- Fix squawk button using addEventListener instead of inline onclick
- Add missing airbandStatus element to fix null error
- Improve squawk modal with click-outside-to-close

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:29:31 +00:00
Smittix 2e27efdfbf Fix status dot color, map tiles, and button issues
- Fix status dot to be red when inactive, green when tracking
- Add additional map invalidateSize call to fix missing tiles on load
- Add type="button" and z-index to strip buttons for proper click handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:21:50 +00:00
Smittix 6efa10643e Fix dashboard height calculation to account for stats strip
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:17:24 +00:00
Smittix 71e5803695 Add stats strip with tracking features to ADS-B dashboard
- Add slim statistics bar with live stats (aircraft count, max range,
  highest altitude, fastest speed, closest aircraft, countries, ACARS)
- Add session timer and report generation with JSON export
- Add signal quality indicator with visual dots
- Add squawk code reference modal
- Add flight lookup button (FlightAware integration)
- Add aircraft type icons (jet, helicopter, prop, military, glider)
- Move status indicator and UTC time from header to stats strip
- Reorganize controls bar into logical groups
- Add ICAO country allocation data for nationality detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 15:15:41 +00:00
Jean-François Auger 1107f0e534 Update setup.sh
look like repo for multimon-ng is wrong
2026-01-15 16:13:23 +01:00
Smittix 0b22d0aa1f Remove aircraft mode from main app, link to dashboard instead
- Remove aircraft.html partial and all aircraft mode JS code
- Navigation buttons now link directly to /adsb/dashboard
- Remove Leaflet MarkerCluster (only used for aircraft)
- Clean up help section aircraft references
- Remove checkAdsbTools function and related code
2026-01-15 14:47:27 +00:00
Smittix 353cd16021 Add volume control for airband listening on ADS-B dashboard
- Add volume slider with speaker icon next to squelch control
- Apply initial volume when audio starts
- Add updateAirbandVolume() function for real-time volume changes
2026-01-15 14:19:28 +00:00
Smittix ac6d1b570d Add clear SDR device selection for ADS-B and airband listening
- Add ADS-B device selector with label before START button
- Add Listen label for airband device selector
- Track which device is actively used for ADS-B tracking
- Disable ADS-B device selector while tracking is active
- Update device conflict detection to use actual selected device
- Consolidate device selector initialization into single function
- Remove duplicate device loading from initAirband()
2026-01-15 14:11:19 +00:00
Smittix 319ea2d01d Remove redundant SDR device selector from APRS configuration
APRS now uses the global device selector in the sidebar like other modes
2026-01-15 13:13:33 +00:00
Smittix 6fc64937fb Fix volume control not applying initial knob value
- Apply volume knob value when scanner audio starts on signal detection
- Apply volume knob value when direct listening starts
- Fix visualizer to use correct scannerAudioPlayer element ID
- Add console logging for volume changes
2026-01-15 12:40:31 +00:00
Smittix 323f24a470 Fix ADS-B mobile - use !important, wider breakpoint, remove overflow:hidden 2026-01-15 09:31:51 +00:00
Smittix d98bcc15b8 Fix ADS-B dashboard mobile - selected aircraft panel, back link, controls 2026-01-15 09:29:45 +00:00
Smittix fdd91485fc Fix ADS-B dashboard mobile layout - proper flex/grid handling 2026-01-15 09:26:16 +00:00
Smittix d510ba30f6 Fix mobile navigation and display issues
- Add APRS to mobile navigation bar (was missing)
- Fix CSS that was forcing aircraft visuals to always display
- Only apply flex layout to visuals when they are actually visible
- Fix ADS-B dashboard mobile layout with proper flex ordering
- Reset grid properties on mobile for proper stacking
- Hide ACARS sidebar on mobile (desktop only feature)
2026-01-15 09:22:35 +00:00
Smittix 4bb0c9b9a3 Fix ADS-B dashboard mobile layout and map rendering
- Add mobile CSS for dashboard to allow scrolling and proper stacking
- Set explicit height for map container on mobile (50vh min 300px)
- Remove sidebar max-height restriction on mobile
- Add map invalidateSize() on init, resize, and orientation change
- Fix controls bar wrapping and touch-friendly zoom controls
- Simplify header layout on mobile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:15:01 +00:00
Smittix b3e67e5ef6 Fix mobile responsiveness and map loading issues
- Add comprehensive mobile CSS for viewport scrolling and layout stacking
- Fix Leaflet maps not rendering by adding explicit heights and invalidateSize() calls
- Add touch-friendly controls and proper touch-action for maps
- Simplify header on mobile, hide stats and reduce sizes
- Handle orientation changes and window resize for maps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:12:29 +00:00
Smittix dec890104b Fix acarsdec JSON output flag for newer forks
Use -j instead of -o 4 for JSON output, which is the correct
flag for acarsdec v4.3.1+ (Thibaut Varene fork).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:03:57 +00:00
Smittix 5d8c435c5a Update README.md with new content and formatting 2026-01-15 07:58:01 +00:00
Smittix 3cf371242a Update README with new project information 2026-01-14 21:16:18 +00:00
Smittix aab7b508cc Add files via upload 2026-01-14 21:15:50 +00:00
Smittix 36def8f96a Update README with donation support and project info
Added donation link and updated project description.
2026-01-14 21:14:48 +00:00
Smittix 3c0a654f93 Add GitHub sponsor button for Buy Me a Coffee
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:10:18 +00:00
Smittix 77b4bc9ad4 Add save button to TSCM report
- Add Save Report button next to Print button
- Downloads report as HTML file with date-stamped filename
- Style both buttons consistently with flex container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:37:43 +00:00
Smittix 9f39f1cc2f Add TSCM report generation feature
- Add Generate Report button to TSCM sidebar (appears after sweep)
- Implement generateTscmReport() function that creates professional HTML report
- Report includes: executive summary, device tables by risk level,
  indicators, recommendations, and disclaimers
- Track sweep start/end times for duration calculation
- Fix script tag escaping in template literal to prevent parsing issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:21:55 +00:00
Smittix f326be77cd Modularize index.html with CSS and HTML partials
- Extract inline CSS to static/css/modes/ (acars, aprs, tscm)
- Create HTML partials for all 9 modes in templates/partials/modes/
- Reduce index.html from 11,862 to 10,281 lines (~15% reduction)
- Use Jinja2 includes for cleaner template organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:07:38 +00:00
Smittix 7eba7dbaaa Hide output console in TSCM mode
Prevents pager and 433MHz sensor data from appearing in the TSCM
section, which has its own dedicated dashboard panels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:22:50 +00:00
Smittix dc4434db84 Add mobile responsive design overhaul
- Add responsive.css with shared utilities (hamburger menu, touch targets, responsive typography)
- Add hamburger menu and mobile drawer navigation to main app
- Add horizontal scrolling mobile nav bar for mode switching
- Refactor index.css with mobile-first breakpoints
- Update adsb_dashboard.css for mobile layouts
- Update satellite_dashboard.css for mobile layouts
- Add mobile nav controller to app.js with drawer toggle
- Hide stats/taglines on small screens
- Unified breakpoints: 480px (phone), 768px (tablet), 1024px (desktop)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:30:15 +00:00
Smittix 0eed4a2649 Bump version to 2.9.5
- Update VERSION in config.py
- Add changelog entry for v2.9.5 highlights
- Update CHANGELOG.md with detailed release notes
- Update pyproject.toml version

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:00:44 +00:00
Smittix 7b49c95967 Add welcome page with changelog and mode selection
- Replace simple splash screen with comprehensive welcome page
- Show version number and latest changelog entries
- Add 9-button mode selection grid for direct navigation
- User can now choose which mode to start with
- Responsive layout adapts to mobile screens
- Flow: Welcome → Disclaimer (if needed) → Selected mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:26:23 +00:00
Smittix 30126b1709 Add dropdown navigation menus and fix TSCM baseline recording
- Convert flat mode nav buttons into dropdown menus by category (SDR/RF, Wireless, Security)
- Add CSS styles for dropdown animations and active state highlighting
- Fix baseline recording by feeding device data to recorder endpoints
- Remove redundant threat summary section from TSCM sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:51:45 +00:00
Smittix 66c7db73e2 Integrate device identity engine into TSCM sweep
- Import device identity functions (get_identity_engine, ingest_ble_dict, etc.)
- Initialize and clear identity engine at sweep start
- Feed BLE observations to identity engine during Bluetooth scan
- Feed WiFi observations to identity engine during WiFi scan
- Finalize sessions and emit identity_clusters event at sweep completion
- Include identity cluster statistics in sweep results

The device identity engine provides MAC-randomization resistant detection
by clustering observations using fingerprinting, timing patterns, and
RSSI trajectory analysis.
2026-01-14 16:34:49 +00:00
Smittix 07af3acb84 Fix rtl_433 bias-t flag and add TSCM enhancements
- Fix bias-t option in rtl_433 for RTL-SDR and HackRF:
  - rtl_433's -T flag is for timeout, not bias-t
  - RTL-SDR: Use :biast=1 suffix on device string
  - HackRF: Use bias_t=1 in SoapySDR device string
- Add "Listen (FM/AM)" buttons to TSCM RF signal details
  - Switches to Listening Post mode and tunes to frequency
- Fix device detail header padding to prevent protocol badge
  overlapping with close button
2026-01-14 16:27:07 +00:00
Smittix b2feccdb90 Improve TSCM modal close button visibility
- Add circular background to close button
- Use visible border and solid background color
- Increase z-index to ensure it's above content
- Add hover effect with red background
- Better positioning and sizing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:53:23 +00:00
Smittix db2f46b46e Add root privilege check and warning display
- Add startup check in app.py for root/sudo privileges
- Show warning in terminal if not running as root
- Add running_as_root flag to TSCM devices API response
- Display privilege warning in TSCM UI when not running as root
- Show command to run with sudo in the warning
- Add CSS styling for privilege warning banner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:37:56 +00:00
Smittix ff7c768287 Fix RF scanning and add status feedback
- Remove requirement for sdr_device to be set before RF scanning
- Add RTL-SDR device detection check with rtl_test before scanning
- Lower signal detection threshold from -50dBm to -70dBm
- Lower noise floor threshold from 15dB to 10dB above noise
- Add rf_status event for frontend feedback when RF unavailable
- Show status message in RF panel explaining why scanning isn't working
- Add CSS styling for status messages
- Reset RF status message when sweep starts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:35:47 +00:00
Smittix 236fbf061c Fix TSCM modal readability issues
- Fix transparent modal background: use --bg-card instead of undefined --panel-bg
- Add box-shadow to modal for better visibility
- Fix reason text color: use --text-secondary instead of hard-to-read --text-muted
- Fix device details section headings and table labels
- Fix indicator tags, disclaimer text, and reasons list colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:25:53 +00:00
Smittix 21b0a153e8 Add MAC-randomization resistant device detection for TSCM
- New device_identity.py: Clusters BLE/WiFi observations into probable
  physical devices using passive fingerprinting (not MAC addresses)
- Fingerprinting based on manufacturer data, service UUIDs, capabilities,
  timing patterns, and RSSI trajectories
- Session tracking with automatic gap detection
- Risk indicators: stable RSSI, MAC rotation, ESP32 chipsets, audio-capable
- Full audit trail for all clustering decisions

- New ble_scanner.py: Cross-platform BLE scanning with bleak library
- Detects AirTags, Tile, SmartTags, ESP32 by manufacturer ID
- Fallback to system tools (btmgmt, hcitool, system_profiler)

- Added API endpoints for device identity clustering (/tscm/identity/*)
- Updated setup.sh with bleak dependency
- Updated documentation with TSCM features and hardware requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:19:20 +00:00
Smittix 35ca3f3a07 Add clickable score cards and fix findings panel
Features:
- Score cards (High Interest, Needs Review, etc.) are now clickable
- Clicking a card shows all devices in that category in a modal
- Can click through to see individual device details
- Correlations card shows cross-protocol matches

Fixes:
- Findings panel now shows devices with score >= 3 (was 6)
- Panel items color-coded by score (critical/high/medium)
- Sorted by score descending
- Fixed empty state message

UI:
- Added hover effects on clickable cards
- Added CSS for category device list
- Added protocol badges and mini indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:58:28 +00:00
Smittix 87f72db8ad Add click-to-expand device details and fix score card updates
Features:
- Click any device to see detailed breakdown of why it was scored
- Modal shows score circle, risk level, recommended action
- Lists all indicators that contributed to the score
- Shows device-specific information (MAC, RSSI, etc.)
- Includes disclaimer about findings

Fixes:
- Score cards (High Interest, Needs Review, etc.) now update in real-time
- High-interest devices (score 6+) populate the Detected Threats panel
- Added updateTscmThreatCounts() calls when devices are added

UI:
- Device items now have cursor:pointer to indicate clickability
- Added CSS for modal, score circle, indicator list, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:39:39 +00:00
Smittix 93b763865b Update TSCM with improved WiFi scanning, new scoring UI, and tracker detection
WiFi Scanning:
- Add 'iw' scan method as primary (sometimes works without root)
- Auto-detect wireless interface from /sys/class/net
- Better error logging for permission issues
- Fall back to iwlist if iw fails

UI Updates:
- Replace Critical/High/Medium/Low cards with new scoring model
- Now shows: High Interest (6+), Needs Review (3-5), Informational (0-2)
- Add Correlations count card
- Update counts based on device classification scores

Tracker Detection:
- Add detection for Apple AirTag (by OUI and name)
- Add detection for Tile trackers
- Add detection for Samsung SmartTag
- Add detection for ESP32/ESP8266 devices (Espressif chipset)
- Add generic chipset vendor detection
- New indicator types with appropriate scoring weights

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:28:54 +00:00
Smittix b15b5ad9ba Fix Bluetooth event type being overwritten by device type
The bt_device event was including 'type': device.get('type') which
overwrote the SSE event type 'bt_device' with 'ble', causing the
frontend to not recognize the events.

- Rename device type field from 'type' to 'device_type' in bt_device events
- Update frontend to use device_type for display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:20:41 +00:00
Smittix 364600e545 Fix Linux device detection with more fallback methods
- Restore airodump-ng check for WiFi tools
- Add /sys/class/net/*/wireless fallback for WiFi detection
- Add /sys/class/bluetooth/hci* fallback for Bluetooth detection
- Add hciconfig to Bluetooth tool checks
- Add SubprocessError to exception handling
- Multiple fallback layers ensure detection works even with partial tools

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:10:04 +00:00
Smittix 23b2a2a0c0 Fix TSCM device detection for macOS
- Add macOS-specific WiFi detection using airport utility
- Add macOS-specific Bluetooth detection using system_profiler
- Add fallback to 'iw' command on Linux when iwconfig unavailable
- Properly handle platform differences for device availability checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:07:29 +00:00
Smittix ef6eec3cf8 Integrate TSCM correlation engine with sweep and add comprehensive reporting
- Integrate correlation engine into sweep loop for real-time device profiling
- Add API endpoints for findings (/tscm/findings, /tscm/findings/high-interest,
  /tscm/findings/correlations, /tscm/findings/device/<id>)
- Add meeting window endpoints (/tscm/meeting/start, /tscm/meeting/end, /tscm/meeting/status)
- Add comprehensive report generation endpoint (/tscm/report)
- Update frontend to display scores, indicators, and recommended actions
- Add correlation findings display and cross-protocol analysis
- Show sweep summary with assessment on completion
- Add client-safe legal disclaimers throughout UI and API responses
- Sort devices by score (highest first) for prioritized review

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:04:22 +00:00
Smittix 94f4682f2f Implement TSCM correlation engine and fix scanning issues
Correlation Engine (utils/tscm/correlation.py):
- Device profiles with comprehensive tracking
- Scoring model: 0-2 Informational, 3-5 Review, 6+ High Interest
- Cross-protocol correlation (BLE+RF, WiFi+RF, same vendor)
- Meeting window tracking for time correlation
- Device history for persistence detection
- Indicator types: unknown, audio-capable, persistent, cross-protocol, etc.

Bluetooth Scanning Fixes:
- Added multiple scan methods for Linux (hcitool, btmgmt, bluetoothctl)
- Fixed indentation issues in bluetoothctl scan
- Added comprehensive logging for debugging

RF Scanning Fixes:
- Added logging for each frequency band scan
- Better error reporting from rtl_power
- Increased timeout for reliability

Classification Updates:
- Green/Yellow/Red color coding with reasons
- Audio-capable device detection (microphone badge)
- Proper CSS styling for classification levels
2026-01-14 13:57:56 +00:00
Smittix f407a3cb54 Add TSCM device classification system
Classification levels:
- Green (Informational): Known devices in baseline, expected infrastructure
- Yellow (Needs Review): Unknown BLE devices, new WiFi APs, unidentified RF
- Red (High Interest): Persistent transmitters, audio-capable BLE, trackers,
  devices with repeat detections across scans

Features:
- Device history tracking for repeat detection (24-hour window)
- Audio-capable BLE detection (headphones, mics, speakers)
- Classification reasons shown under each device
- Color-coded indicators with visual styling
- Microphone badge for audio-capable BLE devices
2026-01-14 13:52:28 +00:00
Smittix c11c1200e2 Stream devices to dashboard in real-time during TSCM sweep
- Emit wifi_device, bt_device, rf_signal events as devices are found
- Add frontend handlers to populate device lists in real-time
- Add RF Signals panel to TSCM dashboard
- Dashboard now updates during sweep, not just at the end
2026-01-14 13:43:19 +00:00
Smittix 0acbf87dde Add RF scanning and improve TSCM device naming
- Add _scan_rf_signals() function using rtl_power to scan:
  - FM broadcast band (88-108 MHz) for potential bugs
  - 315/433/868/915 MHz ISM bands
  - 1.2 GHz video transmitter band
  - 2.4 GHz ISM band
- Integrate RF scanning into sweep with 60-second interval
- Add display_name field for all devices with friendly names
- Update frontend to use display_name in dropdowns
- Improve scan status display: '14 WiFi | 20 BT | 3 RF' instead of '14w 20b'
- Auto-select first SDR device when available
2026-01-14 13:40:13 +00:00
Smittix 153336d757 Fix TSCM SDR device detection
SDRFactory.detect_devices() returns SDRDevice dataclass objects,
not dictionaries. Fixed to access attributes directly instead of
using .get() method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:28:07 +00:00
Smittix 570710c556 Implement TSCM device selection and actual scanning
- Add /tscm/devices endpoint to list available WiFi interfaces,
  Bluetooth adapters, and SDR devices
- Add _scan_wifi_networks() for actual WiFi scanning (macOS/Linux)
- Add _scan_bluetooth_devices() for actual Bluetooth scanning
- Update _run_sweep() to perform real scans with selected interfaces
- Add severity_counts tracking in progress events
- Fix frontend to correctly access device and severity data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:23:26 +00:00
Smittix de13d5ea74 Restore lost features and unify button styling
- Restore APRS dynamic device selection and status bar
- Add ACARS status indicator with listening/receiving states
- Fix acars.py: use -o 4 for JSON, correct command order, add macOS pty fix
- Unify all start buttons (green) and stop buttons (red) across app
- Update help documentation with all modes (APRS, ACARS, Listening Post, TSCM)
- Add TSCM Alpha badge to sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:20:52 +00:00
Smittix f36e528086 Add TSCM counter-surveillance mode (Phase 1)
Features:
- New TSCM mode under Security navigation group
- Sweep presets: Quick, Standard, Full, Wireless Cameras, Body-Worn, GPS Trackers
- Device detection with warnings when WiFi/BT/SDR unavailable
- Baseline recording to capture environment "fingerprint"
- Threat detection for known trackers (AirTag, Tile, SmartTag, Chipolo)
- WiFi camera pattern detection
- Real-time SSE streaming for sweep progress
- Futuristic circular scanner progress visualization
- Unified threat dashboard with severity classification

New files:
- routes/tscm.py - TSCM Blueprint with REST API endpoints
- data/tscm_frequencies.py - Surveillance frequency database
- utils/tscm/baseline.py - BaselineRecorder and BaselineComparator
- utils/tscm/detector.py - ThreatDetector for WiFi, BT, RF analysis

Database:
- tscm_baselines, tscm_sweeps, tscm_threats, tscm_schedules tables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix 52ce930c31 Fix listening post frequency display - move MHz beside frequency
- Changed layout from stacked to inline using flexbox
- MHz now appears beside the frequency number (118.000 MHz)
- Uses align-items: baseline for proper text alignment
- Modulation badge (AM/FM) remains below on its own row
- Increased MHz font size slightly for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix bb694c9926 Revamp APRS layout and restore satellite modal
APRS Layout:
- Redesigned visualization panel with flexbox layout
- Map panel now takes 2/3 width with station list on right (1/3)
- Station list has proper min/max width (280-350px)
- Packet log at bottom with max height
- Better use of space for all screen sizes

Satellite Features:
- Restored satellite modal (was missing HTML, only JS existed)
- Add Satellite (TLE) button for manual TLE input
- Update from Celestrak button with category selection
- Categories: Space Stations, Weather, NOAA, GOES, Amateur,
  CubeSats, Starlink, OneWeb, Iridium NEXT, Visual, Geo, Resources
- Tracked satellites list in sidebar
- Modal tabs for TLE input vs Celestrak fetch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix a8c77c8db3 Fix APRS mode integration and remove orphaned signalMeter references
- Add APRS to switchMode modeMap, modeNames, and titles
- Add aprsMode classList toggle
- Add aprsVisuals display toggle
- Add APRS to recon panel hide condition
- Add APRS to RTL-SDR device section visibility
- Stop APRS scan when switching modes
- Remove aprsMode inline style (let CSS class handle visibility)
- Remove signalMeter reference (element was previously deleted)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix 3263638c57 Add APRS amateur radio tracking feature
- Create routes/aprs.py with start/stop/stream endpoints for APRS decoding
- Support multiple regional frequencies (North America, Europe, Australia, etc.)
- Use direwolf (preferred) or multimon-ng as AFSK1200 decoder
- Parse APRS packets for position, weather, messages, and telemetry
- Add APRS visualization panel with Leaflet map and station tracking
- Include station list with callsigns, distance, and last heard time
- Add packet log with raw APRS data display
- Register APRS blueprint and add global state management
- Add direwolf and multimon-ng to dependency definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:35:13 +00:00
Smittix c30e5800df Remove unused signal meter div from output header
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix 161e0d8ea8 Make dependency guidance consistent across all documentation
- Update acarsdec install instruction to point to ./setup.sh
- Add ACARS Messaging to README features list
- Add acarsdec to README acknowledgments section
- All sources now consistently recommend ./setup.sh for acarsdec

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix 93f68aa29d Fix inaccurate dependency information
- Fix multimon-ng GitHub URL typo (EliasOewornal -> EliasOenal)
- Fix acarsdec install info (not in apt repos, must build from source)
- Add hcxdumptool to quick install command
- Add note about acarsdec requiring source build with link to setup.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:32:23 +00:00
Smittix c5ce35ff13 Fix right sidebar grid-column to 3
With ACARS in column 1 and map in column 2, the right sidebar
needs to be in column 3.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 7069c8b636 Fix map display by updating grid-column to 2
ACARS is now in column 1, so main-display (map) needs to be in column 2.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 6149427753 Move ACARS panel to left of map in adsb/dashboard
- Reorder HTML: ACARS sidebar now comes before main-display
- Update grid: auto 1fr 300px (ACARS, Map, Sidebar)
- Swap borders: right border on sidebar, left border on button
- Button now on right side of ACARS panel (bordering map)
- Icon changed to left arrow (collapse direction)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 536b762f97 Fix ACARS toggle button visibility by using flexbox layout
The parent container has overflow:auto which clipped the absolutely
positioned button. Changed to simple flexbox approach where:
- Button is a normal flex child (always visible)
- Content div collapses to width:0 when collapsed
- Map expands to fill available space via flex

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix b423dcedf7 Fix ACARS toggle button icon direction and positioning
- Fix checkAcarsTools error by removing orphaned function call
- Change toggle icon from left arrow to right arrow (indicates collapse direction)
- Fix button positioning to use left edge instead of right edge
- Button now correctly appears on left side of ACARS panel (bordering map)
- Both index.html and adsb_dashboard now behave consistently

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 16cd1fef2d Remove ACARS section from left sidebar menu
ACARS controls are now in the collapsible sidebar next to the map,
so the redundant section in the left settings panel is no longer needed.

- Remove ACARS Messaging section from aircraft mode settings
- Remove unused JS functions (toggleAcarsPanel, setAcarsRegion, etc.)
- Keep addAcarsToOutput helper used by main sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix c94d0a642d Fix ACARS sidebar to collapse outward and expand map
- Change collapsed width from 32px to 0 so map expands fully
- Position collapse button as absolute overlay on map edge
- Button slides to edge of map when collapsed (right: 100%)
- Content fades out smoothly instead of abrupt hide
- Applied same fix to both adsb_dashboard.css and index.html

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 135390788d Add ACARS aircraft messaging feature with collapsible sidebar
- Add routes/acars.py with start/stop/stream endpoints for ACARS decoding
- Build acarsdec from source in Dockerfile (not available in Debian slim)
- Add acarsdec installation script to setup.sh for native installs
- Add ACARS to dependency checker in utils/dependencies.py
- Add collapsible ACARS sidebar next to map in aircraft tracking tab
- Add collapsible ACARS panel in ADS-B dashboard with same layout
- Include guidance about needing two SDRs for simultaneous ADS-B + ACARS
- Support regional frequency presets (N.America, Europe, Asia-Pacific)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 98e4e38809 Fix install hang on Linux Mint/Ubuntu with newer kernels
The soapysdr-tools package pulls in xtrx-dkms, which fails to compile
its kernel module on Kernel 6.14+ and causes apt to hang. Explicitly
exclude xtrx-dkms since most users don't have XTRX hardware.

Fixes #56

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
Smittix 6d5a12a21f Fix dump1090 not found in Docker by building from source
The dump1090 packages are not available in Debian slim repos, causing
the Docker build to silently skip installation. This builds dump1090-fa
from FlightAware's source repository instead.

Fixes #46

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:31:56 +00:00
James Smith fe3b3b536c bug fixes and feature updates 2026-01-14 10:30:24 +00:00
James Smith aa8a6baac4 Fix macOS setup: build multimon-ng from source and improve UX
- Add install_multimon_ng_from_source_macos() since multimon-ng is not
  available in Homebrew core
- Fix brew_install() to properly check return code before printing success
- Show startup instructions before tool check so users see them on macOS
- Make missing Bluetooth tools a warning on macOS instead of hard failure
  (bluetoothctl/hcitool/hciconfig are Linux-only BlueZ utilities)
2026-01-12 13:15:59 +00:00
Smittix b0982249c3 Add device debug endpoint and fix RTL-SDR detection issues
- Add /devices/debug endpoint for detailed SDR detection diagnostics
- Add kernel driver blacklisting to setup.sh for Debian/Ubuntu
- Blacklists dvb_usb_rtl28xxu, rtl2832, rtl2830, r820t modules
- Update docs to use correct venv command: sudo -E venv/bin/python

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:14:36 +00:00
Smittix cf91c2484f Fix login system: add health route exemption, translate comments
- Add 'health' to allowed routes to prevent Docker healthcheck failures
- Translate Spanish comments to English for consistency
- Reset binary database file to avoid committing user data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:39:44 +00:00
Smittix b3a8a69244 Merge pull request #52 from zielu92/patch-1
Update README.md with project details and features
2026-01-11 20:33:33 +00:00
Jon Ander Oribe f51b193876 Update config.py 2026-01-11 18:12:19 +01:00
Jon Ander Oribe 0846d1f360 Update login.html 2026-01-11 18:05:13 +01:00
Jon Ander Oribe dd56617c4c Implement user authentication with hashed passwords
Replaces hardcoded admin credentials with a users table in the database, storing hashed passwords and user roles. Updates the login logic in app.py to authenticate against the database using Werkzeug's password hashing utilities. Adds admin credential configuration to config.py and ensures a default admin user is created during database initialization.
2026-01-11 17:54:43 +01:00
Jon Ander Oribe 03ce847196 Logout button added 2026-01-11 14:56:25 +01:00
Jon Ander Oribe 1a7a33041c Styles improvement 2026-01-11 14:17:13 +01:00
Jon Ander Oribe 6da8b11301 Add login system with authentication and login page
Introduced a login system to restrict access to the application. Added session-based authentication in app.py, including login and logout routes, and a new login.html template for the login form. Updated .dockerignore to exclude .uv directory.
2026-01-11 14:06:55 +01:00
Michał Zieliński 8cd1ecffc4 Update README.md with project details and features
Fixed typo in Docker Compose
2026-01-11 12:54:14 +01:00
Smittix 7967b71405 Merge pull request #49 from armegia/armegia
In Ubuntu Desktop 25.10, dump1090 is dump1090-mutability. Because of this, cmd_exists dump1090 fails even after successful apt install. Added code to create a symbolic link from /usr/local/sbin/dump1090 to the dump1090-mutability if it exists
2026-01-10 19:22:12 +00:00
Antonio M cd0d5971e2 In Ubuntu Desktop 25.10, dump1090 is dump1090-mutability. Because of this, cmd_exists dump1090 fails even after successful apt install. Added code to create a symbolic link from /usr/local/sbin/dump1090 to the dump1090-mutability if it exists 2026-01-10 18:54:56 +01:00
Smittix b52b4db989 Merge pull request #48 from JonanOribe/main
Add satellite route tests and update .gitignore
2026-01-10 15:04:16 +00:00
Jon Ander Oribe ef5cfb4908 Add satellite route tests and update .gitignore
Added tests for satellite-related routes, including validation, error handling, and mocking of external dependencies. Updated .gitignore to exclude database files (*.db, *.sqlite3) in addition to lock files.
2026-01-10 14:03:49 +01:00
Smittix ee7781ee67 Merge pull request #47 from JonanOribe/main
Testing for Wifi
2026-01-10 11:11:07 +00:00
Jon Ander Oribe 8c5bb32ec6 Testing for Wifi 2026-01-10 07:35:28 +01:00
215 changed files with 100413 additions and 9764 deletions
+4
View File
@@ -15,6 +15,7 @@ venv/
.eggs/
*.egg-info/
*.egg
.uv
# IDE
.idea/
@@ -32,6 +33,9 @@ htmlcov/
# Logs
*.log
# Local Postgres data
pgdata/
# Captured files (don't include in image)
*.cap
*.pcap
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: smittix
+23 -1
View File
@@ -14,6 +14,14 @@ uv.lock
*.log
pager_messages.log
# Local data
downloads/
pgdata/
# Local data
downloads/
pgdata/
# IDE
.idea/
.vscode/
@@ -30,5 +38,19 @@ dist/
build/
*.egg-info/
# Package manager lock files
# Package manager lock files & DB files
uv.lock
*.db
*.sqlite3
intercept.db
# Instance folder (contains database with user data)
instance/
# Agent configs with real credentials (keep template only)
intercept_agent_*.cfg
!intercept_agent.cfg
# Temporary files
/tmp/
*.tmp
+110
View File
@@ -2,6 +2,116 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.11.0] - 2026-01-28
### Added
- **Meshtastic Mesh Network Integration** - LoRa mesh communication support
- Connect to Meshtastic devices (Heltec, T-Beam, RAK) via USB/Serial
- Real-time message streaming via SSE
- Channel configuration with encryption key support
- Node information display with signal metrics (RSSI, SNR)
- Message history with up to 500 messages
- **Ubertooth One BLE Scanner** - Advanced Bluetooth scanning
- Passive BLE packet capture across all 40 BLE channels
- Raw advertising payload access
- Integration with existing Bluetooth scanning modes
- Automatic detection of Ubertooth hardware
- **Offline Mode** - Run iNTERCEPT without internet connectivity
- Bundled Leaflet 1.9.4 (JS, CSS, marker images)
- Bundled Chart.js 4.4.1
- Bundled Inter and JetBrains Mono fonts (woff2)
- Local asset status checking and validation
- **Settings Modal** - New configuration interface accessible from navigation
- Offline tab: Toggle offline mode, configure asset sources
- Display tab: Theme and animation preferences
- About tab: Version info and links
- **Multiple Map Tile Providers** - Choose from:
- OpenStreetMap (default)
- CartoDB Dark
- CartoDB Positron (light)
- ESRI World Imagery
- Custom tile server URL
### Changed
- **Dashboard Templates** - Conditional asset loading based on offline settings
- **Bluetooth Scanner** - Added Ubertooth backend alongside BlueZ/DBus
- **Dependencies** - Added meshtastic SDK to requirements.txt
### Technical
- Added `routes/meshtastic.py` for Meshtastic API endpoints
- Added `utils/meshtastic.py` for device management
- Added `utils/bluetooth/ubertooth_scanner.py` for Ubertooth support
- Added `routes/offline.py` for offline mode API
- Added `static/js/core/settings-manager.js` for client-side settings
- Added `static/css/settings.css` for settings modal styles
- Added `static/css/modes/meshtastic.css` for Meshtastic UI
- Added `static/js/modes/meshtastic.js` for Meshtastic frontend
- Added `templates/partials/modes/meshtastic.html` for Meshtastic mode
- Added `templates/partials/settings-modal.html` for settings UI
- Added `static/vendor/` directory structure for bundled assets
---
## [2.10.0] - 2026-01-25
### Added
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
- Full-screen dashboard with interactive maritime map
- Vessel details: name, MMSI, callsign, destination, ETA
- Navigation data: speed, course, heading, rate of turn
- Ship type classification and dimensions
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
- Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
- MMSI country identification via Maritime Identification Digits (MID) lookup
- Position extraction and map markers for distress alerts
- Prominent visual overlay for DISTRESS and URGENCY alerts
- Permanent database storage for critical alerts with acknowledgement workflow
- **Spy Stations Database** - Number stations and diplomatic HF networks
- Comprehensive database from priyom.org
- Station profiles with frequencies, schedules, operators
- Filter by type (number/diplomatic), country, and mode
- Tune integration with Listening Post
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
### Changed
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
- **Dependencies** - Added scipy and numpy for DSC signal processing
### Fixed
- **DSC Position Decoder** - Corrected octal literal in quadrant check
---
## [2.9.5] - 2026-01-14
### Added
- **MAC-Randomization Resistant Detection** - TSCM now identifies devices using randomized MAC addresses
- **Clickable Score Cards** - Click on threat scores to see detailed findings
- **Device Detail Expansion** - Click-to-expand device details in TSCM results
- **Root Privilege Check** - Warning display when running without required privileges
- **Real-time Device Streaming** - Devices stream to dashboard during TSCM sweep
### Changed
- **TSCM Correlation Engine** - Improved device correlation with comprehensive reporting
- **Device Classification System** - Enhanced threat classification and scoring
- **WiFi Scanning** - Improved scanning reliability and device naming
### Fixed
- **RF Scanning** - Fixed scanning issues with improved status feedback
- **TSCM Modal Readability** - Improved modal styling and close button visibility
- **Linux Device Detection** - Added more fallback methods for device detection
- **macOS Device Detection** - Fixed TSCM device detection on macOS
- **Bluetooth Event Type** - Fixed device type being overwritten
- **rtl_433 Bias-T Flag** - Corrected bias-t flag handling
---
## [2.9.0] - 2026-01-10
### Added
+122
View File
@@ -0,0 +1,122 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking.
## Common Commands
### Setup and Running
```bash
# Initial setup (installs dependencies and configures SDR tools)
./setup.sh
# Run the application (requires sudo for SDR/network access)
sudo -E venv/bin/python intercept.py
# Or activate venv first
source venv/bin/activate
sudo -E python intercept.py
```
### Testing
```bash
# Run all tests
pytest
# Run specific test file
pytest tests/test_bluetooth.py
# Run with coverage
pytest --cov=routes --cov=utils
# Run a specific test
pytest tests/test_bluetooth.py::test_function_name -v
```
### Linting and Formatting
```bash
# Lint with ruff
ruff check .
# Auto-fix linting issues
ruff check --fix .
# Format with black
black .
# Type checking
mypy .
```
## Architecture
### Entry Points
- `intercept.py` - Main entry point script
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
### Route Blueprints (routes/)
Each signal type has its own Flask blueprint:
- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng
- `sensor.py` - 433MHz IoT sensors via rtl_433
- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003)
- `acars.py` - Aircraft datalink messages via acarsdec
- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs)
- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs)
- `satellite.py` - Pass prediction using TLE data
- `aprs.py` - Amateur packet radio via direwolf
- `rtlamr.py` - Utility meter reading
### Core Utilities (utils/)
**SDR Abstraction Layer** (`utils/sdr/`):
- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay)
- Each type has a `CommandBuilder` for generating CLI commands
**Bluetooth Module** (`utils/bluetooth/`):
- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ
- `aggregator.py` - Merges observations across time
- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag)
- `heuristics.py` - Behavioral analysis for device classification
**TSCM (Counter-Surveillance)** (`utils/tscm/`):
- `baseline.py` - Snapshot "normal" RF environment
- `detector.py` - Compare current scan to baseline, flag anomalies
- `device_identity.py` - Track devices despite MAC randomization
- `correlation.py` - Cross-reference Bluetooth and WiFi observations
**WiFi Utilities** (`utils/wifi/`):
- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS)
- `channel_analyzer.py` - Frequency band analysis
### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min).
**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes.
### External Tool Integrations
| Tool | Purpose | Integration |
|------|---------|-------------|
| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng |
| multimon-ng | Pager decoding | Reads from rtl_fm stdout |
| rtl_433 | 433MHz sensors | JSON output parsing |
| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) |
| acarsdec | ACARS messages | Output parsing |
| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing |
| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable |
### Configuration
- `config.py` - Environment variable support with `INTERCEPT_` prefix
- Database: SQLite in `instance/` directory for settings, baselines, history
## Testing Notes
Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration.
+90 -7
View File
@@ -31,17 +31,100 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# GPS support
gpsd-clients \
# Utilities
# APRS
direwolf \
# WiFi Extra
hcxdumptool \
hcxtools \
# SDR Hardware & SoapySDR
soapysdr-tools \
soapysdr-module-rtlsdr \
soapysdr-module-hackrf \
soapysdr-module-lms7 \
limesuite \
hackrf \
# Utilities
curl \
procps \
&& rm -rf /var/lib/apt/lists/*
# Install dump1090 for ADS-B (package name varies by distribution)
RUN apt-get update && \
(apt-get install -y --no-install-recommends dump1090-mutability || \
apt-get install -y --no-install-recommends dump1090-fa || \
apt-get install -y --no-install-recommends dump1090 || \
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
rm -rf /var/lib/apt/lists/*
# Build dump1090-fa and acarsdec from source (packages not available in slim repos)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
pkg-config \
cmake \
libncurses-dev \
libsndfile1-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
# Build dump1090
&& cd /tmp \
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
&& cd dump1090 \
&& make \
&& cp dump1090 /usr/bin/dump1090-fa \
&& ln -s /usr/bin/dump1090-fa /usr/bin/dump1090 \
&& rm -rf /tmp/dump1090 \
# Build AIS-catcher
&& cd /tmp \
&& git clone https://github.com/jvde-github/AIS-catcher.git \
&& cd AIS-catcher \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& cp AIS-catcher /usr/bin/AIS-catcher \
&& cd /tmp \
&& rm -rf /tmp/AIS-catcher \
# Build readsb
&& cd /tmp \
&& git clone --depth 1 https://github.com/wiedehopf/readsb.git \
&& cd readsb \
&& make BLADERF=no PLUTOSDR=no SOAPYSDR=yes \
&& cp readsb /usr/bin/readsb \
&& cd /tmp \
&& rm -rf /tmp/readsb \
# Build rx_tools
&& cd /tmp \
&& git clone https://github.com/rxseger/rx_tools.git \
&& cd rx_tools \
&& mkdir build && cd build \
&& cmake .. \
&& make \
&& make install \
&& cd /tmp \
&& rm -rf /tmp/rx_tools \
# Build acarsdec
&& cd /tmp \
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON \
&& make \
&& cp acarsdec /usr/bin/acarsdec \
&& rm -rf /tmp/acarsdec \
# Cleanup build tools to reduce image size
&& apt-get remove -y \
build-essential \
git \
pkg-config \
cmake \
libncurses-dev \
libsndfile1-dev \
libsoapysdr-dev \
libhackrf-dev \
liblimesuite-dev \
libsqlite3-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libzmq3-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
+44 -7
View File
@@ -6,13 +6,20 @@
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
</p>
<p align="center">
Support the developer of this open-source project
</p>
<p align="center">
<a href="https://www.buymeacoffee.com/smittix" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
</p>
<p align="center">
<strong>Signal Intelligence Platform</strong><br>
A web-based interface for software-defined radio tools.
</p>
<p align="center">
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
<img src="static/images/screenshots/intercept-main.png" alt="Screenshot">
</p>
---
@@ -22,10 +29,17 @@
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **Listening Post** - Frequency scanner with audio monitoring
- **Satellite Tracking** - Pass prediction using TLE data
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **Meshtastic** - LoRa mesh network integration
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
---
@@ -38,7 +52,7 @@
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
sudo -E venv/bin/python intercept.py
```
### Docker (Alternative)
@@ -46,14 +60,27 @@ sudo python3 intercept.py
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
docker-compose up -d
docker compose up -d
```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Then open **/adsb/history** for the reporting dashboard.
### Open the Interface
After starting, open **http://localhost:5050** in your browser.
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
---
@@ -81,14 +108,16 @@ Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
## Discord Server
<p align="center">
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
<a href="https://discord.gg/EyeksEJmWE">Join our Discord</a>
</p>
---
## Documentation
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
- [Distributed Agents](docs/DISTRIBUTED_AGENTS.md) - Remote sensor node deployment
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
- [Security](docs/SECURITY.md) - Network security and best practices
@@ -121,9 +150,17 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
[rtl_433](https://github.com/merbanan/rtl_433) |
[dump1090](https://github.com/flightaware/dump1090) |
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
[acarsdec](https://github.com/TLeconte/acarsdec) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[Celestrak](https://celestrak.org/)
[Celestrak](https://celestrak.org/) |
[Priyom.org](https://priyom.org/)
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2026-01-04_e27bf619",
"downloaded": "2026-01-07T14:55:20.680977Z"
"version": "2026-01-11_fae1348c",
"downloaded": "2026-01-12T15:55:42.769654Z"
}
+305 -12
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import sys
import site
from utils.database import get_db
# Ensure user site-packages is available (may be disabled when running as root/sudo)
if not site.ENABLE_USER_SITE:
user_site = site.getusersitepackages()
@@ -23,9 +25,9 @@ import subprocess
from typing import Any
from flask import Flask, render_template, jsonify, send_file, Response, request
from config import VERSION
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
from werkzeug.security import check_password_hash
from config import VERSION, CHANGELOG
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
@@ -34,20 +36,40 @@ from utils.constants import (
MAX_AIRCRAFT_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
import logging
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# Track application start time for uptime calculation
import time as _time
_app_start_time = _time.time()
logger = logging.getLogger('intercept.database')
# Create Flask app
app = Flask(__name__)
app.secret_key = "signals_intelligence_secret" # Required for flash messages
# Set up rate limiting
limiter = Limiter(
key_func=get_remote_address, # Identifies the user by their IP
app=app,
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
)
# Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
# ============================================
# ERROR HANDLERS
# ============================================
@app.errorhandler(429)
def ratelimit_handler(e):
logger.warning(f"Rate limit exceeded for IP: {request.remote_addr}")
flash("Too many login attempts. Please wait one minute before trying again.", "error")
return render_template('login.html', version=VERSION), 429
# ============================================
# SECURITY HEADERS
@@ -69,6 +91,25 @@ def add_security_headers(response):
return response
# ============================================
# CONTEXT PROCESSORS
# ============================================
@app.context_processor
def inject_offline_settings():
"""Inject offline settings into all templates."""
from utils.database import get_setting
return {
'offline_settings': {
'enabled': get_setting('offline.enabled', False),
'assets_source': get_setting('offline.assets_source', 'cdn'),
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
'tile_provider': get_setting('offline.tile_provider', 'openstreetmap'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
}
# ============================================
# GLOBAL PROCESS MANAGEMENT
# ============================================
@@ -103,6 +144,37 @@ satellite_process = None
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
satellite_lock = threading.Lock()
# ACARS aircraft messaging
acars_process = None
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
acars_lock = threading.Lock()
# APRS amateur radio tracking
aprs_process = None
aprs_rtl_process = None
aprs_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
aprs_lock = threading.Lock()
# RTLAMR utility meter reading
rtlamr_process = None
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
rtlamr_lock = threading.Lock()
# AIS vessel tracking
ais_process = None
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
ais_lock = threading.Lock()
# DSC (Digital Selective Calling)
dsc_process = None
dsc_rtl_process = None
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# ============================================
# GLOBAL STATE DICTIONARIES
# ============================================
@@ -126,6 +198,12 @@ bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
# Vessel (AIS) state - using DataStore for automatic cleanup
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
# Satellite state
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
@@ -135,21 +213,73 @@ cleanup_manager.register(wifi_clients)
cleanup_manager.register(bt_devices)
cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft)
cleanup_manager.register(ais_vessels)
cleanup_manager.register(dsc_messages)
# ============================================
# MAIN ROUTES
# ============================================
@app.before_request
def require_login():
# Routes that don't require login (to avoid infinite redirect loop)
allowed_routes = ['login', 'static', 'favicon', 'health', 'health_check']
# Controller API endpoints use API key auth, not session auth
# Allow agent push/pull endpoints without session login
if request.path.startswith('/controller/'):
return None # Skip session check, controller routes handle their own auth
# If user is not logged in and the current route is not allowed...
if 'logged_in' not in session and request.endpoint not in allowed_routes:
return redirect(url_for('login'))
@app.route('/logout')
def logout():
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
@limiter.limit("5 per minute") # Limit to 5 login attempts per minute per IP
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
# Connect to DB and find user
with get_db() as conn:
cursor = conn.execute(
'SELECT password_hash, role FROM users WHERE username = ?',
(username,)
)
user = cursor.fetchone()
# Verify user exists and password is correct
if user and check_password_hash(user['password_hash'], password):
# Store data in session
session['logged_in'] = True
session['username'] = username
session['role'] = user['role']
logger.info(f"User '{username}' logged in successfully.")
return redirect(url_for('index'))
else:
logger.warning(f"Failed login attempt for username: {username}")
flash("ACCESS DENIED: INVALID CREDENTIALS", "error")
return render_template('login.html', version=VERSION)
@app.route('/')
def index() -> str:
tools = {
'rtl_fm': check_tool('rtl_fm'),
'multimon': check_tool('multimon-ng'),
'rtl_433': check_tool('rtl_433')
'rtl_433': check_tool('rtl_433'),
'rtlamr': check_tool('rtlamr')
}
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
return render_template('index.html', tools=tools, devices=devices, version=VERSION)
return render_template('index.html', tools=tools, devices=devices, version=VERSION, changelog=CHANGELOG)
@app.route('/favicon.svg')
@@ -164,6 +294,120 @@ def get_devices() -> Response:
return jsonify([d.to_dict() for d in devices])
@app.route('/devices/debug')
def get_devices_debug() -> Response:
"""Get detailed SDR device detection diagnostics."""
import shutil
diagnostics = {
'tools': {},
'rtl_test': {},
'soapy': {},
'usb': {},
'kernel_modules': {},
'detected_devices': [],
'suggestions': []
}
# Check for required tools
diagnostics['tools']['rtl_test'] = shutil.which('rtl_test') is not None
diagnostics['tools']['SoapySDRUtil'] = shutil.which('SoapySDRUtil') is not None
diagnostics['tools']['lsusb'] = shutil.which('lsusb') is not None
# Run rtl_test and capture full output
if diagnostics['tools']['rtl_test']:
try:
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
timeout=5
)
diagnostics['rtl_test'] = {
'returncode': result.returncode,
'stdout': result.stdout[:2000] if result.stdout else '',
'stderr': result.stderr[:2000] if result.stderr else ''
}
# Check for common errors
combined = (result.stdout or '') + (result.stderr or '')
if 'No supported devices found' in combined:
diagnostics['suggestions'].append('No RTL-SDR device detected. Check USB connection.')
if 'usb_claim_interface error' in combined:
diagnostics['suggestions'].append('Device busy - kernel DVB driver may have claimed it. Run: sudo modprobe -r dvb_usb_rtl28xxu')
if 'Permission denied' in combined.lower():
diagnostics['suggestions'].append('USB permission denied. Add udev rules or run as root.')
except subprocess.TimeoutExpired:
diagnostics['rtl_test'] = {'error': 'Timeout after 5 seconds'}
except Exception as e:
diagnostics['rtl_test'] = {'error': str(e)}
else:
diagnostics['suggestions'].append('rtl_test not found. Install rtl-sdr package.')
# Run SoapySDRUtil
if diagnostics['tools']['SoapySDRUtil']:
try:
result = subprocess.run(
['SoapySDRUtil', '--find'],
capture_output=True,
text=True,
timeout=10
)
diagnostics['soapy'] = {
'returncode': result.returncode,
'stdout': result.stdout[:2000] if result.stdout else '',
'stderr': result.stderr[:2000] if result.stderr else ''
}
except subprocess.TimeoutExpired:
diagnostics['soapy'] = {'error': 'Timeout after 10 seconds'}
except Exception as e:
diagnostics['soapy'] = {'error': str(e)}
# Check USB devices (Linux)
if diagnostics['tools']['lsusb']:
try:
result = subprocess.run(
['lsusb'],
capture_output=True,
text=True,
timeout=5
)
# Filter for common SDR vendor IDs
sdr_vendors = ['0bda', '1d50', '1df7', '0403'] # Realtek, OpenMoko/HackRF, SDRplay, FTDI
usb_lines = [l for l in result.stdout.split('\n')
if any(v in l.lower() for v in sdr_vendors) or 'rtl' in l.lower() or 'sdr' in l.lower()]
diagnostics['usb']['devices'] = usb_lines if usb_lines else ['No SDR-related USB devices found']
except Exception as e:
diagnostics['usb'] = {'error': str(e)}
# Check for loaded kernel modules that conflict (Linux)
if platform.system() == 'Linux':
try:
result = subprocess.run(
['lsmod'],
capture_output=True,
text=True,
timeout=5
)
conflicting = ['dvb_usb_rtl28xxu', 'rtl2832', 'rtl2830']
loaded = [m for m in conflicting if m in result.stdout]
diagnostics['kernel_modules']['conflicting_loaded'] = loaded
if loaded:
diagnostics['suggestions'].append(f"Conflicting kernel modules loaded: {', '.join(loaded)}. Run: sudo modprobe -r {' '.join(loaded)}")
except Exception as e:
diagnostics['kernel_modules'] = {'error': str(e)}
# Get detected devices
devices = SDRFactory.detect_devices()
diagnostics['detected_devices'] = [d.to_dict() for d in devices]
if not devices and not diagnostics['suggestions']:
diagnostics['suggestions'].append('No devices detected. Check USB connection and driver installation.')
return jsonify(diagnostics)
@app.route('/dependencies')
def get_dependencies() -> Response:
"""Get status of all tool dependencies."""
@@ -302,14 +546,20 @@ def health_check() -> Response:
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
'wifi_networks_count': len(wifi_networks),
'wifi_clients_count': len(wifi_clients),
'bt_devices_count': len(bt_devices),
'dsc_messages_count': len(dsc_messages),
}
})
@@ -317,16 +567,18 @@ def health_check() -> Response:
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
global current_process, sensor_process, wifi_process, adsb_process
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
# Import adsb module to reset its state
# Import adsb and ais modules to reset their state
from routes import adsb as adsb_module
from routes import ais as ais_module
killed = []
processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090'
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
]
for proc in processes_to_kill:
@@ -351,6 +603,25 @@ def kill_all() -> Response:
adsb_process = None
adsb_module.adsb_using_service = False
# Reset AIS state
with ais_lock:
ais_process = None
ais_module.ais_running = False
# Reset ACARS state
with acars_lock:
acars_process = None
# Reset APRS state
with aprs_lock:
aprs_process = None
aprs_rtl_process = None
# Reset DSC state
with dsc_lock:
dsc_process = None
dsc_rtl_process = None
return jsonify({'status': 'killed', 'processes': killed})
@@ -403,10 +674,32 @@ def main() -> None:
print("=" * 50)
print(" INTERCEPT // Signal Intelligence")
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
print(" Pager / 433MHz / Aircraft / ACARS / Satellite / WiFi / BT")
print("=" * 50)
print()
# Check if running as root (required for WiFi monitor mode, some BT operations)
import os
if os.geteuid() != 0:
print("\033[93m" + "=" * 50)
print(" ⚠️ WARNING: Not running as root/sudo")
print("=" * 50)
print(" Some features require root privileges:")
print(" - WiFi monitor mode and scanning")
print(" - Bluetooth low-level operations")
print(" - RTL-SDR access (on some systems)")
print()
print(" To run with full capabilities:")
print(" sudo -E venv/bin/python intercept.py")
print("=" * 50 + "\033[0m")
print()
# Store for API access
app.config['RUNNING_AS_ROOT'] = False
else:
app.config['RUNNING_AS_ROOT'] = True
print("Running as root - full capabilities enabled")
print()
# Clean up any stale processes from previous runs
cleanup_stale_processes()
@@ -441,4 +734,4 @@ def main() -> None:
debug=args.debug,
threaded=True,
load_dotenv=False,
)
)
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# DSC (Digital Selective Calling) decoder wrapper
# Invokes the Python DSC decoder module
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Set PYTHONPATH to include project root
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
# Run the decoder module
exec python3 -m utils.dsc.decoder "$@"
+77 -1
View File
@@ -7,7 +7,71 @@ import os
import sys
# Application version
VERSION = "2.9.0"
VERSION = "2.11.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.11.0",
"date": "January 2026",
"highlights": [
"Meshtastic LoRa mesh network integration",
"Ubertooth One BLE scanning support",
"Offline mode with bundled assets",
"Settings modal with tile provider configuration",
]
},
{
"version": "2.10.0",
"date": "January 2026",
"highlights": [
"AIS vessel tracking with VHF DSC distress monitoring",
"Spy Stations database (number stations & diplomatic HF)",
"MMSI country identification and distress alert overlays",
"SDR device conflict detection for AIS/DSC",
]
},
{
"version": "2.9.5",
"date": "January 2026",
"highlights": [
"Enhanced TSCM with MAC-randomization resistant detection",
"Clickable score cards and device detail expansion",
"RF scanning improvements with status feedback",
"Root privilege check and warning display",
]
},
{
"version": "2.9.0",
"date": "January 2026",
"highlights": [
"New dropdown navigation menus for cleaner UI",
"TSCM baseline recording now captures device data",
"Device identity engine integration for threat detection",
"Welcome screen with mode selection",
]
},
{
"version": "2.8.0",
"date": "December 2025",
"highlights": [
"Added TSCM counter-surveillance mode",
"WiFi/Bluetooth device correlation engine",
"Tracker detection (AirTag, Tile, SmartTag)",
"Risk scoring and threat classification",
]
},
{
"version": "2.7.0",
"date": "November 2025",
"highlights": [
"Multi-SDR hardware support via SoapySDR",
"LimeSDR, HackRF, Airspy, SDRplay support",
"Improved aircraft database with photo lookup",
"GPS auto-detection and integration",
]
},
]
def _get_env(key: str, default: str) -> str:
@@ -75,12 +139,24 @@ BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
# ADS-B settings
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
# Satellite settings
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
# Admin credentials
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
def configure_logging() -> None:
"""Configure application logging."""
+449
View File
@@ -0,0 +1,449 @@
"""
TSCM (Technical Surveillance Countermeasures) Frequency Database
Known surveillance device frequencies, sweep presets, and threat signatures
for counter-surveillance operations.
"""
from __future__ import annotations
# =============================================================================
# Known Surveillance Frequencies (MHz)
# =============================================================================
SURVEILLANCE_FREQUENCIES = {
'wireless_mics': [
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'},
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'},
{'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'},
{'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'},
{'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'},
{'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'},
],
'wireless_cameras': [
{'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'},
{'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'},
{'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'},
{'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'},
],
'gps_trackers': [
{'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'},
{'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'},
{'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'},
{'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'},
{'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'},
],
'body_worn': [
{'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'},
{'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'},
{'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'},
{'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'},
{'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'},
{'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'},
],
'common_bugs': [
{'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'},
{'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'},
{'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'},
{'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'},
{'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'},
{'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'},
],
'ism_bands': [
{'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'},
{'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'},
{'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'},
{'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'},
{'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'},
{'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'},
{'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'},
],
}
# =============================================================================
# Sweep Presets
# =============================================================================
SWEEP_PRESETS = {
'quick': {
'name': 'Quick Scan',
'description': 'Fast 2-minute check of most common bug frequencies',
'duration_seconds': 120,
'ranges': [
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
{'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'},
{'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'standard': {
'name': 'Standard Sweep',
'description': 'Comprehensive 5-minute sweep of common surveillance bands',
'duration_seconds': 300,
'ranges': [
{'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'},
{'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'},
{'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'},
{'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'},
{'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'full': {
'name': 'Full Spectrum',
'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)',
'duration_seconds': 900,
'ranges': [
{'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'},
],
'wifi': True,
'bluetooth': True,
'rf': True,
},
'wireless_cameras': {
'name': 'Wireless Cameras',
'description': 'Focus on video transmission frequencies',
'duration_seconds': 180,
'ranges': [
{'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'},
{'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'},
],
'wifi': True, # WiFi cameras
'bluetooth': False,
'rf': True,
},
'body_worn': {
'name': 'Body-Worn Devices',
'description': 'Detect body wires and covert transmitters',
'duration_seconds': 240,
'ranges': [
{'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'},
{'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'},
{'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'},
{'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'},
{'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'},
],
'wifi': False,
'bluetooth': True, # BLE bugs
'rf': True,
},
'gps_trackers': {
'name': 'GPS Trackers',
'description': 'Detect cellular-based GPS tracking devices',
'duration_seconds': 180,
'ranges': [
{'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'},
{'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'},
],
'wifi': False,
'bluetooth': True, # BLE trackers
'rf': True,
},
'bluetooth_only': {
'name': 'Bluetooth/BLE Trackers',
'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)',
'duration_seconds': 60,
'ranges': [],
'wifi': False,
'bluetooth': True,
'rf': False,
},
'wifi_only': {
'name': 'WiFi Devices',
'description': 'Scan for hidden WiFi cameras and access points',
'duration_seconds': 60,
'ranges': [],
'wifi': True,
'bluetooth': False,
'rf': False,
},
}
# =============================================================================
# Known Tracker Signatures
# =============================================================================
BLE_TRACKER_SIGNATURES = {
'apple_airtag': {
'name': 'Apple AirTag',
'company_id': 0x004C,
'patterns': ['findmy', 'airtag'],
'risk': 'high',
'description': 'Apple Find My network tracker',
},
'tile': {
'name': 'Tile Tracker',
'company_id': 0x00ED,
'patterns': ['tile'],
'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'],
'risk': 'high',
'description': 'Tile Bluetooth tracker',
},
'samsung_smarttag': {
'name': 'Samsung SmartTag',
'company_id': 0x0075,
'patterns': ['smarttag', 'smartthings'],
'risk': 'high',
'description': 'Samsung SmartThings tracker',
},
'chipolo': {
'name': 'Chipolo',
'company_id': 0x0A09,
'patterns': ['chipolo'],
'risk': 'high',
'description': 'Chipolo Bluetooth tracker',
},
'generic_beacon': {
'name': 'Unknown BLE Beacon',
'company_id': None,
'patterns': [],
'risk': 'medium',
'description': 'Unidentified BLE beacon device',
},
}
# =============================================================================
# Threat Classification
# =============================================================================
THREAT_TYPES = {
'new_device': {
'name': 'New Device',
'description': 'Device not present in baseline',
'default_severity': 'medium',
},
'tracker': {
'name': 'Tracking Device',
'description': 'Known BLE tracker detected',
'default_severity': 'high',
},
'unknown_signal': {
'name': 'Unknown Signal',
'description': 'Unidentified RF transmission',
'default_severity': 'medium',
},
'burst_transmission': {
'name': 'Burst Transmission',
'description': 'Intermittent/store-and-forward signal detected',
'default_severity': 'high',
},
'hidden_camera': {
'name': 'Potential Hidden Camera',
'description': 'WiFi camera or video transmitter detected',
'default_severity': 'critical',
},
'gsm_bug': {
'name': 'GSM/Cellular Bug',
'description': 'Cellular transmission in non-phone device context',
'default_severity': 'critical',
},
'rogue_ap': {
'name': 'Rogue Access Point',
'description': 'Unauthorized WiFi access point',
'default_severity': 'high',
},
'anomaly': {
'name': 'Signal Anomaly',
'description': 'Unusual signal pattern or behavior',
'default_severity': 'low',
},
}
SEVERITY_LEVELS = {
'critical': {
'level': 4,
'color': '#ff0000',
'description': 'Immediate action required - active surveillance likely',
},
'high': {
'level': 3,
'color': '#ff6600',
'description': 'Strong indicator of surveillance device',
},
'medium': {
'level': 2,
'color': '#ffcc00',
'description': 'Potential threat - requires investigation',
},
'low': {
'level': 1,
'color': '#00cc00',
'description': 'Minor anomaly - low probability of threat',
},
}
# =============================================================================
# WiFi Camera Detection Patterns
# =============================================================================
WIFI_CAMERA_PATTERNS = {
'ssid_patterns': [
'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr',
'hikvision', 'dahua', 'reolink', 'wyze', 'ring',
'arlo', 'nest', 'blink', 'eufy', 'yi',
],
'oui_manufacturers': [
'Hikvision',
'Dahua',
'Axis Communications',
'Hanwha Techwin',
'Vivotek',
'Ubiquiti',
'Wyze Labs',
'Amazon Technologies', # Ring
'Google', # Nest
],
'mac_prefixes': {
'C0:25:E9': 'TP-Link Camera',
'A4:DA:22': 'TP-Link Camera',
'78:8C:B5': 'TP-Link Camera',
'D4:6E:0E': 'TP-Link Camera',
'2C:AA:8E': 'Wyze Camera',
'AC:CF:85': 'Hikvision',
'54:C4:15': 'Hikvision',
'C0:56:E3': 'Hikvision',
'3C:EF:8C': 'Dahua',
'A0:BD:1D': 'Dahua',
'E4:24:6C': 'Dahua',
},
}
# =============================================================================
# Utility Functions
# =============================================================================
def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]:
"""
Determine the risk level for a given frequency.
Returns:
Tuple of (risk_level, category_name)
"""
for category, ranges in SURVEILLANCE_FREQUENCIES.items():
for freq_range in ranges:
if freq_range['start'] <= frequency_mhz <= freq_range['end']:
return freq_range['risk'], freq_range['name']
return 'low', 'Unknown Band'
def get_sweep_preset(preset_name: str) -> dict | None:
"""Get a sweep preset by name."""
return SWEEP_PRESETS.get(preset_name)
def get_all_sweep_presets() -> dict:
"""Get all available sweep presets."""
return {
name: {
'name': preset['name'],
'description': preset['description'],
'duration_seconds': preset['duration_seconds'],
}
for name, preset in SWEEP_PRESETS.items()
}
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None:
"""
Check if a BLE device matches known tracker signatures.
Args:
device_name: Device name to check against patterns
manufacturer_data: Manufacturer data as bytes or hex string
Returns:
Tracker info dict if match found, None otherwise
"""
if device_name:
name_lower = device_name.lower()
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
for pattern in tracker_info.get('patterns', []):
if pattern in name_lower:
return tracker_info
if manufacturer_data:
# Convert hex string to bytes if needed
mfr_bytes = manufacturer_data
if isinstance(manufacturer_data, str):
try:
mfr_bytes = bytes.fromhex(manufacturer_data)
except ValueError:
return None
if len(mfr_bytes) >= 2:
company_id = int.from_bytes(mfr_bytes[:2], 'little')
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
if tracker_info.get('company_id') == company_id:
return tracker_info
return None
def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: str | None = None) -> bool:
"""Check if a WiFi device might be a hidden camera."""
if ssid:
ssid_lower = ssid.lower()
for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']:
if pattern in ssid_lower:
return True
if mac:
mac_prefix = mac[:8].upper()
if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']:
return True
if vendor:
vendor_lower = vendor.lower()
for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']:
if manufacturer.lower() in vendor_lower:
return True
return False
def get_threat_severity(threat_type: str, context: dict | None = None) -> str:
"""
Determine threat severity based on type and context.
Args:
threat_type: Type of threat from THREAT_TYPES
context: Optional context dict with signal_strength, etc.
Returns:
Severity level string
"""
threat_info = THREAT_TYPES.get(threat_type, {})
base_severity = threat_info.get('default_severity', 'medium')
if context:
# Upgrade severity based on signal strength (closer = more concerning)
signal = context.get('signal_strength')
if signal and signal > -50: # Very strong signal
if base_severity == 'medium':
return 'high'
elif base_severity == 'high':
return 'critical'
return base_severity
+64 -2
View File
@@ -1,5 +1,11 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
#
# Basic usage:
# docker compose up -d
#
# With ADS-B history (Postgres):
# docker compose --profile history up -d
services:
intercept:
@@ -13,15 +19,23 @@ services:
# USB device mapping (alternative to privileged mode)
# devices:
# - /dev/bus/usb:/dev/bus/usb
volumes:
# volumes:
# Persist data directory
- ./data:/app/data
# - ./data:/app/data
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# ADS-B history is disabled by default
# To enable, use: docker compose --profile history up -d
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
# - INTERCEPT_ADSB_DB_HOST=adsb_db
# - INTERCEPT_ADSB_DB_PORT=5432
# - INTERCEPT_ADSB_DB_NAME=intercept_adsb
# - INTERCEPT_ADSB_DB_USER=intercept
# - INTERCEPT_ADSB_DB_PASSWORD=intercept
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
@@ -32,6 +46,54 @@ services:
retries: 3
start_period: 10s
# ADS-B history with Postgres persistence
# Enable with: docker compose --profile history up -d
intercept-history:
build: .
container_name: intercept
profiles:
- history
depends_on:
- adsb_db
ports:
- "5050:5050"
privileged: true
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
- INTERCEPT_ADSB_HISTORY_ENABLED=true
- INTERCEPT_ADSB_DB_HOST=adsb_db
- INTERCEPT_ADSB_DB_PORT=5432
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
- INTERCEPT_ADSB_DB_USER=intercept
- INTERCEPT_ADSB_DB_PASSWORD=intercept
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
adsb_db:
image: postgres:16-alpine
container_name: intercept-adsb-db
profiles:
- history
environment:
- POSTGRES_DB=intercept_adsb
- POSTGRES_USER=intercept
- POSTGRES_PASSWORD=intercept
volumes:
- ./pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
interval: 10s
timeout: 5s
retries: 5
# Optional: Add volume for persistent SQLite database
# volumes:
# intercept-data:
View File
+506
View File
@@ -0,0 +1,506 @@
# Intercept Distributed Agent System
This document describes the distributed agent architecture that allows multiple remote sensor nodes to feed data into a central Intercept controller.
## Overview
The agent system uses a hub-and-spoke architecture where:
- **Controller**: The main Intercept instance that aggregates data from multiple agents
- **Agents**: Lightweight sensor nodes running on remote devices with SDR hardware
```
┌─────────────────────────────────┐
│ INTERCEPT CONTROLLER │
│ (port 5050) │
│ │
│ - Web UI with agent selector │
│ - /controller/manage page │
│ - Multi-agent SSE stream │
│ - Push data storage │
└─────────────────────────────────┘
▲ ▲ ▲
│ │ │
Push/Pull │ │ │ Push/Pull
│ │ │
┌────┴───┐ ┌────┴───┐ ┌────┴───┐
│ Agent │ │ Agent │ │ Agent │
│ :8020 │ │ :8020 │ │ :8020 │
│ │ │ │ │ │
│[RTL-SDR] │[HackRF] │ │[LimeSDR]
└────────┘ └────────┘ └────────┘
```
## Quick Start
### 1. Start the Controller
The controller is the main Intercept application:
```bash
cd intercept
python app.py
# Runs on http://localhost:5050
```
### 2. Configure an Agent
Create a config file on the remote machine:
```ini
# intercept_agent.cfg
[agent]
name = sensor-node-1
port = 8020
allowed_ips =
allow_cors = false
[controller]
url = http://192.168.1.100:5050
api_key = your-secret-key-here
push_enabled = true
push_interval = 5
[modes]
pager = true
sensor = true
adsb = true
wifi = true
bluetooth = true
```
### 3. Start the Agent
```bash
python intercept_agent.py --config intercept_agent.cfg
# Runs on http://localhost:8020
```
### 4. Register the Agent
Go to `http://controller:5050/controller/manage` and add the agent:
- **Name**: sensor-node-1 (must match config)
- **Base URL**: http://agent-ip:8020
- **API Key**: your-secret-key-here (must match config)
## Architecture
### Data Flow
The system supports two data flow patterns:
#### Push (Agent → Controller)
Agents automatically push captured data to the controller:
1. Agent captures data (e.g., rtl_433 sensor readings)
2. Data is queued in the `ControllerPushClient`
3. Agent POSTs to `http://controller/controller/api/ingest`
4. Controller validates API key and stores in `push_payloads` table
5. Data is available via SSE stream at `/controller/stream/all`
```
Agent Controller
│ │
│ POST /controller/api/ingest │
│ Header: X-API-Key: secret │
│ Body: {agent_name, scan_type, │
│ payload, timestamp} │
│ ──────────────────────────────► │
│ │
│ 200 OK │
│ ◄────────────────────────────── │
```
#### Pull (Controller → Agent)
The controller can also pull data on-demand:
1. User selects agent in UI dropdown
2. User clicks "Start Listening"
3. Controller proxies request to agent
4. Agent starts the mode and returns status
5. Controller polls agent for data
```
Browser Controller Agent
│ │ │
│ POST /controller/ │ │
│ agents/1/sensor/start│ │
│ ─────────────────────► │ │
│ │ POST /sensor/start │
│ │ ────────────────────────► │
│ │ │
│ │ {status: started} │
│ │ ◄──────────────────────── │
│ {status: success} │ │
│ ◄───────────────────── │ │
```
### Authentication
API key authentication secures the push mechanism:
1. Agent config specifies `api_key` in `[controller]` section
2. Agent sends `X-API-Key` header with each push request
3. Controller looks up agent by name in database
4. Controller compares provided key with stored key
5. Mismatched keys return 401 Unauthorized
### Database Schema
Two tables support the agent system:
```sql
-- Registered agents
CREATE TABLE agents (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT,
capabilities TEXT, -- JSON: {pager: true, sensor: true, ...}
interfaces TEXT, -- JSON: {devices: [...]}
gps_coords TEXT, -- JSON: {lat, lon}
last_seen TIMESTAMP,
is_active BOOLEAN
);
-- Pushed data from agents
CREATE TABLE push_payloads (
id INTEGER PRIMARY KEY,
agent_id INTEGER,
scan_type TEXT, -- pager, sensor, adsb, wifi, etc.
payload TEXT, -- JSON data
received_at TIMESTAMP,
FOREIGN KEY (agent_id) REFERENCES agents(id)
);
```
## Agent REST API
The agent exposes these endpoints:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check (returns `{status: "healthy"}`) |
| `/capabilities` | GET | Available modes, devices, GPS status |
| `/status` | GET | Running modes, uptime, push status |
| `/{mode}/start` | POST | Start a mode (pager, sensor, adsb, etc.) |
| `/{mode}/stop` | POST | Stop a mode |
| `/{mode}/status` | GET | Mode-specific status |
| `/{mode}/data` | GET | Current data snapshot |
### Example: Start Sensor Mode
```bash
curl -X POST http://agent:8020/sensor/start \
-H "Content-Type: application/json" \
-d '{"frequency": 433.92, "device_index": 0}'
```
Response:
```json
{
"status": "started",
"mode": "sensor",
"command": "/usr/local/bin/rtl_433 -d 0 -f 433.92M -F json",
"gps_enabled": true
}
```
### Example: Get Capabilities
```bash
curl http://agent:8020/capabilities
```
Response:
```json
{
"modes": {
"pager": true,
"sensor": true,
"adsb": true,
"wifi": true,
"bluetooth": true
},
"devices": [
{
"index": 0,
"name": "RTLSDRBlog, Blog V4",
"sdr_type": "rtlsdr",
"capabilities": {
"freq_min_mhz": 24.0,
"freq_max_mhz": 1766.0
}
}
],
"gps": true,
"gps_position": {
"lat": 33.543,
"lon": -82.194,
"altitude": 70.0
},
"tool_details": {
"sensor": {
"name": "433MHz Sensors",
"ready": true,
"tools": {
"rtl_433": {"installed": true, "required": true}
}
}
}
}
```
## Supported Modes
All modes are fully implemented in the agent with the following tools and data formats:
| Mode | Tool(s) | Data Format | Notes |
|------|---------|-------------|-------|
| `sensor` | rtl_433 | JSON readings | ISM band devices (433/868/915 MHz) |
| `pager` | rtl_fm + multimon-ng | POCSAG/FLEX messages | Address, function, message content |
| `adsb` | dump1090 | SBS-format aircraft | ICAO, callsign, position, altitude |
| `ais` | AIS-catcher | JSON vessels | MMSI, position, speed, vessel info |
| `acars` | acarsdec | JSON messages | Aircraft tail, label, message text |
| `aprs` | rtl_fm + direwolf | APRS packets | Callsign, position, path |
| `wifi` | airodump-ng | Networks + clients | BSSID, ESSID, signal, clients |
| `bluetooth` | bluetoothctl | Device list | MAC, name, RSSI |
| `rtlamr` | rtl_tcp + rtlamr | Meter readings | Meter ID, consumption data |
| `dsc` | rtl_fm (+ dsc-decoder) | DSC messages | MMSI, distress category, position |
| `tscm` | WiFi/BT analysis | Anomaly reports | New/rogue devices detected |
| `satellite` | skyfield (TLE) | Pass predictions | No SDR required |
| `listening_post` | rtl_fm scanner | Signal detections | Frequency, modulation |
### Mode-Specific Notes
**Listening Post**: Full FFT streaming isn't practical over HTTP. Instead, the agent provides:
- Signal detection events when activity is found
- Current scanning frequency
- Activity log of detected signals
**TSCM**: Analyzes WiFi and Bluetooth data for anomalies:
- Builds baseline of known devices
- Reports new/unknown devices as anomalies
- No SDR required (uses WiFi/BT data)
**Satellite**: Pure computational mode:
- Calculates pass predictions from TLE data
- Requires observer location (lat/lon)
- No SDR required
**Audio Modes**: Modes requiring real-time audio (airband, listening_post audio) are limited via agents. Use rtl_tcp for remote audio streaming instead.
## Controller API
### Agent Management
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/controller/agents` | GET | List all agents |
| `/controller/agents` | POST | Register new agent |
| `/controller/agents/{id}` | GET | Get agent details |
| `/controller/agents/{id}` | DELETE | Remove agent |
| `/controller/agents/{id}?refresh=true` | GET | Refresh agent capabilities |
### Proxy Operations
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/controller/agents/{id}/{mode}/start` | POST | Start mode on agent |
| `/controller/agents/{id}/{mode}/stop` | POST | Stop mode on agent |
| `/controller/agents/{id}/{mode}/data` | GET | Get data from agent |
### Push Ingestion
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/controller/api/ingest` | POST | Receive pushed data from agents |
### SSE Streams
| Endpoint | Description |
|----------|-------------|
| `/controller/stream/all` | Combined stream from all agents |
## Frontend Integration
### Agent Selector
The main UI includes an agent dropdown in supported modes:
```html
<select id="agentSelect">
<option value="local">Local (This Device)</option>
<option value="1">● sensor-node-1</option>
</select>
```
When an agent is selected:
1. Device list updates to show agent's SDR devices
2. Start/Stop commands route through controller proxy
3. Data displays with agent name badge
### Multi-Agent Mode
Enable "Show All Agents" checkbox to:
- Connect to `/controller/stream/all` SSE
- Display combined data from all agents
- Show agent name badge on each data item
## GPS Integration
Agents can include GPS coordinates with captured data:
1. Agent connects to local `gpsd` daemon
2. GPS position included in `/capabilities` and `/status`
3. Each data snapshot includes `agent_gps` field
4. Controller can use GPS for trilateration (multiple agents)
## Configuration Reference
### Agent Config (`intercept_agent.cfg`)
```ini
[agent]
# Agent identity (must be unique across all agents)
name = sensor-node-1
# Port to listen on
port = 8020
# Restrict connections to specific IPs (comma-separated, empty = all)
allowed_ips =
# Enable CORS headers
allow_cors = false
[controller]
# Controller URL (required for push)
url = http://192.168.1.100:5050
# API key for authentication
api_key = your-secret-key
# Enable automatic data push
push_enabled = true
# Push interval in seconds
push_interval = 5
[modes]
# Enable/disable specific modes
pager = true
sensor = true
adsb = true
ais = true
wifi = true
bluetooth = true
```
## Troubleshooting
### Agent not appearing in controller
1. Check agent is running: `curl http://agent:8020/health`
2. Verify agent is registered in `/controller/manage`
3. Check API key matches between agent config and controller registration
4. Check network connectivity between agent and controller
### Push data not arriving
1. Check agent status: `curl http://agent:8020/status`
- Verify `push_enabled: true` and `push_connected: true`
2. Check controller logs for authentication errors
3. Verify API key matches
4. Check if mode is running and producing data
### Mode won't start on agent
1. Check capabilities: `curl http://agent:8020/capabilities`
2. Verify required tools are installed (check `tool_details`)
3. Check if SDR device is available (not in use by another process)
### No data from sensor mode
1. Verify rtl_433 is running: `ps aux | grep rtl_433`
2. Check sensor status: `curl http://agent:8020/sensor/status`
3. Note: Empty data is normal if no 433MHz devices are transmitting nearby
## Security Considerations
1. **API Keys**: Always use strong, unique API keys for each agent
2. **Network**: Consider running agents on a private network or VPN
3. **HTTPS**: For production, use HTTPS between agents and controller
4. **Firewall**: Restrict agent ports to controller IP only
5. **allowed_ips**: Use this config option to restrict agent connections
## Dashboard Integration
Agent support has been integrated into the following specialized dashboards:
### ADS-B Dashboard (`/adsb/dashboard`)
- Agent selector in header bar
- Routes tracking start/stop through agent proxy when remote agent selected
- Connects to multi-agent stream for data from remote agents
- Displays agent badge on aircraft from remote sources
- Updates observer location from agent's GPS coordinates
### AIS Dashboard (`/ais/dashboard`)
- Agent selector in header bar
- Routes AIS and DSC mode operations through agent proxy
- Connects to multi-agent stream for vessel data
- Displays agent badge on vessels from remote sources
- Updates observer location from agent's GPS coordinates
### Main Dashboard (`/`)
- Agent selector in sidebar
- Supports sensor, pager, WiFi, Bluetooth modes via agents
- SDR conflict detection with device-aware warnings
- Real-time sync with agent's running mode state
### Multi-SDR Agent Support
For agents with multiple SDR devices, the system now tracks which device each mode is using:
```json
{
"running_modes": ["sensor", "adsb"],
"running_modes_detail": {
"sensor": {"device": 0, "started_at": "2024-01-15T10:30:00Z"},
"adsb": {"device": 1, "started_at": "2024-01-15T10:35:00Z"}
}
}
```
This allows:
- Smart conflict detection (only warns if same device is in use)
- Display of which device each mode is using
- Parallel operation of multiple SDR modes on multi-SDR agents
### Agent Mode Warnings
When an agent has SDR modes running, the UI displays:
- Warning banner showing active modes with device numbers
- Stop buttons for each running mode
- Refresh button to re-sync with agent state
### Pages Without Agent Support
The following pages don't require SDR-based agent support:
- **Satellite Dashboard** (`/satellite/dashboard`) - Uses TLE orbital calculations, no SDR
- **History pages** - Display stored data, not live SDR streams
## Files
| File | Description |
|------|-------------|
| `intercept_agent.py` | Standalone agent server |
| `intercept_agent.cfg` | Agent configuration template |
| `routes/controller.py` | Controller API blueprint |
| `utils/agent_client.py` | HTTP client for agents |
| `utils/database.py` | Agent CRUD operations |
| `static/js/core/agents.js` | Frontend agent management |
| `templates/agents.html` | Agent management page |
| `templates/adsb_dashboard.html` | ADS-B page with agent integration |
| `templates/ais_dashboard.html` | AIS page with agent integration |
+193 -2
View File
@@ -16,6 +16,28 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices**
- **Smart meters** and utility monitors
## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
- **Full-screen dashboard** - dedicated popout with interactive map
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
- **Navigation data** - speed, course, heading, rate of turn
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
- **Vessel dimensions** - length, width, draught
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Spy Stations (Number Stations)
- **Comprehensive database** of active number stations and diplomatic networks
- **Station profiles** - frequencies, schedules, operators, descriptions
- **Filter by type** - number stations vs diplomatic networks
- **Filter by country** - Russia, Cuba, Israel, Poland, North Korea, etc.
- **Filter by mode** - USB, AM, CW, OFDM
- **Tune integration** - click to tune Listening Post to station frequency
- **Source links** - references to priyom.org for detailed information
- **Famous stations** - UVB-76 "The Buzzer", Cuban HM01, Israeli E17z
## ADS-B Aircraft Tracking
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
@@ -26,6 +48,8 @@ Complete feature list for all modules.
- **Aircraft filtering** - show all, military only, civil only, or emergency only
- **Marker clustering** - group nearby aircraft at lower zoom levels
- **Reception statistics** - max range, message rate, busiest hour, total seen
- **Persistent ADS-B history** - optional Postgres-backed message and snapshot storage
- **History reporting dashboard** - session controls, aircraft timelines, and detail modal
- **Observer location** - manual input or GPS geolocation
- **Audio alerts** - notifications for military and emergency aircraft
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
@@ -35,6 +59,31 @@ Complete feature list for all modules.
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
</p>
## AIS Vessel Tracking
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
- **Full-screen dashboard** - dedicated popout with maritime map
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
- **Vessel trails** - optional track history visualization
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
### VHF DSC Channel 70 Monitoring
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
- **Position extraction** - Automatic lat/lon parsing from distress messages
- **Map markers** - Distress positions plotted with pulsing alert markers
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
- **Audio alerts** - Notification sound for critical messages
- **Alert persistence** - Critical alerts stored permanently in database
- **Acknowledgement workflow** - Track response status with notes
- **SDR conflict detection** - Prevents device collisions with AIS tracking
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -75,13 +124,119 @@ Complete feature list for all modules.
## Bluetooth Scanning
- **BLE and Classic** Bluetooth device scanning
- **Multiple scan modes** - hcitool, bluetoothctl
- **Multiple scan modes** - hcitool, bluetoothctl, bleak
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
- **Device classification** - phones, audio, wearables, computers
- **Manufacturer lookup** via OUI database
- **Manufacturer lookup** via OUI database and Bluetooth Company IDs
- **Proximity radar** visualization
- **Device type breakdown** chart
## TSCM Counter-Surveillance Mode
Technical Surveillance Countermeasures (TSCM) screening for detecting wireless surveillance indicators.
### Wireless Sweep Features
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
- **Baseline comparison** - detect new/unknown devices vs known environment
### MAC-Randomization Resistant Detection
- **Device fingerprinting** based on advertisement payloads, not MAC addresses
- **Behavioral clustering** - groups observations into probable physical devices
- **Session tracking** - monitors device presence windows
- **Timing pattern analysis** - detects characteristic advertising intervals
- **RSSI trajectory correlation** - identifies co-located devices
### Risk Assessment
- **Three-tier scoring model**:
- Informational (0-2): Known or expected devices
- Needs Review (3-5): Unusual devices requiring assessment
- High Interest (6+): Multiple indicators warrant investigation
- **Risk indicators**: Stable RSSI, audio-capable, ESP32 chipsets, hidden identity, MAC rotation
- **Audit trail** - full evidence chain for each link/flag
- **Client-safe disclaimers** - findings are indicators, not confirmed surveillance
### Limitations (Documented)
- Cannot detect non-transmitting devices
- False positives/negatives expected
- Results require professional verification
- No cryptographic de-randomization
- Passive screening only (no active probing by default)
## Meshtastic Mesh Networks
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
### Device Support
- **Heltec** - LoRa32 series
- **T-Beam** - TTGO T-Beam with GPS
- **RAK** - WisBlock series
- Any Meshtastic-compatible device via USB/Serial
### Features
- **Real-time messaging** - Stream messages as they arrive
- **Channel configuration** - Set encryption keys and channel names
- **Node information** - View connected nodes with signal metrics
- **Message history** - Up to 500 messages retained
- **Signal quality** - RSSI and SNR for each message
- **Hop tracking** - See message hop count
### Requirements
- Physical Meshtastic device connected via USB
- Meshtastic Python SDK (`pip install meshtastic`)
## Ubertooth One BLE Scanning
Advanced Bluetooth Low Energy scanning using Ubertooth One hardware.
### Capabilities
- **40-channel scanning** - Capture BLE advertisements across all channels
- **Raw payload access** - Full advertising data for analysis
- **Passive sniffing** - No active scanning required
- **MAC address extraction** - Public and random address types
- **RSSI measurement** - Signal strength for proximity estimation
### Integration
- Works alongside standard BlueZ/DBus Bluetooth scanning
- Automatically detected when ubertooth-btle is available
- Falls back to standard adapter if Ubertooth not present
### Requirements
- Ubertooth One hardware
- ubertooth-btle command-line tool installed
- libubertooth library
## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
### Architecture
- **Hub-and-spoke model** - Central controller with multiple remote agents
- **Push and Pull modes** - Agents can push data automatically or respond to on-demand requests
- **API key authentication** - Secure communication between agents and controller
### Agent Features
- **Standalone deployment** - Run on Raspberry Pi, mini PCs, or any Linux device with SDR
- **All modes supported** - Pager, sensor, ADS-B, AIS, WiFi, Bluetooth, and more
- **GPS integration** - Automatic location tagging from USB GPS receivers
- **Multi-SDR support** - Run multiple modes simultaneously on agents with multiple SDRs
- **Capability discovery** - Controller auto-detects available modes and devices
### Controller Features
- **Agent management UI** - Register, test, and remove agents from `/controller/manage`
- **Real-time status** - Health monitoring with online/offline indicators
- **Unified data stream** - Aggregate data from all agents via SSE
- **Dashboard integration** - Agent selector in ADS-B, AIS, and main dashboards
- **Device conflict detection** - Smart warnings when SDR is in use
### Use Cases
- **Wide-area monitoring** - Cover larger geographic areas with distributed sensors
- **Remote installations** - Deploy sensors in locations without direct access
- **Redundancy** - Multiple nodes for reliable coverage
- **Triangulation** - Use multiple GPS-enabled agents for signal location
## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode
@@ -103,6 +258,42 @@ Complete feature list for all modules.
| ? | Open help (when not typing) |
| Escape | Close help/modals |
## Offline Mode
Run iNTERCEPT without internet connectivity by using bundled local assets.
### Bundled Assets
- **Leaflet 1.9.4** - Map library with marker images
- **Chart.js 4.4.1** - Signal strength graphs
- **Inter font** - Primary UI font (400, 500, 600, 700 weights)
- **JetBrains Mono font** - Monospace/code font (400, 500, 600, 700 weights)
### Settings Modal
Access via the gear icon in the navigation bar:
- **Offline Tab** - Toggle offline mode, configure asset sources (CDN vs local)
- **Display Tab** - Theme and animation preferences
- **About Tab** - Version info and links
### Map Tile Providers
Choose from multiple tile sources for maps:
- **OpenStreetMap** - Default, general purpose
- **CartoDB Dark** - Dark themed, matches UI
- **CartoDB Positron** - Light themed
- **ESRI World Imagery** - Satellite imagery
- **Custom URL** - Connect to your own tile server (e.g., local OpenStreetMap tile cache)
### Local Asset Status
The settings modal shows availability status for each bundled asset:
- Green "Available" badge when asset is present
- Red "Missing" badge when asset is not found
- Click "Check Assets" to refresh status
### Use Cases
- **Air-gapped environments** - Run on isolated networks
- **Field deployments** - Operate without reliable internet
- **Local tile servers** - Use pre-cached map tiles for specific regions
- **Reduced latency** - Faster loading with local assets
## General
- **Web-based interface** - no desktop app needed
+52 -7
View File
@@ -139,14 +139,10 @@ pip install -r requirements.txt
After installation:
```bash
# Standard
sudo python3 intercept.py
# With virtual environment
sudo venv/bin/python intercept.py
sudo -E venv/bin/python intercept.py
# Custom port
INTERCEPT_PORT=8080 sudo python3 intercept.py
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
```
Open **http://localhost:5050** in your browser.
@@ -183,6 +179,7 @@ Open **http://localhost:5050** in your browser.
|---------|---------|
| `flask` | Web server |
| `skyfield` | Satellite tracking |
| `bleak` | BLE scanning with manufacturer data (TSCM) |
---
@@ -203,9 +200,57 @@ https://github.com/flightaware/dump1090
---
## TSCM Mode Requirements
TSCM (Technical Surveillance Countermeasures) mode requires specific hardware for full functionality:
### BLE Scanning (Tracker Detection)
- Any Bluetooth adapter supported by your OS
- `bleak` Python library for manufacturer data detection
- Detects: AirTags, Tile, SmartTags, ESP32/ESP8266 devices
```bash
# Install bleak
pip install bleak>=0.21.0
# Or via apt (Debian/Ubuntu)
sudo apt install python3-bleak
```
### RF Spectrum Analysis
- **RTL-SDR dongle** (required for RF sweeps)
- `rtl_power` command from `rtl-sdr` package
Frequency bands scanned:
| Band | Frequency | Purpose |
|------|-----------|---------|
| FM Broadcast | 88-108 MHz | FM bugs |
| 315 MHz ISM | 315 MHz | US wireless devices |
| 433 MHz ISM | 433-434 MHz | EU wireless devices |
| 868 MHz ISM | 868-869 MHz | EU IoT devices |
| 915 MHz ISM | 902-928 MHz | US IoT devices |
| 1.2 GHz | 1200-1300 MHz | Video transmitters |
| 2.4 GHz ISM | 2400-2500 MHz | WiFi/BT/Video |
```bash
# Linux
sudo apt install rtl-sdr
# macOS
brew install librtlsdr
```
### WiFi Scanning
- Standard WiFi adapter (managed mode for basic scanning)
- Monitor mode capable adapter for advanced features
- `aircrack-ng` suite for monitor mode management
---
## Notes
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
- **Bluetooth on macOS**: Uses bleak library (CoreBluetooth backend), bluez tools not needed
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
- **TSCM on macOS**: BLE and WiFi scanning work; RF spectrum requires RTL-SDR
+1 -3
View File
@@ -336,9 +336,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
Run INTERCEPT with sudo:
```bash
sudo python3 intercept.py
# Or with venv:
sudo venv/bin/python intercept.py
sudo -E venv/bin/python intercept.py
```
### Interface not found after enabling monitor mode
+85 -1
View File
@@ -79,6 +79,38 @@ The system highlights aircraft transmitting emergency squawks:
- **7600** - Radio failure
- **7700** - General emergency
## ADS-B History (Optional)
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
### Enable History
Set the following environment variables (Docker recommended):
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_HISTORY_ENABLED` | `false` | Enables history storage and reporting |
| `INTERCEPT_ADSB_DB_HOST` | `localhost` | Postgres host (use `adsb_db` in Docker) |
| `INTERCEPT_ADSB_DB_PORT` | `5432` | Postgres port |
| `INTERCEPT_ADSB_DB_NAME` | `intercept_adsb` | Database name |
| `INTERCEPT_ADSB_DB_USER` | `intercept` | Database user |
| `INTERCEPT_ADSB_DB_PASSWORD` | `intercept` | Database password |
### Docker Setup
`docker-compose.yml` includes an `adsb_db` service and a persistent volume for history storage:
```bash
docker compose up -d
```
### Using the History Dashboard
1. Open **/adsb/history**
2. Use **Start Tracking** to run ADS-B in headless mode
3. View aircraft history and timelines
4. Stop tracking when desired (session history is recorded)
## Satellite Mode
1. **Set Location** - Choose location source:
@@ -98,6 +130,58 @@ The system highlights aircraft transmitting emergency squawks:
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
4. Select satellites to add
## Remote Agents (Distributed SIGINT)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
### Setting Up an Agent
1. **Install INTERCEPT** on the remote machine
2. **Create config file** (`intercept_agent.cfg`):
```ini
[agent]
name = sensor-node-1
port = 8020
[controller]
url = http://192.168.1.100:5050
api_key = your-secret-key
push_enabled = true
[modes]
pager = true
sensor = true
adsb = true
```
3. **Start the agent**:
```bash
python intercept_agent.py --config intercept_agent.cfg
```
### Registering Agents in the Controller
1. Navigate to `/controller/manage` in the main INTERCEPT instance
2. Enter agent details:
- **Name**: Must match config file (e.g., `sensor-node-1`)
- **Base URL**: Agent address (e.g., `http://192.168.1.50:8020`)
- **API Key**: Must match config file
3. Click "Register Agent"
4. Use "Test" to verify connectivity
### Using Remote Agents
Once registered, agents appear in mode dropdowns:
1. **Select agent** from the dropdown in supported modes
2. **Start mode** - Commands are proxied to the remote agent
3. **View data** - Data streams back to your browser via SSE
### Multi-Agent Streaming
Enable "Show All Agents" to aggregate data from all registered agents simultaneously.
For complete documentation, see [Distributed Agents Guide](DISTRIBUTED_AGENTS.md).
## Configuration
INTERCEPT can be configured via environment variables:
@@ -110,7 +194,7 @@ INTERCEPT can be configured via environment variables:
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
Example: `INTERCEPT_PORT=8080 sudo python3 intercept.py`
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
## Command-line Options
+18
View File
@@ -0,0 +1,18 @@
title: iNTERCEPT
description: Signal Intelligence Platform - A web-based interface for software-defined radio tools
url: https://smittix.github.io
baseurl: /intercept
# Build settings
include:
- _headers
# Exclude files from build
exclude:
- README.md
- SECURITY.md
- TROUBLESHOOTING.md
- USAGE.md
- FEATURES.md
- HARDWARE.md
- DISTRIBUTED_AGENTS.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT - Signal Intelligence Platform</title>
<meta name="description" content="A web-based interface for software-defined radio tools. Pager decoding, ADS-B tracking, WiFi scanning, and more.">
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<a href="#" class="nav-logo">iNTERCEPT</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#screenshots">Screenshots</a>
<a href="#installation">Install</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
<a href="https://github.com/smittix/intercept" class="nav-btn" target="_blank">GitHub</a>
</div>
</div>
</nav>
<header class="hero">
<div class="hero-content">
<div class="hero-badge">Open Source SIGINT Platform</div>
<h1>iNTERCEPT</h1>
<p class="hero-subtitle">A unified web interface for software-defined radio tools. Monitor pagers, track aircraft, scan WiFi networks, and more — all from your browser.</p>
<div class="hero-buttons">
<a href="#installation" class="btn btn-primary">Get Started</a>
<a href="https://github.com/smittix/intercept" class="btn btn-secondary" target="_blank">View on GitHub</a>
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">12+</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
<span class="stat-value">200+</span>
<span class="stat-label">Protocols</span>
</div>
<div class="stat">
<span class="stat-value">$25</span>
<span class="stat-label">Min Hardware</span>
</div>
</div>
</div>
<div class="hero-image">
<img src="images/dashboard.png" alt="iNTERCEPT Dashboard">
</div>
</header>
<section id="features" class="features">
<div class="container">
<h2>Capabilities</h2>
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📟</div>
<h3>Pager Decoding</h3>
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
</div>
<div class="feature-card">
<div class="feature-icon">✈️</div>
<h3>Aircraft Tracking</h3>
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📡</div>
<h3>433MHz Sensors</h3>
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📻</div>
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛰️</div>
<h3>Satellite Tracking</h3>
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📶</div>
<h3>WiFi Scanning</h3>
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔵</div>
<h3>Bluetooth Scanning</h3>
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h3>TSCM</h3>
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Meter Reading</h3>
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🚢</div>
<h3>Vessel Tracking</h3>
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔢</div>
<h3>Spy Stations</h3>
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<h3>Remote Agents</h3>
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📴</div>
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📡</div>
<h3>Meshtastic</h3>
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
</div>
</div>
</div>
</section>
<section id="screenshots" class="screenshots">
<div class="container">
<h2>See It In Action</h2>
<p class="section-subtitle">A clean, modern interface for complex RF operations</p>
<div class="screenshot-gallery">
<div class="screenshot-item">
<img src="images/dashboard.png" alt="Main Dashboard">
<span class="screenshot-label">Dashboard</span>
</div>
<div class="screenshot-item">
<img src="images/tscm.png" alt="TSCM Counter-Surveillance">
<span class="screenshot-label">TSCM Counter-Surveillance</span>
</div>
<div class="screenshot-item">
<img src="images/bluetooth.png" alt="Bluetooth Scanner">
<span class="screenshot-label">Bluetooth Scanner</span>
</div>
<div class="screenshot-item">
<img src="images/wifi.png" alt="WiFi Scanner">
<span class="screenshot-label">WiFi Scanner</span>
</div>
<div class="screenshot-item">
<img src="images/scanner.png" alt="Listening Post">
<span class="screenshot-label">Listening Post</span>
</div>
<div class="screenshot-item">
<img src="images/sensors.png" alt="433MHz Sensor Monitor">
<span class="screenshot-label">433MHz Sensors</span>
</div>
<div class="screenshot-item">
<img src="images/tscm-detail.png" alt="Device Detail Dialog">
<span class="screenshot-label">Device Analysis</span>
</div>
<div class="screenshot-item">
<img src="images/remote-agents.png" alt="Remote Agents Management">
<span class="screenshot-label">Remote Agents</span>
</div>
<div class="screenshot-item">
<img src="images/ais.png" alt="AIS Vessel Tracking">
<span class="screenshot-label">AIS Vessel Tracking</span>
</div>
</div>
</div>
</section>
<section id="installation" class="installation">
<div class="container">
<h2>Quick Start</h2>
<p class="section-subtitle">Get up and running in minutes</p>
<div class="platform-note">
<p><strong>Supported Platforms:</strong> Officially tested on Debian and Ubuntu. Partial support for macOS. Other distributions have not been fully tested.</p>
</div>
<div class="install-options">
<div class="install-card">
<h3>Standard Installation</h3>
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo -E venv/bin/python intercept.py</code></pre>
</div>
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
</div>
<div class="install-card">
<h3>Docker</h3>
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
docker compose up -d</code></pre>
</div>
<p class="install-note">Requires privileged mode for USB SDR access</p>
</div>
</div>
<div class="post-install">
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
</div>
</div>
</section>
<section class="hardware">
<div class="container">
<h2>Hardware</h2>
<p class="section-subtitle">Minimal hardware, maximum capability</p>
<div class="hardware-grid">
<div class="hardware-card required">
<div class="hardware-tag">Required</div>
<h3>RTL-SDR</h3>
<p>Core SDR functionality for all radio features</p>
<span class="price">~$25-35</span>
</div>
<div class="hardware-card optional">
<div class="hardware-tag">Optional</div>
<h3>WiFi Adapter</h3>
<p>Monitor mode support for WiFi scanning</p>
<span class="price">~$20-40</span>
</div>
<div class="hardware-card optional">
<div class="hardware-tag">Optional</div>
<h3>GPS Receiver</h3>
<p>Real-time location for mapping features</p>
<span class="price">~$10</span>
</div>
</div>
<p class="hardware-note">iNTERCEPT also supports HackRF, LimeSDR, Airspy, and SDRplay via SoapySDR</p>
</div>
</section>
<section class="cta">
<div class="container">
<h2>Ready to start intercepting?</h2>
<p>Join the community and start exploring the RF spectrum</p>
<div class="cta-buttons">
<a href="https://github.com/smittix/intercept" class="btn btn-primary" target="_blank">Get iNTERCEPT</a>
<a href="https://discord.gg/EyeksEJmWE" class="btn btn-secondary" target="_blank">Join Discord</a>
</div>
</div>
</section>
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-brand">
<span class="footer-logo">iNTERCEPT</span>
<p>Signal Intelligence Platform</p>
</div>
<div class="footer-links">
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
</div>
</div>
<div class="footer-bottom">
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
<p class="disclaimer">For educational and authorized testing purposes only.</p>
</div>
</div>
</footer>
<!-- Lightbox Modal -->
<div id="lightbox" class="lightbox">
<span class="lightbox-close">&times;</span>
<img class="lightbox-img" id="lightbox-img" src="" alt="">
<div class="lightbox-caption" id="lightbox-caption"></div>
</div>
<script>
// Lightbox functionality
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
const lightboxCaption = document.getElementById('lightbox-caption');
const closeBtn = document.querySelector('.lightbox-close');
document.querySelectorAll('.screenshot-item').forEach(item => {
item.addEventListener('click', () => {
const img = item.querySelector('img');
const label = item.querySelector('.screenshot-label');
lightbox.classList.add('active');
lightboxImg.src = img.src;
lightboxCaption.textContent = label.textContent;
document.body.style.overflow = 'hidden';
});
});
function closeLightbox() {
lightbox.classList.remove('active');
document.body.style.overflow = '';
}
closeBtn.addEventListener('click', closeLightbox);
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeLightbox();
});
</script>
</body>
</html>
+694
View File
@@ -0,0 +1,694 @@
/* INTERCEPT GitHub Pages - Dark Theme */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-card-hover: #22222e;
--text-primary: #f0f0f5;
--text-secondary: #8888a0;
--text-muted: #5c5c70;
--accent: #00d4aa;
--accent-hover: #00f0c0;
--accent-glow: rgba(0, 212, 170, 0.2);
--border: #2a2a38;
--code-bg: #0d0d14;
--gradient-start: #00d4aa;
--gradient-end: #0088ff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* Navigation */
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(10, 10, 15, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-logo {
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
letter-spacing: 2px;
}
.nav-links {
display: flex;
align-items: center;
gap: 32px;
}
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: var(--text-primary);
}
.nav-btn {
background: var(--accent);
color: var(--bg-primary) !important;
padding: 8px 20px;
border-radius: 6px;
font-weight: 600;
}
.nav-btn:hover {
background: var(--accent-hover);
}
/* Hero */
.hero {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 60px;
padding: 120px 24px 80px;
max-width: 1400px;
margin: 0 auto;
}
.hero-badge {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--accent);
background: var(--accent-glow);
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--accent);
margin-bottom: 24px;
letter-spacing: 1px;
text-transform: uppercase;
}
.hero h1 {
font-family: 'JetBrains Mono', monospace;
font-size: 4.5rem;
font-weight: 700;
letter-spacing: 8px;
margin-bottom: 24px;
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.25rem;
color: var(--text-secondary);
margin-bottom: 40px;
max-width: 500px;
line-height: 1.8;
}
.hero-buttons {
display: flex;
gap: 16px;
margin-bottom: 60px;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 8px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
font-size: 0.95rem;
}
.btn-primary {
background: var(--accent);
color: var(--bg-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 30px var(--accent-glow);
}
.btn-secondary {
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
border-color: var(--text-secondary);
background: var(--bg-card);
}
.hero-stats {
display: flex;
gap: 48px;
}
.stat {
display: flex;
flex-direction: column;
}
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 2rem;
font-weight: 600;
color: var(--accent);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.hero-image {
position: relative;
}
.hero-image img {
width: 100%;
border-radius: 12px;
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
/* Sections */
section {
padding: 100px 0;
}
section h2 {
font-family: 'JetBrains Mono', monospace;
font-size: 2.5rem;
font-weight: 600;
text-align: center;
margin-bottom: 16px;
letter-spacing: 2px;
}
.section-subtitle {
text-align: center;
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 60px;
}
/* Features */
.features {
background: var(--bg-secondary);
}
.features-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
.feature-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
transition: all 0.3s;
}
.feature-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
transform: translateY(-4px);
}
.feature-icon {
font-size: 2rem;
margin-bottom: 16px;
}
.feature-card h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.feature-card p {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.7;
}
/* Screenshots */
.screenshot-gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.screenshot-item {
position: relative;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
transition: all 0.3s;
cursor: pointer;
}
.screenshot-item:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 212, 170, 0.15);
}
.screenshot-item img {
width: 100%;
height: auto;
display: block;
}
.screenshot-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 500;
}
/* Lightbox */
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 20px;
}
.lightbox.active {
display: flex;
}
.lightbox-close {
position: absolute;
top: 20px;
right: 30px;
font-size: 40px;
color: var(--text-primary);
cursor: pointer;
transition: color 0.2s;
z-index: 1001;
}
.lightbox-close:hover {
color: var(--accent);
}
.lightbox-img {
max-width: 90%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.lightbox-caption {
margin-top: 20px;
font-size: 1.1rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Installation */
.installation {
background: var(--bg-secondary);
}
.install-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
margin-bottom: 48px;
}
.install-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
}
.install-card h3 {
font-size: 1.2rem;
margin-bottom: 20px;
color: var(--text-primary);
}
.code-block {
background: var(--code-bg);
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin-bottom: 16px;
}
.code-block pre {
margin: 0;
}
.code-block code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--accent);
line-height: 1.8;
}
.install-note {
font-size: 0.85rem;
color: var(--text-muted);
}
.platform-note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 32px;
}
.platform-note p {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0;
}
.platform-note strong {
color: var(--text-primary);
}
.post-install {
text-align: center;
padding: 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
}
.post-install p {
margin-bottom: 8px;
color: var(--text-secondary);
}
.post-install code {
font-family: 'JetBrains Mono', monospace;
background: var(--code-bg);
padding: 4px 10px;
border-radius: 4px;
color: var(--accent);
}
/* Hardware */
.hardware-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-bottom: 32px;
}
.hardware-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
text-align: center;
position: relative;
}
.hardware-tag {
position: absolute;
top: 16px;
right: 16px;
font-size: 0.7rem;
padding: 4px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.hardware-card.required .hardware-tag {
background: var(--accent-glow);
color: var(--accent);
border: 1px solid var(--accent);
}
.hardware-card.optional .hardware-tag {
background: rgba(136, 136, 160, 0.1);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.hardware-card h3 {
font-size: 1.2rem;
margin-bottom: 12px;
margin-top: 8px;
}
.hardware-card p {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 16px;
}
.hardware-card .price {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
color: var(--accent);
font-weight: 600;
}
.hardware-note {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
/* CTA */
.cta {
background: linear-gradient(135deg, rgba(0, 212, 170, 0.1), rgba(0, 136, 255, 0.1));
text-align: center;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.cta h2 {
margin-bottom: 16px;
}
.cta p {
color: var(--text-secondary);
margin-bottom: 32px;
}
.cta-buttons {
display: flex;
justify-content: center;
gap: 16px;
}
/* Footer */
.footer {
background: var(--bg-secondary);
padding: 60px 0 32px;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 32px;
border-bottom: 1px solid var(--border);
margin-bottom: 32px;
}
.footer-logo {
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
font-weight: 600;
color: var(--accent);
letter-spacing: 2px;
}
.footer-brand p {
color: var(--text-muted);
font-size: 0.9rem;
margin-top: 8px;
}
.footer-links {
display: flex;
gap: 32px;
}
.footer-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
.footer-links a:hover {
color: var(--accent);
}
.footer-bottom {
text-align: center;
}
.footer-bottom p {
color: var(--text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.footer-bottom a {
color: var(--accent);
text-decoration: none;
}
.disclaimer {
font-style: italic;
opacity: 0.7;
}
/* Responsive */
@media (max-width: 1024px) {
.hero {
grid-template-columns: 1fr;
text-align: center;
padding-top: 100px;
}
.hero-subtitle {
max-width: 100%;
}
.hero-buttons {
justify-content: center;
}
.hero-stats {
justify-content: center;
}
.hero-image {
order: -1;
max-width: 600px;
margin: 0 auto;
}
.features-grid {
grid-template-columns: repeat(2, 1fr);
}
.screenshot-gallery {
grid-template-columns: repeat(2, 1fr);
}
.install-options {
grid-template-columns: 1fr;
}
.hardware-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.hero h1 {
font-size: 2.5rem;
letter-spacing: 4px;
}
.hero-stats {
flex-direction: column;
gap: 24px;
}
.features-grid {
grid-template-columns: 1fr;
}
.screenshot-gallery {
grid-template-columns: 1fr;
}
.nav-links {
display: none;
}
.footer-content {
flex-direction: column;
gap: 24px;
text-align: center;
}
.cta-buttons {
flex-direction: column;
align-items: center;
}
}
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# =============================================================================
# INTERCEPT AGENT CONFIGURATION
# =============================================================================
# This file configures the Intercept remote agent.
# Copy this file and customize for your deployment.
[agent]
# Agent name (used to identify this node in the controller)
# Default: system hostname
name = sensor-node-1
# HTTP server port
# Default: 8020
port = 8020
# Comma-separated list of allowed client IPs (empty = allow all)
# Example: 192.168.1.100, 192.168.1.101, 10.0.0.0/8
allowed_ips =
# Enable CORS headers for browser-based clients
# Default: false
allow_cors = false
[controller]
# Controller URL for push mode
# Example: http://192.168.1.100:5050
url =
# API key for controller authentication (shared secret)
api_key =
# Enable automatic push of scan data to controller
# Default: false
push_enabled = false
# Push interval in seconds (minimum time between pushes)
# Default: 5
push_interval = 5
[modes]
# Enable/disable specific modes on this agent
# Set to false to disable a mode even if tools are available
# Default: all true
pager = true
sensor = true
adsb = true
ais = true
acars = true
aprs = true
wifi = true
bluetooth = true
dsc = true
rtlamr = true
tscm = true
satellite = true
listening_post = true
+3824
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.0.0"
version = "2.10.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -29,6 +29,11 @@ dependencies = [
"flask>=2.0.0",
"skyfield>=1.45",
"pyserial>=3.5",
"Werkzeug>=3.1.5",
"flask-limiter>=2.5.4",
"bleak>=0.21.0",
"flask-sock",
"requests>=2.28.0",
]
[project.urls]
+16 -1
View File
@@ -1,17 +1,32 @@
# Core dependencies
flask>=2.0.0
flask-limiter>=2.5.4
requests>=2.28.0
Werkzeug>=3.1.5
# ADS-B history (optional - only needed for Postgres persistence)
psycopg2-binary>=2.9.9
# BLE scanning with manufacturer data detection (optional - for TSCM)
bleak>=0.21.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
# DSC decoding (optional - only needed for VHF DSC maritime distress)
scipy>=1.10.0
numpy>=1.24.0
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
# Meshtastic mesh network support (optional - only needed for Meshtastic features)
meshtastic>=2.0.0
# Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0
# pytest-cov>=4.0.0
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
flask-sock
flask-sock
+29
View File
@@ -4,22 +4,51 @@ def register_blueprints(app):
"""Register all route blueprints with the Flask app."""
from .pager import pager_bp
from .sensor import sensor_bp
from .rtlamr import rtlamr_bp
from .wifi import wifi_bp
from .wifi_v2 import wifi_v2_bp
from .bluetooth import bluetooth_bp
from .bluetooth_v2 import bluetooth_v2_bp
from .adsb import adsb_bp
from .ais import ais_bp
from .dsc import dsc_bp
from .acars import acars_bp
from .aprs import aprs_bp
from .satellite import satellite_bp
from .gps import gps_bp
from .settings import settings_bp
from .correlation import correlation_bp
from .listening_post import listening_post_bp
from .meshtastic import meshtastic_bp
from .tscm import tscm_bp, init_tscm_state
from .spy_stations import spy_stations_bp
from .controller import controller_bp
from .offline import offline_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
app.register_blueprint(rtlamr_bp)
app.register_blueprint(wifi_bp)
app.register_blueprint(wifi_v2_bp) # New unified WiFi API
app.register_blueprint(bluetooth_bp)
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
app.register_blueprint(adsb_bp)
app.register_blueprint(ais_bp)
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
app.register_blueprint(acars_bp)
app.register_blueprint(aprs_bp)
app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
app.register_blueprint(meshtastic_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
app.register_blueprint(controller_bp) # Remote agent controller
app.register_blueprint(offline_bp) # Offline mode settings
# Initialize TSCM state with queue and lock from app
import app as app_module
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
+373
View File
@@ -0,0 +1,373 @@
"""ACARS aircraft messaging routes."""
from __future__ import annotations
import io
import json
import os
import platform
import pty
import queue
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide
DEFAULT_ACARS_FREQUENCIES = [
'131.550', # Primary worldwide
'130.025', # Secondary USA/Canada
'129.125', # USA
'131.525', # Europe
'131.725', # Europe secondary
]
# Message counter for statistics
acars_message_count = 0
acars_last_message_time = None
def find_acarsdec():
"""Find acarsdec binary."""
return shutil.which('acarsdec')
def get_acarsdec_json_flag(acarsdec_path: str) -> str:
"""Detect which JSON output flag acarsdec supports.
Different forks use different flags:
- TLeconte v4.0+: uses -j for JSON stdout
- TLeconte v3.x: uses -o 4 for JSON stdout
- f00b4r0 fork (DragonOS): uses --output json:file:- for JSON stdout
"""
try:
# Get help/version by running acarsdec with no args (shows usage)
result = subprocess.run(
[acarsdec_path],
capture_output=True,
text=True,
timeout=5
)
output = result.stdout + result.stderr
import re
# Check for f00b4r0 fork signature: uses --output instead of -j/-o
# f00b4r0's help shows "--output" for output configuration
if '--output' in output or 'json:file:' in output.lower():
logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)")
return '--output'
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE)
if version_match:
major = int(version_match.group(1))
# Version 4.0+ uses -j for JSON stdout
if major >= 4:
return '-j'
# Version 3.x uses -o for output mode
else:
return '-o'
except Exception as e:
logger.debug(f"Could not detect acarsdec version: {e}")
# Default to -j (TLeconte modern standard)
return '-j'
def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
"""Stream acarsdec JSON output to queue."""
global acars_message_count, acars_last_message_time
try:
app_module.acars_queue.put({'type': 'status', 'status': 'started'})
# Use appropriate sentinel based on mode (text mode for pty on macOS)
sentinel = '' if is_text_mode else b''
for line in iter(process.stdout.readline, sentinel):
if is_text_mode:
line = line.strip()
else:
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# acarsdec -o 4 outputs JSON, one message per line
data = json.loads(line)
# Add our metadata
data['type'] = 'acars'
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
# Update stats
acars_message_count += 1
acars_last_message_time = time.time()
app_module.acars_queue.put(data)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{ts} | ACARS | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON - could be status message
if line:
logger.debug(f"acarsdec non-JSON: {line[:100]}")
except Exception as e:
logger.error(f"ACARS stream error: {e}")
app_module.acars_queue.put({'type': 'error', 'message': str(e)})
finally:
app_module.acars_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.acars_lock:
app_module.acars_process = None
@acars_bp.route('/tools')
def check_acars_tools() -> Response:
"""Check for ACARS decoding tools."""
has_acarsdec = find_acarsdec() is not None
return jsonify({
'acarsdec': has_acarsdec,
'ready': has_acarsdec
})
@acars_bp.route('/status')
def acars_status() -> Response:
"""Get ACARS decoder status."""
running = False
if app_module.acars_process:
running = app_module.acars_process.poll() is None
return jsonify({
'running': running,
'message_count': acars_message_count,
'last_message_time': acars_last_message_time,
'queue_size': app_module.acars_queue.qsize()
})
@acars_bp.route('/start', methods=['POST'])
def start_acars() -> Response:
"""Start ACARS decoder."""
global acars_message_count, acars_last_message_time
with app_module.acars_lock:
if app_module.acars_process and app_module.acars_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'ACARS decoder already running'
}), 409
# Check for acarsdec
acarsdec_path = find_acarsdec()
if not acarsdec_path:
return jsonify({
'status': 'error',
'message': 'acarsdec not found. Install with: sudo apt install acarsdec'
}), 400
data = request.json or {}
# Validate inputs
try:
device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Get frequencies - use provided or defaults
frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES)
if isinstance(frequencies, str):
frequencies = [f.strip() for f in frequencies.split(',')]
# Clear queue
while not app_module.acars_queue.empty():
try:
app_module.acars_queue.get_nowait()
except queue.Empty:
break
# Reset stats
acars_message_count = 0
acars_last_message_time = None
# Build acarsdec command
# Different forks have different syntax:
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
# Note: gain/ppm must come BEFORE -r
json_flag = get_acarsdec_json_flag(acarsdec_path)
cmd = [acarsdec_path]
if json_flag == '--output':
# f00b4r0 fork: --output json:file (no path = stdout)
cmd.extend(['--output', 'json:file'])
elif json_flag == '-j':
cmd.append('-j') # JSON output (TLeconte v4+)
else:
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
# Add gain if not auto (must be before -r)
if gain and str(gain) != '0':
cmd.extend(['-g', str(gain)])
# Add PPM correction if specified (must be before -r)
if ppm and str(ppm) != '0':
cmd.extend(['-p', str(ppm)])
# Add device and frequencies
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
if json_flag == '--output':
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
cmd.extend(['-m', '256'])
cmd.extend(['--rtlsdr', str(device)])
else:
cmd.extend(['-r', str(device)])
cmd.extend(frequencies)
logger.info(f"Starting ACARS decoder: {' '.join(cmd)}")
try:
is_text_mode = False
# On macOS, use pty to avoid stdout buffering issues
if platform.system() == 'Darwin':
master_fd, slave_fd = pty.openpty()
process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=subprocess.PIPE,
start_new_session=True
)
os.close(slave_fd)
# Wrap master_fd as a text file for line-buffered reading
process.stdout = io.open(master_fd, 'r', buffering=1)
is_text_mode = True
else:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
# Wait briefly to check if process started
time.sleep(PROCESS_START_WAIT)
if process.poll() is not None:
# Process died
stderr = ''
if process.stderr:
stderr = process.stderr.read().decode('utf-8', errors='replace')
error_msg = f'acarsdec failed to start'
if stderr:
error_msg += f': {stderr[:200]}'
logger.error(error_msg)
return jsonify({'status': 'error', 'message': error_msg}), 500
app_module.acars_process = process
# Start output streaming thread
thread = threading.Thread(
target=stream_acars_output,
args=(process, is_text_mode),
daemon=True
)
thread.start()
return jsonify({
'status': 'started',
'frequencies': frequencies,
'device': device,
'gain': gain
})
except Exception as e:
logger.error(f"Failed to start ACARS decoder: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@acars_bp.route('/stop', methods=['POST'])
def stop_acars() -> Response:
"""Stop ACARS decoder."""
with app_module.acars_lock:
if not app_module.acars_process:
return jsonify({
'status': 'error',
'message': 'ACARS decoder not running'
}), 400
try:
app_module.acars_process.terminate()
app_module.acars_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.acars_process.kill()
except Exception as e:
logger.error(f"Error stopping ACARS: {e}")
app_module.acars_process = None
return jsonify({'status': 'stopped'})
@acars_bp.route('/stream')
def stream_acars() -> Response:
"""SSE stream for ACARS messages."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@acars_bp.route('/frequencies')
def get_frequencies() -> Response:
"""Get default ACARS frequencies."""
return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES,
'regions': {
'north_america': ['129.125', '130.025', '130.450', '131.550'],
'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'],
}
})
+601 -117
View File
@@ -2,27 +2,39 @@
from __future__ import annotations
import json
import os
import queue
import shutil
import socket
import subprocess
import threading
import time
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response, render_template
import app as app_module
from utils.logging import adsb_logger as logger
from utils.validation import (
validate_device_index, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
import json
import os
import queue
import shutil
import socket
import subprocess
import threading
import time
from datetime import datetime, timezone
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response, render_template
from flask import make_response
import psycopg2
from psycopg2.extras import RealDictCursor
import app as app_module
from config import (
ADSB_DB_HOST,
ADSB_DB_NAME,
ADSB_DB_PASSWORD,
ADSB_DB_PORT,
ADSB_DB_USER,
ADSB_HISTORY_ENABLED,
)
from utils.logging import adsb_logger as logger
from utils.validation import (
validate_device_index, validate_gain,
validate_rtl_tcp_host, validate_rtl_tcp_port
)
from utils.sse import format_sse
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
from utils.constants import (
ADSB_SBS_PORT,
ADSB_TERMINATE_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
@@ -33,9 +45,10 @@ from utils.constants import (
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
ADSB_UPDATE_INTERVAL,
DUMP1090_START_WAIT,
)
from utils import aircraft_db
DUMP1090_START_WAIT,
)
from utils import aircraft_db
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -46,6 +59,8 @@ adsb_messages_received = 0
adsb_last_message_time = None
adsb_bytes_received = 0
adsb_lines_received = 0
adsb_active_device = None # Track which device index is being used
_sbs_error_logged = False # Suppress repeated connection error logs
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
_looked_up_icaos: set[str] = set()
@@ -54,7 +69,7 @@ _looked_up_icaos: set[str] = set()
aircraft_db.load_database()
# Common installation paths for dump1090 (when not in PATH)
DUMP1090_PATHS = [
DUMP1090_PATHS = [
# Homebrew on Apple Silicon (M1/M2/M3)
'/opt/homebrew/bin/dump1090',
'/opt/homebrew/bin/dump1090-fa',
@@ -67,8 +82,202 @@ DUMP1090_PATHS = [
'/usr/bin/dump1090',
'/usr/bin/dump1090-fa',
'/usr/bin/dump1090-mutability',
]
]
def _get_part(parts: list[str], index: int) -> str | None:
if len(parts) <= index:
return None
value = parts[index].strip()
return value or None
def _parse_sbs_timestamp(date_str: str | None, time_str: str | None) -> datetime | None:
if not date_str or not time_str:
return None
combined = f"{date_str} {time_str}"
for fmt in ("%Y/%m/%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
try:
parsed = datetime.strptime(combined, fmt)
return parsed.replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def _parse_int(value: str | None) -> int | None:
if value is None:
return None
try:
return int(float(value))
except (ValueError, TypeError):
return None
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def _build_history_record(
parts: list[str],
msg_type: str,
icao: str,
msg_time: datetime | None,
logged_time: datetime | None,
service_addr: str,
raw_line: str,
) -> dict[str, Any]:
return {
'received_at': datetime.now(timezone.utc),
'msg_time': msg_time,
'logged_time': logged_time,
'icao': icao,
'msg_type': _parse_int(msg_type),
'callsign': _get_part(parts, 10),
'altitude': _parse_int(_get_part(parts, 11)),
'speed': _parse_int(_get_part(parts, 12)),
'heading': _parse_int(_get_part(parts, 13)),
'vertical_rate': _parse_int(_get_part(parts, 16)),
'lat': _parse_float(_get_part(parts, 14)),
'lon': _parse_float(_get_part(parts, 15)),
'squawk': _get_part(parts, 17),
'session_id': _get_part(parts, 2),
'aircraft_id': _get_part(parts, 3),
'flight_id': _get_part(parts, 5),
'raw_line': raw_line,
'source_host': service_addr,
}
_history_schema_checked = False
def _get_history_connection():
return psycopg2.connect(
host=ADSB_DB_HOST,
port=ADSB_DB_PORT,
dbname=ADSB_DB_NAME,
user=ADSB_DB_USER,
password=ADSB_DB_PASSWORD,
)
def _ensure_history_schema() -> None:
global _history_schema_checked
if _history_schema_checked:
return
try:
with _get_history_connection() as conn:
_ensure_adsb_schema(conn)
_history_schema_checked = True
except Exception as exc:
logger.warning("ADS-B schema check failed: %s", exc)
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
try:
parsed = int(value) if value is not None else default
except (ValueError, TypeError):
parsed = default
if min_value is not None:
parsed = max(min_value, parsed)
if max_value is not None:
parsed = min(max_value, parsed)
return parsed
def _get_active_session() -> dict[str, Any] | None:
if not ADSB_HISTORY_ENABLED:
return None
_ensure_history_schema()
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT *
FROM adsb_sessions
WHERE ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
"""
)
return cur.fetchone()
except Exception as exc:
logger.warning("ADS-B session lookup failed: %s", exc)
return None
def _record_session_start(
*,
device_index: int | None,
sdr_type: str | None,
remote_host: str | None,
remote_port: int | None,
start_source: str | None,
started_by: str | None,
) -> dict[str, Any] | None:
if not ADSB_HISTORY_ENABLED:
return None
_ensure_history_schema()
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO adsb_sessions (
device_index,
sdr_type,
remote_host,
remote_port,
start_source,
started_by
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
device_index,
sdr_type,
remote_host,
remote_port,
start_source,
started_by,
),
)
return cur.fetchone()
except Exception as exc:
logger.warning("ADS-B session start record failed: %s", exc)
return None
def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) -> dict[str, Any] | None:
if not ADSB_HISTORY_ENABLED:
return None
_ensure_history_schema()
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE adsb_sessions
SET ended_at = NOW(),
stop_source = COALESCE(%s, stop_source),
stopped_by = COALESCE(%s, stopped_by)
WHERE ended_at IS NULL
RETURNING *
""",
(stop_source, stopped_by),
)
return cur.fetchone()
except Exception as exc:
logger.warning("ADS-B session stop record failed: %s", exc)
return None
def find_dump1090():
"""Find dump1090 binary, checking PATH and common locations."""
@@ -98,16 +307,20 @@ def check_dump1090_service():
return None
def parse_sbs_stream(service_addr):
"""Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
host, port = service_addr.split(':')
port = int(port)
def parse_sbs_stream(service_addr):
"""Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
adsb_history_writer.start()
adsb_snapshot_writer.start()
host, port = service_addr.split(':')
port = int(port)
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
adsb_connected = False
adsb_messages_received = 0
_sbs_error_logged = False
while adsb_using_service:
try:
@@ -115,6 +328,7 @@ def parse_sbs_stream(service_addr):
sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port))
adsb_connected = True
_sbs_error_logged = False # Reset so we log next error
logger.info("Connected to SBS stream")
buffer = ""
@@ -143,18 +357,31 @@ def parse_sbs_stream(service_addr):
if adsb_lines_received <= 3:
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG':
if adsb_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue
msg_type = parts[1]
icao = parts[4].upper()
if not icao:
continue
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG':
if adsb_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue
msg_type = parts[1]
icao = parts[4].upper()
if not icao:
continue
msg_time = _parse_sbs_timestamp(_get_part(parts, 6), _get_part(parts, 7))
logged_time = _parse_sbs_timestamp(_get_part(parts, 8), _get_part(parts, 9))
history_record = _build_history_record(
parts=parts,
msg_type=msg_type,
icao=icao,
msg_time=msg_time,
logged_time=logged_time,
service_addr=service_addr,
raw_line=line,
)
adsb_history_writer.enqueue(history_record)
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
# Look up aircraft type from database (once per ICAO)
if icao not in _looked_up_icaos:
@@ -225,12 +452,30 @@ def parse_sbs_stream(service_addr):
now = time.time()
if now - last_update >= ADSB_UPDATE_INTERVAL:
for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft:
app_module.adsb_queue.put({
'type': 'aircraft',
**app_module.adsb_aircraft[update_icao]
})
for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft:
snapshot = app_module.adsb_aircraft[update_icao]
app_module.adsb_queue.put({
'type': 'aircraft',
**snapshot
})
adsb_snapshot_writer.enqueue({
'captured_at': datetime.now(timezone.utc),
'icao': update_icao,
'callsign': snapshot.get('callsign'),
'registration': snapshot.get('registration'),
'type_code': snapshot.get('type_code'),
'type_desc': snapshot.get('type_desc'),
'altitude': snapshot.get('altitude'),
'speed': snapshot.get('speed'),
'heading': snapshot.get('heading'),
'vertical_rate': snapshot.get('vertical_rate'),
'lat': snapshot.get('lat'),
'lon': snapshot.get('lon'),
'squawk': snapshot.get('squawk'),
'source_host': service_addr,
'snapshot': snapshot,
})
pending_updates.clear()
last_update = now
@@ -241,7 +486,9 @@ def parse_sbs_stream(service_addr):
adsb_connected = False
except OSError as e:
adsb_connected = False
logger.warning(f"SBS connection error: {e}, reconnecting...")
if not _sbs_error_logged:
logger.warning(f"SBS connection error: {e}, reconnecting...")
_sbs_error_logged = True
time.sleep(SBS_RECONNECT_DELAY)
adsb_connected = False
@@ -276,17 +523,18 @@ def check_adsb_tools():
})
@adsb_bp.route('/status')
def adsb_status():
"""Get ADS-B tracking status for debugging."""
@adsb_bp.route('/status')
def adsb_status():
"""Get ADS-B tracking status for debugging."""
# Check if dump1090 process is still running
dump1090_running = False
if app_module.adsb_process:
dump1090_running = app_module.adsb_process.poll() is None
return jsonify({
'tracking_active': adsb_using_service,
'connected_to_sbs': adsb_connected,
return jsonify({
'tracking_active': adsb_using_service,
'active_device': adsb_active_device,
'connected_to_sbs': adsb_connected,
'messages_received': adsb_messages_received,
'bytes_received': adsb_bytes_received,
'lines_received': adsb_lines_received,
@@ -296,25 +544,50 @@ def adsb_status():
'queue_size': app_module.adsb_queue.qsize(),
'dump1090_path': find_dump1090(),
'dump1090_running': dump1090_running,
'port_30003_open': check_dump1090_service() is not None
})
'port_30003_open': check_dump1090_service() is not None
})
@adsb_bp.route('/session')
def adsb_session():
"""Get ADS-B session status and uptime."""
session = _get_active_session()
uptime_seconds = None
if session and session.get('started_at'):
started_at = session['started_at']
if isinstance(started_at, datetime):
uptime_seconds = int((datetime.now(timezone.utc) - started_at).total_seconds())
return jsonify({
'tracking_active': adsb_using_service,
'connected_to_sbs': adsb_connected,
'active_device': adsb_active_device,
'session': session,
'uptime_seconds': uptime_seconds,
})
@adsb_bp.route('/start', methods=['POST'])
def start_adsb():
"""Start ADS-B tracking."""
global adsb_using_service
with app_module.adsb_lock:
if adsb_using_service:
return jsonify({'status': 'already_running', 'message': 'ADS-B tracking already active'}), 409
data = request.json or {}
# Validate inputs
try:
gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
def start_adsb():
"""Start ADS-B tracking."""
global adsb_using_service, adsb_active_device
with app_module.adsb_lock:
if adsb_using_service:
session = _get_active_session()
return jsonify({
'status': 'already_running',
'message': 'ADS-B tracking already active',
'session': session
}), 409
data = request.json or {}
start_source = data.get('source')
started_by = request.remote_addr
# Validate inputs
try:
gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
@@ -330,21 +603,45 @@ def start_adsb():
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
thread.start()
return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'})
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
thread.start()
session = _record_session_start(
device_index=device,
sdr_type='remote',
remote_host=remote_sbs_host,
remote_port=remote_sbs_port,
start_source=start_source,
started_by=started_by,
)
return jsonify({
'status': 'started',
'message': f'Connected to remote dump1090 at {remote_addr}',
'session': session
})
# Check if dump1090 is already running externally (e.g., user started it manually)
existing_service = check_dump1090_service()
if existing_service:
logger.info(f"Found existing dump1090 service at {existing_service}")
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
thread.start()
return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'})
if existing_service:
logger.info(f"Found existing dump1090 service at {existing_service}")
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
thread.start()
session = _record_session_start(
device_index=device,
sdr_type='external',
remote_host='localhost',
remote_port=ADSB_SBS_PORT,
start_source=start_source,
started_by=started_by,
)
return jsonify({
'status': 'started',
'message': 'Connected to existing dump1090 service',
'session': session
})
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
@@ -364,17 +661,20 @@ def start_adsb():
if not dump1090_path:
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
# Kill any stale app-started process
# Kill any stale app-started process (use process group to ensure full cleanup)
if app_module.adsb_process:
try:
app_module.adsb_process.terminate()
pgid = os.getpgid(app_module.adsb_process.pid)
os.killpg(pgid, 15) # SIGTERM
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, OSError):
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
app_module.adsb_process.kill()
except OSError:
pgid = os.getpgid(app_module.adsb_process.pid)
os.killpg(pgid, 9) # SIGKILL
except (ProcessLookupError, OSError):
pass
app_module.adsb_process = None
logger.info("Killed stale ADS-B process")
# Create device object and build command via abstraction layer
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
@@ -392,11 +692,13 @@ def start_adsb():
if sdr_type == SDRType.RTL_SDR:
cmd[0] = dump1090_path
try:
app_module.adsb_process = subprocess.Popen(
cmd,
try:
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
app_module.adsb_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE
stderr=subprocess.PIPE,
start_new_session=True # Create new process group for clean shutdown
)
time.sleep(DUMP1090_START_WAIT)
@@ -420,33 +722,60 @@ def start_adsb():
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg})
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start()
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
adsb_using_service = True
adsb_active_device = device # Track which device is being used
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start()
session = _record_session_start(
device_index=device,
sdr_type=sdr_type.value,
remote_host=None,
remote_port=None,
start_source=start_source,
started_by=started_by,
)
return jsonify({
'status': 'started',
'message': 'ADS-B tracking started',
'device': device,
'session': session
})
except Exception as e:
return jsonify({'status': 'error', 'message': str(e)})
@adsb_bp.route('/stop', methods=['POST'])
def stop_adsb():
"""Stop ADS-B tracking."""
global adsb_using_service
with app_module.adsb_lock:
if app_module.adsb_process:
app_module.adsb_process.terminate()
try:
def stop_adsb():
"""Stop ADS-B tracking."""
global adsb_using_service, adsb_active_device
data = request.json or {}
stop_source = data.get('source')
stopped_by = request.remote_addr
with app_module.adsb_lock:
if app_module.adsb_process:
try:
# Kill the entire process group to ensure all child processes are terminated
pgid = os.getpgid(app_module.adsb_process.pid)
os.killpg(pgid, 15) # SIGTERM
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.adsb_process.kill()
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
# Force kill if terminate didn't work
pgid = os.getpgid(app_module.adsb_process.pid)
os.killpg(pgid, 9) # SIGKILL
except (ProcessLookupError, OSError):
pass
app_module.adsb_process = None
adsb_using_service = False
app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
return jsonify({'status': 'stopped'})
logger.info("ADS-B process stopped")
adsb_using_service = False
adsb_active_device = None
app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
session = _record_session_stop(stop_source=stop_source, stopped_by=stopped_by)
return jsonify({'status': 'stopped', 'session': session})
@adsb_bp.route('/stream')
@@ -472,10 +801,165 @@ def stream_adsb():
return response
@adsb_bp.route('/dashboard')
def adsb_dashboard():
"""Popout ADS-B dashboard."""
return render_template('adsb_dashboard.html')
@adsb_bp.route('/dashboard')
def adsb_dashboard():
"""Popout ADS-B dashboard."""
return render_template('adsb_dashboard.html')
@adsb_bp.route('/history')
def adsb_history():
"""ADS-B history reporting dashboard."""
resp = make_response(render_template('adsb_history.html', history_enabled=ADSB_HISTORY_ENABLED))
resp.headers['Cache-Control'] = 'no-store'
return resp
@adsb_bp.route('/history/summary')
def adsb_history_summary():
"""Summary stats for ADS-B history window."""
if not ADSB_HISTORY_ENABLED:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
window = f'{since_minutes} minutes'
sql = """
SELECT
(SELECT COUNT(*) FROM adsb_messages WHERE received_at >= NOW() - INTERVAL %s) AS message_count,
(SELECT COUNT(*) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS snapshot_count,
(SELECT COUNT(DISTINCT icao) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS aircraft_count,
(SELECT MIN(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS first_seen,
(SELECT MAX(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS last_seen
"""
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, (window, window, window, window, window))
row = cur.fetchone() or {}
return jsonify(row)
except Exception as exc:
logger.warning("ADS-B history summary failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
@adsb_bp.route('/history/aircraft')
def adsb_history_aircraft():
"""List latest aircraft snapshots for a time window."""
if not ADSB_HISTORY_ENABLED:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
search = (request.args.get('search') or '').strip()
window = f'{since_minutes} minutes'
pattern = f'%{search}%'
sql = """
SELECT *
FROM (
SELECT DISTINCT ON (icao)
icao,
callsign,
registration,
type_code,
type_desc,
altitude,
speed,
heading,
vertical_rate,
lat,
lon,
squawk,
captured_at AS last_seen
FROM adsb_snapshots
WHERE captured_at >= NOW() - INTERVAL %s
AND (%s = '' OR icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)
ORDER BY icao, captured_at DESC
) latest
ORDER BY last_seen DESC
LIMIT %s
"""
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
rows = cur.fetchall()
return jsonify({'aircraft': rows, 'count': len(rows)})
except Exception as exc:
logger.warning("ADS-B history aircraft query failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
@adsb_bp.route('/history/timeline')
def adsb_history_timeline():
"""Timeline snapshots for a specific aircraft."""
if not ADSB_HISTORY_ENABLED:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
icao = (request.args.get('icao') or '').strip().upper()
if not icao:
return jsonify({'error': 'icao is required'}), 400
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
window = f'{since_minutes} minutes'
sql = """
SELECT captured_at, altitude, speed, heading, vertical_rate, lat, lon, squawk
FROM adsb_snapshots
WHERE icao = %s
AND captured_at >= NOW() - INTERVAL %s
ORDER BY captured_at ASC
LIMIT %s
"""
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, (icao, window, limit))
rows = cur.fetchall()
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
except Exception as exc:
logger.warning("ADS-B history timeline query failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
@adsb_bp.route('/history/messages')
def adsb_history_messages():
"""Raw message history for a specific aircraft."""
if not ADSB_HISTORY_ENABLED:
return jsonify({'error': 'ADS-B history is disabled'}), 503
_ensure_history_schema()
icao = (request.args.get('icao') or '').strip().upper()
since_minutes = _parse_int_param(request.args.get('since_minutes'), 30, 1, 10080)
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
window = f'{since_minutes} minutes'
sql = """
SELECT received_at, msg_type, callsign, altitude, speed, heading, vertical_rate, lat, lon, squawk
FROM adsb_messages
WHERE received_at >= NOW() - INTERVAL %s
AND (%s = '' OR icao = %s)
ORDER BY received_at DESC
LIMIT %s
"""
try:
with _get_history_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql, (window, icao, icao, limit))
rows = cur.fetchall()
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
except Exception as exc:
logger.warning("ADS-B history message query failed: %s", exc)
return jsonify({'error': 'History database unavailable'}), 503
# ============================================
+483
View File
@@ -0,0 +1,483 @@
"""AIS vessel tracking routes."""
from __future__ import annotations
import json
import os
import queue
import shutil
import socket
import subprocess
import threading
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response, render_template
import app as app_module
from utils.logging import get_logger
from utils.validation import validate_device_index, validate_gain
from utils.sse import format_sse
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
AIS_TCP_PORT,
AIS_TERMINATE_TIMEOUT,
AIS_SOCKET_TIMEOUT,
AIS_RECONNECT_DELAY,
AIS_UPDATE_INTERVAL,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
)
logger = get_logger('intercept.ais')
ais_bp = Blueprint('ais', __name__, url_prefix='/ais')
# Track AIS state
ais_running = False
ais_connected = False
ais_messages_received = 0
ais_last_message_time = None
ais_active_device = None
_ais_error_logged = True
# Common installation paths for AIS-catcher
AIS_CATCHER_PATHS = [
'/usr/local/bin/AIS-catcher',
'/usr/bin/AIS-catcher',
'/opt/homebrew/bin/AIS-catcher',
'/opt/homebrew/bin/aiscatcher',
]
def find_ais_catcher():
"""Find AIS-catcher binary, checking PATH and common locations."""
# First try PATH
for name in ['AIS-catcher', 'aiscatcher']:
path = shutil.which(name)
if path:
return path
# Check common installation paths
for path in AIS_CATCHER_PATHS:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def parse_ais_stream(port: int):
"""Parse JSON data from AIS-catcher TCP server."""
global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged
logger.info(f"AIS stream parser started, connecting to localhost:{port}")
ais_connected = True
ais_messages_received = 0
_ais_error_logged = True
while ais_running:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT)
sock.connect(('localhost', port))
ais_connected = True
_ais_error_logged = True
logger.info("Connected to AIS-catcher TCP server")
buffer = ""
last_update = time.time()
pending_updates = set()
while ais_running:
try:
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
logger.warning("AIS connection closed (no data)")
break
buffer += data
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
vessel = process_ais_message(msg)
if vessel:
mmsi = vessel.get('mmsi')
if mmsi:
app_module.ais_vessels.set(mmsi, vessel)
pending_updates.add(mmsi)
ais_messages_received += 1
ais_last_message_time = time.time()
except json.JSONDecodeError:
if ais_messages_received < 5:
logger.debug(f"Invalid JSON: {line[:100]}")
# Batch updates
now = time.time()
if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates:
if mmsi in app_module.ais_vessels:
try:
app_module.ais_queue.put_nowait({
'type': 'vessel',
**app_module.ais_vessels[mmsi]
})
except queue.Full:
pass
pending_updates.clear()
last_update = now
except socket.timeout:
continue
sock.close()
ais_connected = False
except OSError as e:
ais_connected = False
if not _ais_error_logged:
logger.warning(f"AIS connection error: {e}, reconnecting...")
_ais_error_logged = True
time.sleep(AIS_RECONNECT_DELAY)
ais_connected = False
logger.info("AIS stream parser stopped")
def process_ais_message(msg: dict) -> dict | None:
"""Process AIS-catcher JSON message and extract vessel data."""
# AIS-catcher outputs different message types
# We're interested in position reports and static data
mmsi = msg.get('mmsi')
if not mmsi:
return None
mmsi = str(mmsi)
# Get existing vessel data or create new
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
# Extract common fields
# AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat'
lat_val = msg.get('latitude') or msg.get('lat')
lon_val = msg.get('longitude') or msg.get('lon')
if lat_val is not None and lon_val is not None:
try:
lat = float(lat_val)
lon = float(lon_val)
# Validate coordinates (AIS uses 181 for unavailable)
if -90 <= lat <= 90 and -180 <= lon <= 180:
vessel['lat'] = lat
vessel['lon'] = lon
except (ValueError, TypeError):
pass
# Speed over ground (knots)
if 'speed' in msg:
try:
speed = float(msg['speed'])
if speed < 102.3: # 102.3 = not available
vessel['speed'] = round(speed, 1)
except (ValueError, TypeError):
pass
# Course over ground (degrees)
if 'course' in msg:
try:
course = float(msg['course'])
if course < 360: # 360 = not available
vessel['course'] = round(course, 1)
except (ValueError, TypeError):
pass
# True heading (degrees)
if 'heading' in msg:
try:
heading = int(msg['heading'])
if heading < 511: # 511 = not available
vessel['heading'] = heading
except (ValueError, TypeError):
pass
# Navigation status
if 'status' in msg:
vessel['nav_status'] = msg['status']
if 'status_text' in msg:
vessel['nav_status_text'] = msg['status_text']
# Vessel name (from Type 5 or Type 24 messages)
if 'shipname' in msg:
name = msg['shipname'].strip().strip('@')
if name:
vessel['name'] = name
# Callsign
if 'callsign' in msg:
callsign = msg['callsign'].strip().strip('@')
if callsign:
vessel['callsign'] = callsign
# Ship type
if 'shiptype' in msg:
vessel['ship_type'] = msg['shiptype']
if 'shiptype_text' in msg:
vessel['ship_type_text'] = msg['shiptype_text']
# Destination
if 'destination' in msg:
dest = msg['destination'].strip().strip('@')
if dest:
vessel['destination'] = dest
# ETA
if 'eta' in msg:
vessel['eta'] = msg['eta']
# Dimensions
if 'to_bow' in msg and 'to_stern' in msg:
try:
length = int(msg['to_bow']) + int(msg['to_stern'])
if length > 0:
vessel['length'] = length
except (ValueError, TypeError):
pass
if 'to_port' in msg and 'to_starboard' in msg:
try:
width = int(msg['to_port']) + int(msg['to_starboard'])
if width > 0:
vessel['width'] = width
except (ValueError, TypeError):
pass
# Draught
if 'draught' in msg:
try:
draught = float(msg['draught'])
if draught > 0:
vessel['draught'] = draught
except (ValueError, TypeError):
pass
# Rate of turn
if 'turn' in msg:
try:
turn = float(msg['turn'])
if -127 <= turn <= 127: # Valid range
vessel['rate_of_turn'] = turn
except (ValueError, TypeError):
pass
# Message type for debugging
if 'type' in msg:
vessel['last_msg_type'] = msg['type']
# Timestamp
vessel['last_seen'] = time.time()
return vessel
@ais_bp.route('/tools')
def check_ais_tools():
"""Check for AIS decoding tools and hardware."""
has_ais_catcher = find_ais_catcher() is not None
# Check what SDR hardware is detected
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
return jsonify({
'ais_catcher': has_ais_catcher,
'ais_catcher_path': find_ais_catcher(),
'has_rtlsdr': has_rtlsdr,
'device_count': len(devices)
})
@ais_bp.route('/status')
def ais_status():
"""Get AIS tracking status for debugging."""
process_running = False
if app_module.ais_process:
process_running = app_module.ais_process.poll() is None
return jsonify({
'tracking_active': ais_running,
'active_device': ais_active_device,
'connected': ais_connected,
'messages_received': ais_messages_received,
'last_message_time': ais_last_message_time,
'vessel_count': len(app_module.ais_vessels),
'vessels': dict(app_module.ais_vessels),
'queue_size': app_module.ais_queue.qsize(),
'ais_catcher_path': find_ais_catcher(),
'process_running': process_running
})
@ais_bp.route('/start', methods=['POST'])
def start_ais():
"""Start AIS tracking."""
global ais_running, ais_active_device
with app_module.ais_lock:
if ais_running:
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
data = request.json or {}
# Validate inputs
try:
gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Find AIS-catcher
ais_catcher_path = find_ais_catcher()
if not ais_catcher_path:
return jsonify({
'status': 'error',
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
}), 400
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Kill any existing process
if app_module.ais_process:
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 15)
app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.ais_process = None
logger.info("Killed existing AIS process")
# Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
bias_t = data.get('bias_t', False)
tcp_port = AIS_TCP_PORT
cmd = builder.build_ais_command(
device=sdr_device,
gain=float(gain),
bias_t=bias_t,
tcp_port=tcp_port
)
# Use the found AIS-catcher path
cmd[0] = ais_catcher_path
try:
logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}")
app_module.ais_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True
)
# Wait for process to start
time.sleep(2.0)
if app_module.ais_process.poll() is not None:
stderr_output = ''
if app_module.ais_process.stderr:
try:
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg}), 500
ais_running = True
ais_active_device = device
# Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
thread.start()
return jsonify({
'status': 'started',
'message': 'AIS tracking started',
'device': device,
'port': tcp_port
})
except Exception as e:
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@ais_bp.route('/stop', methods=['POST'])
def stop_ais():
"""Stop AIS tracking."""
global ais_running, ais_active_device
with app_module.ais_lock:
if app_module.ais_process:
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 15)
app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.ais_process = None
logger.info("AIS process stopped")
ais_running = False
ais_active_device = None
app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'})
@ais_bp.route('/stream')
def stream_ais():
"""SSE stream for AIS vessels."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
return render_template('ais_dashboard.html')
+1887
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+788
View File
@@ -0,0 +1,788 @@
"""
Controller routes for managing remote Intercept agents.
This blueprint provides:
- Agent CRUD operations
- Proxy endpoints to forward requests to agents
- Push data ingestion endpoint
- Multi-agent SSE stream
"""
from __future__ import annotations
import json
import logging
import queue
import time
from datetime import datetime, timezone
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.database import (
create_agent, get_agent, get_agent_by_name, list_agents,
update_agent, delete_agent, store_push_payload, get_recent_payloads
)
from utils.agent_client import (
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
)
from utils.sse import format_sse
from utils.trilateration import (
DeviceLocationTracker, PathLossModel, Trilateration,
AgentObservation, estimate_location_from_observations
)
logger = logging.getLogger('intercept.controller')
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
# Multi-agent data queue for combined SSE stream
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
# =============================================================================
# Agent CRUD
# =============================================================================
@controller_bp.route('/agents', methods=['GET'])
def get_agents():
"""List all registered agents."""
active_only = request.args.get('active_only', 'true').lower() == 'true'
agents = list_agents(active_only=active_only)
# Optionally refresh status for each agent
refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh:
for agent in agents:
try:
client = create_client_from_agent(agent)
agent['healthy'] = client.health_check()
except Exception:
agent['healthy'] = False
return jsonify({
'status': 'success',
'agents': agents,
'count': len(agents)
})
@controller_bp.route('/agents', methods=['POST'])
def register_agent():
"""
Register a new remote agent.
Expected JSON body:
{
"name": "sensor-node-1",
"base_url": "http://192.168.1.50:8020",
"api_key": "optional-shared-secret",
"description": "Optional description"
}
"""
data = request.json or {}
# Validate required fields
name = data.get('name', '').strip()
base_url = data.get('base_url', '').strip()
if not name:
return jsonify({'status': 'error', 'message': 'Agent name is required'}), 400
if not base_url:
return jsonify({'status': 'error', 'message': 'Base URL is required'}), 400
# Check if agent already exists
existing = get_agent_by_name(name)
if existing:
return jsonify({
'status': 'error',
'message': f'Agent with name "{name}" already exists'
}), 409
# Try to connect and get capabilities
api_key = data.get('api_key', '').strip() or None
client = AgentClient(base_url, api_key=api_key)
capabilities = None
interfaces = None
try:
caps = client.get_capabilities()
capabilities = caps.get('modes', {})
interfaces = {'devices': caps.get('devices', [])}
except (AgentHTTPError, AgentConnectionError) as e:
logger.warning(f"Could not fetch capabilities from {base_url}: {e}")
# Create agent
try:
agent_id = create_agent(
name=name,
base_url=base_url,
api_key=api_key,
description=data.get('description'),
capabilities=capabilities,
interfaces=interfaces
)
# Update last_seen since we just connected
if capabilities is not None:
update_agent(agent_id, update_last_seen=True)
agent = get_agent(agent_id)
return jsonify({
'status': 'success',
'message': 'Agent registered successfully',
'agent': agent
}), 201
except Exception as e:
logger.exception("Failed to create agent")
return jsonify({'status': 'error', 'message': str(e)}), 500
@controller_bp.route('/agents/<int:agent_id>', methods=['GET'])
def get_agent_detail(agent_id: int):
"""Get details of a specific agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
# Optionally refresh from agent
refresh = request.args.get('refresh', 'false').lower() == 'true'
if refresh:
try:
client = create_client_from_agent(agent)
metadata = client.refresh_metadata()
if metadata['healthy']:
caps = metadata['capabilities'] or {}
# Store full interfaces structure (wifi, bt, sdr)
agent_interfaces = caps.get('interfaces', {})
# Fallback: also include top-level devices for backwards compatibility
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
agent = get_agent(agent_id)
agent['healthy'] = True
else:
agent['healthy'] = False
except Exception:
agent['healthy'] = False
return jsonify({'status': 'success', 'agent': agent})
@controller_bp.route('/agents/<int:agent_id>', methods=['PUT', 'PATCH'])
def update_agent_detail(agent_id: int):
"""Update an agent's details."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
data = request.json or {}
# Update allowed fields
update_agent(
agent_id,
base_url=data.get('base_url'),
description=data.get('description'),
api_key=data.get('api_key'),
is_active=data.get('is_active')
)
agent = get_agent(agent_id)
return jsonify({'status': 'success', 'agent': agent})
@controller_bp.route('/agents/<int:agent_id>', methods=['DELETE'])
def remove_agent(agent_id: int):
"""Delete an agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
delete_agent(agent_id)
return jsonify({'status': 'success', 'message': 'Agent deleted'})
@controller_bp.route('/agents/<int:agent_id>/refresh', methods=['POST'])
def refresh_agent_metadata(agent_id: int):
"""Refresh an agent's capabilities and status."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try:
client = create_client_from_agent(agent)
metadata = client.refresh_metadata()
if metadata['healthy']:
caps = metadata['capabilities'] or {}
# Store full interfaces structure (wifi, bt, sdr)
agent_interfaces = caps.get('interfaces', {})
# Fallback: also include top-level devices for backwards compatibility
if not agent_interfaces.get('sdr_devices') and caps.get('devices'):
agent_interfaces['sdr_devices'] = caps.get('devices', [])
update_agent(
agent_id,
capabilities=caps.get('modes'),
interfaces=agent_interfaces,
update_last_seen=True
)
agent = get_agent(agent_id)
return jsonify({
'status': 'success',
'agent': agent,
'metadata': metadata
})
else:
return jsonify({
'status': 'error',
'message': 'Agent is not reachable'
}), 503
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
# =============================================================================
# Agent Status - Get running state
# =============================================================================
@controller_bp.route('/agents/<int:agent_id>/status', methods=['GET'])
def get_agent_status(agent_id: int):
"""Get an agent's current status including running modes."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try:
client = create_client_from_agent(agent)
status = client.get_status()
return jsonify({
'status': 'success',
'agent_id': agent_id,
'agent_name': agent['name'],
'agent_status': status
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Failed to reach agent: {e}'
}), 503
@controller_bp.route('/agents/health', methods=['GET'])
def check_all_agents_health():
"""
Check health of all registered agents in one call.
More efficient than checking each agent individually.
Returns health status, response time, and running modes for each agent.
"""
agents_list = list_agents(active_only=True)
results = []
for agent in agents_list:
result = {
'id': agent['id'],
'name': agent['name'],
'healthy': False,
'response_time_ms': None,
'running_modes': [],
'error': None
}
try:
client = create_client_from_agent(agent)
# Time the health check
start_time = time.time()
is_healthy = client.health_check()
response_time = (time.time() - start_time) * 1000
result['healthy'] = is_healthy
result['response_time_ms'] = round(response_time, 1)
if is_healthy:
# Update last_seen in database
update_agent(agent['id'], update_last_seen=True)
# Also fetch running modes
try:
status = client.get_status()
result['running_modes'] = status.get('running_modes', [])
result['running_modes_detail'] = status.get('running_modes_detail', {})
except Exception:
pass # Status fetch is optional
except AgentConnectionError as e:
result['error'] = f'Connection failed: {str(e)}'
except AgentHTTPError as e:
result['error'] = f'HTTP error: {str(e)}'
except Exception as e:
result['error'] = str(e)
results.append(result)
return jsonify({
'status': 'success',
'timestamp': datetime.now(timezone.utc).isoformat(),
'agents': results,
'total': len(results),
'healthy_count': sum(1 for r in results if r['healthy'])
})
# =============================================================================
# Proxy Operations - Forward requests to agents
# =============================================================================
@controller_bp.route('/agents/<int:agent_id>/<mode>/start', methods=['POST'])
def proxy_start_mode(agent_id: int, mode: str):
"""Start a mode on a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
params = request.json or {}
try:
client = create_client_from_agent(agent)
result = client.start_mode(mode, params)
# Update last_seen
update_agent(agent_id, update_last_seen=True)
return jsonify({
'status': 'success',
'agent_id': agent_id,
'mode': mode,
'result': result
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/stop', methods=['POST'])
def proxy_stop_mode(agent_id: int, mode: str):
"""Stop a mode on a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try:
client = create_client_from_agent(agent)
result = client.stop_mode(mode)
update_agent(agent_id, update_last_seen=True)
return jsonify({
'status': 'success',
'agent_id': agent_id,
'mode': mode,
'result': result
})
except AgentConnectionError as e:
return jsonify({
'status': 'error',
'message': f'Cannot connect to agent: {e}'
}), 503
except AgentHTTPError as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/status', methods=['GET'])
def proxy_mode_status(agent_id: int, mode: str):
"""Get mode status from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try:
client = create_client_from_agent(agent)
result = client.get_mode_status(mode)
return jsonify({
'status': 'success',
'agent_id': agent_id,
'mode': mode,
'result': result
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
@controller_bp.route('/agents/<int:agent_id>/<mode>/data', methods=['GET'])
def proxy_mode_data(agent_id: int, mode: str):
"""Get current data from a remote agent."""
agent = get_agent(agent_id)
if not agent:
return jsonify({'status': 'error', 'message': 'Agent not found'}), 404
try:
client = create_client_from_agent(agent)
result = client.get_mode_data(mode)
# Tag data with agent info
result['agent_id'] = agent_id
result['agent_name'] = agent['name']
return jsonify({
'status': 'success',
'agent_id': agent_id,
'agent_name': agent['name'],
'mode': mode,
'data': result
})
except (AgentHTTPError, AgentConnectionError) as e:
return jsonify({
'status': 'error',
'message': f'Agent error: {e}'
}), 502
# =============================================================================
# Push Data Ingestion
# =============================================================================
@controller_bp.route('/api/ingest', methods=['POST'])
def ingest_push_data():
"""
Receive pushed data from remote agents.
Expected JSON body:
{
"agent_name": "sensor-node-1",
"scan_type": "adsb",
"interface": "rtlsdr0",
"payload": {...},
"received_at": "2024-01-15T10:30:00Z"
}
Expected header:
X-API-Key: shared-secret (if agent has api_key configured)
"""
data = request.json
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
agent_name = data.get('agent_name')
if not agent_name:
return jsonify({'status': 'error', 'message': 'agent_name required'}), 400
# Find agent
agent = get_agent_by_name(agent_name)
if not agent:
return jsonify({'status': 'error', 'message': 'Unknown agent'}), 401
# Validate API key if configured
if agent.get('api_key'):
provided_key = request.headers.get('X-API-Key', '')
if provided_key != agent['api_key']:
logger.warning(f"Invalid API key from agent {agent_name}")
return jsonify({'status': 'error', 'message': 'Invalid API key'}), 401
# Store payload
try:
payload_id = store_push_payload(
agent_id=agent['id'],
scan_type=data.get('scan_type', 'unknown'),
payload=data.get('payload', {}),
interface=data.get('interface'),
received_at=data.get('received_at')
)
# Emit to SSE stream
try:
agent_data_queue.put_nowait({
'type': 'agent_data',
'agent_id': agent['id'],
'agent_name': agent_name,
'scan_type': data.get('scan_type'),
'interface': data.get('interface'),
'payload': data.get('payload'),
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
})
except queue.Full:
logger.warning("Agent data queue full, data may be lost")
return jsonify({
'status': 'accepted',
'payload_id': payload_id
}), 202
except Exception as e:
logger.exception("Failed to store push payload")
return jsonify({'status': 'error', 'message': str(e)}), 500
@controller_bp.route('/api/payloads', methods=['GET'])
def get_payloads():
"""Get recent push payloads."""
agent_id = request.args.get('agent_id', type=int)
scan_type = request.args.get('scan_type')
limit = request.args.get('limit', 100, type=int)
payloads = get_recent_payloads(
agent_id=agent_id,
scan_type=scan_type,
limit=min(limit, 1000)
)
return jsonify({
'status': 'success',
'payloads': payloads,
'count': len(payloads)
})
# =============================================================================
# Multi-Agent SSE Stream
# =============================================================================
@controller_bp.route('/stream/all')
def stream_all_agents():
"""
Combined SSE stream for data from all agents.
This endpoint streams push data as it arrives from agents.
Each message is tagged with agent_id and agent_name.
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = agent_data_queue.get(timeout=1.0)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
# =============================================================================
# Agent Management Page
# =============================================================================
@controller_bp.route('/manage')
def agent_management_page():
"""Render the agent management page."""
from flask import render_template
from config import VERSION
return render_template('agents.html', version=VERSION)
@controller_bp.route('/monitor')
def network_monitor_page():
"""Render the network monitor page for multi-agent aggregated view."""
from flask import render_template
return render_template('network_monitor.html')
# =============================================================================
# Device Location Estimation (Trilateration)
# =============================================================================
# Global device location tracker
device_tracker = DeviceLocationTracker(
trilateration=Trilateration(
path_loss_model=PathLossModel('outdoor'),
min_observations=2
),
observation_window_seconds=120.0, # 2 minute window
min_observations=2
)
@controller_bp.route('/api/location/observe', methods=['POST'])
def add_location_observation():
"""
Add an observation for device location estimation.
Expected JSON body:
{
"device_id": "AA:BB:CC:DD:EE:FF",
"agent_name": "sensor-node-1",
"agent_lat": 40.7128,
"agent_lon": -74.0060,
"rssi": -55,
"frequency_mhz": 2400 (optional)
}
Returns location estimate if enough data, null otherwise.
"""
data = request.json or {}
required = ['device_id', 'agent_name', 'agent_lat', 'agent_lon', 'rssi']
for field in required:
if field not in data:
return jsonify({'status': 'error', 'message': f'Missing required field: {field}'}), 400
# Look up agent GPS from database if not provided
agent_lat = data.get('agent_lat')
agent_lon = data.get('agent_lon')
if agent_lat is None or agent_lon is None:
agent = get_agent_by_name(data['agent_name'])
if agent and agent.get('gps_coords'):
coords = agent['gps_coords']
agent_lat = coords.get('lat') or coords.get('latitude')
agent_lon = coords.get('lon') or coords.get('longitude')
if agent_lat is None or agent_lon is None:
return jsonify({
'status': 'error',
'message': 'Agent GPS coordinates required'
}), 400
estimate = device_tracker.add_observation(
device_id=data['device_id'],
agent_name=data['agent_name'],
agent_lat=float(agent_lat),
agent_lon=float(agent_lon),
rssi=float(data['rssi']),
frequency_mhz=data.get('frequency_mhz')
)
return jsonify({
'status': 'success',
'device_id': data['device_id'],
'location': estimate.to_dict() if estimate else None
})
@controller_bp.route('/api/location/estimate', methods=['POST'])
def estimate_location():
"""
Estimate device location from provided observations.
Expected JSON body:
{
"observations": [
{"agent_lat": 40.7128, "agent_lon": -74.0060, "rssi": -55, "agent_name": "node-1"},
{"agent_lat": 40.7135, "agent_lon": -74.0055, "rssi": -70, "agent_name": "node-2"},
{"agent_lat": 40.7120, "agent_lon": -74.0050, "rssi": -62, "agent_name": "node-3"}
],
"environment": "outdoor" (optional: outdoor, indoor, free_space)
}
"""
data = request.json or {}
observations = data.get('observations', [])
if len(observations) < 2:
return jsonify({
'status': 'error',
'message': 'At least 2 observations required'
}), 400
environment = data.get('environment', 'outdoor')
try:
result = estimate_location_from_observations(observations, environment)
return jsonify({
'status': 'success' if result else 'insufficient_data',
'location': result
})
except Exception as e:
logger.exception("Location estimation failed")
return jsonify({'status': 'error', 'message': str(e)}), 500
@controller_bp.route('/api/location/<device_id>', methods=['GET'])
def get_device_location(device_id: str):
"""Get the latest location estimate for a device."""
estimate = device_tracker.get_location(device_id)
if not estimate:
return jsonify({
'status': 'not_found',
'device_id': device_id,
'location': None
})
return jsonify({
'status': 'success',
'device_id': device_id,
'location': estimate.to_dict()
})
@controller_bp.route('/api/location/all', methods=['GET'])
def get_all_locations():
"""Get all current device location estimates."""
locations = device_tracker.get_all_locations()
return jsonify({
'status': 'success',
'count': len(locations),
'devices': {
device_id: estimate.to_dict()
for device_id, estimate in locations.items()
}
})
@controller_bp.route('/api/location/near', methods=['GET'])
def get_devices_near():
"""
Find devices near a location.
Query params:
lat: latitude
lon: longitude
radius: radius in meters (default 100)
"""
try:
lat = float(request.args.get('lat', 0))
lon = float(request.args.get('lon', 0))
radius = float(request.args.get('radius', 100))
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
results = device_tracker.get_devices_near(lat, lon, radius)
return jsonify({
'status': 'success',
'center': {'lat': lat, 'lon': lon},
'radius_meters': radius,
'count': len(results),
'devices': [
{'device_id': device_id, 'location': estimate.to_dict()}
for device_id, estimate in results
]
})
+575
View File
@@ -0,0 +1,575 @@
"""VHF DSC (Digital Selective Calling) routes.
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
distress and safety communications per ITU-R M.493.
"""
from __future__ import annotations
import json
import logging
import os
import pty
import queue
import select
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.constants import (
DSC_VHF_FREQUENCY_MHZ,
DSC_SAMPLE_RATE,
DSC_TERMINATE_TIMEOUT,
)
from utils.database import (
store_dsc_alert,
get_dsc_alerts,
get_dsc_alert,
acknowledge_dsc_alert,
get_dsc_alert_summary,
)
from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse
from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.dsc')
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state)
dsc_running = False
def _get_dsc_decoder_path() -> str | None:
"""Get path to DSC decoder."""
# Check for our custom decoder
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
return project_bin
# Check system PATH
system_decoder = shutil.which('dsc-decoder')
if system_decoder:
return system_decoder
return None
def _check_dsc_tools() -> dict:
"""Check availability of DSC decoding tools."""
rtl_fm_path = get_tool_path('rtl_fm')
decoder_path = _get_dsc_decoder_path()
# Check for scipy/numpy (needed for decoder)
scipy_available = False
try:
import scipy
import numpy
scipy_available = True
except ImportError:
pass
return {
'rtl_fm': {
'available': rtl_fm_path is not None,
'path': rtl_fm_path
},
'dsc_decoder': {
'available': decoder_path is not None,
'path': decoder_path
},
'scipy': {
'available': scipy_available,
'note': 'Required for DSC signal processing'
},
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
}
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
"""
Stream DSC decoder output to queue using PTY for unbuffered output.
Args:
master_fd: PTY master file descriptor
decoder_process: Decoder subprocess
"""
global dsc_running
try:
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
buffer = ""
while dsc_running:
try:
ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
if ready:
try:
data = os.read(master_fd, 1024)
if not data:
break
buffer += data.decode('utf-8', errors='replace')
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
# Parse DSC message
parsed = parse_dsc_message(line)
if parsed:
# Generate unique message ID
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
parsed['id'] = msg_id
# Store in transient DataStore
app_module.dsc_messages.set(msg_id, parsed)
# Queue for SSE
try:
app_module.dsc_queue.put_nowait(parsed)
except queue.Full:
logger.warning("DSC queue full, dropping message")
# Store critical alerts permanently
if parsed.get('is_critical'):
_store_critical_alert(parsed)
else:
# Raw output for debugging
app_module.dsc_queue.put({
'type': 'raw',
'text': line
})
except OSError:
break
# Check if process is still running
if decoder_process.poll() is not None:
break
except Exception as e:
logger.error(f"DSC decoder error: {e}")
app_module.dsc_queue.put({
'type': 'error',
'error': str(e)
})
finally:
try:
os.close(master_fd)
except OSError:
pass
decoder_process.wait()
dsc_running = False
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
def _store_critical_alert(msg: dict) -> None:
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
try:
store_dsc_alert(
source_mmsi=msg.get('source_mmsi', ''),
format_code=str(msg.get('format_code', '')),
category=msg.get('category', 'UNKNOWN'),
source_name=msg.get('source_name'),
dest_mmsi=msg.get('dest_mmsi'),
nature_of_distress=msg.get('nature_of_distress'),
latitude=msg.get('latitude'),
longitude=msg.get('longitude'),
raw_message=msg.get('raw_message')
)
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
except Exception as e:
logger.error(f"Failed to store DSC alert: {e}")
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
"""Monitor rtl_fm stderr for errors."""
global dsc_running
try:
for line in process.stderr:
if not dsc_running:
break
err_text = line.decode('utf-8', errors='replace').strip()
if err_text:
logger.debug(f"[RTL_FM] {err_text}")
# Check for device busy error
if 'usb_claim_interface' in err_text.lower():
app_module.dsc_queue.put({
'type': 'error',
'error': 'SDR device busy',
'error_type': 'DEVICE_BUSY',
'suggestion': 'Use a different SDR device or stop other SDR processes'
})
# Check for other common errors
if 'no supported devices' in err_text.lower():
app_module.dsc_queue.put({
'type': 'error',
'error': 'No SDR device found',
'error_type': 'NO_DEVICE'
})
except Exception:
pass
@dsc_bp.route('/status')
def get_status() -> Response:
"""Get DSC decoder status."""
global dsc_running
with app_module.dsc_lock:
running = (
dsc_running and
app_module.dsc_process is not None and
app_module.dsc_process.poll() is None
)
# Get message counts
message_count = len(app_module.dsc_messages)
alert_summary = get_dsc_alert_summary()
return jsonify({
'running': running,
'frequency': DSC_VHF_FREQUENCY_MHZ,
'message_count': message_count,
'alerts': alert_summary
})
@dsc_bp.route('/tools')
def check_tools() -> Response:
"""Check DSC decoder tool availability."""
tools = _check_dsc_tools()
return jsonify(tools)
@dsc_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
"""Start DSC decoder."""
global dsc_running
with app_module.dsc_lock:
if app_module.dsc_process and app_module.dsc_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'DSC decoder already running'
}), 409
# Check tools
tools = _check_dsc_tools()
if not tools['ready']:
missing = []
if not tools['rtl_fm']['available']:
missing.append('rtl_fm')
if not tools['dsc_decoder']['available']:
missing.append('dsc-decoder')
if not tools['scipy']['available']:
missing.append('scipy/numpy')
return jsonify({
'status': 'error',
'message': f'Missing required tools: {", ".join(missing)}'
}), 400
data = request.json or {}
# Validate device
try:
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Validate gain
try:
gain = validate_gain(data.get('gain', '40'))
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Check if device is in use by AIS
try:
from routes import ais as ais_module
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
# AIS is running - check if same device
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': f'SDR device {device} is in use by AIS tracking',
'suggestion': 'Use a different SDR device or stop AIS tracking first',
'in_use_by': 'ais'
}), 409
except ImportError:
pass
# Clear queue
while not app_module.dsc_queue.empty():
try:
app_module.dsc_queue.get_nowait()
except queue.Empty:
break
# Build rtl_fm command
rtl_fm_path = tools['rtl_fm']['path']
decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
rtl_cmd = [
rtl_fm_path,
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
'-s', str(DSC_SAMPLE_RATE),
'-d', str(device),
'-g', str(gain),
'-M', 'fm', # FM demodulation
'-l', '0', # No squelch for DSC
'-E', 'dc' # DC blocking filter
]
# Decoder command
decoder_cmd = [decoder_path]
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
logger.info(f"Starting DSC decoder: {full_cmd}")
try:
# Start rtl_fm subprocess
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Start stderr monitor thread
stderr_thread = threading.Thread(
target=monitor_rtl_stderr,
args=(rtl_process,),
daemon=True
)
stderr_thread.start()
# Create PTY for decoder output
master_fd, slave_fd = pty.openpty()
# Start decoder subprocess
decoder_process = subprocess.Popen(
decoder_cmd,
stdin=rtl_process.stdout,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
os.close(slave_fd)
rtl_process.stdout.close()
# Store process references
app_module.dsc_process = decoder_process
app_module.dsc_rtl_process = rtl_process
dsc_running = True
# Start output streaming thread
output_thread = threading.Thread(
target=stream_dsc_decoder,
args=(master_fd, decoder_process),
daemon=True
)
output_thread.start()
return jsonify({
'status': 'started',
'frequency': DSC_VHF_FREQUENCY_MHZ,
'device': device,
'gain': gain,
'command': full_cmd
})
except FileNotFoundError as e:
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running
with app_module.dsc_lock:
if not app_module.dsc_process:
return jsonify({'status': 'not_running'})
dsc_running = False
# Terminate rtl_fm process first
if app_module.dsc_rtl_process:
try:
app_module.dsc_rtl_process.terminate()
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
try:
app_module.dsc_rtl_process.kill()
except OSError:
pass
except OSError:
pass
# Terminate decoder process
if app_module.dsc_process:
try:
app_module.dsc_process.terminate()
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
try:
app_module.dsc_process.kill()
except OSError:
pass
except OSError:
pass
app_module.dsc_process = None
app_module.dsc_rtl_process = None
return jsonify({'status': 'stopped'})
@dsc_bp.route('/stream')
def stream() -> Response:
"""SSE stream for real-time DSC messages."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@dsc_bp.route('/messages')
def get_messages() -> Response:
"""Get current DSC messages from transient store."""
messages = list(app_module.dsc_messages.values())
# Sort by timestamp (newest first)
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
return jsonify({
'count': len(messages),
'messages': messages
})
@dsc_bp.route('/alerts')
def get_alerts_endpoint() -> Response:
"""Get stored DSC alerts (paginated)."""
# Parse query params
category = request.args.get('category')
acknowledged = request.args.get('acknowledged')
limit = min(int(request.args.get('limit', 50)), 200)
offset = int(request.args.get('offset', 0))
# Convert acknowledged param
ack_filter = None
if acknowledged is not None:
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
alerts = get_dsc_alerts(
category=category,
acknowledged=ack_filter,
limit=limit,
offset=offset
)
summary = get_dsc_alert_summary()
return jsonify({
'alerts': alerts,
'count': len(alerts),
'summary': summary,
'pagination': {
'limit': limit,
'offset': offset
}
})
@dsc_bp.route('/alerts/<int:alert_id>')
def get_alert(alert_id: int) -> Response:
"""Get a specific DSC alert by ID."""
alert = get_dsc_alert(alert_id)
if not alert:
return jsonify({
'status': 'error',
'message': 'Alert not found'
}), 404
return jsonify(alert)
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
def acknowledge_alert(alert_id: int) -> Response:
"""Acknowledge a DSC alert."""
data = request.json or {}
notes = data.get('notes')
success = acknowledge_dsc_alert(alert_id, notes)
if not success:
return jsonify({
'status': 'error',
'message': 'Alert not found'
}), 404
return jsonify({
'status': 'acknowledged',
'alert_id': alert_id
})
@dsc_bp.route('/alerts/summary')
def get_alerts_summary() -> Response:
"""Get summary of unacknowledged DSC alerts."""
summary = get_dsc_alert_summary()
return jsonify(summary)
-26
View File
@@ -161,32 +161,6 @@ def get_position():
})
@gps_bp.route('/debug')
def debug_gps():
"""Debug endpoint showing GPS client state."""
reader = get_gps_reader()
if not reader:
return jsonify({
'reader': None,
'message': 'No GPS client initialized'
})
position = reader.position
return jsonify({
'running': reader.is_running,
'source': 'gpsd',
'device': reader.device_path,
'host': reader.host,
'port': reader.port,
'has_position': position is not None,
'position': position.to_dict() if position else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error,
'callbacks_registered': len(reader._callbacks),
})
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position updates."""
+35 -12
View File
@@ -398,6 +398,8 @@ def _start_audio_stream(frequency: float, modulation: str):
]
if scanner_config.get('bias_t', False):
sdr_cmd.append('-T')
# Explicitly output to stdout (some rtl_fm versions need this)
sdr_cmd.append('-')
else:
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
rx_fm_path = find_rx_fm()
@@ -438,9 +440,12 @@ def _start_audio_stream(frequency: float, modulation: str):
]
try:
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
logger.info(f"Starting audio pipeline: {shell_cmd}")
# Use shell pipe for reliable streaming
# Log stderr to temp files for error diagnosis
rtl_stderr_log = '/tmp/rtl_fm_stderr.log'
ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log'
shell_cmd = f"{' '.join(sdr_cmd)} 2>{rtl_stderr_log} | {' '.join(encoder_cmd)} 2>{ffmpeg_stderr_log}"
logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}")
audio_rtl_process = None # Not used in shell mode
audio_process = subprocess.Popen(
@@ -456,8 +461,20 @@ def _start_audio_stream(frequency: float, modulation: str):
time.sleep(0.3)
if audio_process.poll() is not None:
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
logger.error(f"Audio pipeline exited immediately: {stderr}")
# Read stderr from temp files
rtl_stderr = ''
ffmpeg_stderr = ''
try:
with open(rtl_stderr_log, 'r') as f:
rtl_stderr = f.read().strip()
except:
pass
try:
with open(ffmpeg_stderr_log, 'r') as f:
ffmpeg_stderr = f.read().strip()
except:
pass
logger.error(f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}")
return
audio_running = True
@@ -775,8 +792,6 @@ def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
global scanner_running
logger.info("Audio start request received")
# Stop scanner if running
if scanner_running:
scanner_running = False
@@ -868,20 +883,28 @@ def stream_audio() -> Response:
return Response(b'', mimetype='audio/mpeg', status=204)
def generate():
# Capture local reference to avoid race condition with stop
proc = audio_process
if not proc or not proc.stdout:
return
try:
while audio_running and audio_process and audio_process.poll() is None:
while audio_running and proc.poll() is None:
# Use select to avoid blocking forever
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
if ready:
chunk = audio_process.stdout.read(4096)
chunk = proc.stdout.read(4096)
if chunk:
yield chunk
else:
break
else:
# Timeout - check if process died
if proc.poll() is not None:
break
except GeneratorExit:
pass
except:
pass
except Exception as e:
logger.error(f"Audio stream error: {e}")
return Response(
generate(),
+491
View File
@@ -0,0 +1,491 @@
"""Meshtastic mesh network routes.
Provides endpoints for connecting to Meshtastic devices, configuring
channels with encryption keys, and streaming received messages.
Requires a physical Meshtastic device (Heltec, T-Beam, RAK, etc.)
connected via USB/Serial.
"""
from __future__ import annotations
import queue
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.sse import format_sse
from utils.meshtastic import (
get_meshtastic_client,
start_meshtastic,
stop_meshtastic,
is_meshtastic_available,
MeshtasticMessage,
)
logger = get_logger('intercept.meshtastic')
meshtastic_bp = Blueprint('meshtastic', __name__, url_prefix='/meshtastic')
# Queue for SSE message streaming
_mesh_queue: queue.Queue = queue.Queue(maxsize=500)
# Store recent messages for history
_recent_messages: list[dict] = []
MAX_HISTORY = 500
def _message_callback(msg: MeshtasticMessage) -> None:
"""Callback to queue messages for SSE stream."""
msg_dict = msg.to_dict()
# Add to history
_recent_messages.append(msg_dict)
if len(_recent_messages) > MAX_HISTORY:
_recent_messages.pop(0)
# Queue for SSE
try:
_mesh_queue.put_nowait(msg_dict)
except queue.Full:
try:
_mesh_queue.get_nowait()
_mesh_queue.put_nowait(msg_dict)
except queue.Empty:
pass
@meshtastic_bp.route('/status')
def get_status():
"""
Get Meshtastic connection status.
Returns:
JSON with connection status, device info, and node information.
"""
if not is_meshtastic_available():
return jsonify({
'available': False,
'running': False,
'error': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
})
client = get_meshtastic_client()
if not client:
return jsonify({
'available': True,
'running': False,
'device': None,
'node_info': None,
})
node_info = client.get_node_info() if client.is_running else None
return jsonify({
'available': True,
'running': client.is_running,
'device': client.device_path,
'error': client.error,
'node_info': node_info.to_dict() if node_info else None,
})
@meshtastic_bp.route('/start', methods=['POST'])
def start_mesh():
"""
Start Meshtastic listener.
Connects to a Meshtastic device and begins receiving messages.
The device must be connected via USB/Serial.
JSON body (optional):
{
"device": "/dev/ttyUSB0" // Serial port path. Auto-discovers if not provided.
}
Returns:
JSON with connection status.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'message': 'Meshtastic SDK not installed. Install with: pip install meshtastic'
}), 400
client = get_meshtastic_client()
if client and client.is_running:
return jsonify({
'status': 'already_running',
'device': client.device_path
})
# Clear queue and history
while not _mesh_queue.empty():
try:
_mesh_queue.get_nowait()
except queue.Empty:
break
_recent_messages.clear()
# Get optional device path
data = request.get_json(silent=True) or {}
device = data.get('device')
# Validate device path if provided
if device:
device = str(device).strip()
if not device:
device = None
# Start client
success = start_meshtastic(device=device, callback=_message_callback)
if success:
client = get_meshtastic_client()
node_info = client.get_node_info() if client else None
return jsonify({
'status': 'started',
'device': client.device_path if client else None,
'node_info': node_info.to_dict() if node_info else None,
})
else:
client = get_meshtastic_client()
return jsonify({
'status': 'error',
'message': client.error if client else 'Failed to connect to Meshtastic device'
}), 500
@meshtastic_bp.route('/stop', methods=['POST'])
def stop_mesh():
"""
Stop Meshtastic listener.
Disconnects from the Meshtastic device and stops receiving messages.
Returns:
JSON confirmation.
"""
stop_meshtastic()
return jsonify({'status': 'stopped'})
@meshtastic_bp.route('/channels')
def get_channels():
"""
Get configured channels on the connected device.
Returns:
JSON with list of channel configurations.
Note: PSK values are not returned for security - only encryption status.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
channels = client.get_channels()
return jsonify({
'status': 'ok',
'channels': [ch.to_dict() for ch in channels],
'count': len(channels)
})
@meshtastic_bp.route('/channels/<int:index>', methods=['POST'])
def configure_channel(index: int):
"""
Configure a channel with name and/or encryption key.
This allows joining encrypted channels by providing the PSK.
The configuration is written to the connected Meshtastic device.
Args:
index: Channel index (0-7). Channel 0 is typically the primary channel.
JSON body:
{
"name": "MyChannel", // Optional: Channel name
"psk": "base64:ABC123..." // Optional: Encryption key
}
PSK formats:
- "none" : Disable encryption
- "default" : Use default public key (NOT SECURE - known key)
- "random" : Generate new random AES-256 key
- "base64:..." : Base64-encoded 16-byte (AES-128) or 32-byte (AES-256) key
- "0x..." : Hex-encoded key
- "simple:passphrase" : Derive AES-256 key from passphrase using SHA-256
Returns:
JSON with configuration result.
Security note:
The "default" key is publicly known (shipped in source code).
Use "random" or provide your own key for secure communications.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
if not 0 <= index <= 7:
return jsonify({
'status': 'error',
'message': 'Channel index must be 0-7'
}), 400
data = request.get_json(silent=True) or {}
name = data.get('name')
psk = data.get('psk')
if not name and not psk:
return jsonify({
'status': 'error',
'message': 'Must provide name and/or psk'
}), 400
# Sanitize name if provided
if name:
name = str(name).strip()[:12] # Meshtastic channel names max 12 chars
# Validate PSK format if provided
if psk:
psk = str(psk).strip()
success, message = client.set_channel(index, name=name, psk=psk)
if success:
# Return updated channel info
channels = client.get_channels()
updated = next((ch for ch in channels if ch.index == index), None)
return jsonify({
'status': 'ok',
'message': message,
'channel': updated.to_dict() if updated else None
})
else:
return jsonify({
'status': 'error',
'message': message
}), 500
@meshtastic_bp.route('/send', methods=['POST'])
def send_message():
"""
Send a text message to the mesh network.
JSON body:
{
"text": "Hello mesh!", // Required: message text (max 237 chars)
"channel": 0, // Optional: channel index (default 0)
"to": "!a1b2c3d4" // Optional: destination node (default broadcast)
}
Returns:
JSON with send status.
"""
if not is_meshtastic_available():
return jsonify({
'status': 'error',
'message': 'Meshtastic SDK not installed'
}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
data = request.get_json(silent=True) or {}
text = data.get('text', '').strip()
if not text:
return jsonify({
'status': 'error',
'message': 'Message text is required'
}), 400
if len(text) > 237:
return jsonify({
'status': 'error',
'message': 'Message too long (max 237 characters)'
}), 400
channel = data.get('channel', 0)
if not isinstance(channel, int) or not 0 <= channel <= 7:
return jsonify({
'status': 'error',
'message': 'Channel must be 0-7'
}), 400
destination = data.get('to')
logger.info(f"Sending message: text='{text[:50]}...', channel={channel}, to={destination}")
success, error = client.send_text(text, channel=channel, destination=destination)
logger.info(f"Send result: success={success}, error={error}")
if success:
return jsonify({'status': 'sent'})
else:
return jsonify({
'status': 'error',
'message': error or 'Failed to send message'
}), 500
@meshtastic_bp.route('/messages')
def get_messages():
"""
Get recent message history.
Returns the most recent messages received since the listener was started.
Limited to the last 500 messages.
Query parameters:
limit: Maximum number of messages to return (default: all)
channel: Filter by channel index (optional)
Returns:
JSON with message list.
"""
limit = request.args.get('limit', type=int)
channel = request.args.get('channel', type=int)
messages = _recent_messages.copy()
# Filter by channel if specified
if channel is not None:
messages = [m for m in messages if m.get('channel') == channel]
# Apply limit
if limit and limit > 0:
messages = messages[-limit:]
return jsonify({
'status': 'ok',
'messages': messages,
'count': len(messages)
})
@meshtastic_bp.route('/stream')
def stream_messages():
"""
SSE stream of Meshtastic messages.
Provides real-time Server-Sent Events stream of incoming messages.
Connect to this endpoint with EventSource to receive live updates.
Event format:
data: {"type": "meshtastic", "from": "!a1b2c3d4", "message": "Hello", ...}
Keepalive events are sent every 30 seconds to maintain the connection.
Returns:
SSE stream (text/event-stream)
"""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = _mesh_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@meshtastic_bp.route('/node')
def get_node():
"""
Get local node information.
Returns information about the connected Meshtastic device including
its ID, name, hardware model, and current position (if available).
Returns:
JSON with node information.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device'
}), 400
node_info = client.get_node_info()
if node_info:
return jsonify({
'status': 'ok',
'node': node_info.to_dict()
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to get node information'
}), 500
@meshtastic_bp.route('/nodes')
def get_nodes():
"""
Get all tracked mesh nodes with their positions.
Returns all nodes that have been seen on the mesh network,
including their positions (if reported), battery levels, and signal info.
Query parameters:
with_position: If 'true', only return nodes with valid positions
Returns:
JSON with list of nodes.
"""
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({
'status': 'error',
'message': 'Not connected to Meshtastic device',
'nodes': []
}), 400
nodes = client.get_nodes()
nodes_list = [n.to_dict() for n in nodes]
# Filter to only nodes with positions if requested
with_position = request.args.get('with_position', '').lower() == 'true'
if with_position:
nodes_list = [n for n in nodes_list if n.get('has_position')]
return jsonify({
'status': 'ok',
'nodes': nodes_list,
'count': len(nodes_list),
'with_position_count': sum(1 for n in nodes_list if n.get('has_position'))
})
+163
View File
@@ -0,0 +1,163 @@
"""
Offline mode routes - Asset management and settings for offline operation.
"""
from flask import Blueprint, jsonify, request
from utils.database import get_setting, set_setting
import os
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
# Default offline settings
OFFLINE_DEFAULTS = {
'offline.enabled': False,
'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn',
'offline.tile_provider': 'openstreetmap',
'offline.tile_server_url': ''
}
# Asset paths to check
ASSET_PATHS = {
'leaflet': [
'static/vendor/leaflet/leaflet.js',
'static/vendor/leaflet/leaflet.css'
],
'chartjs': [
'static/vendor/chartjs/chart.umd.min.js'
],
'inter': [
'static/vendor/fonts/Inter-Regular.woff2',
'static/vendor/fonts/Inter-Medium.woff2',
'static/vendor/fonts/Inter-SemiBold.woff2',
'static/vendor/fonts/Inter-Bold.woff2'
],
'jetbrains': [
'static/vendor/fonts/JetBrainsMono-Regular.woff2',
'static/vendor/fonts/JetBrainsMono-Medium.woff2',
'static/vendor/fonts/JetBrainsMono-SemiBold.woff2',
'static/vendor/fonts/JetBrainsMono-Bold.woff2'
],
'leaflet_images': [
'static/vendor/leaflet/images/marker-icon.png',
'static/vendor/leaflet/images/marker-icon-2x.png',
'static/vendor/leaflet/images/marker-shadow.png',
'static/vendor/leaflet/images/layers.png',
'static/vendor/leaflet/images/layers-2x.png'
]
}
def get_offline_settings():
"""Get all offline settings with defaults."""
settings = {}
for key, default in OFFLINE_DEFAULTS.items():
settings[key] = get_setting(key, default)
return settings
@offline_bp.route('/settings', methods=['GET'])
def get_settings():
"""Get current offline settings."""
settings = get_offline_settings()
return jsonify({
'status': 'success',
'settings': settings
})
@offline_bp.route('/settings', methods=['POST'])
def save_setting():
"""Save an offline setting."""
data = request.get_json()
if not data or 'key' not in data or 'value' not in data:
return jsonify({'status': 'error', 'message': 'Missing key or value'}), 400
key = data['key']
value = data['value']
# Validate key is an allowed setting
if key not in OFFLINE_DEFAULTS:
return jsonify({'status': 'error', 'message': f'Unknown setting: {key}'}), 400
# Validate value type matches default
default_type = type(OFFLINE_DEFAULTS[key])
if not isinstance(value, default_type):
# Try to convert
try:
if default_type == bool:
value = str(value).lower() in ('true', '1', 'yes')
else:
value = default_type(value)
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': f'Invalid value type for {key}'
}), 400
set_setting(key, value)
return jsonify({
'status': 'success',
'key': key,
'value': value
})
@offline_bp.route('/status', methods=['GET'])
def get_status():
"""Check status of local assets."""
# Get the app root directory
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
results = {}
all_available = True
for asset_name, paths in ASSET_PATHS.items():
available = True
missing = []
for path in paths:
full_path = os.path.join(app_root, path)
if not os.path.exists(full_path):
available = False
missing.append(path)
results[asset_name] = {
'available': available,
'missing': missing if not available else []
}
if not available:
all_available = False
return jsonify({
'status': 'success',
'all_available': all_available,
'assets': results,
'offline_enabled': get_setting('offline.enabled', False)
})
@offline_bp.route('/check-asset', methods=['GET'])
def check_asset():
"""Check if a specific asset file exists."""
path = request.args.get('path', '')
if not path:
return jsonify({'status': 'error', 'message': 'Missing path parameter'}), 400
# Security: only allow checking within static/vendor
if not path.startswith('/static/vendor/'):
return jsonify({'status': 'error', 'message': 'Invalid path'}), 400
# Remove leading slash and construct full path
relative_path = path.lstrip('/')
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
full_path = os.path.join(app_root, relative_path)
exists = os.path.exists(full_path)
return jsonify({
'status': 'success',
'path': path,
'exists': exists
})
+5 -1
View File
@@ -25,6 +25,7 @@ from utils.validation import (
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
from utils.sdr import SDRFactory, SDRType, SDRValidationError
from utils.dependencies import get_tool_path
pager_bp = Blueprint('pager', __name__)
@@ -245,7 +246,10 @@ def start_decoding() -> Response:
bias_t=bias_t
)
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
multimon_path = get_tool_path('multimon-ng')
if not multimon_path:
return jsonify({'status': 'error', 'message': 'multimon-ng not found'}), 400
multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
logger.info(f"Running: {full_cmd}")
+250
View File
@@ -0,0 +1,250 @@
"""RTLAMR utility meter monitoring routes."""
from __future__ import annotations
import json
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.logging import sensor_logger as logger
from utils.validation import (
validate_frequency, validate_device_index, validate_gain, validate_ppm
)
from utils.sse import format_sse
from utils.process import safe_terminate, register_process
rtlamr_bp = Blueprint('rtlamr', __name__)
# Store rtl_tcp process separately
rtl_tcp_process = None
rtl_tcp_lock = threading.Lock()
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtlamr JSON output to queue."""
try:
app_module.rtlamr_queue.put({'type': 'status', 'text': 'started'})
for line in iter(process.stdout.readline, b''):
line = line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
# rtlamr outputs JSON objects, one per line
data = json.loads(line)
data['type'] = 'rtlamr'
app_module.rtlamr_queue.put(data)
# Log if enabled
if app_module.logging_enabled:
try:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | RTLAMR | {json.dumps(data)}\n")
except Exception:
pass
except json.JSONDecodeError:
# Not JSON, send as raw
app_module.rtlamr_queue.put({'type': 'raw', 'text': line})
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
process.wait()
app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'})
with app_module.rtlamr_lock:
app_module.rtlamr_process = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response:
global rtl_tcp_process
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
data = request.json or {}
# Validate inputs
try:
freq = validate_frequency(data.get('frequency', '912.0'))
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Clear queue
while not app_module.rtlamr_queue.empty():
try:
app_module.rtlamr_queue.get_nowait()
except queue.Empty:
break
# Get message type (default to scm)
msgtype = data.get('msgtype', 'scm')
output_format = data.get('format', 'json')
# Start rtl_tcp first
with rtl_tcp_lock:
if not rtl_tcp_process:
logger.info("Starting rtl_tcp server...")
try:
rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0']
# Add device index if not 0
if device and device != '0':
rtl_tcp_cmd.extend(['-d', str(device)])
# Add gain if not auto
if gain and gain != '0':
rtl_tcp_cmd.extend(['-g', str(gain)])
# Add PPM correction if not 0
if ppm and ppm != '0':
rtl_tcp_cmd.extend(['-p', str(ppm)])
rtl_tcp_process = subprocess.Popen(
rtl_tcp_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Wait a moment for rtl_tcp to start
time.sleep(3)
logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'})
except Exception as e:
logger.error(f"Failed to start rtl_tcp: {e}")
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
# Build rtlamr command
cmd = [
'rtlamr',
'-server=127.0.0.1:1234',
f'-msgtype={msgtype}',
f'-format={output_format}',
f'-centerfreq={int(float(freq) * 1e6)}'
]
# Add filter options if provided
filterid = data.get('filterid')
if filterid:
cmd.append(f'-filterid={filterid}')
filtertype = data.get('filtertype')
if filtertype:
cmd.append(f'-filtertype={filtertype}')
# Unique messages only
if data.get('unique', True):
cmd.append('-unique=true')
full_cmd = ' '.join(cmd)
logger.info(f"Running: {full_cmd}")
try:
app_module.rtlamr_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Start output thread
thread = threading.Thread(target=stream_rtlamr_output, args=(app_module.rtlamr_process,))
thread.daemon = True
thread.start()
# Monitor stderr
def monitor_stderr():
for line in app_module.rtlamr_process.stderr:
err = line.decode('utf-8', errors='replace').strip()
if err:
logger.debug(f"[rtlamr] {err}")
app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'})
stderr_thread = threading.Thread(target=monitor_stderr)
stderr_thread.daemon = True
stderr_thread.start()
app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
return jsonify({'status': 'started', 'command': full_cmd})
except FileNotFoundError:
# If rtlamr fails, clean up rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e:
# If rtlamr fails, clean up rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
global rtl_tcp_process
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
app_module.rtlamr_process.terminate()
try:
app_module.rtlamr_process.wait(timeout=2)
except subprocess.TimeoutExpired:
app_module.rtlamr_process.kill()
app_module.rtlamr_process = None
# Also stop rtl_tcp
with rtl_tcp_lock:
if rtl_tcp_process:
rtl_tcp_process.terminate()
try:
rtl_tcp_process.wait(timeout=2)
except subprocess.TimeoutExpired:
rtl_tcp_process.kill()
rtl_tcp_process = None
logger.info("rtl_tcp stopped")
return jsonify({'status': 'stopped'})
@rtlamr_bp.route('/stream_rtlamr')
def stream_rtlamr() -> Response:
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.rtlamr_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
+124
View File
@@ -2,6 +2,10 @@
from __future__ import annotations
import os
import subprocess
import sys
from flask import Blueprint, jsonify, request, Response
from utils.database import (
@@ -164,3 +168,123 @@ def get_device_correlations() -> Response:
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# RTL-SDR DVB Driver Management
# =============================================================================
DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t']
BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf'
@settings_bp.route('/rtlsdr/driver-status', methods=['GET'])
def check_dvb_driver_status() -> Response:
"""Check if DVB kernel drivers are loaded and blocking RTL-SDR devices."""
if sys.platform != 'linux':
return jsonify({
'status': 'success',
'platform': sys.platform,
'issue_detected': False,
'message': 'DVB driver conflict only affects Linux systems'
})
# Check which DVB modules are currently loaded
loaded_modules = []
try:
result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5)
lsmod_output = result.stdout
for mod in DVB_MODULES:
if mod in lsmod_output:
loaded_modules.append(mod)
except Exception as e:
logger.warning(f"Could not check loaded modules: {e}")
# Check if blacklist file exists
blacklist_exists = os.path.exists(BLACKLIST_FILE)
# Check blacklist file contents
blacklist_contents = []
if blacklist_exists:
try:
with open(BLACKLIST_FILE, 'r') as f:
blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')]
except Exception:
pass
issue_detected = len(loaded_modules) > 0
return jsonify({
'status': 'success',
'platform': 'linux',
'issue_detected': issue_detected,
'loaded_modules': loaded_modules,
'blacklist_file_exists': blacklist_exists,
'blacklist_contents': blacklist_contents,
'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected'
})
@settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST'])
def blacklist_dvb_drivers() -> Response:
"""Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices."""
if sys.platform != 'linux':
return jsonify({
'status': 'error',
'message': 'This feature is only available on Linux'
}), 400
# Check if we have permission (need to be running as root or with sudo)
if os.geteuid() != 0:
return jsonify({
'status': 'error',
'message': 'Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t'
}), 403
errors = []
successes = []
# Create blacklist file if it doesn't exist
if not os.path.exists(BLACKLIST_FILE):
try:
blacklist_content = """# RTL-SDR blacklist - prevents DVB drivers from claiming RTL-SDR devices
# Created by INTERCEPT
blacklist dvb_usb_rtl28xxu
blacklist rtl2832
blacklist rtl2830
blacklist r820t
"""
with open(BLACKLIST_FILE, 'w') as f:
f.write(blacklist_content)
successes.append(f'Created {BLACKLIST_FILE}')
except Exception as e:
errors.append(f'Failed to create blacklist file: {e}')
# Unload the modules
for mod in DVB_MODULES:
try:
result = subprocess.run(
['modprobe', '-r', mod],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
successes.append(f'Unloaded module: {mod}')
# returncode != 0 is OK - module might not be loaded
except Exception as e:
logger.warning(f"Could not unload {mod}: {e}")
if errors:
return jsonify({
'status': 'partial',
'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.',
'successes': successes,
'errors': errors
})
return jsonify({
'status': 'success',
'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.',
'successes': successes
})
+625
View File
@@ -0,0 +1,625 @@
"""Spy Stations routes - Number stations and diplomatic HF networks."""
from flask import Blueprint, jsonify, request
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
# Active spy stations data from priyom.org
STATIONS = [
# Number Stations (Intelligence)
{
"id": "e06",
"name": "E06",
"nickname": "English Man",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
"operator": "Russian 6",
"schedule": "Weekdays, 2 transmissions 1 hour apart",
"source_url": "https://priyom.org/number-stations/english/e06"
},
{
"id": "s06",
"name": "S06",
"nickname": "Russian Man",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
"operator": "Russian 6",
"schedule": "Same schedule as E06, alternating languages",
"source_url": "https://priyom.org/number-stations/russian/s06"
},
{
"id": "uvb76",
"name": "UVB-76",
"nickname": "The Buzzer",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4625, "primary": True},
{"freq_khz": 5779, "primary": False},
{"freq_khz": 6810, "primary": False},
{"freq_khz": 7490, "primary": False},
],
"mode": "USB",
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
},
{
"id": "hm01",
"name": "HM01",
"nickname": "Cuban Numbers",
"type": "number",
"country": "Cuba",
"country_code": "CU",
"frequencies": [
{"freq_khz": 9065, "primary": True},
{"freq_khz": 9155, "primary": False},
{"freq_khz": 9240, "primary": False},
{"freq_khz": 9330, "primary": False},
{"freq_khz": 10345, "primary": False},
{"freq_khz": 10715, "primary": False},
{"freq_khz": 10860, "primary": False},
{"freq_khz": 11435, "primary": False},
{"freq_khz": 11462, "primary": False},
{"freq_khz": 11530, "primary": False},
{"freq_khz": 11635, "primary": False},
{"freq_khz": 12180, "primary": False},
{"freq_khz": 13435, "primary": False},
{"freq_khz": 14375, "primary": False},
{"freq_khz": 16180, "primary": False},
{"freq_khz": 17480, "primary": False},
],
"mode": "AM/OFDM",
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
"operator": "DGI (Cuban Intelligence)",
"schedule": "Multiple daily transmissions",
"source_url": "https://priyom.org/number-stations/cuba/hm01"
},
{
"id": "e07",
"name": "E07",
"nickname": "7-dash",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 5292, "primary": True},
{"freq_khz": 6388, "primary": False},
{"freq_khz": 7482, "primary": False},
{"freq_khz": 8576, "primary": False},
],
"mode": "USB",
"description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.",
"operator": "Russian 7",
"schedule": "Irregular, typically evenings UTC",
"source_url": "https://priyom.org/number-stations/english/e07"
},
{
"id": "e11",
"name": "E11",
"nickname": "Mazielka",
"type": "number",
"country": "Poland",
"country_code": "PL",
"frequencies": [
{"freq_khz": 4030, "primary": True},
{"freq_khz": 5240, "primary": False},
{"freq_khz": 6910, "primary": False},
],
"mode": "USB",
"description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.",
"operator": "ABW (Polish Intelligence)",
"schedule": "Weekly transmissions",
"source_url": "https://priyom.org/number-stations/english/e11"
},
{
"id": "e17z",
"name": "E17z",
"nickname": "Israeli Numbers",
"type": "number",
"country": "Israel",
"country_code": "IL",
"frequencies": [
{"freq_khz": 4779, "primary": True},
{"freq_khz": 5091, "primary": False},
{"freq_khz": 6446, "primary": False},
],
"mode": "USB",
"description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.",
"operator": "Mossad (suspected)",
"schedule": "Irregular schedule",
"source_url": "https://priyom.org/number-stations/english/e17z"
},
{
"id": "g06",
"name": "G06",
"nickname": "Russian German",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.",
"operator": "Russian 6",
"schedule": "Same schedule as E06",
"source_url": "https://priyom.org/number-stations/german/g06"
},
{
"id": "v02a",
"name": "V02a",
"nickname": "Cuban Spy Numbers",
"type": "number",
"country": "Cuba",
"country_code": "CU",
"frequencies": [
{"freq_khz": 5855, "primary": True},
{"freq_khz": 9330, "primary": False},
{"freq_khz": 11635, "primary": False},
],
"mode": "AM",
"description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.",
"operator": "DGI (Cuban Intelligence)",
"schedule": "Evening transmissions, weekdays",
"source_url": "https://priyom.org/number-stations/spanish/v02a"
},
{
"id": "v07",
"name": "V07",
"nickname": "Russian 7 Voice",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 3756, "primary": True},
{"freq_khz": 4625, "primary": False},
],
"mode": "USB",
"description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.",
"operator": "Russian 7",
"schedule": "Irregular transmissions",
"source_url": "https://priyom.org/number-stations/russian/v07"
},
{
"id": "s11a",
"name": "S11a",
"nickname": "Russian Phonetic",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4560, "primary": True},
{"freq_khz": 5200, "primary": False},
],
"mode": "USB",
"description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).",
"operator": "GRU (suspected)",
"schedule": "Weekly scheduled transmissions",
"source_url": "https://priyom.org/number-stations/russian/s11a"
},
{
"id": "v13",
"name": "V13",
"nickname": "The Pip",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 3756, "primary": True},
{"freq_khz": 5448, "primary": False},
],
"mode": "USB",
"description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/military-stations/russia/the-pip"
},
{
"id": "v24",
"name": "V24",
"nickname": "Air Horn",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 3243, "primary": True},
],
"mode": "USB",
"description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/military-stations/russia/the-air-horn"
},
{
"id": "vc01",
"name": "VC01",
"nickname": "Chinese Robot",
"type": "number",
"country": "China",
"country_code": "CN",
"frequencies": [
{"freq_khz": 8300, "primary": True},
{"freq_khz": 9725, "primary": False},
{"freq_khz": 11430, "primary": False},
{"freq_khz": 13750, "primary": False},
],
"mode": "AM",
"description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.",
"operator": "MSS (Chinese Intelligence)",
"schedule": "Daily transmissions",
"source_url": "https://priyom.org/number-stations/chinese/vc01"
},
{
"id": "v22",
"name": "V22",
"nickname": "Chinese Lady",
"type": "number",
"country": "China",
"country_code": "CN",
"frequencies": [
{"freq_khz": 7883, "primary": True},
{"freq_khz": 9170, "primary": False},
],
"mode": "AM",
"description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.",
"operator": "MSS (Chinese Intelligence)",
"schedule": "Evening transmissions UTC",
"source_url": "https://priyom.org/number-stations/chinese/v22"
},
# Diplomatic Stations
{
"id": "bulgaria_mfa",
"name": "Bulgaria MFA",
"nickname": "Sofia Diplomatic",
"type": "diplomatic",
"country": "Bulgaria",
"country_code": "BG",
"frequencies": [
{"freq_khz": 5145, "primary": True},
{"freq_khz": 6755, "primary": False},
{"freq_khz": 7670, "primary": False},
{"freq_khz": 9155, "primary": False},
{"freq_khz": 10175, "primary": False},
{"freq_khz": 11445, "primary": False},
{"freq_khz": 14725, "primary": False},
{"freq_khz": 18520, "primary": False},
],
"mode": "RFSM-8000/MIL-STD-188-110",
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
"operator": "Bulgarian MFA",
"schedule": "Daily scheduled transmissions",
"source_url": "https://priyom.org/diplomatic/bulgaria"
},
{
"id": "czechia_mfa",
"name": "Czechia MFA",
"nickname": "Czech Diplomatic",
"type": "diplomatic",
"country": "Czechia",
"country_code": "CZ",
"frequencies": [
{"freq_khz": 6830, "primary": True},
{"freq_khz": 8130, "primary": False},
{"freq_khz": 10232, "primary": False},
{"freq_khz": 13890, "primary": False},
],
"mode": "PACTOR-III",
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
"operator": "Czech MFA / MoD",
"schedule": "Regular scheduled traffic",
"source_url": "https://priyom.org/diplomatic/czechia"
},
{
"id": "egypt_mfa",
"name": "Egypt MFA",
"nickname": "Egyptian Diplomatic",
"type": "diplomatic",
"country": "Egypt",
"country_code": "EG",
"frequencies": [
{"freq_khz": 7830, "primary": True},
{"freq_khz": 9048, "primary": False},
{"freq_khz": 10780, "primary": False},
{"freq_khz": 13950, "primary": False},
],
"mode": "SITOR/Codan 3012",
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
"operator": "Egyptian MFA",
"schedule": "Daily traffic windows",
"source_url": "https://priyom.org/diplomatic/egypt"
},
{
"id": "dprk_mfa",
"name": "DPRK MFA",
"nickname": "North Korea Diplomatic",
"type": "diplomatic",
"country": "North Korea",
"country_code": "KP",
"frequencies": [
{"freq_khz": 7200, "primary": True},
{"freq_khz": 9450, "primary": False},
{"freq_khz": 11475, "primary": False},
{"freq_khz": 13785, "primary": False},
{"freq_khz": 15245, "primary": False},
{"freq_khz": 17550, "primary": False},
{"freq_khz": 21680, "primary": False},
{"freq_khz": 25120, "primary": False},
],
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
"operator": "DPRK MFA",
"schedule": "Daily, multiple time slots",
"source_url": "https://priyom.org/diplomatic/north-korea"
},
{
"id": "russia_mfa",
"name": "Russia MFA",
"nickname": "Russian Diplomatic",
"type": "diplomatic",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 5154, "primary": True},
{"freq_khz": 7654, "primary": False},
{"freq_khz": 9045, "primary": False},
{"freq_khz": 10755, "primary": False},
{"freq_khz": 13455, "primary": False},
{"freq_khz": 16354, "primary": False},
{"freq_khz": 18954, "primary": False},
],
"mode": "Perelivt/Serdolik/X06/OFDM",
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
"operator": "Russian MFA",
"schedule": "24/7 network operations",
"source_url": "https://priyom.org/diplomatic/russia"
},
{
"id": "tunisia_mfa",
"name": "Tunisia MFA",
"nickname": "Tunisian Diplomatic",
"type": "diplomatic",
"country": "Tunisia",
"country_code": "TN",
"frequencies": [
{"freq_khz": 5810, "primary": True},
{"freq_khz": 7954, "primary": False},
{"freq_khz": 8014, "primary": False},
{"freq_khz": 8180, "primary": False},
{"freq_khz": 10113, "primary": False},
{"freq_khz": 10176, "primary": False},
{"freq_khz": 11111, "primary": False},
{"freq_khz": 12140, "primary": False},
{"freq_khz": 13945, "primary": False},
{"freq_khz": 14700, "primary": False},
{"freq_khz": 14724, "primary": False},
{"freq_khz": 15635, "primary": False},
{"freq_khz": 16125, "primary": False},
{"freq_khz": 16285, "primary": False},
{"freq_khz": 16290, "primary": False},
{"freq_khz": 18295, "primary": False},
{"freq_khz": 19675, "primary": False},
{"freq_khz": 23540, "primary": False},
{"freq_khz": 24080, "primary": False},
{"freq_khz": 24170, "primary": False},
{"freq_khz": 26890, "primary": False},
],
"mode": "2G ALE/PACTOR-II",
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
"operator": "Tunisian MFA",
"schedule": "Regular diplomatic traffic",
"source_url": "https://priyom.org/diplomatic/tunisia"
},
{
"id": "usa_state",
"name": "US State Dept",
"nickname": "American Diplomatic",
"type": "diplomatic",
"country": "United States",
"country_code": "US",
"frequencies": [
{"freq_khz": 5749, "primary": True},
{"freq_khz": 6903, "primary": False},
{"freq_khz": 8059, "primary": False},
{"freq_khz": 10734, "primary": False},
{"freq_khz": 11169, "primary": False},
{"freq_khz": 13504, "primary": False},
{"freq_khz": 16284, "primary": False},
{"freq_khz": 18249, "primary": False},
{"freq_khz": 20811, "primary": False},
{"freq_khz": 24884, "primary": False},
],
"mode": "2G ALE (MIL-STD-188-141A)",
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
"operator": "US State Department",
"schedule": "24/7 global network",
"source_url": "https://priyom.org/diplomatic/united-states"
},
{
"id": "morocco_mfa",
"name": "Morocco MFA",
"nickname": "Moroccan Diplomatic",
"type": "diplomatic",
"country": "Morocco",
"country_code": "MA",
"frequencies": [
{"freq_khz": 8010, "primary": True},
{"freq_khz": 11205, "primary": False},
{"freq_khz": 14620, "primary": False},
],
"mode": "PACTOR-II/ALE",
"description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.",
"operator": "Moroccan MFA",
"schedule": "Daily scheduled traffic",
"source_url": "https://priyom.org/diplomatic/morocco"
},
{
"id": "poland_mfa",
"name": "Poland MFA",
"nickname": "Polish Diplomatic",
"type": "diplomatic",
"country": "Poland",
"country_code": "PL",
"frequencies": [
{"freq_khz": 6825, "primary": True},
{"freq_khz": 9250, "primary": False},
{"freq_khz": 13485, "primary": False},
],
"mode": "STANAG-4285/ALE",
"description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.",
"operator": "Polish MFA",
"schedule": "Regular diplomatic traffic",
"source_url": "https://priyom.org/diplomatic/poland"
},
{
"id": "france_mfa",
"name": "France MFA",
"nickname": "French Diplomatic",
"type": "diplomatic",
"country": "France",
"country_code": "FR",
"frequencies": [
{"freq_khz": 6910, "primary": True},
{"freq_khz": 10640, "primary": False},
{"freq_khz": 13870, "primary": False},
{"freq_khz": 16840, "primary": False},
],
"mode": "MIL-STD-188-110/ALE",
"description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.",
"operator": "French MFA",
"schedule": "24/7 network operations",
"source_url": "https://priyom.org/diplomatic/france"
},
{
"id": "romania_mfa",
"name": "Romania MFA",
"nickname": "Romanian Diplomatic",
"type": "diplomatic",
"country": "Romania",
"country_code": "RO",
"frequencies": [
{"freq_khz": 5390, "primary": True},
{"freq_khz": 8158, "primary": False},
{"freq_khz": 11555, "primary": False},
],
"mode": "PACTOR-III/ALE",
"description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.",
"operator": "Romanian MFA",
"schedule": "Scheduled daily windows",
"source_url": "https://priyom.org/diplomatic/romania"
},
{
"id": "algeria_mfa",
"name": "Algeria MFA",
"nickname": "Algerian Diplomatic",
"type": "diplomatic",
"country": "Algeria",
"country_code": "DZ",
"frequencies": [
{"freq_khz": 7706, "primary": True},
{"freq_khz": 10235, "primary": False},
{"freq_khz": 14385, "primary": False},
],
"mode": "SITOR-B/PACTOR",
"description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.",
"operator": "Algerian MFA",
"schedule": "Daily scheduled transmissions",
"source_url": "https://priyom.org/diplomatic/algeria"
},
{
"id": "egypt_mfa_m14a",
"name": "Egypt MFA M14a",
"nickname": "Egyptian Extended",
"type": "diplomatic",
"country": "Egypt",
"country_code": "EG",
"frequencies": [
{"freq_khz": 12175, "primary": True},
{"freq_khz": 16360, "primary": False},
],
"mode": "Codan 3012/SITOR",
"description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.",
"operator": "Egyptian MFA",
"schedule": "Daily traffic windows",
"source_url": "https://priyom.org/diplomatic/egypt"
},
]
@spy_stations_bp.route('/stations')
def get_stations():
"""Return all spy stations, optionally filtered."""
station_type = request.args.get('type')
country = request.args.get('country')
mode = request.args.get('mode')
filtered = STATIONS
if station_type:
filtered = [s for s in filtered if s['type'] == station_type]
if country:
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
if mode:
mode_lower = mode.lower()
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
return jsonify({
'status': 'success',
'count': len(filtered),
'stations': filtered
})
@spy_stations_bp.route('/stations/<station_id>')
def get_station(station_id):
"""Get a single station by ID."""
for station in STATIONS:
if station['id'] == station_id:
return jsonify({
'status': 'success',
'station': station
})
return jsonify({
'status': 'error',
'message': 'Station not found'
}), 404
@spy_stations_bp.route('/filters')
def get_filters():
"""Return available filter options."""
types = list(set(s['type'] for s in STATIONS))
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
return jsonify({
'status': 'success',
'filters': {
'types': types,
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
'modes': modes
}
})
+3290
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -1098,3 +1098,318 @@ def stream_wifi():
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
# =============================================================================
# V2 API Endpoints - Using unified WiFi scanner
# =============================================================================
from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner
@wifi_bp.route('/v2/capabilities')
def get_v2_capabilities():
"""Get WiFi scanning capabilities on this system."""
try:
scanner = get_wifi_scanner()
caps = scanner.check_capabilities()
return jsonify({
'platform': caps.platform,
'is_root': caps.is_root,
'can_quick_scan': caps.can_quick_scan,
'can_deep_scan': caps.can_deep_scan,
'preferred_quick_tool': caps.preferred_quick_tool,
'interfaces': caps.interfaces,
'default_interface': caps.default_interface,
'has_monitor_capable_interface': caps.has_monitor_capable_interface,
'monitor_interface': caps.monitor_interface,
'issues': caps.issues,
'tools': {
'nmcli': caps.has_nmcli,
'iw': caps.has_iw,
'iwlist': caps.has_iwlist,
'airport': caps.has_airport,
'airmon_ng': caps.has_airmon_ng,
'airodump_ng': caps.has_airodump_ng,
},
})
except Exception as e:
logger.exception("Error checking capabilities")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
def v2_quick_scan():
"""Perform a quick one-shot WiFi scan using system tools."""
try:
data = request.json or {}
interface = data.get('interface')
timeout = data.get('timeout', 10.0)
scanner = get_wifi_scanner()
result = scanner.quick_scan(interface=interface, timeout=timeout)
if result.error:
return jsonify({
'error': result.error,
'access_points': [],
'channel_stats': [],
'recommendations': [],
}), 200 # Return 200 with error in body for cleaner handling
return jsonify({
'access_points': [ap.to_summary_dict() for ap in result.access_points],
'channel_stats': [s.to_dict() for s in result.channel_stats],
'recommendations': [r.to_dict() for r in result.recommendations],
'duration_seconds': result.duration_seconds,
'warnings': result.warnings,
})
except Exception as e:
logger.exception("Error in quick scan")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/scan/start', methods=['POST'])
def v2_start_scan():
"""Start continuous deep scan with airodump-ng."""
try:
data = request.json or {}
interface = data.get('interface')
band = data.get('band', 'all')
channel = data.get('channel')
scanner = get_wifi_scanner()
success = scanner.start_deep_scan(interface=interface, band=band, channel=channel)
if success:
return jsonify({'status': 'started'})
else:
status = scanner.get_status()
return jsonify({'error': status.error or 'Failed to start scan'}), 400
except Exception as e:
logger.exception("Error starting deep scan")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
def v2_stop_scan():
"""Stop the current scan."""
try:
scanner = get_wifi_scanner()
scanner.stop_deep_scan()
return jsonify({'status': 'stopped'})
except Exception as e:
logger.exception("Error stopping scan")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/scan/status')
def v2_scan_status():
"""Get current scan status."""
try:
scanner = get_wifi_scanner()
status = scanner.get_status()
return jsonify({
'is_scanning': status.is_scanning,
'scan_mode': status.scan_mode,
'interface': status.interface,
'started_at': status.started_at.isoformat() if status.started_at else None,
'networks_found': status.networks_found,
'clients_found': status.clients_found,
'error': status.error,
})
except Exception as e:
logger.exception("Error getting scan status")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/networks')
def v2_get_networks():
"""Get all discovered networks."""
try:
scanner = get_wifi_scanner()
networks = scanner.access_points
return jsonify({
'networks': [ap.to_summary_dict() for ap in networks],
'total': len(networks),
})
except Exception as e:
logger.exception("Error getting networks")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/clients')
def v2_get_clients():
"""Get all discovered clients."""
try:
scanner = get_wifi_scanner()
clients = scanner.clients
return jsonify({
'clients': [c.to_dict() for c in clients],
'total': len(clients),
})
except Exception as e:
logger.exception("Error getting clients")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/probes')
def v2_get_probes():
"""Get probe requests."""
try:
scanner = get_wifi_scanner()
probes = scanner.probe_requests
return jsonify({
'probes': [p.to_dict() for p in probes[-100:]], # Last 100
'total': len(probes),
})
except Exception as e:
logger.exception("Error getting probes")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/channels')
def v2_get_channels():
"""Get channel statistics and recommendations."""
try:
scanner = get_wifi_scanner()
stats = scanner._calculate_channel_stats()
recommendations = scanner._generate_recommendations(stats)
return jsonify({
'channel_stats': [s.to_dict() for s in stats],
'recommendations': [r.to_dict() for r in recommendations],
})
except Exception as e:
logger.exception("Error getting channel stats")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/stream')
def v2_stream():
"""SSE stream for real-time WiFi events."""
def generate():
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
@wifi_bp.route('/v2/export')
def v2_export():
"""Export scan data as CSV or JSON."""
try:
format_type = request.args.get('format', 'json')
data_type = request.args.get('type', 'all')
scanner = get_wifi_scanner()
if format_type == 'json':
data = {}
if data_type in ('all', 'networks'):
data['networks'] = [ap.to_summary_dict() for ap in scanner.access_points]
if data_type in ('all', 'clients'):
data['clients'] = [c.to_dict() for c in scanner.clients]
if data_type in ('all', 'probes'):
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
response = Response(
json.dumps(data, indent=2, default=str),
mimetype='application/json',
)
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.json'
return response
elif format_type == 'csv':
import csv
import io
output = io.StringIO()
writer = csv.writer(output)
# Write networks
writer.writerow(['Networks'])
writer.writerow(['BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', 'Vendor', 'Clients', 'First Seen', 'Last Seen'])
for ap in scanner.access_points:
writer.writerow([
ap.bssid,
ap.essid or '[Hidden]',
ap.channel,
ap.band,
ap.rssi_current,
ap.security,
ap.vendor,
ap.client_count,
ap.first_seen.isoformat() if ap.first_seen else '',
ap.last_seen.isoformat() if ap.last_seen else '',
])
writer.writerow([])
# Write clients
writer.writerow(['Clients'])
writer.writerow(['MAC', 'BSSID', 'Vendor', 'RSSI', 'Probed SSIDs', 'First Seen', 'Last Seen'])
for c in scanner.clients:
writer.writerow([
c.mac,
c.associated_bssid or '',
c.vendor,
c.rssi_current,
', '.join(c.probed_ssids),
c.first_seen.isoformat() if c.first_seen else '',
c.last_seen.isoformat() if c.last_seen else '',
])
response = Response(
output.getvalue(),
mimetype='text/csv',
)
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.csv'
return response
else:
return jsonify({'error': f'Unknown format: {format_type}'}), 400
except Exception as e:
logger.exception("Error exporting data")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
def v2_set_baseline():
"""Set current networks as baseline."""
try:
scanner = get_wifi_scanner()
scanner.set_baseline()
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
except Exception as e:
logger.exception("Error setting baseline")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
def v2_clear_baseline():
"""Clear the baseline."""
try:
scanner = get_wifi_scanner()
scanner.clear_baseline()
return jsonify({'status': 'baseline_cleared'})
except Exception as e:
logger.exception("Error clearing baseline")
return jsonify({'error': str(e)}), 500
@wifi_bp.route('/v2/clear', methods=['POST'])
def v2_clear_data():
"""Clear all discovered data."""
try:
scanner = get_wifi_scanner()
scanner.clear_data()
return jsonify({'status': 'cleared'})
except Exception as e:
logger.exception("Error clearing data")
return jsonify({'error': str(e)}), 500
+516
View File
@@ -0,0 +1,516 @@
"""
WiFi v2 API routes.
New unified WiFi scanning API with Quick Scan and Deep Scan modes,
channel analysis, hidden SSID correlation, and SSE streaming.
"""
from __future__ import annotations
import csv
import io
import json
import logging
from datetime import datetime
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.wifi import (
get_wifi_scanner,
analyze_channels,
get_hidden_correlator,
SCAN_MODE_QUICK,
SCAN_MODE_DEEP,
)
from utils.sse import format_sse
logger = logging.getLogger(__name__)
wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2')
# =============================================================================
# Capabilities
# =============================================================================
@wifi_v2_bp.route('/capabilities', methods=['GET'])
def get_capabilities():
"""
Get WiFi scanning capabilities.
Returns available tools, interfaces, and scan mode support.
"""
scanner = get_wifi_scanner()
caps = scanner.check_capabilities()
return jsonify(caps.to_dict())
# =============================================================================
# Quick Scan
# =============================================================================
@wifi_v2_bp.route('/scan/quick', methods=['POST'])
def quick_scan():
"""
Perform a quick one-shot WiFi scan.
Uses system tools (nmcli, iw, iwlist, airport) without monitor mode.
Request body:
interface: Optional interface name
timeout: Optional scan timeout in seconds (default 15)
Returns:
WiFiScanResult with discovered networks and channel analysis.
"""
data = request.get_json() or {}
interface = data.get('interface')
timeout = float(data.get('timeout', 15))
scanner = get_wifi_scanner()
result = scanner.quick_scan(interface=interface, timeout=timeout)
return jsonify(result.to_dict())
# =============================================================================
# Deep Scan (Monitor Mode)
# =============================================================================
@wifi_v2_bp.route('/scan/start', methods=['POST'])
def start_deep_scan():
"""
Start a deep scan using airodump-ng.
Requires monitor mode interface and root privileges.
Request body:
interface: Monitor mode interface (e.g., 'wlan0mon')
band: Band to scan ('2.4', '5', 'all')
channel: Optional specific channel to monitor
"""
data = request.get_json() or {}
interface = data.get('interface')
band = data.get('band', 'all')
channel = data.get('channel')
if channel:
try:
channel = int(channel)
except ValueError:
return jsonify({'error': 'Invalid channel'}), 400
scanner = get_wifi_scanner()
success = scanner.start_deep_scan(
interface=interface,
band=band,
channel=channel,
)
if success:
return jsonify({
'status': 'started',
'mode': SCAN_MODE_DEEP,
'interface': interface or scanner._capabilities.monitor_interface,
})
else:
return jsonify({
'status': 'error',
'error': scanner._status.error,
}), 400
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
def stop_deep_scan():
"""Stop the deep scan."""
scanner = get_wifi_scanner()
scanner.stop_deep_scan()
return jsonify({
'status': 'stopped',
})
@wifi_v2_bp.route('/scan/status', methods=['GET'])
def get_scan_status():
"""Get current scan status."""
scanner = get_wifi_scanner()
status = scanner.get_status()
return jsonify(status.to_dict())
# =============================================================================
# Data Endpoints
# =============================================================================
@wifi_v2_bp.route('/networks', methods=['GET'])
def get_networks():
"""
Get all discovered networks.
Query params:
band: Filter by band ('2.4GHz', '5GHz', '6GHz')
security: Filter by security type ('Open', 'WEP', 'WPA', 'WPA2', 'WPA3')
hidden: Filter hidden networks only (true/false)
min_rssi: Minimum RSSI threshold
sort: Sort field ('rssi', 'channel', 'essid', 'last_seen')
order: Sort order ('asc', 'desc')
format: Response format ('full', 'summary')
"""
scanner = get_wifi_scanner()
networks = scanner.access_points
# Apply filters
band = request.args.get('band')
if band:
networks = [n for n in networks if n.band == band]
security = request.args.get('security')
if security:
networks = [n for n in networks if n.security == security]
hidden = request.args.get('hidden')
if hidden == 'true':
networks = [n for n in networks if n.is_hidden]
elif hidden == 'false':
networks = [n for n in networks if not n.is_hidden]
min_rssi = request.args.get('min_rssi')
if min_rssi:
try:
min_rssi = int(min_rssi)
networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi]
except ValueError:
pass
# Apply sorting
sort_field = request.args.get('sort', 'rssi')
order = request.args.get('order', 'desc')
reverse = order == 'desc'
sort_key_map = {
'rssi': lambda n: n.rssi_current or -100,
'channel': lambda n: n.channel or 0,
'essid': lambda n: (n.essid or '').lower(),
'last_seen': lambda n: n.last_seen,
'clients': lambda n: n.client_count,
}
if sort_field in sort_key_map:
networks.sort(key=sort_key_map[sort_field], reverse=reverse)
# Format output
output_format = request.args.get('format', 'summary')
if output_format == 'full':
return jsonify([n.to_dict() for n in networks])
else:
return jsonify([n.to_summary_dict() for n in networks])
@wifi_v2_bp.route('/networks/<bssid>', methods=['GET'])
def get_network(bssid):
"""Get a specific network by BSSID."""
scanner = get_wifi_scanner()
network = scanner.get_network(bssid)
if network:
return jsonify(network.to_dict())
else:
return jsonify({'error': 'Network not found'}), 404
@wifi_v2_bp.route('/clients', methods=['GET'])
def get_clients():
"""
Get all discovered clients.
Query params:
associated: Filter by association status (true/false)
bssid: Filter by associated BSSID
min_rssi: Minimum RSSI threshold
"""
scanner = get_wifi_scanner()
clients = scanner.clients
# Apply filters
associated = request.args.get('associated')
if associated == 'true':
clients = [c for c in clients if c.is_associated]
elif associated == 'false':
clients = [c for c in clients if not c.is_associated]
bssid = request.args.get('bssid')
if bssid:
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
min_rssi = request.args.get('min_rssi')
if min_rssi:
try:
min_rssi = int(min_rssi)
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
except ValueError:
pass
return jsonify([c.to_dict() for c in clients])
@wifi_v2_bp.route('/clients/<mac>', methods=['GET'])
def get_client(mac):
"""Get a specific client by MAC address."""
scanner = get_wifi_scanner()
client = scanner.get_client(mac)
if client:
return jsonify(client.to_dict())
else:
return jsonify({'error': 'Client not found'}), 404
@wifi_v2_bp.route('/probes', methods=['GET'])
def get_probes():
"""
Get captured probe requests.
Query params:
client_mac: Filter by client MAC
ssid: Filter by probed SSID
limit: Maximum number of results
"""
scanner = get_wifi_scanner()
probes = scanner.probe_requests
# Apply filters
client_mac = request.args.get('client_mac')
if client_mac:
probes = [p for p in probes if p.client_mac == client_mac.upper()]
ssid = request.args.get('ssid')
if ssid:
probes = [p for p in probes if p.probed_ssid == ssid]
# Apply limit
limit = request.args.get('limit')
if limit:
try:
limit = int(limit)
probes = probes[-limit:] # Most recent
except ValueError:
pass
return jsonify([p.to_dict() for p in probes])
# =============================================================================
# Channel Analysis
# =============================================================================
@wifi_v2_bp.route('/channels', methods=['GET'])
def get_channel_stats():
"""
Get channel utilization statistics and recommendations.
Query params:
include_dfs: Include DFS channels in recommendations (true/false)
"""
scanner = get_wifi_scanner()
include_dfs = request.args.get('include_dfs', 'false') == 'true'
stats, recommendations = analyze_channels(
scanner.access_points,
include_dfs=include_dfs,
)
return jsonify({
'stats': [s.to_dict() for s in stats],
'recommendations': [r.to_dict() for r in recommendations],
})
# =============================================================================
# Hidden SSID Correlation
# =============================================================================
@wifi_v2_bp.route('/hidden', methods=['GET'])
def get_hidden_correlations():
"""
Get revealed hidden SSIDs from correlation.
Returns mapping of BSSID -> revealed SSID.
"""
correlator = get_hidden_correlator()
return jsonify(correlator.get_all_revealed())
# =============================================================================
# Baseline Management
# =============================================================================
@wifi_v2_bp.route('/baseline/set', methods=['POST'])
def set_baseline():
"""Mark current networks as baseline (known networks)."""
scanner = get_wifi_scanner()
scanner.set_baseline()
return jsonify({
'status': 'baseline_set',
'network_count': len(scanner._baseline_networks),
'set_at': datetime.now().isoformat(),
})
@wifi_v2_bp.route('/baseline/clear', methods=['POST'])
def clear_baseline():
"""Clear the baseline."""
scanner = get_wifi_scanner()
scanner.clear_baseline()
return jsonify({
'status': 'baseline_cleared',
})
# =============================================================================
# SSE Streaming
# =============================================================================
@wifi_v2_bp.route('/stream', methods=['GET'])
def event_stream():
"""
Server-Sent Events stream for real-time updates.
Events:
- network_update: Network discovered/updated
- client_update: Client discovered/updated
- probe_request: Probe request detected
- hidden_revealed: Hidden SSID revealed
- scan_started, scan_stopped, scan_error
- keepalive: Periodic keepalive
"""
def generate() -> Generator[str, None, None]:
scanner = get_wifi_scanner()
for event in scanner.get_event_stream():
yield format_sse(event)
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
# =============================================================================
# Data Management
# =============================================================================
@wifi_v2_bp.route('/clear', methods=['POST'])
def clear_data():
"""Clear all discovered data."""
scanner = get_wifi_scanner()
scanner.clear_data()
return jsonify({
'status': 'cleared',
})
# =============================================================================
# Export
# =============================================================================
@wifi_v2_bp.route('/export', methods=['GET'])
def export_data():
"""
Export scan data.
Query params:
format: 'json' or 'csv' (default: json)
type: 'networks', 'clients', 'probes', 'all' (default: all)
"""
scanner = get_wifi_scanner()
export_format = request.args.get('format', 'json')
export_type = request.args.get('type', 'all')
if export_format == 'csv':
return _export_csv(scanner, export_type)
else:
return _export_json(scanner, export_type)
def _export_json(scanner, export_type: str) -> Response:
"""Export data as JSON."""
data = {}
if export_type in ('networks', 'all'):
data['networks'] = [n.to_dict() for n in scanner.access_points]
if export_type in ('clients', 'all'):
data['clients'] = [c.to_dict() for c in scanner.clients]
if export_type in ('probes', 'all'):
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
data['exported_at'] = datetime.now().isoformat()
data['network_count'] = len(scanner.access_points)
data['client_count'] = len(scanner.clients)
response = Response(
json.dumps(data, indent=2),
mimetype='application/json',
)
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
return response
def _export_csv(scanner, export_type: str) -> Response:
"""Export data as CSV."""
output = io.StringIO()
if export_type in ('networks', 'all'):
writer = csv.writer(output)
writer.writerow([
'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security',
'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen'
])
for n in scanner.access_points:
writer.writerow([
n.bssid,
n.essid or '[Hidden]',
n.channel,
n.band,
n.rssi_current,
n.security,
n.cipher,
n.auth,
n.vendor or '',
n.client_count,
n.first_seen.isoformat(),
n.last_seen.isoformat(),
])
if export_type == 'all':
writer.writerow([]) # Blank line separator
if export_type in ('clients', 'all'):
writer = csv.writer(output)
if export_type == 'clients':
writer.writerow([
'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs',
'First Seen', 'Last Seen'
])
for c in scanner.clients:
writer.writerow([
c.mac,
c.vendor or '',
c.rssi_current,
c.associated_bssid or '',
', '.join(c.probed_ssids),
c.first_seen.isoformat(),
c.last_seen.isoformat(),
])
response = Response(output.getvalue(), mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
return response
Regular → Executable
+539 -24
View File
@@ -69,6 +69,18 @@ echo
# ----------------------------
# Helpers
# ----------------------------
NON_INTERACTIVE=false
for arg in "$@"; do
case "$arg" in
--non-interactive)
NON_INTERACTIVE=true
;;
*)
;;
esac
done
cmd_exists() {
local c="$1"
command -v "$c" >/dev/null 2>&1 && return 0
@@ -76,6 +88,32 @@ cmd_exists() {
return 1
}
ask_yes_no() {
local prompt="$1"
local default="${2:-n}" # default to no for safety
local response
if $NON_INTERACTIVE; then
info "Non-interactive mode: defaulting to ${default} for prompt: ${prompt}"
[[ "$default" == "y" ]]
return
fi
if [[ ! -t 0 ]]; then
warn "No TTY available for prompt: ${prompt}"
[[ "$default" == "y" ]]
return
fi
if [[ "$default" == "y" ]]; then
read -r -p "$prompt [Y/n]: " response
[[ -z "$response" || "$response" =~ ^[Yy] ]]
else
read -r -p "$prompt [y/N]: " response
[[ "$response" =~ ^[Yy] ]]
fi
}
have_any() {
local c
for c in "$@"; do
@@ -111,6 +149,18 @@ detect_os() {
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
}
detect_dragonos() {
IS_DRAGONOS=false
# Check for DragonOS markers
if [[ -f /etc/dragonos-release ]] || \
[[ -d /usr/share/dragonos ]] || \
grep -qi "dragonos" /etc/os-release 2>/dev/null; then
IS_DRAGONOS=true
warn "DragonOS detected! This distro has many tools pre-installed."
warn "The script will prompt before making system changes."
fi
}
# ----------------------------
# Required tool checks (with alternates)
# ----------------------------
@@ -128,6 +178,17 @@ check_required() {
fi
}
check_optional() {
local label="$1"; shift
local desc="$1"; shift
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, optional)"
fi
}
check_tools() {
info "Checking required tools..."
missing_required=()
@@ -136,9 +197,13 @@ check_tools() {
info "Core SDR:"
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
check_required "rtl_test" "RTL-SDR device detection" rtl_test
check_required "rtl_tcp" "RTL-SDR TCP server" rtl_tcp
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
echo
info "GPS:"
@@ -229,9 +294,9 @@ install_python_deps() {
if ! python -m pip install -r requirements.txt 2>/dev/null; then
warn "Some pip packages failed - checking if apt packages cover them..."
# Verify critical packages are available
python -c "import flask; import requests" 2>/dev/null || {
fail "Critical Python packages (flask, requests) not installed"
echo "Try: sudo apt install python3-flask python3-requests"
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
echo "Try: pip install flask requests flask-limiter"
exit 1
}
ok "Core Python dependencies available"
@@ -265,12 +330,90 @@ brew_install() {
return 0
fi
info "brew: installing ${pkg}..."
brew install "$pkg"
ok "brew: installed ${pkg}"
if brew install "$pkg" 2>&1; then
ok "brew: installed ${pkg}"
return 0
else
return 1
fi
}
install_rtlamr_from_source() {
info "Installing rtlamr from source (requires Go)..."
# Check if Go is installed, install if needed
if ! cmd_exists go; then
if [[ "$OS" == "macos" ]]; then
info "Installing Go via Homebrew..."
brew_install go || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
else
info "Installing Go via apt..."
$SUDO apt-get install -y golang >/dev/null 2>&1 || { warn "Failed to install Go. Cannot install rtlamr."; return 1; }
fi
fi
# Set up Go environment
export GOPATH="${GOPATH:-$HOME/go}"
export PATH="$GOPATH/bin:$PATH"
mkdir -p "$GOPATH/bin"
info "Building rtlamr..."
if go install github.com/bemasher/rtlamr@latest 2>/dev/null; then
# Link to system path
if [[ -f "$GOPATH/bin/rtlamr" ]]; then
if [[ "$OS" == "macos" ]]; then
if [[ -w /usr/local/bin ]]; then
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
else
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
fi
else
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
fi
ok "rtlamr installed successfully"
else
warn "rtlamr binary not found after build"
return 1
fi
else
warn "Failed to build rtlamr"
return 1
fi
}
install_multimon_ng_from_source_macos() {
info "multimon-ng not available via Homebrew. Building from source..."
# Ensure build dependencies are installed
brew_install cmake
brew_install libsndfile
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning multimon-ng..."
git clone --depth 1 https://github.com/EliasOenal/multimon-ng.git "$tmp_dir/multimon-ng" >/dev/null 2>&1 \
|| { fail "Failed to clone multimon-ng"; exit 1; }
cd "$tmp_dir/multimon-ng"
info "Compiling multimon-ng..."
mkdir -p build && cd build
cmake .. >/dev/null 2>&1 || { fail "cmake failed for multimon-ng"; exit 1; }
make >/dev/null 2>&1 || { fail "make failed for multimon-ng"; exit 1; }
# Install to /usr/local/bin (no sudo needed on Homebrew systems typically)
if [[ -w /usr/local/bin ]]; then
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
else
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
fi
ok "multimon-ng installed successfully from source"
)
}
install_macos_packages() {
TOTAL_STEPS=12
TOTAL_STEPS=15
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -280,7 +423,15 @@ install_macos_packages() {
brew_install librtlsdr
progress "Installing multimon-ng"
brew_install multimon-ng
# multimon-ng is not in Homebrew core, so build from source
if ! cmd_exists multimon-ng; then
install_multimon_ng_from_source_macos
else
ok "multimon-ng already installed"
fi
progress "Installing direwolf (APRS decoder)"
(brew_install direwolf) || warn "direwolf not available via Homebrew"
progress "Installing ffmpeg"
brew_install ffmpeg
@@ -288,9 +439,33 @@ install_macos_packages() {
progress "Installing rtl_433"
brew_install rtl_433
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
echo
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
if ask_yes_no "Do you want to install rtlamr?"; then
install_rtlamr_from_source
else
warn "Skipping rtlamr installation. You can install it later if needed."
fi
else
ok "rtlamr already installed"
fi
progress "Installing dump1090"
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
progress "Installing acarsdec"
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
else
ok "AIS-catcher already installed"
fi
progress "Installing aircrack-ng"
brew_install aircrack-ng
@@ -303,7 +478,21 @@ install_macos_packages() {
progress "Installing gpsd"
brew_install gpsd
progress "Installing Ubertooth tools (optional)"
if ! cmd_exists ubertooth-btle; then
echo
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
if ask_yes_no "Do you want to install Ubertooth tools?"; then
brew_install ubertooth || warn "Ubertooth not available via Homebrew"
else
warn "Skipping Ubertooth installation. You can install it later if needed."
fi
else
ok "Ubertooth already installed"
fi
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
echo
}
@@ -334,6 +523,15 @@ apt_try_install_any() {
return 1
}
apt_install_if_missing() {
local pkg="$1"
if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
ok "apt: ${pkg} already installed"
return 0
fi
apt_install "$pkg"
}
install_dump1090_from_source_debian() {
info "dump1090 not available via APT. Building from source (required)..."
@@ -372,6 +570,137 @@ install_dump1090_from_source_debian() {
)
}
install_acarsdec_from_source_debian() {
info "acarsdec not available via APT. Building from source..."
apt_install build-essential git cmake \
librtlsdr-dev libusb-1.0-0-dev libsndfile1-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning acarsdec..."
git clone --depth 1 https://github.com/TLeconte/acarsdec.git "$tmp_dir/acarsdec" >/dev/null 2>&1 \
|| { warn "Failed to clone acarsdec"; exit 1; }
cd "$tmp_dir/acarsdec"
mkdir -p build && cd build
info "Compiling acarsdec..."
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
ok "acarsdec installed successfully."
else
warn "Failed to build acarsdec from source. ACARS decoding will not be available."
fi
)
}
install_aiscatcher_from_source_debian() {
info "AIS-catcher not available via APT. Building from source..."
apt_install build-essential git cmake pkg-config \
librtlsdr-dev libusb-1.0-0-dev libcurl4-openssl-dev zlib1g-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning AIS-catcher..."
git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \
|| { warn "Failed to clone AIS-catcher"; exit 1; }
cd "$tmp_dir/AIS-catcher"
mkdir -p build && cd build
info "Compiling AIS-catcher..."
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
ok "AIS-catcher installed successfully."
else
warn "Failed to build AIS-catcher from source. AIS vessel tracking will not be available."
fi
)
}
install_ubertooth_from_source_debian() {
info "Building Ubertooth from source..."
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config libbluetooth-dev
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning Ubertooth..."
git clone --depth 1 https://github.com/greatscottgadgets/ubertooth.git "$tmp_dir/ubertooth" >/dev/null 2>&1 \
|| { warn "Failed to clone Ubertooth"; exit 1; }
cd "$tmp_dir/ubertooth/host"
mkdir -p build && cd build
info "Compiling Ubertooth..."
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig
ok "Ubertooth installed successfully from source."
else
warn "Failed to build Ubertooth from source."
fi
)
}
install_rtlsdr_blog_drivers_debian() {
# The RTL-SDR Blog drivers provide better support for:
# - RTL-SDR Blog V4 (R828D tuner)
# - RTL-SDR Blog V3 with bias-t improvements
# - Better overall compatibility with all RTL-SDR devices
# These drivers are backward compatible with standard RTL-SDR devices.
info "Installing RTL-SDR Blog drivers (improved V4 support)..."
# Install build dependencies
apt_install build-essential git cmake libusb-1.0-0-dev pkg-config
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning RTL-SDR Blog driver fork..."
git clone https://github.com/rtlsdrblog/rtl-sdr-blog.git "$tmp_dir/rtl-sdr-blog" >/dev/null 2>&1 \
|| { warn "Failed to clone RTL-SDR Blog drivers"; exit 1; }
cd "$tmp_dir/rtl-sdr-blog"
mkdir -p build && cd build
info "Compiling RTL-SDR Blog drivers..."
if cmake .. -DINSTALL_UDEV_RULES=ON -DDETACH_KERNEL_DRIVER=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig
# Copy udev rules if they exist
if [[ -f ../rtl-sdr.rules ]]; then
$SUDO cp ../rtl-sdr.rules /etc/udev/rules.d/20-rtlsdr-blog.rules
$SUDO udevadm control --reload-rules || true
$SUDO udevadm trigger || true
fi
ok "RTL-SDR Blog drivers installed successfully."
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
else
warn "Failed to build RTL-SDR Blog drivers. Using stock drivers."
warn "If you have an RTL-SDR Blog V4, you may need to install drivers manually."
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
fi
)
}
setup_udev_rules_debian() {
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
@@ -389,31 +718,135 @@ EOF
echo
}
blacklist_kernel_drivers_debian() {
local blacklist_file="/etc/modprobe.d/blacklist-rtlsdr.conf"
if [[ -f "$blacklist_file" ]]; then
ok "RTL-SDR kernel driver blacklist already present"
return 0
fi
info "Blacklisting conflicting DVB kernel drivers..."
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
blacklist dvb_usb_rtl28xxu
blacklist rtl2832
blacklist rtl2830
blacklist r820t
EOF
# Unload modules if currently loaded
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
if lsmod | grep -q "^$mod"; then
$SUDO modprobe -r "$mod" 2>/dev/null || true
fi
done
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
echo
}
install_debian_packages() {
need_sudo
# Suppress needrestart prompts (Ubuntu Server 22.04+)
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
# Keep APT interactive when a TTY is available.
if $NON_INTERACTIVE; then
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
elif [[ -t 0 ]]; then
export DEBIAN_FRONTEND=readline
export NEEDRESTART_MODE=a
else
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=15
TOTAL_STEPS=20
CURRENT_STEP=0
progress "Updating APT package lists"
$SUDO apt-get update -y >/dev/null
progress "Installing RTL-SDR"
apt_install rtl-sdr
if ! $IS_DRAGONOS; then
# Handle package conflict between librtlsdr0 and librtlsdr2
# The newer librtlsdr0 (2.0.2) conflicts with older librtlsdr2 (2.0.1)
if dpkg -l | grep -q "librtlsdr2"; then
info "Detected librtlsdr2 conflict - upgrading to librtlsdr0..."
# Remove packages that depend on librtlsdr2, then remove librtlsdr2
# These will be reinstalled with librtlsdr0 support
$SUDO apt-get remove -y dump1090-mutability libgnuradio-osmosdr0.2.0t64 rtl-433 librtlsdr2 rtl-sdr 2>/dev/null || true
$SUDO apt-get autoremove -y 2>/dev/null || true
ok "Removed conflicting librtlsdr2 packages"
fi
# If rtl-sdr is in broken state, remove it completely first
if dpkg -l | grep -q "^.[^i].*rtl-sdr" || ! dpkg -l rtl-sdr 2>/dev/null | grep -q "^ii"; then
info "Removing broken rtl-sdr package..."
$SUDO dpkg --remove --force-remove-reinstreq rtl-sdr 2>/dev/null || true
$SUDO dpkg --purge --force-remove-reinstreq rtl-sdr 2>/dev/null || true
fi
# Force remove librtlsdr2 if it still exists
if dpkg -l | grep -q "librtlsdr2"; then
info "Force removing librtlsdr2..."
$SUDO dpkg --remove --force-all librtlsdr2 2>/dev/null || true
$SUDO dpkg --purge --force-all librtlsdr2 2>/dev/null || true
fi
# Clean up any partial installations
$SUDO dpkg --configure -a 2>/dev/null || true
$SUDO apt-get --fix-broken install -y 2>/dev/null || true
fi
apt_install_if_missing rtl-sdr
progress "RTL-SDR Blog drivers"
if cmd_exists rtl_test; then
info "RTL-SDR tools already installed."
if $IS_DRAGONOS; then
info "Skipping RTL-SDR Blog driver installation (DragonOS has working drivers)."
else
echo "RTL-SDR Blog drivers provide improved support for V4 dongles."
echo "Installing these will REPLACE your current RTL-SDR drivers."
if ask_yes_no "Install RTL-SDR Blog drivers?"; then
install_rtlsdr_blog_drivers_debian
else
ok "Keeping existing RTL-SDR drivers."
fi
fi
else
install_rtlsdr_blog_drivers_debian
fi
progress "Installing multimon-ng"
apt_install multimon-ng
progress "Installing direwolf (APRS decoder)"
apt_install direwolf || true
progress "Installing ffmpeg"
apt_install ffmpeg
progress "Installing rtl_433"
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
progress "Installing rtlamr (optional)"
# rtlamr is optional - used for utility meter monitoring
if ! cmd_exists rtlamr; then
echo
info "rtlamr is used for utility meter monitoring (electric/gas/water meters)."
if ask_yes_no "Do you want to install rtlamr?"; then
install_rtlamr_from_source
else
warn "Skipping rtlamr installation. You can install it later if needed."
fi
else
ok "rtlamr already installed"
fi
progress "Installing aircrack-ng"
apt_install aircrack-ng || true
@@ -426,8 +859,23 @@ install_debian_packages() {
progress "Installing Bluetooth tools"
apt_install bluez bluetooth || true
progress "Installing Ubertooth tools (optional)"
if ! cmd_exists ubertooth-btle; then
echo
info "Ubertooth is used for advanced Bluetooth packet sniffing with Ubertooth One hardware."
if ask_yes_no "Do you want to install Ubertooth tools?"; then
apt_install libubertooth-dev ubertooth || install_ubertooth_from_source_debian
else
warn "Skipping Ubertooth installation. You can install it later if needed."
fi
else
ok "Ubertooth already installed"
fi
progress "Installing SoapySDR"
apt_install soapysdr-tools || true
# Exclude xtrx-dkms - its kernel module fails to build on newer kernels (6.14+)
# and causes apt to hang. Most users don't have XTRX hardware anyway.
apt_install soapysdr-tools xtrx-dkms- || true
progress "Installing gpsd"
apt_install gpsd gpsd-clients || true
@@ -437,15 +885,50 @@ install_debian_packages() {
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
# bleak for BLE scanning with manufacturer data (TSCM mode)
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
progress "Installing dump1090"
if ! cmd_exists dump1090; then
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi
if ! cmd_exists dump1090; then
if cmd_exists dump1090-mutability; then
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
fi
fi
cmd_exists dump1090 || install_dump1090_from_source_debian
progress "Installing acarsdec"
if ! cmd_exists acarsdec; then
apt_install acarsdec || true
fi
cmd_exists acarsdec || install_acarsdec_from_source_debian
progress "Installing AIS-catcher"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
install_aiscatcher_from_source_debian
else
ok "AIS-catcher already installed"
fi
progress "Configuring udev rules"
setup_udev_rules_debian
progress "Kernel driver configuration"
echo
if $IS_DRAGONOS; then
info "DragonOS already has RTL-SDR drivers configured correctly."
info "Skipping kernel driver blacklist (not needed)."
else
echo "The DVB-T kernel drivers conflict with RTL-SDR userspace access."
echo "Blacklisting them allows rtl_sdr tools to access the device."
if ask_yes_no "Blacklist conflicting kernel drivers?"; then
blacklist_kernel_drivers_debian
else
warn "Skipped kernel driver blacklist. RTL-SDR may not work without manual config."
fi
fi
}
# ----------------------------
@@ -455,26 +938,57 @@ final_summary_and_hard_fail() {
check_tools
echo "============================================"
echo
echo "To start INTERCEPT:"
echo " sudo -E venv/bin/python intercept.py"
echo
echo "Then open http://localhost:5050 in your browser"
echo
echo "============================================"
if [[ "${#missing_required[@]}" -eq 0 ]]; then
ok "All REQUIRED tools are installed."
else
fail "Missing REQUIRED tools:"
for t in "${missing_required[@]}"; do echo " - $t"; done
echo
fail "Exiting because required tools are missing."
echo
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
exit 1
if [[ "$OS" == "macos" ]]; then
warn "macOS note: bluetoothctl/hcitool/hciconfig are Linux (BlueZ) tools and unavailable on macOS."
warn "Bluetooth functionality will be limited. Other features should work."
else
fail "Exiting because required tools are missing."
exit 1
fi
fi
}
# ----------------------------
# Pre-flight summary
# ----------------------------
show_install_summary() {
info "Installation Summary:"
echo
echo "To start INTERCEPT:"
echo " source venv/bin/activate"
echo " sudo python intercept.py"
echo " OS: $OS"
$IS_DRAGONOS && echo " DragonOS: Yes (safe mode enabled)"
echo
echo "Then open http://localhost:5050 in your browser"
echo " This script will:"
echo " - Install missing SDR tools (rtl-sdr, multimon-ng, etc.)"
echo " - Install Python dependencies in a virtual environment"
echo
if ! $IS_DRAGONOS; then
echo " You will be prompted before:"
echo " - Installing RTL-SDR Blog drivers (replaces existing)"
echo " - Blacklisting kernel DVB drivers"
fi
echo
if $NON_INTERACTIVE; then
info "Non-interactive mode: continuing without prompt."
return
fi
if ! ask_yes_no "Continue with installation?" "y"; then
info "Installation cancelled."
exit 0
fi
}
# ----------------------------
@@ -482,6 +996,8 @@ final_summary_and_hard_fail() {
# ----------------------------
main() {
detect_os
detect_dragonos
show_install_summary
if [[ "$OS" == "macos" ]]; then
install_macos_packages
@@ -494,4 +1010,3 @@ main() {
}
main "$@"
File diff suppressed because it is too large Load Diff
+615
View File
@@ -0,0 +1,615 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #141a24;
--border-color: #1f2937;
--border-glow: rgba(74, 158, 255, 0.6);
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-cyan: #4a9eff;
--accent-green: #22c55e;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
.radar-bg {
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1;
opacity: 0.3;
}
@keyframes scan {
0% { top: -4px; }
100% { top: 100vh; }
}
.header {
position: relative;
z-index: 2;
padding: 12px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.logo {
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
font-size: 12px;
margin-left: 10px;
letter-spacing: 1px;
}
.status-bar {
display: flex;
align-items: center;
gap: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.back-link {
color: var(--accent-cyan);
text-decoration: none;
font-size: 11px;
padding: 6px 12px;
border: 1px solid var(--accent-cyan);
border-radius: 4px;
}
.history-shell {
position: relative;
z-index: 2;
padding: 16px 18px 28px;
display: flex;
flex-direction: column;
gap: 16px;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.session-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 14px;
align-items: center;
background: linear-gradient(120deg, rgba(15, 18, 24, 0.95), rgba(20, 26, 36, 0.95));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 14px 16px;
box-shadow: 0 0 18px rgba(0, 0, 0, 0.35);
}
.session-status {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-dim);
box-shadow: 0 0 12px rgba(75, 85, 99, 0.6);
}
.status-dot.active {
background: var(--accent-green);
box-shadow: 0 0 14px rgba(34, 197, 94, 0.8);
}
.session-label {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
}
.session-value {
font-size: 14px;
font-weight: 600;
}
.session-metric {
display: flex;
flex-direction: column;
gap: 6px;
}
#sessionNotice {
color: var(--accent-cyan);
}
.session-controls {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.session-controls select {
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
min-width: 180px;
}
.primary-btn.stop {
background: var(--accent-amber);
color: #0a0c10;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 16px;
box-shadow: 0 0 14px rgba(0, 0, 0, 0.3);
}
.summary-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.3px;
margin-bottom: 6px;
}
.summary-value {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 16px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-group label {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1.2px;
}
.control-group input,
.control-group select {
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
min-width: 160px;
}
.primary-btn {
background: var(--accent-cyan);
border: none;
color: #0a0c10;
font-weight: 600;
padding: 10px 18px;
border-radius: 6px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
}
.status-pill {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--accent-amber);
color: var(--accent-amber);
text-transform: uppercase;
letter-spacing: 1px;
}
.content-grid {
display: grid;
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
gap: 16px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 420px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--text-secondary);
}
.panel-meta {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
}
.panel-body {
padding: 12px 14px;
flex: 1;
overflow: auto;
}
.aircraft-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.aircraft-table th,
.aircraft-table td {
padding: 8px 6px;
border-bottom: 1px solid rgba(31, 41, 55, 0.6);
text-align: left;
}
.aircraft-table th {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
.aircraft-row {
cursor: pointer;
transition: background 0.15s ease;
}
.aircraft-row:hover {
background: rgba(74, 158, 255, 0.1);
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
.empty-row td,
.empty-row {
color: var(--text-dim);
text-align: center;
padding: 18px 10px;
}
.detail-card {
padding: 12px 14px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 12px;
}
.detail-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
}
.detail-meta {
color: var(--text-secondary);
font-size: 12px;
}
.chart-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px;
height: 180px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.chart-title {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
#altitudeChart {
width: 100%;
height: 100%;
}
#speedChart,
#headingChart,
#verticalChart {
width: 100%;
height: 100%;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.timeline-row {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid rgba(31, 41, 55, 0.6);
border-radius: 6px;
background: rgba(15, 18, 24, 0.6);
}
.squawk-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
color: var(--text-secondary);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 8, 15, 0.65);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 50;
}
.modal-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.modal-card {
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 18px;
width: min(820px, 92vw);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
position: relative;
}
.modal-close {
position: absolute;
top: 12px;
right: 12px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 14px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
}
.modal-subtitle {
color: var(--text-secondary);
font-size: 12px;
margin-top: 4px;
}
.modal-actions {
display: flex;
gap: 8px;
}
.nav-btn {
background: rgba(74, 158, 255, 0.15);
border: 1px solid rgba(74, 158, 255, 0.4);
color: var(--accent-cyan);
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
}
.modal-body {
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 16px;
}
.modal-photo {
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border-color);
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.modal-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: none;
}
.photo-fallback {
color: var(--text-dim);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.modal-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 18px;
font-size: 12px;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
background: rgba(20, 26, 36, 0.6);
border-radius: 8px;
border: 1px solid rgba(31, 41, 55, 0.6);
}
.detail-row span {
color: var(--text-secondary);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.detail-row strong {
font-size: 13px;
}
@media (max-width: 1024px) {
.content-grid {
grid-template-columns: 1fr;
}
.modal-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.controls {
flex-direction: column;
align-items: stretch;
}
.control-group input,
.control-group select {
min-width: 100%;
}
.panel {
min-height: 320px;
}
.session-controls {
flex-direction: column;
align-items: stretch;
}
.modal-card {
padding: 16px;
}
.modal-details {
grid-template-columns: 1fr;
}
}
+343
View File
@@ -0,0 +1,343 @@
/*
* Agents Management CSS
* Styles for the remote agent management interface
*/
/* CSS Variables (inherited from main theme) */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--text-primary: #e0e0e0;
--text-secondary: #888;
--border-color: #1a1a2e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-red: #ff3366;
--accent-orange: #ff9f1c;
}
/* Agent indicator in navigation */
.agent-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator-dot.remote {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot.multiple {
background: var(--accent-orange);
box-shadow: 0 0 6px var(--accent-orange);
}
.agent-indicator-label {
font-size: 11px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
/* Agent selector dropdown */
.agent-selector {
position: relative;
}
.agent-selector-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: none;
}
.agent-selector-dropdown.show {
display: block;
}
.agent-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-header h4 {
margin: 0;
font-size: 12px;
color: var(--accent-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-selector-manage {
font-size: 11px;
color: var(--accent-cyan);
text-decoration: none;
}
.agent-selector-manage:hover {
text-decoration: underline;
}
.agent-selector-list {
max-height: 300px;
overflow-y: auto;
}
.agent-selector-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-item:last-child {
border-bottom: none;
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item.local {
border-left: 3px solid var(--accent-green);
}
.agent-selector-item-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green);
}
.agent-selector-item-status.offline {
background: var(--accent-red);
}
.agent-selector-item-info {
flex: 1;
min-width: 0;
}
.agent-selector-item-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-url {
font-size: 10px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-check {
color: var(--accent-green);
opacity: 0;
}
.agent-selector-item.selected .agent-selector-item-check {
opacity: 1;
}
/* Agent badge in data displays */
.agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: 'JetBrains Mono', monospace;
}
.agent-badge.local,
.agent-badge.agent-local {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
}
.agent-badge.agent-remote {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
}
/* WiFi table agent column */
.wifi-networks-table .col-agent {
width: 100px;
text-align: center;
}
.wifi-networks-table th.col-agent {
font-size: 10px;
}
/* Bluetooth table agent column */
.bt-devices-table .col-agent {
width: 100px;
text-align: center;
}
.agent-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Agent column in data tables */
.data-table .agent-col {
width: 120px;
max-width: 120px;
}
/* Multi-agent stream indicator */
.multi-agent-indicator {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: 11px;
color: var(--text-secondary);
z-index: 100;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.multi-agent-indicator-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Agent connection status toast */
.agent-toast {
position: fixed;
top: 80px;
right: 20px;
padding: 10px 15px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
z-index: 1001;
animation: slideInRight 0.3s ease;
}
.agent-toast.connected {
border-color: var(--accent-green);
color: var(--accent-green);
}
.agent-toast.disconnected {
border-color: var(--accent-red);
color: var(--accent-red);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.agent-indicator {
padding: 4px 8px;
}
.agent-indicator-label {
display: none;
}
.agent-selector-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}
File diff suppressed because it is too large Load Diff
+696
View File
@@ -0,0 +1,696 @@
/**
* Activity Timeline Component
* Reusable, configuration-driven timeline visualization
* Supports visual modes: compact, enriched, summary
*/
/* ============================================
CSS VARIABLES (with fallbacks)
============================================ */
.activity-timeline {
--timeline-bg: var(--bg-card, #1a1a1a);
--timeline-border: var(--border-color, #333);
--timeline-bg-secondary: var(--bg-secondary, #252525);
--timeline-bg-elevated: var(--bg-elevated, #2a2a2a);
--timeline-text-primary: var(--text-primary, #fff);
--timeline-text-secondary: var(--text-secondary, #888);
--timeline-text-dim: var(--text-dim, #666);
--timeline-accent: var(--accent-cyan, #4a9eff);
--timeline-status-new: var(--signal-new, #3b82f6);
--timeline-status-baseline: var(--signal-baseline, #6b7280);
--timeline-status-burst: var(--signal-burst, #f59e0b);
--timeline-status-flagged: var(--signal-emergency, #ef4444);
--timeline-status-gone: var(--text-dim, #666);
}
/* ============================================
TIMELINE CONTAINER
============================================ */
.activity-timeline {
background: var(--timeline-bg);
border: 1px solid var(--timeline-border);
border-radius: 6px;
font-family: 'JetBrains Mono', 'SF Mono', 'Consolas', monospace;
font-size: 11px;
}
.activity-timeline.collapsed .activity-timeline-body {
display: none;
}
.activity-timeline.collapsed .activity-timeline-header {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 10px;
}
.activity-timeline.collapsed .activity-timeline-collapse-icon {
transform: rotate(-90deg);
}
/* ============================================
HEADER
============================================ */
.activity-timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background 0.15s ease;
}
.activity-timeline-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.activity-timeline-collapse-icon {
margin-right: 8px;
font-size: 10px;
transition: transform 0.2s ease;
color: var(--timeline-text-dim);
}
.activity-timeline-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--timeline-text-secondary);
}
.activity-timeline-header-stats {
display: flex;
gap: 12px;
font-size: 10px;
color: var(--timeline-text-dim);
}
.activity-timeline-header-stat {
display: flex;
align-items: center;
gap: 4px;
}
.activity-timeline-header-stat .stat-value {
color: var(--timeline-text-primary);
font-weight: 500;
}
/* ============================================
BODY
============================================ */
.activity-timeline-body {
padding: 0 12px 12px 12px;
border-top: 1px solid var(--timeline-border);
}
/* ============================================
CONTROLS
============================================ */
.activity-timeline-controls {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 0;
flex-wrap: wrap;
}
.activity-timeline-btn {
background: var(--timeline-bg-secondary);
border: 1px solid var(--timeline-border);
color: var(--timeline-text-secondary);
font-size: 9px;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.activity-timeline-btn:hover {
background: var(--timeline-bg-elevated);
color: var(--timeline-text-primary);
}
.activity-timeline-btn.active {
background: var(--timeline-accent);
color: #000;
border-color: var(--timeline-accent);
}
.activity-timeline-window {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: var(--timeline-text-dim);
margin-left: auto;
}
.activity-timeline-window-select {
background: var(--timeline-bg-secondary);
border: 1px solid var(--timeline-border);
color: var(--timeline-text-primary);
font-size: 9px;
padding: 3px 6px;
border-radius: 3px;
font-family: inherit;
}
/* ============================================
TIME AXIS
============================================ */
.activity-timeline-axis {
display: flex;
justify-content: space-between;
padding: 0 50px 0 140px;
margin-bottom: 6px;
font-size: 9px;
color: var(--timeline-text-dim);
}
.activity-timeline-axis-label {
position: relative;
}
.activity-timeline-axis-label::before {
content: '';
position: absolute;
top: -4px;
left: 50%;
width: 1px;
height: 4px;
background: var(--timeline-border);
}
/* ============================================
LANES CONTAINER
============================================ */
.activity-timeline-lanes {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 180px;
overflow-y: auto;
margin-top: 6px;
}
.activity-timeline-lanes::-webkit-scrollbar {
width: 6px;
}
.activity-timeline-lanes::-webkit-scrollbar-track {
background: var(--timeline-bg-secondary);
border-radius: 3px;
}
.activity-timeline-lanes::-webkit-scrollbar-thumb {
background: var(--timeline-border);
border-radius: 3px;
}
.activity-timeline-lanes::-webkit-scrollbar-thumb:hover {
background: var(--timeline-text-dim);
}
/* ============================================
INDIVIDUAL LANE
============================================ */
.activity-timeline-lane {
display: flex;
align-items: stretch;
min-height: 32px;
background: var(--timeline-bg-secondary);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
transition: background 0.15s ease;
}
.activity-timeline-lane:hover {
background: var(--timeline-bg-elevated);
}
.activity-timeline-lane.expanded {
min-height: auto;
}
.activity-timeline-lane.baseline {
opacity: 0.5;
}
.activity-timeline-lane.baseline:hover {
opacity: 0.8;
}
/* Status indicator strip */
.activity-timeline-status {
width: 4px;
min-width: 4px;
flex-shrink: 0;
}
.activity-timeline-status[data-status="new"] {
background: var(--timeline-status-new);
}
.activity-timeline-status[data-status="baseline"] {
background: var(--timeline-status-baseline);
}
.activity-timeline-status[data-status="burst"] {
background: var(--timeline-status-burst);
}
.activity-timeline-status[data-status="flagged"] {
background: var(--timeline-status-flagged);
}
.activity-timeline-status[data-status="gone"] {
background: var(--timeline-status-gone);
}
/* Label section */
.activity-timeline-label {
width: 130px;
min-width: 130px;
padding: 6px 8px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
border-right: 1px solid var(--timeline-border);
overflow: hidden;
}
.activity-timeline-id {
color: var(--timeline-text-primary);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.activity-timeline-name {
color: var(--timeline-text-dim);
font-size: 9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* ============================================
TRACK (where bars are drawn)
============================================ */
.activity-timeline-track {
flex: 1;
position: relative;
height: 100%;
min-height: 32px;
padding: 4px 8px;
}
.activity-timeline-track-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
}
/* ============================================
SIGNAL BARS
============================================ */
.activity-timeline-bar {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 14px;
min-width: 2px;
border-radius: 2px;
transition: opacity 0.15s ease;
}
/* Strength variants */
.activity-timeline-bar[data-strength="1"] { height: 5px; }
.activity-timeline-bar[data-strength="2"] { height: 9px; }
.activity-timeline-bar[data-strength="3"] { height: 13px; }
.activity-timeline-bar[data-strength="4"] { height: 17px; }
.activity-timeline-bar[data-strength="5"] { height: 21px; }
/* Status colors */
.activity-timeline-bar[data-status="new"],
.activity-timeline-bar[data-status="repeated"] {
background: var(--timeline-status-new);
box-shadow: 0 0 4px rgba(59, 130, 246, 0.3);
}
.activity-timeline-bar[data-status="baseline"] {
background: var(--timeline-status-baseline);
}
.activity-timeline-bar[data-status="burst"] {
background: var(--timeline-status-burst);
box-shadow: 0 0 5px rgba(245, 158, 11, 0.4);
}
.activity-timeline-bar[data-status="flagged"] {
background: var(--timeline-status-flagged);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
animation: timeline-flagged-pulse 2s ease-in-out infinite;
}
@keyframes timeline-flagged-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.activity-timeline-lane:hover .activity-timeline-bar {
opacity: 0.9;
}
/* ============================================
EXPANDED VIEW (tick marks)
============================================ */
.activity-timeline-ticks {
display: none;
position: relative;
height: 24px;
margin-top: 4px;
border-top: 1px solid var(--timeline-border);
padding-top: 4px;
}
.activity-timeline-lane.expanded .activity-timeline-ticks {
display: block;
}
.activity-timeline-tick {
position: absolute;
bottom: 0;
width: 1px;
background: var(--timeline-accent);
}
.activity-timeline-tick[data-strength="1"] { height: 4px; }
.activity-timeline-tick[data-strength="2"] { height: 8px; }
.activity-timeline-tick[data-strength="3"] { height: 12px; }
.activity-timeline-tick[data-strength="4"] { height: 16px; }
.activity-timeline-tick[data-strength="5"] { height: 20px; }
/* ============================================
STATS COLUMN
============================================ */
.activity-timeline-stats {
width: 45px;
min-width: 45px;
padding: 4px 6px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
font-size: 9px;
color: var(--timeline-text-dim);
border-left: 1px solid var(--timeline-border);
}
.activity-timeline-stat-count {
color: var(--timeline-text-primary);
font-weight: 500;
}
.activity-timeline-stat-label {
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* ============================================
ANNOTATIONS
============================================ */
.activity-timeline-annotations {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--timeline-border);
max-height: 80px;
overflow-y: auto;
}
.activity-timeline-annotation {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-size: 10px;
color: var(--timeline-text-secondary);
background: var(--timeline-bg-secondary);
border-radius: 3px;
margin-bottom: 4px;
}
.activity-timeline-annotation-icon {
font-size: 10px;
width: 14px;
text-align: center;
}
.activity-timeline-annotation[data-type="new"] {
border-left: 2px solid var(--timeline-status-new);
}
.activity-timeline-annotation[data-type="burst"] {
border-left: 2px solid var(--timeline-status-burst);
}
.activity-timeline-annotation[data-type="pattern"] {
border-left: 2px solid var(--timeline-accent);
}
.activity-timeline-annotation[data-type="flagged"] {
border-left: 2px solid var(--timeline-status-flagged);
color: var(--timeline-status-flagged);
}
.activity-timeline-annotation[data-type="gone"] {
border-left: 2px solid var(--timeline-status-gone);
}
/* ============================================
TOOLTIP
============================================ */
.activity-timeline-tooltip {
position: fixed;
z-index: 10000;
background: var(--timeline-bg-elevated);
border: 1px solid var(--timeline-border);
border-radius: 4px;
padding: 8px 10px;
font-size: 10px;
color: var(--timeline-text-primary);
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 240px;
font-family: 'JetBrains Mono', monospace;
}
.activity-timeline-tooltip-header {
font-weight: 600;
margin-bottom: 4px;
color: var(--timeline-accent);
}
.activity-timeline-tooltip-row {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--timeline-text-secondary);
line-height: 1.5;
}
.activity-timeline-tooltip-row span:last-child {
color: var(--timeline-text-primary);
}
/* ============================================
LEGEND
============================================ */
.activity-timeline-legend {
display: flex;
gap: 12px;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--timeline-border);
font-size: 9px;
color: var(--timeline-text-dim);
}
.activity-timeline-legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.activity-timeline-legend-dot {
width: 6px;
height: 6px;
border-radius: 2px;
}
.activity-timeline-legend-dot.new { background: var(--timeline-status-new); }
.activity-timeline-legend-dot.baseline { background: var(--timeline-status-baseline); }
.activity-timeline-legend-dot.burst { background: var(--timeline-status-burst); }
.activity-timeline-legend-dot.flagged { background: var(--timeline-status-flagged); }
/* ============================================
EMPTY STATE
============================================ */
.activity-timeline-empty {
text-align: center;
padding: 24px 16px;
color: var(--timeline-text-dim);
font-size: 11px;
}
.activity-timeline-empty-icon {
font-size: 20px;
margin-bottom: 8px;
opacity: 0.4;
}
/* More indicator */
.activity-timeline-more {
text-align: center;
padding: 8px;
font-size: 10px;
color: var(--timeline-text-dim);
}
/* ============================================
VISUAL MODE: COMPACT
============================================ */
.activity-timeline--compact .activity-timeline-lanes {
max-height: 140px;
}
.activity-timeline--compact .activity-timeline-lane {
min-height: 26px;
}
.activity-timeline--compact .activity-timeline-label {
width: 100px;
min-width: 100px;
padding: 4px 6px;
}
.activity-timeline--compact .activity-timeline-id {
display: none;
}
.activity-timeline--compact .activity-timeline-name {
font-size: 10px;
color: var(--timeline-text-secondary);
}
.activity-timeline--compact .activity-timeline-track {
min-height: 26px;
}
.activity-timeline--compact .activity-timeline-bar {
height: 10px !important;
}
.activity-timeline--compact .activity-timeline-bar[data-strength="1"] { height: 4px !important; }
.activity-timeline--compact .activity-timeline-bar[data-strength="2"] { height: 6px !important; }
.activity-timeline--compact .activity-timeline-bar[data-strength="3"] { height: 8px !important; }
.activity-timeline--compact .activity-timeline-bar[data-strength="4"] { height: 10px !important; }
.activity-timeline--compact .activity-timeline-bar[data-strength="5"] { height: 12px !important; }
.activity-timeline--compact .activity-timeline-stats {
width: 30px;
min-width: 30px;
}
.activity-timeline--compact .activity-timeline-stat-label {
display: none;
}
.activity-timeline--compact .activity-timeline-legend {
display: none;
}
.activity-timeline--compact .activity-timeline-axis {
padding-left: 110px;
padding-right: 40px;
}
/* ============================================
VISUAL MODE: SUMMARY
============================================ */
.activity-timeline--summary .activity-timeline-lanes {
max-height: 100px;
}
.activity-timeline--summary .activity-timeline-lane {
min-height: 20px;
}
.activity-timeline--summary .activity-timeline-label {
width: 80px;
min-width: 80px;
padding: 3px 6px;
}
.activity-timeline--summary .activity-timeline-id,
.activity-timeline--summary .activity-timeline-name {
font-size: 9px;
}
.activity-timeline--summary .activity-timeline-status {
width: 3px;
min-width: 3px;
}
.activity-timeline--summary .activity-timeline-track {
min-height: 20px;
}
.activity-timeline--summary .activity-timeline-bar {
height: 8px !important;
border-radius: 1px;
}
.activity-timeline--summary .activity-timeline-stats {
display: none;
}
.activity-timeline--summary .activity-timeline-ticks {
display: none !important;
}
.activity-timeline--summary .activity-timeline-annotations {
display: none;
}
.activity-timeline--summary .activity-timeline-legend {
display: none;
}
.activity-timeline--summary .activity-timeline-axis {
padding-left: 90px;
padding-right: 10px;
font-size: 8px;
}
/* ============================================
BACKWARD COMPATIBILITY NOTE
The old signal-timeline.css is still loaded
for existing TSCM code that uses those classes.
New code should use activity-timeline classes.
============================================ */
+879
View File
@@ -0,0 +1,879 @@
/**
* Device Cards Component CSS
* Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
*/
/* ============================================
CSS VARIABLES
============================================ */
:root {
/* Protocol colors */
--proto-ble: #3b82f6;
--proto-ble-bg: rgba(59, 130, 246, 0.15);
--proto-classic: #8b5cf6;
--proto-classic-bg: rgba(139, 92, 246, 0.15);
/* Range band colors */
--range-very-close: #ef4444;
--range-close: #f97316;
--range-nearby: #eab308;
--range-far: #6b7280;
--range-unknown: #374151;
/* Heuristic badge colors */
--heuristic-new: #3b82f6;
--heuristic-persistent: #22c55e;
--heuristic-beacon: #f59e0b;
--heuristic-strong: #ef4444;
--heuristic-random: #6b7280;
}
/* ============================================
DEVICE CARD BASE
============================================ */
.device-card {
cursor: pointer;
transition: all 0.15s ease;
}
.device-card:hover {
border-color: var(--accent-cyan, #00d4ff);
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
}
.device-card:active {
transform: scale(0.995);
}
/* ============================================
DEVICE IDENTITY
============================================ */
.device-identity {
margin-bottom: 10px;
}
.device-name {
font-family: 'Inter', -apple-system, sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-address {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.device-address .address-value {
color: var(--accent-cyan, #00d4ff);
}
.device-address .address-type {
color: var(--text-dim, #666);
font-size: 10px;
}
/* ============================================
PROTOCOL BADGES
============================================ */
.signal-proto-badge.device-protocol {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid;
}
/* ============================================
HEURISTIC BADGES
============================================ */
.device-heuristic-badge {
display: inline-flex;
align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 2px 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
color: var(--badge-color);
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
}
.device-heuristic-badge.new {
--badge-color: var(--heuristic-new);
animation: heuristicPulse 2s ease-in-out infinite;
}
.device-heuristic-badge.persistent {
--badge-color: var(--heuristic-persistent);
}
.device-heuristic-badge.beacon_like {
--badge-color: var(--heuristic-beacon);
}
.device-heuristic-badge.strong_stable {
--badge-color: var(--heuristic-strong);
}
.device-heuristic-badge.random_address {
--badge-color: var(--heuristic-random);
}
@keyframes heuristicPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ============================================
SIGNAL ROW & RSSI DISPLAY
============================================ */
.device-signal-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px;
background: var(--bg-secondary, #1a1a1a);
border-radius: 6px;
margin-bottom: 8px;
}
.rssi-display {
display: flex;
align-items: center;
gap: 10px;
}
.rssi-current {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
min-width: 70px;
}
/* ============================================
RSSI SPARKLINE
============================================ */
.rssi-sparkline,
.rssi-sparkline-svg {
display: inline-block;
vertical-align: middle;
}
.rssi-sparkline-empty {
opacity: 0.5;
}
.rssi-sparkline-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.rssi-value {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
}
.rssi-current-value {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 500;
margin-left: 6px;
}
.sparkline-dot {
animation: sparklinePulse 1.5s ease-in-out infinite;
}
@keyframes sparklinePulse {
0%, 100% { r: 2; opacity: 1; }
50% { r: 3; opacity: 0.8; }
}
/* ============================================
RANGE BAND INDICATOR
============================================ */
.device-range-band {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: color-mix(in srgb, var(--range-color) 15%, transparent);
border-radius: 4px;
border-left: 3px solid var(--range-color);
}
.device-range-band .range-label {
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 600;
color: var(--range-color);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.device-range-band .range-estimate {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-dim, #666);
}
.device-range-band .range-confidence {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-dim, #666);
padding: 1px 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
/* ============================================
MANUFACTURER INFO
============================================ */
.device-manufacturer {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-secondary, #888);
margin-bottom: 6px;
}
.device-manufacturer .mfr-icon {
font-size: 12px;
opacity: 0.7;
}
.device-manufacturer .mfr-name {
font-family: 'Inter', sans-serif;
}
/* ============================================
META ROW
============================================ */
.device-meta-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 10px;
color: var(--text-dim, #666);
}
.device-seen-count {
display: flex;
align-items: center;
gap: 3px;
font-family: 'JetBrains Mono', monospace;
}
.device-seen-count .seen-icon {
font-size: 10px;
opacity: 0.7;
}
.device-timestamp {
font-family: 'JetBrains Mono', monospace;
}
/* ============================================
SERVICE UUIDS
============================================ */
.device-uuids {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.device-uuid {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
padding: 2px 6px;
background: var(--bg-tertiary, #1a1a1a);
border-radius: 3px;
color: var(--text-secondary, #888);
border: 1px solid var(--border-color, #333);
}
/* ============================================
HEURISTICS DETAIL VIEW
============================================ */
.device-heuristics-detail {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.heuristic-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--bg-tertiary, #1a1a1a);
border-radius: 4px;
border: 1px solid var(--border-color, #333);
}
.heuristic-item.active {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
}
.heuristic-item .heuristic-name {
font-size: 10px;
text-transform: capitalize;
color: var(--text-secondary, #888);
}
.heuristic-item .heuristic-status {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
}
.heuristic-item.active .heuristic-status {
color: var(--accent-green, #22c55e);
}
.heuristic-item:not(.active) .heuristic-status {
color: var(--text-dim, #666);
}
/* ============================================
MESSAGE CARDS
============================================ */
.message-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--message-bg);
border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
border-radius: 8px;
margin-bottom: 12px;
animation: messageSlideIn 0.25s ease;
position: relative;
}
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-card.message-card-hiding {
opacity: 0;
transform: translateY(-8px);
transition: all 0.2s ease;
}
.message-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--message-color);
border-radius: 8px 0 0 8px;
}
.message-card-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
color: var(--message-color);
}
.message-card-icon svg {
width: 100%;
height: 100%;
}
.message-card-icon svg.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.message-card-content {
flex: 1;
min-width: 0;
}
.message-card-title {
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin-bottom: 2px;
}
.message-card-text {
font-size: 12px;
color: var(--text-secondary, #888);
line-height: 1.4;
}
.message-card-details {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-dim, #666);
margin-top: 4px;
}
.message-card-dismiss {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
background: none;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s, color 0.15s;
}
.message-card-dismiss:hover {
opacity: 1;
color: var(--text-primary, #e0e0e0);
}
.message-card-dismiss svg {
width: 100%;
height: 100%;
}
.message-card-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.message-action-btn {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid var(--border-color, #333);
background: var(--bg-secondary, #1a1a1a);
color: var(--text-secondary, #888);
cursor: pointer;
transition: all 0.15s;
}
.message-action-btn:hover {
background: var(--bg-tertiary, #252525);
border-color: var(--border-light, #444);
color: var(--text-primary, #e0e0e0);
}
.message-action-btn.primary {
background: color-mix(in srgb, var(--message-color) 20%, transparent);
border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
color: var(--message-color);
}
.message-action-btn.primary:hover {
background: color-mix(in srgb, var(--message-color) 30%, transparent);
}
/* ============================================
DEVICE FILTER BAR
============================================ */
.device-filter-bar {
flex-wrap: wrap;
}
.device-filter-bar .signal-filter-btn .filter-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
/* ============================================
RESPONSIVE ADJUSTMENTS
============================================ */
@media (max-width: 600px) {
.device-signal-row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.rssi-display {
justify-content: center;
}
.device-range-band {
justify-content: center;
}
.device-heuristics-detail {
grid-template-columns: 1fr;
}
.message-card {
padding: 10px 12px;
}
.message-card-title {
font-size: 12px;
}
.message-card-text {
font-size: 11px;
}
}
/* ============================================
BLUETOOTH DEVICE LIST CONTAINER
============================================ */
#btDeviceListContent {
display: block !important;
padding: 10px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
/* Pure inline-styled cards - ensure no interference */
#btDeviceListContent > div[data-bt-device-id] {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
height: auto !important;
min-height: auto !important;
overflow: visible !important;
}
/* Legacy card support */
#btDeviceListContent .device-card,
#btDeviceListContent .signal-card {
margin: 0 0 10px 0;
height: auto !important;
min-height: auto !important;
overflow: visible !important;
}
/* Ensure card body is visible */
.device-card .signal-card-body,
.signal-card .signal-card-body {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
visibility: visible !important;
opacity: 1 !important;
height: auto !important;
overflow: visible !important;
}
.device-card .device-identity,
.signal-card .device-identity {
display: block !important;
visibility: visible !important;
}
.device-card .device-signal-row,
.signal-card .device-signal-row {
display: flex !important;
visibility: visible !important;
}
.device-card .device-meta-row,
.signal-card .device-meta-row {
display: flex !important;
visibility: visible !important;
}
/* ============================================
ENHANCED MODAL STYLES
============================================ */
.signal-details-modal-header .modal-header-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.signal-details-modal-subtitle {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-dim, #666);
}
.signal-details-modal-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.signal-details-copy-addr-btn {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 8px 16px;
background: var(--bg-secondary, #252525);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
color: var(--text-secondary, #888);
cursor: pointer;
transition: all 0.15s ease;
}
.signal-details-copy-addr-btn:hover {
background: var(--bg-tertiary, #1a1a1a);
color: var(--text-primary, #e0e0e0);
}
/* Modal Header Section */
.modal-device-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-color, #333);
}
.modal-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* Modal Sections */
.modal-section {
margin-bottom: 20px;
}
.modal-section:last-child {
margin-bottom: 0;
}
.modal-section-title {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim, #666);
margin-bottom: 12px;
}
/* Signal Display */
.modal-signal-display {
display: flex;
align-items: center;
gap: 24px;
padding: 16px;
background: var(--bg-secondary, #1a1a1a);
border-radius: 8px;
margin-bottom: 12px;
}
.modal-rssi-large {
font-family: 'JetBrains Mono', monospace;
font-size: 36px;
font-weight: 700;
color: var(--accent-cyan, #00d4ff);
line-height: 1;
}
.modal-rssi-large .rssi-unit {
font-size: 14px;
font-weight: 400;
color: var(--text-dim, #666);
margin-left: 4px;
}
.modal-sparkline {
flex: 1;
display: flex;
justify-content: flex-end;
}
/* Signal Stats Grid */
.modal-signal-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.modal-signal-stats .stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
background: var(--bg-secondary, #1a1a1a);
border-radius: 6px;
text-align: center;
}
.modal-signal-stats .stat-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #666);
margin-bottom: 4px;
}
.modal-signal-stats .stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
/* Info Grid */
.modal-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.modal-info-grid .info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-secondary, #1a1a1a);
border-radius: 6px;
}
.modal-info-grid .info-label {
font-size: 11px;
color: var(--text-dim, #666);
}
.modal-info-grid .info-value {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #e0e0e0);
}
.modal-info-grid .info-value.mono {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan, #00d4ff);
}
/* UUID List */
.modal-uuid-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.modal-uuid {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 4px 8px;
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
color: var(--text-secondary, #888);
}
/* Heuristics Grid */
.modal-heuristics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.heuristic-check {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--bg-secondary, #1a1a1a);
border-radius: 6px;
border: 1px solid var(--border-color, #333);
}
.heuristic-check.active {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
}
.heuristic-indicator {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-dim, #666);
}
.heuristic-check.active .heuristic-indicator {
color: var(--accent-green, #22c55e);
}
.heuristic-label {
font-size: 11px;
text-transform: capitalize;
color: var(--text-secondary, #888);
}
/* ============================================
RESPONSIVE MODAL
============================================ */
@media (max-width: 600px) {
.modal-signal-stats {
grid-template-columns: repeat(2, 1fr);
}
.modal-info-grid {
grid-template-columns: 1fr;
}
.modal-signal-display {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.modal-sparkline {
width: 100%;
justify-content: center;
}
.modal-device-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
/* ============================================
DARK MODE OVERRIDES (if needed)
============================================ */
@media (prefers-color-scheme: dark) {
.device-card {
--bg-secondary: #1a1a1a;
--bg-tertiary: #141414;
}
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Proximity Visualization Components
* Styles for radar and timeline heatmap
*/
/* ============================================
PROXIMITY RADAR
============================================ */
.proximity-radar-svg {
display: block;
margin: 0 auto;
}
.radar-device {
transition: transform 0.2s ease;
}
.radar-device:hover {
transform: scale(1.3);
}
.radar-dot-pulse circle:first-child {
animation: radar-pulse 1.5s ease-out infinite;
}
@keyframes radar-pulse {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-sweep {
transform-origin: 50% 50%;
}
/* Radar filter buttons */
.bt-radar-filter-btn {
transition: all 0.2s ease;
}
.bt-radar-filter-btn:hover {
background: var(--bg-hover, #333) !important;
color: #fff !important;
}
.bt-radar-filter-btn.active {
background: #00d4ff !important;
color: #000 !important;
border-color: #00d4ff !important;
}
#btRadarPauseBtn.active {
background: #f97316 !important;
color: #000 !important;
border-color: #f97316 !important;
}
/* ============================================
TIMELINE HEATMAP
============================================ */
.timeline-heatmap-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 8px 0;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-color, #333);
}
.heatmap-control-group {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim, #888);
}
.heatmap-select {
background: var(--bg-tertiary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
color: var(--text-primary, #e0e0e0);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
}
.heatmap-select:hover {
border-color: var(--accent-color, #00d4ff);
}
.heatmap-btn {
background: var(--bg-tertiary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
color: var(--text-dim, #888);
font-size: 10px;
padding: 4px 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.heatmap-btn:hover {
background: var(--bg-hover, #252525);
color: var(--text-primary, #e0e0e0);
}
.heatmap-btn.active {
background: #f97316;
color: #000;
border-color: #f97316;
}
.timeline-heatmap-content {
max-height: 300px;
overflow-y: auto;
overflow-x: auto;
}
.heatmap-loading,
.heatmap-empty,
.heatmap-error {
color: var(--text-dim, #666);
text-align: center;
padding: 30px;
font-size: 12px;
}
.heatmap-error {
color: #ef4444;
}
.heatmap-grid {
display: flex;
flex-direction: column;
gap: 2px;
min-width: max-content;
}
.heatmap-row {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s ease;
}
.heatmap-row:hover:not(.heatmap-header) {
background: rgba(255, 255, 255, 0.05);
}
.heatmap-row.selected {
background: rgba(0, 212, 255, 0.1);
outline: 1px solid rgba(0, 212, 255, 0.3);
}
.heatmap-header {
cursor: default;
border-bottom: 1px solid var(--border-color, #333);
margin-bottom: 4px;
}
.heatmap-label {
width: 120px;
min-width: 120px;
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 8px;
overflow: hidden;
}
.heatmap-label .device-name {
font-size: 10px;
color: var(--text-primary, #e0e0e0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heatmap-label .device-rssi {
font-size: 9px;
color: var(--text-dim, #666);
font-family: monospace;
}
.heatmap-cells {
display: flex;
gap: 1px;
}
.heatmap-cell {
border-radius: 2px;
transition: transform 0.1s ease;
}
.heatmap-cell:hover {
transform: scale(1.5);
z-index: 10;
position: relative;
}
.heatmap-time-label {
font-size: 8px;
color: var(--text-dim, #666);
text-align: center;
transform: rotate(-45deg);
white-space: nowrap;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 12px;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--border-color, #333);
font-size: 10px;
color: var(--text-dim, #666);
}
.legend-label {
font-weight: 500;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* ============================================
ZONE SUMMARY
============================================ */
#btZoneSummary {
padding: 8px 0;
}
#btZoneSummary > div {
min-width: 60px;
}
/* ============================================
RESPONSIVE ADJUSTMENTS
============================================ */
@media (max-width: 768px) {
.timeline-heatmap-controls {
flex-direction: column;
align-items: stretch;
}
.heatmap-control-group {
justify-content: space-between;
}
.proximity-radar-svg {
max-width: 100%;
height: auto;
}
#btRadarControls {
flex-direction: column;
gap: 4px;
}
#btZoneSummary {
flex-wrap: wrap;
}
}
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
/**
* Signal Activity Timeline Component
* Lightweight visualization for RF signal presence over time
* Used for TSCM sweeps and investigative analysis
*/
/* ============================================
TIMELINE CONTAINER
============================================ */
.signal-timeline {
background: var(--bg-card, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
}
.signal-timeline.collapsed .signal-timeline-body {
display: none;
}
.signal-timeline.collapsed .signal-timeline-header {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.signal-timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
cursor: pointer;
user-select: none;
}
.signal-timeline-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.signal-timeline-body {
padding: 0 12px 12px 12px;
border-top: 1px solid var(--border-color, #333);
}
.signal-timeline-collapse-icon {
margin-right: 8px;
font-size: 10px;
transition: transform 0.2s ease;
}
.signal-timeline.collapsed .signal-timeline-collapse-icon {
transform: rotate(-90deg);
}
.signal-timeline-header-stats {
display: flex;
gap: 12px;
font-size: 10px;
color: var(--text-dim, #666);
}
.signal-timeline-header-stat {
display: flex;
align-items: center;
gap: 4px;
}
.signal-timeline-header-stat .stat-value {
color: var(--text-primary, #fff);
font-weight: 500;
}
.signal-timeline-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #888);
}
.signal-timeline-controls {
display: flex;
gap: 6px;
align-items: center;
}
.signal-timeline-btn {
background: var(--bg-secondary, #252525);
border: 1px solid var(--border-color, #333);
color: var(--text-secondary, #888);
font-size: 9px;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.signal-timeline-btn:hover {
background: var(--bg-elevated, #2a2a2a);
color: var(--text-primary, #fff);
}
.signal-timeline-btn.active {
background: var(--accent-cyan, #4a9eff);
color: #000;
border-color: var(--accent-cyan, #4a9eff);
}
/* Time window selector */
.signal-timeline-window {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: var(--text-dim, #666);
}
.signal-timeline-window select {
background: var(--bg-secondary, #252525);
border: 1px solid var(--border-color, #333);
color: var(--text-primary, #fff);
font-size: 9px;
padding: 3px 6px;
border-radius: 3px;
font-family: inherit;
}
/* ============================================
TIME AXIS
============================================ */
.signal-timeline-axis {
display: flex;
justify-content: space-between;
padding: 0 80px 0 100px;
margin-bottom: 8px;
font-size: 9px;
color: var(--text-dim, #666);
}
.signal-timeline-axis-label {
position: relative;
}
.signal-timeline-axis-label::before {
content: '';
position: absolute;
top: -4px;
left: 50%;
width: 1px;
height: 4px;
background: var(--border-color, #333);
}
/* ============================================
SWIMLANES
============================================ */
.signal-timeline-lanes {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 160px;
overflow-y: auto;
margin-top: 8px;
}
.signal-timeline-lanes::-webkit-scrollbar {
width: 6px;
}
.signal-timeline-lanes::-webkit-scrollbar-track {
background: var(--bg-secondary, #252525);
border-radius: 3px;
}
.signal-timeline-lanes::-webkit-scrollbar-thumb {
background: var(--border-color, #444);
border-radius: 3px;
}
.signal-timeline-lanes::-webkit-scrollbar-thumb:hover {
background: var(--text-dim, #666);
}
.signal-timeline-lane {
display: flex;
align-items: stretch;
min-height: 36px;
background: var(--bg-secondary, #252525);
border-radius: 3px;
overflow: hidden;
}
.signal-timeline-lane:hover {
background: var(--bg-elevated, #2a2a2a);
}
.signal-timeline-lane.expanded {
min-height: auto;
}
.signal-timeline-lane.baseline {
opacity: 0.5;
}
.signal-timeline-lane.baseline:hover {
opacity: 0.8;
}
/* Signal label */
.signal-timeline-label {
width: 130px;
min-width: 130px;
padding: 6px 8px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
border-right: 1px solid var(--border-color, #333);
overflow: hidden;
}
.signal-timeline-freq {
color: var(--text-primary, #fff);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.signal-timeline-name {
color: var(--text-dim, #666);
font-size: 9px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Status indicator */
.signal-timeline-status {
width: 4px;
min-width: 4px;
}
.signal-timeline-status[data-status="new"] {
background: var(--signal-new, #3b82f6);
}
.signal-timeline-status[data-status="baseline"] {
background: var(--signal-baseline, #6b7280);
}
.signal-timeline-status[data-status="burst"] {
background: var(--signal-burst, #f59e0b);
}
.signal-timeline-status[data-status="flagged"] {
background: var(--signal-emergency, #ef4444);
}
.signal-timeline-status[data-status="gone"] {
background: var(--text-dim, #666);
}
/* ============================================
TRACK (where bars are drawn)
============================================ */
.signal-timeline-track {
flex: 1;
position: relative;
height: 100%;
min-height: 36px;
padding: 4px 8px;
cursor: pointer;
}
.signal-timeline-track-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
}
/* Grid lines */
.signal-timeline-grid {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--border-color, #333);
opacity: 0.3;
}
/* ============================================
SIGNAL BARS
============================================ */
.signal-timeline-bar {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 16px;
min-width: 2px;
border-radius: 2px;
transition: opacity 0.15s ease;
}
/* Strength variants (height) */
.signal-timeline-bar[data-strength="1"] { height: 6px; }
.signal-timeline-bar[data-strength="2"] { height: 10px; }
.signal-timeline-bar[data-strength="3"] { height: 14px; }
.signal-timeline-bar[data-strength="4"] { height: 18px; }
.signal-timeline-bar[data-strength="5"] { height: 22px; }
/* Status colors */
.signal-timeline-bar[data-status="new"] {
background: var(--signal-new, #3b82f6);
box-shadow: 0 0 6px rgba(59, 130, 246, 0.4);
}
.signal-timeline-bar[data-status="baseline"] {
background: var(--signal-baseline, #6b7280);
}
.signal-timeline-bar[data-status="burst"] {
background: var(--signal-burst, #f59e0b);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
}
.signal-timeline-bar[data-status="flagged"] {
background: var(--signal-emergency, #ef4444);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
animation: flaggedPulse 2s ease-in-out infinite;
}
@keyframes flaggedPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.signal-timeline-lane:hover .signal-timeline-bar {
opacity: 0.9;
}
/* ============================================
EXPANDED VIEW (tick marks)
============================================ */
.signal-timeline-ticks {
display: none;
position: relative;
height: 24px;
margin-top: 4px;
border-top: 1px solid var(--border-color, #333);
padding-top: 4px;
}
.signal-timeline-lane.expanded .signal-timeline-ticks {
display: block;
}
.signal-timeline-tick {
position: absolute;
bottom: 0;
width: 1px;
background: var(--accent-cyan, #4a9eff);
}
.signal-timeline-tick[data-strength="1"] { height: 4px; }
.signal-timeline-tick[data-strength="2"] { height: 8px; }
.signal-timeline-tick[data-strength="3"] { height: 12px; }
.signal-timeline-tick[data-strength="4"] { height: 16px; }
.signal-timeline-tick[data-strength="5"] { height: 20px; }
/* ============================================
ANNOTATIONS
============================================ */
.signal-timeline-annotations {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border-color, #333);
max-height: 60px;
overflow-y: auto;
}
.signal-timeline-annotation {
padding: 3px 6px;
font-size: 9px;
margin-bottom: 2px;
}
.signal-timeline-annotation {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-size: 10px;
color: var(--text-secondary, #888);
background: var(--bg-secondary, #252525);
border-radius: 3px;
margin-bottom: 4px;
}
.signal-timeline-annotation-icon {
font-size: 12px;
}
.signal-timeline-annotation[data-type="new"] {
border-left: 2px solid var(--signal-new, #3b82f6);
}
.signal-timeline-annotation[data-type="burst"] {
border-left: 2px solid var(--signal-burst, #f59e0b);
}
.signal-timeline-annotation[data-type="pattern"] {
border-left: 2px solid var(--accent-cyan, #4a9eff);
}
.signal-timeline-annotation[data-type="flagged"] {
border-left: 2px solid var(--signal-emergency, #ef4444);
color: var(--signal-emergency, #ef4444);
}
/* ============================================
TOOLTIP
============================================ */
.signal-timeline-tooltip {
position: fixed;
z-index: 1000;
background: var(--bg-elevated, #2a2a2a);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
padding: 8px 10px;
font-size: 10px;
color: var(--text-primary, #fff);
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 220px;
}
.signal-timeline-tooltip-header {
font-weight: 600;
margin-bottom: 4px;
color: var(--accent-cyan, #4a9eff);
}
.signal-timeline-tooltip-row {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--text-secondary, #888);
}
.signal-timeline-tooltip-row span:last-child {
color: var(--text-primary, #fff);
}
/* ============================================
STATS ROW
============================================ */
.signal-timeline-stats {
width: 50px;
min-width: 50px;
padding: 4px 6px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
font-size: 9px;
color: var(--text-dim, #666);
border-left: 1px solid var(--border-color, #333);
}
.signal-timeline-stat-count {
color: var(--text-primary, #fff);
font-weight: 500;
}
.signal-timeline-stat-label {
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* ============================================
EMPTY STATE
============================================ */
.signal-timeline-empty {
text-align: center;
padding: 30px 20px;
color: var(--text-dim, #666);
font-size: 11px;
}
.signal-timeline-empty-icon {
font-size: 24px;
margin-bottom: 8px;
opacity: 0.5;
}
/* ============================================
LEGEND - compact inline version
============================================ */
.signal-timeline-legend {
display: none; /* Hide by default - status colors are self-explanatory */
}
.signal-timeline-legend-item {
display: flex;
align-items: center;
gap: 3px;
}
.signal-timeline-legend-dot {
width: 6px;
height: 6px;
border-radius: 2px;
}
.signal-timeline-legend-dot.new { background: var(--signal-new, #3b82f6); }
.signal-timeline-legend-dot.baseline { background: var(--signal-baseline, #6b7280); }
.signal-timeline-legend-dot.burst { background: var(--signal-burst, #f59e0b); }
.signal-timeline-legend-dot.flagged { background: var(--signal-emergency, #ef4444); }
/* ============================================
NOW MARKER
============================================ */
.signal-timeline-now {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent-green, #22c55e);
z-index: 5;
}
.signal-timeline-now::after {
content: 'NOW';
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
font-size: 8px;
color: var(--accent-green, #22c55e);
font-weight: 600;
}
/* ============================================
MARKER (first seen indicator)
============================================ */
.signal-timeline-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 8px solid var(--signal-new, #3b82f6);
z-index: 4;
}
.signal-timeline-marker::after {
content: attr(data-label);
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
font-size: 8px;
color: var(--signal-new, #3b82f6);
white-space: nowrap;
}
+67
View File
@@ -0,0 +1,67 @@
/* Local font declarations for offline mode */
/* Inter - Primary UI font */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono - Monospace/code font */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/vendor/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
+1837 -306
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
/* Container Layout */
.landing-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: var(--bg-primary);
display: flex;
flex-direction: column; /* Stack logo, title, box vertically */
align-items: center;
justify-content: center;
overflow: hidden;
}
.landing-content {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
/* Background Effects */
.landing-scanline {
position: absolute;
top: 0; left: 0; width: 100%; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scanlineMove 5s linear infinite;
opacity: 0.4;
z-index: 1; /* Behind content */
pointer-events: none;
}
@keyframes scanlineMove {
0% { top: 0; }
100% { top: 100%; }
}
/* Typography */
.landing-title {
font-family: 'JetBrains Mono', monospace;
font-size: 2.2rem;
font-weight: 700;
letter-spacing: 0.4em;
color: var(--text-primary);
margin: 20px 0 5px 0;
text-indent: 0.4em;
text-align: center;
}
.landing-tagline {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan);
font-size: 0.9rem;
letter-spacing: 0.15em;
margin-bottom: 30px;
}
/* The Login Box */
.login-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
padding: 30px;
border-radius: 4px;
width: 380px;
z-index: 20;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.6), inset 0 0 20px var(--accent-cyan-dim);
box-sizing: border-box; /* Ensures padding doesn't hide inputs */
display: flex;
flex-direction: column;
}
/* Hacker Style Error */
.flash-error {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-red);
background: rgba(239, 68, 68, 0.1);
border-left: 3px solid var(--accent-red);
padding: 10px;
margin-bottom: 20px;
display: flex;
gap: 10px;
text-transform: uppercase;
box-sizing: border-box;
}
.error-prefix { font-weight: 700; opacity: 0.7; }
/* Inputs */
.form-input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--accent-cyan);
padding: 12px;
margin-bottom: 15px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
outline: none;
box-sizing: border-box; /* Crucial for visibility */
}
.landing-enter-btn {
width: 100%;
background: transparent;
border: 2px solid var(--accent-cyan);
color: var(--accent-cyan);
padding: 15px;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
letter-spacing: 3px;
cursor: pointer;
transition: all 0.3s ease;
box-sizing: border-box;
}
.landing-version {
margin-top: 25px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 2px;
}
+91
View File
@@ -0,0 +1,91 @@
/* ACARS Sidebar Styles */
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
/* Main ACARS Sidebar (Collapsible) */
.main-acars-sidebar {
display: flex;
flex-direction: row;
background: var(--bg-panel);
border-left: 1px solid var(--border-color);
}
.main-acars-collapse-btn {
width: 24px;
min-width: 24px;
background: rgba(0,0,0,0.4);
border: none;
border-right: 1px solid var(--border-color);
color: var(--accent-cyan);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 6px 0;
transition: background 0.2s;
}
.main-acars-collapse-btn:hover {
background: rgba(74, 158, 255, 0.15);
}
.main-acars-collapse-label {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 8px;
font-weight: 600;
letter-spacing: 1px;
}
.main-acars-sidebar.collapsed .main-acars-collapse-label { display: block; }
.main-acars-sidebar:not(.collapsed) .main-acars-collapse-label { display: none; }
#mainAcarsCollapseIcon {
font-size: 10px;
transition: transform 0.3s;
}
.main-acars-sidebar.collapsed #mainAcarsCollapseIcon {
transform: rotate(180deg);
}
.main-acars-content {
width: 196px;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.3s ease, opacity 0.2s ease;
}
.main-acars-sidebar.collapsed .main-acars-content {
width: 0;
opacity: 0;
pointer-events: none;
}
.main-acars-messages {
max-height: 350px;
}
.main-acars-msg {
padding: 6px 8px;
border-bottom: 1px solid var(--border-color);
animation: fadeInMsg 0.3s ease;
}
.main-acars-msg:hover {
background: rgba(74, 158, 255, 0.05);
}
@keyframes fadeInMsg {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
/* ACARS Status Indicator */
.acars-status-dot.listening {
background: var(--accent-cyan) !important;
animation: acars-pulse 1.5s ease-in-out infinite;
}
.acars-status-dot.receiving {
background: var(--accent-green) !important;
}
.acars-status-dot.error {
background: var(--accent-red) !important;
}
@keyframes acars-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
}
+328
View File
@@ -0,0 +1,328 @@
/* APRS Function Bar (Stats Strip) Styles */
.aprs-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 6px 12px;
margin-bottom: 10px;
overflow-x: auto;
}
.aprs-strip-inner {
display: flex;
align-items: center;
gap: 8px;
min-width: max-content;
}
.aprs-strip .strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.aprs-strip .strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.aprs-strip .strip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.aprs-strip .strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.aprs-strip .strip-divider {
width: 1px;
height: 28px;
background: var(--border-color);
margin: 0 4px;
}
/* Signal stat coloring */
.aprs-strip .signal-stat.good .strip-value { color: var(--accent-green); }
.aprs-strip .signal-stat.warning .strip-value { color: var(--accent-yellow); }
.aprs-strip .signal-stat.poor .strip-value { color: var(--accent-red); }
/* Controls */
.aprs-strip .strip-control {
display: flex;
align-items: center;
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
}
.aprs-strip .strip-select:hover {
border-color: var(--accent-cyan);
}
.aprs-strip .strip-input-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
}
.aprs-strip .strip-input {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 6px;
border-radius: 4px;
font-size: 10px;
width: 50px;
text-align: center;
}
.aprs-strip .strip-input:hover,
.aprs-strip .strip-input:focus {
border-color: var(--accent-cyan);
outline: none;
}
/* Tool Status Indicators */
.aprs-strip .strip-tools {
display: flex;
gap: 4px;
}
.aprs-strip .strip-tool {
font-size: 9px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 59, 48, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 59, 48, 0.3);
}
.aprs-strip .strip-tool.ok {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border-color: rgba(0, 255, 136, 0.3);
}
/* Buttons */
.aprs-strip .strip-btn {
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.aprs-strip .strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.aprs-strip .strip-btn.primary {
background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%);
border: none;
color: #000;
}
.aprs-strip .strip-btn.primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn.stop {
background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%);
border: none;
color: #fff;
}
.aprs-strip .strip-btn.stop:hover:not(:disabled) {
filter: brightness(1.1);
}
.aprs-strip .strip-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Status indicator */
.aprs-strip .strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.aprs-strip .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-strip .status-dot.listening {
background: var(--accent-cyan);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.tracking {
background: var(--accent-green);
animation: aprs-strip-pulse 1.5s ease-in-out infinite;
}
.aprs-strip .status-dot.error {
background: var(--accent-red);
}
@keyframes aprs-strip-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 4px 2px currentColor; }
50% { opacity: 0.6; box-shadow: none; }
}
/* Time display */
.aprs-strip .strip-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
padding: 4px 8px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
white-space: nowrap;
}
/* APRS Status Bar Styles (Sidebar - legacy) */
.aprs-status-bar {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-status-indicator {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.aprs-status-dot.standby { background: var(--text-muted); }
.aprs-status-dot.listening {
background: var(--accent-cyan);
animation: aprs-pulse 1.5s ease-in-out infinite;
}
.aprs-status-dot.tracking { background: var(--accent-green); }
.aprs-status-dot.error { background: var(--accent-red); }
@keyframes aprs-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
50% { opacity: 0.6; box-shadow: 0 0 8px 4px rgba(74, 158, 255, 0.3); }
}
.aprs-status-text {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
}
.aprs-status-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 9px;
}
.aprs-stat {
color: var(--text-secondary);
}
.aprs-stat-label {
color: var(--text-muted);
}
/* Signal Meter Styles */
.aprs-signal-meter {
margin-top: 12px;
padding: 10px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.aprs-meter-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.aprs-meter-label {
font-size: 10px;
font-weight: bold;
letter-spacing: 1px;
color: var(--text-secondary);
}
.aprs-meter-value {
font-size: 12px;
font-weight: bold;
font-family: monospace;
color: var(--accent-cyan);
min-width: 24px;
}
.aprs-meter-burst {
font-size: 9px;
font-weight: bold;
color: var(--accent-yellow);
background: rgba(255, 193, 7, 0.2);
padding: 2px 6px;
border-radius: 3px;
animation: burst-flash 0.3s ease-out;
}
@keyframes burst-flash {
0% { opacity: 1; transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
.aprs-meter-bar-container {
position: relative;
height: 16px;
background: rgba(0,0,0,0.4);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.aprs-meter-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-cyan) 50%,
var(--accent-yellow) 75%,
var(--accent-red) 100%
);
border-radius: 3px;
transition: width 0.1s ease-out;
}
.aprs-meter-bar.no-signal {
opacity: 0.3;
}
.aprs-meter-ticks {
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-muted);
padding: 0 2px;
}
.aprs-meter-status {
font-size: 9px;
color: var(--text-muted);
text-align: center;
margin-top: 6px;
}
.aprs-meter-status.active {
color: var(--accent-green);
}
.aprs-meter-status.no-signal {
color: var(--accent-yellow);
}
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
/**
* Spy Stations Mode Styles
* Number stations and diplomatic HF networks
*/
/* ============================================
MAIN LAYOUT
============================================ */
.spy-stations-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
min-height: 0;
flex: 1;
overflow-y: auto;
}
.spy-stations-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.spy-stations-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.spy-stations-title svg {
color: var(--accent-cyan);
}
.spy-stations-count {
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 4px 10px;
border-radius: 12px;
}
/* ============================================
STATION GRID
============================================ */
.spy-stations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
padding: 4px;
padding-bottom: 20px;
}
/* ============================================
STATION CARD
============================================ */
.spy-station-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.spy-station-card:hover {
border-color: var(--border-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Card Header */
.spy-station-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.spy-station-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.spy-station-flag {
font-size: 18px;
line-height: 1;
}
.spy-station-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.spy-station-nickname {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Type Badge */
.spy-station-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
flex-shrink: 0;
}
.spy-badge-number {
background: rgba(74, 158, 255, 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(74, 158, 255, 0.3);
}
.spy-badge-diplomatic {
background: rgba(34, 197, 94, 0.15);
color: var(--accent-green);
border: 1px solid rgba(34, 197, 94, 0.3);
}
/* Card Body */
.spy-station-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.spy-station-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.spy-station-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.spy-meta-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.spy-meta-value {
font-size: 12px;
color: var(--text-primary);
}
.spy-meta-mode {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-orange);
}
/* Frequencies */
.spy-station-freqs {
display: flex;
flex-direction: column;
gap: 4px;
}
.spy-freq-list {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
line-height: 1.6;
}
.spy-freq-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.spy-freq-item {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
/* Description */
.spy-station-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Card Footer */
.spy-station-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.1);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
margin-top: auto;
}
/* Frequency Selector Group */
.spy-tune-group {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.spy-freq-select {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 6px 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
min-width: 120px;
cursor: pointer;
}
.spy-freq-select:hover {
border-color: var(--border-light);
}
.spy-freq-select:focus {
outline: none;
border-color: var(--accent-cyan);
}
/* Clickable frequency items in details modal */
.spy-freq-clickable {
cursor: pointer;
transition: all 0.15s ease;
}
.spy-freq-clickable:hover {
background: var(--accent-cyan);
color: #000;
border-color: var(--accent-cyan);
}
/* Tune Button */
.spy-tune-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #000;
background: var(--accent-green);
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.spy-tune-btn:hover {
background: var(--accent-cyan);
transform: scale(1.02);
}
.spy-tune-btn svg {
stroke-width: 2.5;
}
/* Details Button */
.spy-details-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.spy-details-btn:hover {
color: var(--text-primary);
border-color: var(--border-light);
background: var(--bg-secondary);
}
/* ============================================
EMPTY STATE
============================================ */
.spy-station-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
}
.spy-station-empty p {
font-size: 13px;
margin-top: 8px;
}
/* ============================================
MODE VISIBILITY - Ensure sidebar shows when active
============================================ */
#spystationsMode.active {
display: block !important;
}
/* ============================================
FILTER CHECKBOX STYLING
============================================ */
#spystationsMode .inline-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
#spystationsMode .inline-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent-cyan);
}
#spystationsMode .inline-checkbox:hover {
color: var(--text-primary);
}
/* ============================================
RESPONSIVE
============================================ */
/* Large desktop (1200px+) */
@media (min-width: 1200px) {
.spy-stations-grid {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
}
/* Desktop/Tablet landscape (1024px) */
@media (max-width: 1024px) {
.spy-stations-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
/* Tablet portrait (768px) */
@media (max-width: 768px) {
.spy-stations-grid {
grid-template-columns: 1fr;
}
.spy-station-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.spy-station-badge {
align-self: flex-start;
}
.spy-station-meta {
grid-template-columns: 1fr;
}
}
/* Small tablet / large phone (640px) */
@media (max-width: 640px) {
.spy-station-footer {
flex-direction: column;
gap: 8px;
}
.spy-tune-btn,
.spy-details-btn {
width: 100%;
justify-content: center;
min-height: 44px;
}
.spy-tune-group {
width: 100%;
flex-direction: column;
}
.spy-freq-select {
width: 100%;
min-height: 44px;
}
}
/* Mobile (480px) */
@media (max-width: 480px) {
.spy-stations-container {
padding: 8px;
}
.spy-station-body {
padding: 10px;
}
.spy-stations-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
}
.spy-station-desc {
-webkit-line-clamp: 2;
}
}
/* Touch device compliance */
@media (pointer: coarse) {
.spy-tune-btn,
.spy-details-btn,
.spy-freq-select {
min-height: 44px;
}
.spy-freq-clickable {
padding: 8px 12px;
}
}
File diff suppressed because it is too large Load Diff
+660
View File
@@ -0,0 +1,660 @@
/* ============================================
RESPONSIVE UTILITIES - iNTERCEPT
Shared responsive foundation for all pages
============================================ */
/* ============== CSS VARIABLES ============== */
:root {
/* Touch targets */
--touch-min: 44px;
--touch-comfortable: 48px;
/* Responsive spacing */
--spacing-xs: clamp(4px, 1vw, 8px);
--spacing-sm: clamp(8px, 2vw, 12px);
--spacing-md: clamp(12px, 3vw, 20px);
--spacing-lg: clamp(16px, 4vw, 32px);
/* Responsive typography */
--font-xs: clamp(10px, 2.5vw, 11px);
--font-sm: clamp(11px, 2.8vw, 12px);
--font-base: clamp(13px, 3vw, 14px);
--font-md: clamp(14px, 3.5vw, 16px);
--font-lg: clamp(16px, 4vw, 20px);
--font-xl: clamp(20px, 5vw, 28px);
--font-2xl: clamp(24px, 6vw, 40px);
/* Header height for calculations */
--header-height: 52px;
--nav-height: 44px;
}
@media (min-width: 768px) {
:root {
--header-height: 60px;
--nav-height: 48px;
}
}
@media (min-width: 1024px) {
:root {
--header-height: 96px;
--nav-height: 0px;
}
}
/* ============== VIEWPORT HEIGHT FIX ============== */
/* Handles iOS Safari address bar and dynamic viewport */
.full-height {
height: 100dvh;
height: 100vh; /* Fallback */
}
@supports (-webkit-touch-callout: none) {
.full-height {
height: -webkit-fill-available;
}
}
/* ============== HAMBURGER BUTTON ============== */
.hamburger-btn {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: var(--touch-min);
height: var(--touch-min);
padding: 10px;
background: transparent;
border: 1px solid var(--border-color, #1f2937);
border-radius: 6px;
cursor: pointer;
position: relative;
z-index: 1001;
flex-shrink: 0;
transition: background 0.2s ease, border-color 0.2s ease;
}
.hamburger-btn:hover {
background: var(--bg-tertiary, #151a23);
border-color: var(--accent-cyan, #4a9eff);
}
.hamburger-btn span {
display: block;
width: 18px;
height: 2px;
background: var(--accent-cyan, #4a9eff);
margin: 2px 0;
border-radius: 1px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.hamburger-btn.active span:nth-child(1) {
transform: rotate(45deg) translate(4px, 4px);
}
.hamburger-btn.active span:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active span:nth-child(3) {
transform: rotate(-45deg) translate(4px, -4px);
}
/* Hide hamburger on desktop */
@media (min-width: 1024px) {
.hamburger-btn {
display: none;
}
}
/* ============== MOBILE DRAWER ============== */
.mobile-drawer {
position: fixed;
top: 0;
left: 0;
width: min(320px, 85vw);
height: 100dvh;
height: 100vh; /* Fallback */
background: var(--bg-secondary, #0f1218);
border-right: 1px solid var(--border-color, #1f2937);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-top: 60px;
}
.mobile-drawer.open {
transform: translateX(0);
}
/* Show sidebar normally on desktop */
@media (min-width: 1024px) {
.mobile-drawer {
position: static;
transform: none;
width: auto;
height: auto;
padding-top: 0;
z-index: auto;
}
}
/* ============== DRAWER OVERLAY ============== */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 999;
}
.drawer-overlay.visible {
opacity: 1;
visibility: visible;
}
/* Hide overlay on desktop */
@media (min-width: 1024px) {
.drawer-overlay {
display: none;
}
}
/* ============== TOUCH TARGETS ============== */
@media (max-width: 1023px) {
/* Ensure minimum touch target size for interactive elements */
button,
.btn,
.preset-btn,
.mode-nav-btn,
.control-btn,
.nav-action-btn,
.icon-btn {
min-height: var(--touch-min);
min-width: var(--touch-min);
}
select,
input[type="text"],
input[type="number"],
input[type="search"] {
min-height: var(--touch-min);
padding: 10px 12px;
font-size: 16px; /* Prevents iOS zoom on focus */
}
.checkbox-group label,
.radio-group label {
min-height: var(--touch-min);
padding: 10px 14px;
display: flex;
align-items: center;
}
}
/* ============== RESPONSIVE UTILITIES ============== */
/* Hide on mobile */
.hide-mobile {
display: none;
}
@media (min-width: 768px) {
.hide-mobile {
display: initial;
}
}
/* Hide on tablet and up */
.show-mobile-only {
display: initial;
}
@media (min-width: 768px) {
.show-mobile-only {
display: none;
}
}
/* Hide on desktop */
.hide-desktop {
display: initial;
}
@media (min-width: 1024px) {
.hide-desktop {
display: none;
}
}
/* Show only on desktop */
.show-desktop-only {
display: none;
}
@media (min-width: 1024px) {
.show-desktop-only {
display: initial;
}
}
/* ============== SCROLLABLE AREAS ============== */
.scroll-x {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.scroll-x::-webkit-scrollbar {
height: 4px;
}
.scroll-x::-webkit-scrollbar-thumb {
background: var(--border-color, #1f2937);
border-radius: 2px;
}
/* Hide scrollbar on mobile for cleaner look */
@media (max-width: 767px) {
.scroll-x-mobile-hidden {
scrollbar-width: none;
}
.scroll-x-mobile-hidden::-webkit-scrollbar {
display: none;
}
}
/* ============== MOBILE NAVIGATION BAR ============== */
.mobile-nav-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: var(--bg-tertiary, #151a23);
border-bottom: 1px solid var(--border-color, #1f2937);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.mobile-nav-bar::-webkit-scrollbar {
display: none;
}
.mobile-nav-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg-card, #121620);
border: 1px solid var(--border-color, #1f2937);
border-radius: 6px;
color: var(--text-secondary, #9ca3af);
font-size: var(--font-xs);
font-family: inherit;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s ease;
min-height: 36px;
}
.mobile-nav-btn:hover,
.mobile-nav-btn.active {
background: var(--bg-elevated, #1a202c);
border-color: var(--accent-cyan, #4a9eff);
color: var(--text-primary, #e8eaed);
}
.mobile-nav-btn svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* Hide mobile nav bar on desktop */
@media (min-width: 1024px) {
.mobile-nav-bar {
display: none;
}
}
/* ============== RESPONSIVE GRID UTILITIES ============== */
.grid-responsive {
display: grid;
gap: var(--spacing-sm);
}
/* 1 column base */
.grid-1-2 {
grid-template-columns: 1fr;
}
@media (min-width: 480px) {
.grid-1-2 {
grid-template-columns: repeat(2, 1fr);
}
}
.grid-2-3 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.grid-2-3 {
grid-template-columns: repeat(3, 1fr);
}
}
/* ============== TYPOGRAPHY RESPONSIVE ============== */
.text-responsive-xs { font-size: var(--font-xs); }
.text-responsive-sm { font-size: var(--font-sm); }
.text-responsive-base { font-size: var(--font-base); }
.text-responsive-md { font-size: var(--font-md); }
.text-responsive-lg { font-size: var(--font-lg); }
.text-responsive-xl { font-size: var(--font-xl); }
.text-responsive-2xl { font-size: var(--font-2xl); }
/* Ensure minimum readable sizes for tiny text */
.text-min-readable {
font-size: max(10px, var(--font-xs));
}
/* ============== MOBILE LAYOUT FIXES ============== */
@media (max-width: 1023px) {
/* Fix main content to allow scrolling on mobile */
.main-content {
height: auto !important;
min-height: calc(100dvh - var(--header-height) - var(--nav-height));
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* Container should not clip content */
.container {
overflow: visible;
height: auto;
min-height: 100dvh;
}
/* Layout containers need to stack vertically on mobile */
.wifi-layout-container,
.bt-layout-container {
flex-direction: column !important;
height: auto !important;
max-height: none !important;
min-height: auto !important;
overflow: visible !important;
padding: 10px !important;
}
/* Visual panels should be scrollable, not clipped */
.wifi-visuals,
.bt-visuals {
max-height: none !important;
overflow: visible !important;
margin-bottom: 15px;
}
/* Device lists should have reasonable height on mobile */
.wifi-device-list,
.bt-device-list {
max-height: 400px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Visual panels should stack in single column on mobile when visible */
.wifi-visuals,
.bt-visuals {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Only apply flex when aircraft visuals are shown (via JS setting display: grid) */
#aircraftVisuals[style*="grid"] {
display: flex !important;
flex-direction: column !important;
gap: 10px;
}
/* APRS visuals - only when visible */
#aprsVisuals[style*="flex"] {
flex-direction: column !important;
}
.wifi-visual-panel {
grid-column: auto !important;
}
}
/* ============== MOBILE MAP FIXES ============== */
@media (max-width: 1023px) {
/* Aircraft map container needs explicit height on mobile */
.aircraft-map-container {
height: 300px !important;
min-height: 300px !important;
width: 100% !important;
}
#aircraftMap {
height: 100% !important;
width: 100% !important;
min-height: 250px;
}
/* APRS map container */
#aprsMap {
min-height: 300px !important;
height: 300px !important;
width: 100% !important;
}
/* Satellite embed */
.satellite-dashboard-embed {
height: 400px !important;
min-height: 400px !important;
}
/* Map panels should be full width */
.wifi-visual-panel[style*="grid-column: span 2"] {
grid-column: auto !important;
}
/* Make map container full width when it has ACARS sidebar */
.wifi-visual-panel[style*="display: flex"][style*="gap: 0"] {
flex-direction: column !important;
}
/* ACARS sidebar should be below map on mobile */
.main-acars-sidebar {
width: 100% !important;
max-width: none !important;
border-left: none !important;
border-top: 1px solid var(--border-color, #1f2937) !important;
}
.main-acars-sidebar.collapsed {
width: 100% !important;
}
.main-acars-content {
max-height: 200px !important;
}
}
/* ============== LEAFLET MOBILE TOUCH FIXES ============== */
.leaflet-container {
touch-action: pan-x pan-y;
-webkit-tap-highlight-color: transparent;
}
.leaflet-control-zoom {
touch-action: manipulation;
}
.leaflet-control-zoom a {
min-width: var(--touch-min, 44px) !important;
min-height: var(--touch-min, 44px) !important;
line-height: var(--touch-min, 44px) !important;
font-size: 18px !important;
}
/* ============== MOBILE HEADER STATS ============== */
@media (max-width: 1023px) {
.header-stats {
display: none !important;
}
/* Simplify header on mobile */
header h1 {
font-size: 16px !important;
}
header h1 .tagline,
header h1 .version-badge {
display: none;
}
header .subtitle {
font-size: 10px !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
header .logo svg {
width: 30px !important;
height: 30px !important;
}
}
/* ============== MOBILE MODE PANELS ============== */
@media (max-width: 1023px) {
/* Mode panel grids should be single column */
.data-grid,
.stats-grid,
.sensor-grid {
grid-template-columns: 1fr !important;
}
/* Section headers should be easier to tap */
.section h3 {
min-height: var(--touch-min);
padding: 12px !important;
}
/* Tables need horizontal scroll */
.message-table,
.sensor-table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Ensure messages list is scrollable */
#messageList,
#sensorGrid,
.aprs-list {
max-height: 60vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
/* ============== WELCOME PAGE MOBILE ============== */
@media (max-width: 767px) {
.welcome-container {
padding: 15px !important;
max-width: 100% !important;
}
.welcome-header {
flex-direction: column;
text-align: center;
gap: 10px;
}
.welcome-logo svg {
width: 50px;
height: 50px;
}
.welcome-title {
font-size: 24px !important;
}
.welcome-content {
grid-template-columns: 1fr !important;
}
.mode-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 8px !important;
}
.mode-card {
padding: 12px 8px !important;
}
.mode-icon {
font-size: 20px !important;
}
.mode-name {
font-size: 11px !important;
}
.mode-desc {
font-size: 9px !important;
}
.changelog-release {
padding: 10px !important;
}
}
/* ============== TSCM MODE MOBILE ============== */
@media (max-width: 1023px) {
.tscm-layout {
flex-direction: column !important;
height: auto !important;
}
.tscm-spectrum-panel,
.tscm-detection-panel {
width: 100% !important;
max-width: none !important;
height: auto !important;
min-height: 300px;
}
}
/* ============== LISTENING POST MOBILE ============== */
@media (max-width: 1023px) {
.radio-controls-section {
flex-direction: column !important;
gap: 15px;
}
.knobs-row {
flex-wrap: wrap;
justify-content: center;
}
.radio-module-box {
width: 100% !important;
}
}
+36 -9
View File
@@ -115,6 +115,28 @@ body {
gap: 12px;
}
/* Mobile header adjustments */
@media (max-width: 800px) {
.header {
padding: 10px 12px;
flex-wrap: wrap;
gap: 8px;
}
.logo {
font-size: 14px;
letter-spacing: 2px;
}
.logo span {
display: none;
}
.stats-badges {
display: none;
}
}
.stat-badge {
background: var(--bg-card);
border: 1px solid rgba(0, 212, 255, 0.3);
@@ -589,13 +611,14 @@ body {
}
.btn.primary {
background: var(--accent-cyan);
color: var(--bg-dark);
background: var(--accent-green);
color: #fff;
margin-left: auto;
}
.btn.primary:hover {
box-shadow: 0 0 25px rgba(0, 212, 255, 0.5);
background: #1db954;
box-shadow: 0 0 25px rgba(34, 197, 94, 0.5);
}
/* Leaflet dark theme overrides */
@@ -673,24 +696,28 @@ body {
@media (max-width: 800px) {
.dashboard {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
display: flex;
flex-direction: column;
height: auto;
min-height: calc(100vh - 60px);
}
.polar-container,
.map-container {
grid-column: 1;
min-height: 300px;
min-height: 250px;
flex: 1;
}
.sidebar {
grid-column: 1;
flex-direction: column;
max-height: none;
border-left: none;
border-top: 1px solid rgba(0, 212, 255, 0.2);
}
.controls-bar {
grid-row: 4;
flex-wrap: wrap;
padding: 8px 12px;
}
}
+399
View File
@@ -0,0 +1,399 @@
/* Settings Modal Styles */
.settings-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 10000;
overflow-y: auto;
backdrop-filter: blur(4px);
}
.settings-modal.active {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 40px 20px;
}
.settings-content {
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--border-color, #1a1a2e);
border-radius: 8px;
max-width: 600px;
width: 100%;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #1a1a2e);
}
.settings-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
display: flex;
align-items: center;
gap: 8px;
}
.settings-header h2 .icon {
width: 20px;
height: 20px;
color: var(--accent-cyan, #00d4ff);
}
.settings-close {
background: none;
border: none;
color: var(--text-muted, #666);
font-size: 24px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
}
.settings-close:hover {
color: var(--accent-red, #ff4444);
}
/* Settings Tabs */
.settings-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #1a1a2e);
padding: 0 20px;
gap: 4px;
}
.settings-tab {
background: none;
border: none;
padding: 12px 16px;
color: var(--text-muted, #666);
font-size: 13px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.settings-tab:hover {
color: var(--text-primary, #e0e0e0);
}
.settings-tab.active {
color: var(--accent-cyan, #00d4ff);
}
.settings-tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--accent-cyan, #00d4ff);
}
/* Settings Sections */
.settings-section {
display: none;
padding: 20px;
}
.settings-section.active {
display: block;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-group-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, #666);
margin-bottom: 12px;
}
/* Settings Row */
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settings-row:last-child {
border-bottom: none;
}
.settings-label {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-label-text {
font-size: 13px;
color: var(--text-primary, #e0e0e0);
}
.settings-label-desc {
font-size: 11px;
color: var(--text-muted, #666);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-muted, #666);
transition: 0.3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--accent-cyan, #00d4ff);
border-color: var(--accent-cyan, #00d4ff);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(20px);
background-color: white;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.3);
}
/* Select Dropdown */
.settings-select {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
min-width: 160px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 32px;
}
.settings-select:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
/* Text Input */
.settings-input {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary, #e0e0e0);
width: 200px;
}
.settings-input:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
.settings-input::placeholder {
color: var(--text-muted, #666);
}
/* Asset Status */
.asset-status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
padding: 12px;
background: var(--bg-secondary, #0f0f1a);
border-radius: 6px;
}
.asset-status-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.asset-name {
color: var(--text-muted, #888);
}
.asset-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.asset-badge.available {
background: rgba(0, 255, 136, 0.15);
color: var(--accent-green, #00ff88);
}
.asset-badge.missing {
background: rgba(255, 68, 68, 0.15);
color: var(--accent-red, #ff4444);
}
.asset-badge.checking {
background: rgba(255, 170, 0, 0.15);
color: var(--accent-orange, #ffaa00);
}
/* Check Assets Button */
.check-assets-btn {
background: var(--bg-tertiary, #1a1a2e);
border: 1px solid var(--border-color, #2a2a3e);
color: var(--text-primary, #e0e0e0);
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 12px;
transition: all 0.2s;
}
.check-assets-btn:hover {
border-color: var(--accent-cyan, #00d4ff);
color: var(--accent-cyan, #00d4ff);
}
.check-assets-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* About Section */
.about-info {
font-size: 13px;
color: var(--text-muted, #888);
line-height: 1.6;
}
.about-info p {
margin: 0 0 12px 0;
}
.about-info a {
color: var(--accent-cyan, #00d4ff);
text-decoration: none;
}
.about-info a:hover {
text-decoration: underline;
}
.about-version {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan, #00d4ff);
}
/* Tile Provider Custom URL */
.custom-url-row {
margin-top: 8px;
padding-top: 8px;
}
.custom-url-row .settings-input {
width: 100%;
}
/* Info Callout */
.settings-info {
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
font-size: 12px;
color: var(--text-muted, #888);
}
.settings-info strong {
color: var(--accent-cyan, #00d4ff);
}
/* Responsive */
@media (max-width: 640px) {
.settings-modal.active {
padding: 20px 10px;
}
.settings-content {
max-width: 100%;
}
.settings-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.settings-select,
.settings-input {
width: 100%;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
/**
* WiFi Channel Utilization Chart Component
*
* Displays channel utilization as a bar chart with recommendations.
* Shows AP count, client count, and utilization score per channel.
*/
const ChannelChart = (function() {
'use strict';
// ==========================================================================
// Configuration
// ==========================================================================
const CONFIG = {
height: 120,
barWidth: 14,
barSpacing: 2,
padding: { top: 15, right: 10, bottom: 25, left: 30 },
colors: {
low: '#22c55e', // Green - low utilization
medium: '#eab308', // Yellow - medium
high: '#ef4444', // Red - high
recommended: '#3b82f6', // Blue - recommended
},
thresholds: {
low: 0.3,
medium: 0.6,
},
};
// 2.4 GHz non-overlapping channels
const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const NON_OVERLAPPING_2_4 = [1, 6, 11];
// 5 GHz channels (non-DFS)
const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165];
// ==========================================================================
// State
// ==========================================================================
let container = null;
let currentBand = '2.4';
let channelStats = [];
let recommendations = [];
// ==========================================================================
// Initialization
// ==========================================================================
function init(containerId, options = {}) {
container = document.getElementById(containerId);
if (!container) {
console.warn('[ChannelChart] Container not found:', containerId);
return;
}
Object.assign(CONFIG, options);
render();
}
// ==========================================================================
// Update
// ==========================================================================
function update(stats, recs) {
channelStats = stats || [];
recommendations = recs || [];
render();
}
function setBand(band) {
currentBand = band;
render();
}
// ==========================================================================
// Rendering
// ==========================================================================
function render() {
if (!container) return;
const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5;
const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5;
// Build stats map
const statsMap = {};
channelStats.forEach(s => {
statsMap[s.channel] = s;
});
// Build recommendations map
const recsMap = {};
recommendations.forEach((r, i) => {
recsMap[r.channel] = { rank: i + 1, ...r };
});
// Calculate dimensions
const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right;
const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom;
const chartHeight = CONFIG.height;
// Find max values for scaling
let maxApCount = 1;
channelStats.forEach(s => {
if (s.ap_count > maxApCount) maxApCount = s.ap_count;
});
// Build SVG with viewBox for responsive scaling
let svg = `
<svg viewBox="0 0 ${width} ${height}" class="channel-chart-svg" style="width: 100%; height: auto; max-height: ${height}px;">
<defs>
<linearGradient id="utilGradientLow" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.9" />
<stop offset="100%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.5" />
</linearGradient>
<linearGradient id="utilGradientMed" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.9" />
<stop offset="100%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.5" />
</linearGradient>
<linearGradient id="utilGradientHigh" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.9" />
<stop offset="100%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.5" />
</linearGradient>
</defs>
<!-- Y-axis label -->
<text x="10" y="${height / 2}" fill="#666" font-size="10" transform="rotate(-90, 10, ${height / 2})" text-anchor="middle">APs</text>
<!-- Y-axis ticks -->
${renderYAxis(chartHeight, maxApCount)}
<!-- Bars -->
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top})">
${channels.map((ch, i) => {
const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 };
const rec = recsMap[ch];
const isNonOverlapping = nonOverlapping.includes(ch);
return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount);
}).join('')}
</g>
<!-- X-axis labels -->
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top + chartHeight + 5})">
${channels.map((ch, i) => {
const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2;
const isNonOverlapping = nonOverlapping.includes(ch);
return `<text x="${x}" y="12" fill="${isNonOverlapping ? '#fff' : '#666'}" font-size="9" text-anchor="middle">${ch}</text>`;
}).join('')}
</g>
</svg>
`;
// Add legend
svg += renderLegend();
// Add recommendations
if (recommendations.length > 0) {
svg += renderRecommendations();
}
container.innerHTML = svg;
}
function renderYAxis(chartHeight, maxApCount) {
const ticks = [];
const tickCount = Math.min(5, maxApCount);
const step = Math.ceil(maxApCount / tickCount);
for (let i = 0; i <= maxApCount; i += step) {
const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight);
ticks.push(`
<line x1="${CONFIG.padding.left - 5}" y1="${y}" x2="${CONFIG.padding.left}" y2="${y}" stroke="#444" />
<text x="${CONFIG.padding.left - 8}" y="${y + 3}" fill="#666" font-size="9" text-anchor="end">${i}</text>
`);
}
return ticks.join('');
}
function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) {
const x = index * (CONFIG.barWidth + CONFIG.barSpacing);
const barHeight = (stats.ap_count / maxApCount) * chartHeight;
const y = chartHeight - barHeight;
// Determine color based on utilization
let gradient = 'utilGradientLow';
if (stats.utilization_score >= CONFIG.thresholds.medium) {
gradient = 'utilGradientHigh';
} else if (stats.utilization_score >= CONFIG.thresholds.low) {
gradient = 'utilGradientMed';
}
// Recommended channel indicator
const isRecommended = rec && rec.rank <= 3;
const recIndicator = isRecommended ?
`<circle cx="${x + CONFIG.barWidth / 2}" cy="${chartHeight + 20}" r="4" fill="${CONFIG.colors.recommended}" />
<text x="${x + CONFIG.barWidth / 2}" y="${chartHeight + 23}" fill="#fff" font-size="7" text-anchor="middle">${rec.rank}</text>` : '';
// Non-overlapping channel marker
const channelMarker = isNonOverlapping ?
`<rect x="${x}" y="${chartHeight}" width="${CONFIG.barWidth}" height="2" fill="#3b82f6" />` : '';
return `
<g class="channel-bar" data-channel="${channel}">
<!-- Bar background -->
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
fill="#1a1a2e" rx="2" />
<!-- Utilization bar -->
<rect x="${x}" y="${y}" width="${CONFIG.barWidth}" height="${barHeight}"
fill="url(#${gradient})" rx="2" />
<!-- AP count label -->
${stats.ap_count > 0 ? `
<text x="${x + CONFIG.barWidth / 2}" y="${y - 4}" fill="#fff" font-size="9" text-anchor="middle">
${stats.ap_count}
</text>
` : ''}
${channelMarker}
${recIndicator}
<!-- Hover area -->
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
fill="transparent" class="channel-hover" />
</g>
`;
}
function renderLegend() {
return `
<div class="channel-chart-legend" style="display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 10px;">
<div style="display: flex; align-items: center; gap: 4px;">
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.low}; border-radius: 2px;"></span>
<span style="color: #888;">Low</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.medium}; border-radius: 2px;"></span>
<span style="color: #888;">Medium</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.high}; border-radius: 2px;"></span>
<span style="color: #888;">High</span>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<span style="width: 12px; height: 3px; background: #3b82f6; border-radius: 1px;"></span>
<span style="color: #888;">Non-overlapping</span>
</div>
</div>
`;
}
function renderRecommendations() {
const topRecs = recommendations.slice(0, 3);
if (topRecs.length === 0) return '';
return `
<div class="channel-chart-recommendations" style="margin-top: 12px; padding: 8px; background: #1a1a2e; border-radius: 4px;">
<div style="font-size: 10px; color: #888; margin-bottom: 6px;">Recommended Channels:</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
${topRecs.map((rec, i) => `
<div style="display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: ${i === 0 ? 'rgba(59, 130, 246, 0.2)' : '#0d0d1a'}; border-radius: 4px; border: 1px solid ${i === 0 ? '#3b82f6' : '#333'};">
<span style="font-size: 11px; font-weight: bold; color: ${i === 0 ? '#3b82f6' : '#666'};">#${i + 1}</span>
<span style="font-size: 12px; color: #fff;">Ch ${rec.channel}</span>
<span style="font-size: 9px; color: #666;">(${rec.band})</span>
${rec.is_dfs ? '<span style="font-size: 8px; color: #ff6b6b; margin-left: 4px;">DFS</span>' : ''}
</div>
`).join('')}
</div>
</div>
`;
}
// ==========================================================================
// Public API
// ==========================================================================
return {
init,
update,
setBand,
};
})();
+718
View File
@@ -0,0 +1,718 @@
/**
* Device Card Component
* Unified device display for Bluetooth and TSCM modes
*/
const DeviceCard = (function() {
'use strict';
// Range band configuration
const RANGE_BANDS = {
very_close: { label: 'Very Close', color: '#ef4444', description: '< 3m' },
close: { label: 'Close', color: '#f97316', description: '3-10m' },
nearby: { label: 'Nearby', color: '#eab308', description: '10-20m' },
far: { label: 'Far', color: '#6b7280', description: '> 20m' },
unknown: { label: 'Unknown', color: '#374151', description: 'N/A' }
};
// Protocol badge colors
const PROTOCOL_COLORS = {
ble: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
classic: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', border: 'rgba(139, 92, 246, 0.3)' }
};
// Heuristic badge configuration
const HEURISTIC_BADGES = {
new: { label: 'New', color: '#3b82f6', description: 'Not in baseline' },
persistent: { label: 'Persistent', color: '#22c55e', description: 'Continuously present' },
beacon_like: { label: 'Beacon', color: '#f59e0b', description: 'Regular advertising' },
strong_stable: { label: 'Strong', color: '#ef4444', description: 'Strong stable signal' },
random_address: { label: 'Random', color: '#6b7280', description: 'Privacy address' }
};
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Format relative time
*/
function formatRelativeTime(isoString) {
if (!isoString) return '';
const date = new Date(isoString);
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 10) return 'Just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
}
/**
* Create RSSI sparkline SVG
*/
function createSparkline(rssiHistory, options = {}) {
if (!rssiHistory || rssiHistory.length < 2) {
return '<span class="rssi-sparkline-empty">--</span>';
}
const width = options.width || 60;
const height = options.height || 20;
const samples = rssiHistory.slice(-20); // Last 20 samples
// Normalize RSSI values (-100 to -30 range)
const minRssi = -100;
const maxRssi = -30;
const normalizedValues = samples.map(s => {
const rssi = s.rssi || s;
const normalized = (rssi - minRssi) / (maxRssi - minRssi);
return Math.max(0, Math.min(1, normalized));
});
// Generate path
const stepX = width / (normalizedValues.length - 1);
let pathD = '';
normalizedValues.forEach((val, i) => {
const x = i * stepX;
const y = height - (val * height);
pathD += i === 0 ? `M${x},${y}` : ` L${x},${y}`;
});
// Determine color based on latest value
const latestRssi = samples[samples.length - 1].rssi || samples[samples.length - 1];
let strokeColor = '#6b7280';
if (latestRssi > -50) strokeColor = '#22c55e';
else if (latestRssi > -65) strokeColor = '#f59e0b';
else if (latestRssi > -80) strokeColor = '#f97316';
return `
<svg class="rssi-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
/**
* Create heuristic badges HTML
*/
function createHeuristicBadges(flags) {
if (!flags || flags.length === 0) return '';
return flags.map(flag => {
const config = HEURISTIC_BADGES[flag];
if (!config) return '';
return `
<span class="device-heuristic-badge ${flag}"
style="--badge-color: ${config.color}"
title="${escapeHtml(config.description)}">
${escapeHtml(config.label)}
</span>
`;
}).join('');
}
/**
* Create range band indicator
*/
function createRangeBand(band, confidence) {
const config = RANGE_BANDS[band] || RANGE_BANDS.unknown;
const confidencePercent = Math.round((confidence || 0) * 100);
return `
<div class="device-range-band" style="--range-color: ${config.color}">
<span class="range-label">${escapeHtml(config.label)}</span>
<span class="range-estimate">${escapeHtml(config.description)}</span>
${confidence > 0 ? `<span class="range-confidence" title="Confidence">${confidencePercent}%</span>` : ''}
</div>
`;
}
/**
* Create protocol badge
*/
function createProtocolBadge(protocol) {
const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble;
const label = protocol === 'classic' ? 'Classic' : 'BLE';
return `
<span class="signal-proto-badge device-protocol"
style="background: ${config.bg}; color: ${config.color}; border-color: ${config.border}">
${escapeHtml(label)}
</span>
`;
}
/**
* Create a Bluetooth device card
*/
function createDeviceCard(device, options = {}) {
// Debug: log received device data
console.log('[DeviceCard] Creating card for:', device.address, device);
const card = document.createElement('article');
card.className = 'signal-card device-card';
card.dataset.deviceId = device.device_id || '';
card.dataset.protocol = device.protocol || 'ble';
card.dataset.address = device.address || '';
// Add status classes
if (device.heuristic_flags && device.heuristic_flags.includes('new')) {
card.dataset.status = 'new';
} else if (device.in_baseline) {
card.dataset.status = 'baseline';
}
// Store full device data for details modal
try {
card.dataset.deviceData = JSON.stringify(device);
} catch (e) {
card.dataset.deviceData = '{}';
}
const relativeTime = formatRelativeTime(device.last_seen) || 'Unknown';
const sparkline = createSparkline(device.rssi_history) || '';
const heuristicBadges = createHeuristicBadges(device.heuristic_flags) || '';
const rangeBand = createRangeBand(device.range_band, device.range_confidence) || '';
const protocolBadge = createProtocolBadge(device.protocol) || '';
// Build card with explicit defaults for all values
const deviceName = device.name || device.device_id || 'Unknown Device';
const deviceAddress = device.address || 'Unknown';
const addressType = device.address_type || 'unknown';
const rssiDisplay = (device.rssi_current !== null && device.rssi_current !== undefined)
? device.rssi_current + ' dBm' : '--';
const seenCount = device.seen_count || 0;
const inBaseline = device.in_baseline || false;
const mfrName = device.manufacturer_name || '';
// Build the HTML parts separately to avoid template issues
const headerHtml = '<div class="signal-card-header">' +
'<div class="signal-card-badges">' + protocolBadge + heuristicBadges + '</div>' +
'<span class="signal-status-pill" data-status="' + (inBaseline ? 'baseline' : 'new') + '">' +
'<span class="status-dot"></span>' + (inBaseline ? 'Known' : 'New') + '</span>' +
'</div>';
const identityHtml = '<div class="device-identity">' +
'<div class="device-name">' + escapeHtml(deviceName) + '</div>' +
'<div class="device-address">' +
'<span class="address-value">' + escapeHtml(deviceAddress) + '</span>' +
'<span class="address-type">(' + escapeHtml(addressType) + ')</span>' +
'</div></div>';
const signalHtml = '<div class="device-signal-row">' +
'<div class="rssi-display">' +
'<span class="rssi-current" title="Current RSSI">' + rssiDisplay + '</span>' +
sparkline + '</div>' + rangeBand + '</div>';
const mfrHtml = mfrName ?
'<div class="device-manufacturer">' +
'<span class="mfr-icon">🏭</span>' +
'<span class="mfr-name">' + escapeHtml(mfrName) + '</span></div>' : '';
const metaHtml = '<div class="device-meta-row">' +
'<span class="device-seen-count" title="Observation count">' +
'<span class="seen-icon">👁</span>' + seenCount + '×</span>' +
'<span class="device-timestamp" data-timestamp="' + escapeHtml(device.last_seen || '') + '">' +
escapeHtml(relativeTime) + '</span></div>';
const bodyHtml = '<div class="signal-card-body">' +
identityHtml + signalHtml + mfrHtml + metaHtml + '</div>';
card.innerHTML = headerHtml + bodyHtml;
// Make card clickable - opens modal with full details
card.addEventListener('click', () => {
showDeviceDetails(device);
});
return card;
}
/**
* Create advanced panel content
*/
function createAdvancedPanel(device) {
return `
<div class="signal-advanced-content">
<div class="signal-advanced-section">
<div class="signal-advanced-title">Device Details</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Address</span>
<span class="signal-advanced-value">${escapeHtml(device.address)}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Address Type</span>
<span class="signal-advanced-value">${escapeHtml(device.address_type)}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Protocol</span>
<span class="signal-advanced-value">${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'}</span>
</div>
${device.manufacturer_id ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Manufacturer ID</span>
<span class="signal-advanced-value">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
</div>
` : ''}
</div>
</div>
<div class="signal-advanced-section">
<div class="signal-advanced-title">Signal Statistics</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Current RSSI</span>
<span class="signal-advanced-value">${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Median RSSI</span>
<span class="signal-advanced-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Min/Max</span>
<span class="signal-advanced-value">${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Confidence</span>
<span class="signal-advanced-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
</div>
</div>
</div>
<div class="signal-advanced-section">
<div class="signal-advanced-title">Observation Times</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">First Seen</span>
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.first_seen))}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Last Seen</span>
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.last_seen))}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Seen Count</span>
<span class="signal-advanced-value">${device.seen_count} observations</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Rate</span>
<span class="signal-advanced-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
</div>
</div>
</div>
${device.service_uuids && device.service_uuids.length > 0 ? `
<div class="signal-advanced-section">
<div class="signal-advanced-title">Service UUIDs</div>
<div class="device-uuids">
${device.service_uuids.map(uuid => `<span class="device-uuid">${escapeHtml(uuid)}</span>`).join('')}
</div>
</div>
` : ''}
${device.heuristics ? `
<div class="signal-advanced-section">
<div class="signal-advanced-title">Behavioral Analysis</div>
<div class="device-heuristics-detail">
${Object.entries(device.heuristics).map(([key, value]) => `
<div class="heuristic-item ${value ? 'active' : ''}">
<span class="heuristic-name">${escapeHtml(key.replace(/_/g, ' '))}</span>
<span class="heuristic-status">${value ? '✓' : ''}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
}
/**
* Show device details in modal
*/
function showDeviceDetails(device) {
let modal = document.getElementById('deviceDetailsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'deviceDetailsModal';
modal.className = 'signal-details-modal';
modal.innerHTML = `
<div class="signal-details-modal-backdrop"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<div class="modal-header-info">
<span class="signal-details-modal-title"></span>
<span class="signal-details-modal-subtitle"></span>
</div>
<button class="signal-details-modal-close">&times;</button>
</div>
<div class="signal-details-modal-body"></div>
<div class="signal-details-modal-footer">
<button class="signal-details-copy-btn">Copy JSON</button>
<button class="signal-details-copy-addr-btn">Copy Address</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Close handlers
modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.signal-details-modal-close').addEventListener('click', () => {
modal.classList.remove('show');
});
// Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) {
modal.classList.remove('show');
}
});
}
// Update copy button handlers with current device
const copyBtn = modal.querySelector('.signal-details-copy-btn');
const copyAddrBtn = modal.querySelector('.signal-details-copy-addr-btn');
copyBtn.onclick = () => {
navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy JSON'; }, 1500);
});
};
copyAddrBtn.onclick = () => {
navigator.clipboard.writeText(device.address).then(() => {
copyAddrBtn.textContent = 'Copied!';
setTimeout(() => { copyAddrBtn.textContent = 'Copy Address'; }, 1500);
});
};
// Populate modal header
modal.querySelector('.signal-details-modal-title').textContent = device.name || 'Unknown Device';
modal.querySelector('.signal-details-modal-subtitle').textContent = device.address;
// Populate modal body with enhanced content
modal.querySelector('.signal-details-modal-body').innerHTML = createModalContent(device);
modal.classList.add('show');
}
/**
* Create enhanced modal content
*/
function createModalContent(device) {
const protocolLabel = device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth';
const sparkline = createSparkline(device.rssi_history, { width: 120, height: 30 });
return `
<div class="modal-device-header">
<div class="modal-badges">
${createProtocolBadge(device.protocol)}
${createHeuristicBadges(device.heuristic_flags)}
</div>
${createRangeBand(device.range_band, device.range_confidence)}
</div>
<div class="modal-section">
<div class="modal-section-title">Signal Strength</div>
<div class="modal-signal-display">
<div class="modal-rssi-large">${device.rssi_current !== null ? device.rssi_current : '--'}<span class="rssi-unit">dBm</span></div>
<div class="modal-sparkline">${sparkline}</div>
</div>
<div class="modal-signal-stats">
<div class="stat-item">
<span class="stat-label">Median</span>
<span class="stat-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
</div>
<div class="stat-item">
<span class="stat-label">Min</span>
<span class="stat-value">${device.rssi_min !== null ? device.rssi_min + ' dBm' : 'N/A'}</span>
</div>
<div class="stat-item">
<span class="stat-label">Max</span>
<span class="stat-value">${device.rssi_max !== null ? device.rssi_max + ' dBm' : 'N/A'}</span>
</div>
<div class="stat-item">
<span class="stat-label">Confidence</span>
<span class="stat-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
</div>
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Device Information</div>
<div class="modal-info-grid">
<div class="info-item">
<span class="info-label">Address</span>
<span class="info-value mono">${escapeHtml(device.address)}</span>
</div>
<div class="info-item">
<span class="info-label">Address Type</span>
<span class="info-value">${escapeHtml(device.address_type)}</span>
</div>
<div class="info-item">
<span class="info-label">Protocol</span>
<span class="info-value">${protocolLabel}</span>
</div>
${device.manufacturer_name ? `
<div class="info-item">
<span class="info-label">Manufacturer</span>
<span class="info-value">${escapeHtml(device.manufacturer_name)}</span>
</div>
` : ''}
${device.manufacturer_id ? `
<div class="info-item">
<span class="info-label">Manufacturer ID</span>
<span class="info-value mono">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
</div>
` : ''}
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Observation Timeline</div>
<div class="modal-info-grid">
<div class="info-item">
<span class="info-label">First Seen</span>
<span class="info-value">${formatRelativeTime(device.first_seen)}</span>
</div>
<div class="info-item">
<span class="info-label">Last Seen</span>
<span class="info-value">${formatRelativeTime(device.last_seen)}</span>
</div>
<div class="info-item">
<span class="info-label">Observations</span>
<span class="info-value">${device.seen_count}</span>
</div>
<div class="info-item">
<span class="info-label">Rate</span>
<span class="info-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
</div>
</div>
</div>
${device.service_uuids && device.service_uuids.length > 0 ? `
<div class="modal-section">
<div class="modal-section-title">Service UUIDs</div>
<div class="modal-uuid-list">
${device.service_uuids.map(uuid => `<span class="modal-uuid">${escapeHtml(uuid)}</span>`).join('')}
</div>
</div>
` : ''}
${device.heuristics ? `
<div class="modal-section">
<div class="modal-section-title">Behavioral Analysis</div>
<div class="modal-heuristics-grid">
${Object.entries(device.heuristics).map(([key, value]) => `
<div class="heuristic-check ${value ? 'active' : ''}">
<span class="heuristic-indicator">${value ? '✓' : ''}</span>
<span class="heuristic-label">${escapeHtml(key.replace(/_/g, ' '))}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
`;
}
/**
* Toggle advanced panel
*/
function toggleAdvanced(button) {
const card = button.closest('.signal-card');
const panel = card.querySelector('.signal-advanced-panel');
button.classList.toggle('open');
panel.classList.toggle('open');
}
/**
* Copy address to clipboard
*/
function copyAddress(address) {
navigator.clipboard.writeText(address).then(() => {
if (typeof SignalCards !== 'undefined') {
SignalCards.showToast('Address copied');
}
});
}
/**
* Investigate device (placeholder for future implementation)
*/
function investigate(deviceId) {
console.log('Investigate device:', deviceId);
// Could open service discovery, detailed analysis, etc.
}
/**
* Update all device timestamps
*/
function updateTimestamps(container) {
container.querySelectorAll('.device-timestamp[data-timestamp]').forEach(el => {
const timestamp = el.dataset.timestamp;
if (timestamp) {
el.textContent = formatRelativeTime(timestamp);
}
});
}
/**
* Create device filter bar for Bluetooth mode
*/
function createDeviceFilterBar(container, options = {}) {
const filterBar = document.createElement('div');
filterBar.className = 'signal-filter-bar device-filter-bar';
filterBar.id = 'btDeviceFilterBar';
filterBar.innerHTML = `
<button class="signal-filter-btn active" data-filter="status" data-value="all">
All
<span class="signal-filter-count" data-count="all">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="new">
<span class="filter-dot" style="background: var(--signal-new)"></span>
New
<span class="signal-filter-count" data-count="new">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="baseline">
<span class="filter-dot" style="background: var(--signal-baseline)"></span>
Known
<span class="signal-filter-count" data-count="baseline">0</span>
</button>
<span class="signal-filter-divider"></span>
<span class="signal-filter-label">Protocol</span>
<button class="signal-filter-btn protocol-btn active" data-filter="protocol" data-value="all">All</button>
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="ble">BLE</button>
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="classic">Classic</button>
<span class="signal-filter-divider"></span>
<span class="signal-filter-label">Range</span>
<button class="signal-filter-btn range-btn active" data-filter="range" data-value="all">All</button>
<button class="signal-filter-btn range-btn" data-filter="range" data-value="close">Close</button>
<button class="signal-filter-btn range-btn" data-filter="range" data-value="far">Far</button>
<div class="signal-search-container">
<input type="text" class="signal-search-input" id="btSearchInput" placeholder="Search name or address..." />
</div>
`;
// Filter state
const filters = { status: 'all', protocol: 'all', range: 'all', search: '' };
// Apply filters function
const applyFilters = () => {
const cards = container.querySelectorAll('.device-card');
const counts = { all: 0, new: 0, baseline: 0 };
cards.forEach(card => {
const cardStatus = card.dataset.status || 'baseline';
const cardProtocol = card.dataset.protocol;
const deviceData = JSON.parse(card.dataset.deviceData || '{}');
const cardName = (deviceData.name || '').toLowerCase();
const cardAddress = (deviceData.address || '').toLowerCase();
const cardRange = deviceData.range_band || 'unknown';
counts.all++;
if (cardStatus === 'new') counts.new++;
else counts.baseline++;
// Check filters
const statusMatch = filters.status === 'all' || cardStatus === filters.status;
const protocolMatch = filters.protocol === 'all' || cardProtocol === filters.protocol;
const rangeMatch = filters.range === 'all' ||
(filters.range === 'close' && ['very_close', 'close'].includes(cardRange)) ||
(filters.range === 'far' && ['nearby', 'far', 'unknown'].includes(cardRange));
const searchMatch = !filters.search ||
cardName.includes(filters.search) ||
cardAddress.includes(filters.search);
if (statusMatch && protocolMatch && rangeMatch && searchMatch) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
// Update counts
Object.keys(counts).forEach(key => {
const badge = filterBar.querySelector(`[data-count="${key}"]`);
if (badge) badge.textContent = counts[key];
});
};
// Status filter handlers
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filters.status = btn.dataset.value;
applyFilters();
});
});
// Protocol filter handlers
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filters.protocol = btn.dataset.value;
applyFilters();
});
});
// Range filter handlers
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filters.range = btn.dataset.value;
applyFilters();
});
});
// Search handler
const searchInput = filterBar.querySelector('#btSearchInput');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
filters.search = e.target.value.toLowerCase();
applyFilters();
}, 200);
});
filterBar.applyFilters = applyFilters;
return filterBar;
}
// Public API
return {
createDeviceCard,
createSparkline,
createHeuristicBadges,
createRangeBand,
createDeviceFilterBar,
showDeviceDetails,
toggleAdvanced,
copyAddress,
investigate,
updateTimestamps,
escapeHtml,
formatRelativeTime,
RANGE_BANDS,
HEURISTIC_BADGES
};
})();
// Make globally available
window.DeviceCard = DeviceCard;
+326
View File
@@ -0,0 +1,326 @@
/**
* Message Card Component
* Status and alert messages for Bluetooth and TSCM modes
*/
const MessageCard = (function() {
'use strict';
// Message types and their styling
const MESSAGE_TYPES = {
info: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>`,
color: '#3b82f6',
bgColor: 'rgba(59, 130, 246, 0.1)'
},
success: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>`,
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)'
},
warning: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>`,
color: '#f59e0b',
bgColor: 'rgba(245, 158, 11, 0.1)'
},
error: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>`,
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)'
},
scanning: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>`,
color: '#06b6d4',
bgColor: 'rgba(6, 182, 212, 0.1)'
}
};
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Create a message card
*/
function createMessageCard(options) {
const {
type = 'info',
title,
message,
details,
actions,
dismissible = true,
autoHide = 0,
id
} = options;
const config = MESSAGE_TYPES[type] || MESSAGE_TYPES.info;
const card = document.createElement('div');
card.className = `message-card message-card-${type}`;
if (id) card.id = id;
card.style.setProperty('--message-color', config.color);
card.style.setProperty('--message-bg', config.bgColor);
card.innerHTML = `
<div class="message-card-icon">
${config.icon}
</div>
<div class="message-card-content">
${title ? `<div class="message-card-title">${escapeHtml(title)}</div>` : ''}
${message ? `<div class="message-card-text">${escapeHtml(message)}</div>` : ''}
${details ? `<div class="message-card-details">${escapeHtml(details)}</div>` : ''}
</div>
${dismissible ? `
<button class="message-card-dismiss" title="Dismiss">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
` : ''}
${actions && actions.length > 0 ? `
<div class="message-card-actions">
${actions.map(action => `
<button class="message-action-btn ${action.primary ? 'primary' : ''}"
${action.id ? `id="${escapeHtml(action.id)}"` : ''}>
${escapeHtml(action.label)}
</button>
`).join('')}
</div>
` : ''}
`;
// Dismiss handler
if (dismissible) {
card.querySelector('.message-card-dismiss').addEventListener('click', () => {
card.classList.add('message-card-hiding');
setTimeout(() => card.remove(), 200);
});
}
// Action handlers
if (actions && actions.length > 0) {
actions.forEach(action => {
if (action.handler) {
const btn = action.id
? card.querySelector(`#${action.id}`)
: card.querySelector('.message-action-btn');
if (btn) {
btn.addEventListener('click', (e) => {
action.handler(e, card);
});
}
}
});
}
// Auto-hide
if (autoHide > 0) {
setTimeout(() => {
if (card.parentElement) {
card.classList.add('message-card-hiding');
setTimeout(() => card.remove(), 200);
}
}, autoHide);
}
return card;
}
/**
* Create a scanning status card
*/
function createScanningCard(options = {}) {
const {
backend = 'auto',
adapter = 'hci0',
deviceCount = 0,
elapsed = 0,
remaining = null
} = options;
return createMessageCard({
type: 'scanning',
title: 'Scanning for Bluetooth devices...',
message: `Backend: ${backend} | Adapter: ${adapter}`,
details: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}` +
(remaining !== null ? ` | ${Math.round(remaining)}s remaining` : ''),
dismissible: false,
id: 'btScanningStatus'
});
}
/**
* Create a capability warning card
*/
function createCapabilityWarning(issues) {
if (!issues || issues.length === 0) return null;
return createMessageCard({
type: 'warning',
title: 'Bluetooth Capability Issues',
message: issues.join('. '),
dismissible: true,
actions: [
{
label: 'Retry Check',
handler: (e, card) => {
card.remove();
if (typeof window.checkBtCapabilities === 'function') {
window.checkBtCapabilities();
}
}
}
]
});
}
/**
* Create a baseline status card
*/
function createBaselineCard(deviceCount, isSet = true) {
if (isSet) {
return createMessageCard({
type: 'success',
title: 'Baseline Set',
message: `${deviceCount} device${deviceCount !== 1 ? 's' : ''} saved as baseline`,
details: 'New devices will be highlighted',
dismissible: true,
autoHide: 5000
});
} else {
return createMessageCard({
type: 'info',
title: 'No Baseline',
message: 'Set a baseline to track new devices',
dismissible: true,
actions: [
{
label: 'Set Baseline',
primary: true,
handler: () => {
if (typeof window.setBtBaseline === 'function') {
window.setBtBaseline();
}
}
}
]
});
}
}
/**
* Create a scan complete card
*/
function createScanCompleteCard(deviceCount, duration) {
return createMessageCard({
type: 'success',
title: 'Scan Complete',
message: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''} in ${Math.round(duration)}s`,
dismissible: true,
autoHide: 5000,
actions: [
{
label: 'Export Results',
handler: () => {
window.open('/api/bluetooth/export?format=csv', '_blank');
}
}
]
});
}
/**
* Create an error card
*/
function createErrorCard(error, retryHandler) {
return createMessageCard({
type: 'error',
title: 'Scan Error',
message: error,
dismissible: true,
actions: retryHandler ? [
{
label: 'Retry',
primary: true,
handler: retryHandler
}
] : []
});
}
/**
* Show a message in a container
*/
function showMessage(container, options) {
const card = createMessageCard(options);
container.insertBefore(card, container.firstChild);
return card;
}
/**
* Remove a message by ID
*/
function removeMessage(id) {
const card = document.getElementById(id);
if (card) {
card.classList.add('message-card-hiding');
setTimeout(() => card.remove(), 200);
}
}
/**
* Update scanning status
*/
function updateScanningStatus(options) {
const existing = document.getElementById('btScanningStatus');
if (existing) {
const details = existing.querySelector('.message-card-details');
if (details) {
details.textContent = `Found ${options.deviceCount} device${options.deviceCount !== 1 ? 's' : ''}` +
(options.remaining !== null ? ` | ${Math.round(options.remaining)}s remaining` : '');
}
}
}
// Public API
return {
createMessageCard,
createScanningCard,
createCapabilityWarning,
createBaselineCard,
createScanCompleteCard,
createErrorCard,
showMessage,
removeMessage,
updateScanningStatus,
MESSAGE_TYPES
};
})();
// Make globally available
window.MessageCard = MessageCard;
+395
View File
@@ -0,0 +1,395 @@
/**
* Proximity Radar Component
*
* SVG-based circular radar visualization for Bluetooth device proximity.
* Displays devices positioned by estimated distance with concentric rings
* for proximity bands.
*/
const ProximityRadar = (function() {
'use strict';
// Configuration
const CONFIG = {
size: 280,
padding: 20,
centerRadius: 8,
rings: [
{ band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' },
{ band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' },
{ band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' },
],
dotMinSize: 4,
dotMaxSize: 12,
pulseAnimationDuration: 2000,
newDeviceThreshold: 30, // seconds
};
// State
let container = null;
let svg = null;
let devices = new Map();
let isPaused = false;
let activeFilter = null;
let onDeviceClick = null;
let selectedDeviceKey = null;
/**
* Initialize the radar component
*/
function init(containerId, options = {}) {
container = document.getElementById(containerId);
if (!container) {
console.error('[ProximityRadar] Container not found:', containerId);
return;
}
if (options.onDeviceClick) {
onDeviceClick = options.onDeviceClick;
}
createSVG();
}
/**
* Create the SVG radar structure
*/
function createSVG() {
const size = CONFIG.size;
const center = size / 2;
container.innerHTML = `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
<defs>
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
</radialGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background gradient -->
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"
fill="url(#radarGradient)" />
<!-- Proximity rings -->
<g class="radar-rings">
${CONFIG.rings.map((ring, i) => {
const r = ring.radius * (center - CONFIG.padding);
return `
<circle cx="${center}" cy="${center}" r="${r}"
fill="none" stroke="${ring.color}" stroke-opacity="0.3"
stroke-width="1" stroke-dasharray="4,4" />
<text x="${center}" y="${center - r + 12}"
text-anchor="middle" fill="${ring.color}" fill-opacity="0.6"
font-size="9" font-family="monospace">${ring.label}</text>
`;
}).join('')}
</g>
<!-- Sweep line (animated) -->
<line class="radar-sweep" x1="${center}" y1="${center}"
x2="${center}" y2="${CONFIG.padding}"
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
<!-- Center point -->
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
fill="#00d4ff" filter="url(#glow)" />
<!-- Device dots container -->
<g class="radar-devices"></g>
<!-- Legend -->
<g class="radar-legend" transform="translate(${size - 70}, ${size - 55})">
<text x="0" y="0" fill="#666" font-size="8">PROXIMITY</text>
<text x="0" y="0" fill="#666" font-size="7" font-style="italic"
transform="translate(0, 10)">(signal strength)</text>
</g>
</svg>
`;
svg = container.querySelector('svg');
// Add sweep animation
animateSweep();
}
/**
* Animate the radar sweep line
*/
function animateSweep() {
const sweepLine = svg.querySelector('.radar-sweep');
if (!sweepLine) return;
let angle = 0;
const center = CONFIG.size / 2;
function rotate() {
if (isPaused) {
requestAnimationFrame(rotate);
return;
}
angle = (angle + 1) % 360;
const rad = (angle * Math.PI) / 180;
const radius = center - CONFIG.padding;
const x2 = center + Math.sin(rad) * radius;
const y2 = center - Math.cos(rad) * radius;
sweepLine.setAttribute('x2', x2);
sweepLine.setAttribute('y2', y2);
requestAnimationFrame(rotate);
}
requestAnimationFrame(rotate);
}
/**
* Update devices on the radar
*/
function updateDevices(deviceList) {
if (isPaused) return;
// Update device map
deviceList.forEach(device => {
devices.set(device.device_key, device);
});
// Apply filter and render
renderDevices();
}
/**
* Render device dots on the radar
*/
function renderDevices() {
const devicesGroup = svg.querySelector('.radar-devices');
if (!devicesGroup) return;
const center = CONFIG.size / 2;
const maxRadius = center - CONFIG.padding;
// Filter devices
let visibleDevices = Array.from(devices.values());
if (activeFilter === 'newOnly') {
visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold);
} else if (activeFilter === 'strongest') {
visibleDevices = visibleDevices
.filter(d => d.rssi_current != null)
.sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100))
.slice(0, 10);
} else if (activeFilter === 'unapproved') {
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
}
// Build SVG for each device
const dots = visibleDevices.map(device => {
// Calculate position
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
// Calculate dot size based on confidence
const confidence = device.distance_confidence || 0.5;
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
// Get color based on proximity band
const color = getBandColor(device.proximity_band);
// Check if newly seen (pulse animation)
const isNew = device.age_seconds < 5;
const pulseClass = isNew ? 'radar-dot-pulse' : '';
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
return `
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
transform="translate(${x}, ${y})" style="cursor: pointer;">
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
</circle>` : ''}
<circle r="${dotSize}" fill="${color}"
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
</g>
`;
}).join('');
devicesGroup.innerHTML = dots;
// Attach click handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) {
onDeviceClick(deviceKey);
}
});
});
}
/**
* Calculate device position on radar
*/
function calculateDevicePosition(device, center, maxRadius) {
// Calculate radius based on proximity band/distance
let radiusRatio;
const band = device.proximity_band || 'unknown';
if (device.estimated_distance_m != null) {
// Use actual distance (log scale)
const maxDistance = 15;
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
} else {
// Use band-based positioning
switch (band) {
case 'immediate': radiusRatio = 0.15; break;
case 'near': radiusRatio = 0.4; break;
case 'far': radiusRatio = 0.7; break;
default: radiusRatio = 0.9; break;
}
}
// Calculate angle based on device key hash (stable positioning)
const angle = hashToAngle(device.device_key || device.device_id);
const radius = radiusRatio * maxRadius;
const x = center + Math.sin(angle) * radius;
const y = center - Math.cos(angle) * radius;
return { x, y, radius };
}
/**
* Hash string to angle for stable positioning
*/
function hashToAngle(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return (Math.abs(hash) % 360) * (Math.PI / 180);
}
/**
* Get color for proximity band
*/
function getBandColor(band) {
switch (band) {
case 'immediate': return '#22c55e';
case 'near': return '#eab308';
case 'far': return '#ef4444';
default: return '#6b7280';
}
}
/**
* Set filter mode
*/
function setFilter(filter) {
activeFilter = filter === activeFilter ? null : filter;
renderDevices();
}
/**
* Toggle pause state
*/
function setPaused(paused) {
isPaused = paused;
}
/**
* Clear all devices
*/
function clear() {
devices.clear();
selectedDeviceKey = null;
renderDevices();
}
/**
* Highlight a specific device on the radar
*/
function highlightDevice(deviceKey) {
selectedDeviceKey = deviceKey;
renderDevices();
}
/**
* Clear device highlighting
*/
function clearHighlight() {
selectedDeviceKey = null;
renderDevices();
}
/**
* Get zone counts
*/
function getZoneCounts() {
const counts = { immediate: 0, near: 0, far: 0, unknown: 0 };
devices.forEach(device => {
const band = device.proximity_band || 'unknown';
if (counts.hasOwnProperty(band)) {
counts[band]++;
} else {
counts.unknown++;
}
});
return counts;
}
/**
* Escape HTML for safe rendering
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Escape attribute value
*/
function escapeAttr(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Public API
return {
init,
updateDevices,
setFilter,
setPaused,
clear,
getZoneCounts,
highlightDevice,
clearHighlight,
isPaused: () => isPaused,
getFilter: () => activeFilter,
getSelectedDevice: () => selectedDeviceKey,
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = ProximityRadar;
}
window.ProximityRadar = ProximityRadar;
+243
View File
@@ -0,0 +1,243 @@
/**
* RSSI Sparkline Component
* SVG-based real-time RSSI visualization
*/
const RSSISparkline = (function() {
'use strict';
// Default configuration
const DEFAULT_CONFIG = {
width: 80,
height: 24,
maxSamples: 30,
strokeWidth: 1.5,
minRssi: -100,
maxRssi: -30,
showCurrentValue: true,
showGradient: true,
animateUpdates: true
};
// Color thresholds based on RSSI
const RSSI_COLORS = {
excellent: { rssi: -50, color: '#22c55e' }, // Green
good: { rssi: -60, color: '#84cc16' }, // Lime
fair: { rssi: -70, color: '#eab308' }, // Yellow
weak: { rssi: -80, color: '#f97316' }, // Orange
poor: { rssi: -100, color: '#ef4444' } // Red
};
/**
* Get color for RSSI value
*/
function getRssiColor(rssi) {
if (rssi >= RSSI_COLORS.excellent.rssi) return RSSI_COLORS.excellent.color;
if (rssi >= RSSI_COLORS.good.rssi) return RSSI_COLORS.good.color;
if (rssi >= RSSI_COLORS.fair.rssi) return RSSI_COLORS.fair.color;
if (rssi >= RSSI_COLORS.weak.rssi) return RSSI_COLORS.weak.color;
return RSSI_COLORS.poor.color;
}
/**
* Normalize RSSI value to 0-1 range
*/
function normalizeRssi(rssi, min, max) {
return Math.max(0, Math.min(1, (rssi - min) / (max - min)));
}
/**
* Create sparkline SVG element
*/
function createSparklineSvg(samples, config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const { width, height, minRssi, maxRssi, strokeWidth, showGradient } = cfg;
if (!samples || samples.length < 2) {
return createEmptySparkline(width, height);
}
// Normalize samples
const normalized = samples.map(s => {
const rssi = typeof s === 'object' ? s.rssi : s;
return {
value: normalizeRssi(rssi, minRssi, maxRssi),
rssi: rssi
};
});
// Calculate path
const stepX = width / (normalized.length - 1);
let pathD = '';
let areaD = '';
const points = [];
normalized.forEach((sample, i) => {
const x = i * stepX;
const y = height - (sample.value * (height - 2)) - 1; // 1px padding top/bottom
points.push({ x, y, rssi: sample.rssi });
if (i === 0) {
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
} else {
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
}
});
// Close area path
areaD += ` L${width},${height} Z`;
// Get current color based on latest value
const latestRssi = normalized[normalized.length - 1].rssi;
const strokeColor = getRssiColor(latestRssi);
// Create SVG
const gradientId = `sparkline-gradient-${Math.random().toString(36).substr(2, 9)}`;
let gradientDef = '';
if (showGradient) {
gradientDef = `
<defs>
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
</linearGradient>
</defs>
`;
}
return `
<svg class="rssi-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${gradientDef}
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
stroke-linecap="round" stroke-linejoin="round" />
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
r="2" fill="${strokeColor}" class="sparkline-dot" />
</svg>
`;
}
/**
* Create empty sparkline placeholder
*/
function createEmptySparkline(width, height) {
return `
<svg class="rssi-sparkline-svg rssi-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
stroke="#444" stroke-width="1" stroke-dasharray="2,2" />
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
fill="#666" font-size="8" font-family="monospace">No data</text>
</svg>
`;
}
/**
* Create a live sparkline component with update capability
*/
class LiveSparkline {
constructor(container, config = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
this.config = { ...DEFAULT_CONFIG, ...config };
this.samples = [];
this.animationFrame = null;
this.render();
}
addSample(rssi) {
this.samples.push({
rssi: rssi,
timestamp: Date.now()
});
// Limit samples
if (this.samples.length > this.config.maxSamples) {
this.samples.shift();
}
this.render();
}
setSamples(samples) {
this.samples = samples.slice(-this.config.maxSamples);
this.render();
}
render() {
if (!this.container) return;
const svg = createSparklineSvg(this.samples, this.config);
this.container.innerHTML = svg;
// Add current value display if enabled
if (this.config.showCurrentValue && this.samples.length > 0) {
const latest = this.samples[this.samples.length - 1];
const rssi = typeof latest === 'object' ? latest.rssi : latest;
const valueEl = document.createElement('span');
valueEl.className = 'rssi-current-value';
valueEl.textContent = `${rssi} dBm`;
valueEl.style.color = getRssiColor(rssi);
this.container.appendChild(valueEl);
}
}
clear() {
this.samples = [];
this.render();
}
destroy() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
if (this.container) {
this.container.innerHTML = '';
}
}
}
/**
* Create inline sparkline HTML (for use in templates)
*/
function createInlineSparkline(rssiHistory, options = {}) {
const samples = rssiHistory.map(h => typeof h === 'object' ? h.rssi : h);
return createSparklineSvg(samples, options);
}
/**
* Create sparkline with value display
*/
function createSparklineWithValue(rssiHistory, currentRssi, options = {}) {
const { width = 60, height = 20 } = options;
const svg = createInlineSparkline(rssiHistory, { ...options, width, height });
const color = getRssiColor(currentRssi);
return `
<div class="rssi-sparkline-wrapper">
${svg}
<span class="rssi-value" style="color: ${color}">${currentRssi !== null ? currentRssi : '--'} dBm</span>
</div>
`;
}
// Public API
return {
createSparklineSvg,
createInlineSparkline,
createSparklineWithValue,
createEmptySparkline,
LiveSparkline,
getRssiColor,
normalizeRssi,
DEFAULT_CONFIG,
RSSI_COLORS
};
})();
// Make globally available
window.RSSISparkline = RSSISparkline;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+905
View File
@@ -0,0 +1,905 @@
/**
* Signal Activity Timeline Component
* Lightweight visualization for RF signal presence over time
* Used for TSCM sweeps and investigative analysis
*/
const SignalTimeline = (function() {
'use strict';
// Configuration
const config = {
timeWindows: {
'5m': 5 * 60 * 1000,
'15m': 15 * 60 * 1000,
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'2h': 2 * 60 * 60 * 1000
},
defaultWindow: '30m',
maxSignals: 100, // max signals to track in memory
maxDisplayedLanes: 15, // max lanes to show at once (scroll for more)
burstThreshold: 5, // messages in burst window = burst
burstWindow: 60 * 1000, // 1 minute
updateInterval: 5000, // refresh every 5 seconds
barMinWidth: 2 // minimum bar width in pixels
};
// State
const state = {
signals: new Map(), // frequency -> signal data
annotations: [],
filters: {
hideBaseline: false,
showOnlyNew: false,
showOnlyBurst: false
},
timeWindow: config.defaultWindow,
tooltip: null,
updateTimer: null
};
/**
* Signal data structure
*/
function createSignal(frequency, name = null) {
return {
frequency: frequency,
name: name || categorizeFrequency(frequency),
events: [], // { timestamp, strength, duration }
firstSeen: null,
lastSeen: null,
status: 'new', // new, baseline, burst, flagged, gone
pattern: null, // detected pattern description
flagged: false,
transmissionCount: 0
};
}
/**
* Categorize frequency into human-readable name
*/
function categorizeFrequency(freq) {
const f = parseFloat(freq);
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
if (f >= 5150 && f <= 5850) return '5 GHz wireless band';
if (f >= 433 && f <= 434) return '433 MHz low-power band';
if (f >= 868 && f <= 869) return '868 MHz low-power band';
if (f >= 902 && f <= 928) return '915 MHz low-power band';
if (f >= 315 && f <= 316) return '315MHz';
if (f >= 2402 && f <= 2480) return 'Bluetooth band';
if (f >= 144 && f <= 148) return 'VHF amateur band';
if (f >= 420 && f <= 450) return 'UHF amateur band';
return `${freq} MHz`;
}
/**
* Add or update a signal event
*/
function addEvent(frequency, strength = 3, duration = 1000, name = null) {
const now = Date.now();
let signal = state.signals.get(frequency);
if (!signal) {
signal = createSignal(frequency, name);
signal.firstSeen = now;
state.signals.set(frequency, signal);
// Add annotation for new signal
addAnnotation('new', `New signal observed: ${signal.name}`, now);
}
// Add event
signal.events.push({
timestamp: now,
strength: Math.min(5, Math.max(1, strength)),
duration: duration
});
signal.lastSeen = now;
signal.transmissionCount++;
// Update status
updateSignalStatus(signal);
// Detect patterns
detectPatterns(signal);
// Limit events to prevent memory bloat
const windowMs = config.timeWindows['2h'];
signal.events = signal.events.filter(e => now - e.timestamp < windowMs);
// Prune old signals if we exceed max
if (state.signals.size > config.maxSignals) {
pruneOldSignals();
}
return signal;
}
/**
* Remove oldest/least active signals to stay under limit
*/
function pruneOldSignals() {
const signals = Array.from(state.signals.entries());
// Sort by last seen (oldest first), but keep flagged signals
signals.sort((a, b) => {
if (a[1].flagged && !b[1].flagged) return 1;
if (!a[1].flagged && b[1].flagged) return -1;
return a[1].lastSeen - b[1].lastSeen;
});
// Remove oldest signals until under limit
const toRemove = signals.length - config.maxSignals;
for (let i = 0; i < toRemove; i++) {
if (!signals[i][1].flagged) {
state.signals.delete(signals[i][0]);
}
}
}
/**
* Update signal status based on activity
*/
function updateSignalStatus(signal) {
const now = Date.now();
const recentEvents = signal.events.filter(
e => now - e.timestamp < config.burstWindow
);
// Check for burst activity
if (recentEvents.length >= config.burstThreshold) {
if (signal.status !== 'burst') {
signal.status = 'burst';
addAnnotation('burst',
`Activity cluster: ${recentEvents.length} events in ${config.burstWindow/1000}s - ${signal.name}`,
now
);
}
} else if (signal.transmissionCount >= 20) {
// Baseline if seen many times
signal.status = 'baseline';
} else if (now - signal.firstSeen < 5 * 60 * 1000) {
// New if first seen within 5 minutes
signal.status = 'new';
}
// Override if flagged
if (signal.flagged) {
signal.status = 'flagged';
}
}
/**
* Detect repeating patterns in signal events
*/
function detectPatterns(signal) {
if (signal.events.length < 4) return;
// Get intervals between events
const intervals = [];
for (let i = 1; i < signal.events.length; i++) {
intervals.push(signal.events[i].timestamp - signal.events[i-1].timestamp);
}
// Look for consistent interval (within 10% tolerance)
if (intervals.length >= 3) {
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const tolerance = avgInterval * 0.1;
const consistent = intervals.filter(
i => Math.abs(i - avgInterval) <= tolerance
).length;
if (consistent >= intervals.length * 0.7) {
const seconds = Math.round(avgInterval / 1000);
if (seconds >= 1 && seconds <= 3600) {
const patternStr = seconds < 60
? `${seconds}s interval`
: `${Math.round(seconds/60)}m interval`;
if (signal.pattern !== patternStr) {
signal.pattern = patternStr;
addAnnotation('pattern',
`Repeating pattern observed: ${patternStr} - ${signal.name}`,
Date.now()
);
}
}
}
}
}
/**
* Add annotation
*/
function addAnnotation(type, message, timestamp) {
state.annotations.unshift({
type: type,
message: message,
timestamp: timestamp
});
// Limit annotations
if (state.annotations.length > 20) {
state.annotations.pop();
}
}
/**
* Flag a signal for investigation
*/
function flagSignal(frequency) {
const signal = state.signals.get(frequency);
if (signal) {
signal.flagged = !signal.flagged;
signal.status = signal.flagged ? 'flagged' : 'new';
addAnnotation('flagged',
signal.flagged
? `Marked for review: ${signal.name}`
: `Review mark removed: ${signal.name}`,
Date.now()
);
}
}
/**
* Mark signal as gone (no longer transmitting)
*/
function markGone(frequency) {
const signal = state.signals.get(frequency);
if (signal && signal.status !== 'gone') {
signal.status = 'gone';
addAnnotation('gone', `Signal no longer observed: ${signal.name}`, Date.now());
}
}
/**
* Create the timeline DOM element
*/
function createTimeline(containerId, options = {}) {
const container = document.getElementById(containerId);
if (!container) return null;
const startCollapsed = options.collapsed !== false;
const timeline = document.createElement('div');
timeline.className = 'signal-timeline' + (startCollapsed ? ' collapsed' : '');
timeline.id = 'signalTimeline';
timeline.innerHTML = `
<div class="signal-timeline-header" id="timelineHeader">
<div style="display: flex; align-items: center;">
<span class="signal-timeline-collapse-icon"></span>
<span class="signal-timeline-title">Signal Activity Timeline</span>
</div>
<div class="signal-timeline-header-stats" id="timelineHeaderStats">
<div class="signal-timeline-header-stat">
<span class="stat-value" id="timelineStatTotal">0</span>
<span>signals</span>
</div>
<div class="signal-timeline-header-stat">
<span class="stat-value" id="timelineStatNew">0</span>
<span>new</span>
</div>
<div class="signal-timeline-header-stat">
<span class="stat-value" id="timelineStatBurst">0</span>
<span>burst</span>
</div>
</div>
</div>
<div class="signal-timeline-body">
<div class="signal-timeline-controls" style="display: flex; align-items: center; gap: 6px; padding: 8px 0; flex-wrap: wrap;">
<button class="signal-timeline-btn" data-filter="hideBaseline" title="Hide baseline signals">
Hide Known
</button>
<button class="signal-timeline-btn" data-filter="showOnlyNew" title="Show only new signals">
New Only
</button>
<button class="signal-timeline-btn" data-filter="showOnlyBurst" title="Show only burst activity">
Bursts
</button>
<div class="signal-timeline-window" style="margin-left: auto;">
<span>Window:</span>
<select id="timelineWindowSelect">
<option value="5m">5 min</option>
<option value="15m">15 min</option>
<option value="30m" selected>30 min</option>
<option value="1h">1 hour</option>
<option value="2h">2 hours</option>
</select>
</div>
</div>
<div class="signal-timeline-axis" id="timelineAxis"></div>
<div class="signal-timeline-lanes" id="timelineLanes">
<div class="signal-timeline-empty">
<div class="signal-timeline-empty-icon">📡</div>
<div>No signal activity recorded</div>
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as signals are observed</div>
</div>
</div>
<div class="signal-timeline-annotations" id="timelineAnnotations" style="display: none;"></div>
<div class="signal-timeline-legend">
<div class="signal-timeline-legend-item">
<div class="signal-timeline-legend-dot new"></div>
<span>New</span>
</div>
<div class="signal-timeline-legend-item">
<div class="signal-timeline-legend-dot baseline"></div>
<span>Baseline</span>
</div>
<div class="signal-timeline-legend-item">
<div class="signal-timeline-legend-dot burst"></div>
<span>Burst</span>
</div>
<div class="signal-timeline-legend-item">
<div class="signal-timeline-legend-dot flagged"></div>
<span>Flagged</span>
</div>
</div>
</div>
`;
container.appendChild(timeline);
// Set up event listeners
setupEventListeners(timeline);
// Create tooltip element
createTooltip();
// Start update timer
startUpdateTimer();
// Initial render
render();
return timeline;
}
/**
* Set up event listeners
*/
function setupEventListeners(timeline) {
// Collapse toggle
const header = timeline.querySelector('#timelineHeader');
if (header) {
header.addEventListener('click', (e) => {
// Don't toggle if clicking on controls inside header
if (e.target.closest('button') || e.target.closest('select')) return;
timeline.classList.toggle('collapsed');
});
}
// Filter buttons
timeline.querySelectorAll('.signal-timeline-btn[data-filter]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent collapse toggle
const filter = btn.dataset.filter;
state.filters[filter] = !state.filters[filter];
btn.classList.toggle('active', state.filters[filter]);
render();
});
});
// Time window selector
const windowSelect = timeline.querySelector('#timelineWindowSelect');
if (windowSelect) {
windowSelect.addEventListener('click', (e) => e.stopPropagation());
windowSelect.addEventListener('change', (e) => {
state.timeWindow = e.target.value;
render();
});
}
// Lane click to expand
timeline.addEventListener('click', (e) => {
const lane = e.target.closest('.signal-timeline-lane');
if (lane && !e.target.closest('button')) {
lane.classList.toggle('expanded');
}
});
// Lane right-click to flag
timeline.addEventListener('contextmenu', (e) => {
const lane = e.target.closest('.signal-timeline-lane');
if (lane) {
e.preventDefault();
const freq = lane.dataset.frequency;
flagSignal(freq);
render();
}
});
}
/**
* Create tooltip element
*/
function createTooltip() {
if (state.tooltip) return;
state.tooltip = document.createElement('div');
state.tooltip.className = 'signal-timeline-tooltip';
state.tooltip.style.display = 'none';
document.body.appendChild(state.tooltip);
}
/**
* Show tooltip
*/
function showTooltip(e, signal) {
if (!state.tooltip) return;
const now = Date.now();
const duration = signal.lastSeen - signal.firstSeen;
const durationStr = formatDuration(duration);
const lastSeenStr = formatTimeAgo(signal.lastSeen);
state.tooltip.innerHTML = `
<div class="signal-timeline-tooltip-header">${signal.name}</div>
<div class="signal-timeline-tooltip-row">
<span>Frequency:</span>
<span>${signal.frequency} MHz</span>
</div>
<div class="signal-timeline-tooltip-row">
<span>First seen:</span>
<span>${formatTime(signal.firstSeen)}</span>
</div>
<div class="signal-timeline-tooltip-row">
<span>Last seen:</span>
<span>${lastSeenStr}</span>
</div>
<div class="signal-timeline-tooltip-row">
<span>Transmissions:</span>
<span>${signal.transmissionCount}</span>
</div>
${signal.pattern ? `
<div class="signal-timeline-tooltip-row">
<span>Pattern:</span>
<span>${signal.pattern}</span>
</div>
` : ''}
<div class="signal-timeline-tooltip-row">
<span>Status:</span>
<span style="text-transform: capitalize;">${signal.status}</span>
</div>
`;
state.tooltip.style.display = 'block';
state.tooltip.style.left = (e.clientX + 10) + 'px';
state.tooltip.style.top = (e.clientY + 10) + 'px';
}
/**
* Hide tooltip
*/
function hideTooltip() {
if (state.tooltip) {
state.tooltip.style.display = 'none';
}
}
/**
* Start the update timer
*/
function startUpdateTimer() {
if (state.updateTimer) {
clearInterval(state.updateTimer);
}
state.updateTimer = setInterval(() => {
render();
}, config.updateInterval);
}
/**
* Stop the update timer
*/
function stopUpdateTimer() {
if (state.updateTimer) {
clearInterval(state.updateTimer);
state.updateTimer = null;
}
}
/**
* Render the timeline
*/
function render() {
const lanesContainer = document.getElementById('timelineLanes');
const axisContainer = document.getElementById('timelineAxis');
const annotationsContainer = document.getElementById('timelineAnnotations');
if (!lanesContainer) return;
const now = Date.now();
const windowMs = config.timeWindows[state.timeWindow];
const startTime = now - windowMs;
// Render time axis
renderAxis(axisContainer, startTime, now, windowMs);
// Get filtered signals
let signals = Array.from(state.signals.values());
// Apply filters
if (state.filters.hideBaseline) {
signals = signals.filter(s => s.status !== 'baseline');
}
if (state.filters.showOnlyNew) {
signals = signals.filter(s => s.status === 'new');
}
if (state.filters.showOnlyBurst) {
signals = signals.filter(s => s.status === 'burst');
}
// Sort by last seen (most recent first), then by status priority
const statusPriority = { flagged: 0, burst: 1, new: 2, baseline: 3, gone: 4 };
signals.sort((a, b) => {
const priorityDiff = statusPriority[a.status] - statusPriority[b.status];
if (priorityDiff !== 0) return priorityDiff;
return b.lastSeen - a.lastSeen;
});
// Render lanes (limit displayed for performance)
const totalSignals = signals.length;
const displayedSignals = signals.slice(0, config.maxDisplayedLanes);
const hiddenCount = totalSignals - displayedSignals.length;
if (signals.length === 0) {
lanesContainer.innerHTML = `
<div class="signal-timeline-empty">
<div class="signal-timeline-empty-icon">📡</div>
<div>No signal activity recorded</div>
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as signals are observed</div>
</div>
`;
} else {
let html = displayedSignals.map(signal =>
renderLane(signal, startTime, now, windowMs)
).join('');
// Show indicator if there are more signals
if (hiddenCount > 0) {
html += `
<div class="signal-timeline-more" style="text-align: center; padding: 8px; font-size: 10px; color: var(--text-dim, #666);">
+${hiddenCount} more signals (scroll or adjust filters)
</div>
`;
}
lanesContainer.innerHTML = html;
// Add event listeners to new lanes
lanesContainer.querySelectorAll('.signal-timeline-lane').forEach(lane => {
const freq = lane.dataset.frequency;
const signal = state.signals.get(freq);
lane.addEventListener('mouseenter', (e) => showTooltip(e, signal));
lane.addEventListener('mousemove', (e) => showTooltip(e, signal));
lane.addEventListener('mouseleave', hideTooltip);
});
}
// Update header stats
const allSignals = Array.from(state.signals.values());
const statTotal = document.getElementById('timelineStatTotal');
const statNew = document.getElementById('timelineStatNew');
const statBurst = document.getElementById('timelineStatBurst');
if (statTotal) statTotal.textContent = allSignals.length;
if (statNew) statNew.textContent = allSignals.filter(s => s.status === 'new').length;
if (statBurst) statBurst.textContent = allSignals.filter(s => s.status === 'burst').length;
// Render annotations
renderAnnotations(annotationsContainer);
}
/**
* Render time axis
*/
function renderAxis(container, startTime, endTime, windowMs) {
if (!container) return;
const labels = [];
const steps = 6;
for (let i = 0; i <= steps; i++) {
const time = startTime + (windowMs * i / steps);
const label = i === steps ? 'Now' : formatTimeShort(time);
labels.push(`<span class="signal-timeline-axis-label">${label}</span>`);
}
container.innerHTML = labels.join('');
}
/**
* Render a single lane
*/
function renderLane(signal, startTime, endTime, windowMs) {
const isBaseline = signal.status === 'baseline';
// Get events within time window
const visibleEvents = signal.events.filter(
e => e.timestamp >= startTime && e.timestamp <= endTime
);
// Generate bars HTML
const barsHtml = aggregateAndRenderBars(visibleEvents, startTime, windowMs);
// Generate ticks for expanded view
const ticksHtml = visibleEvents.map(event => {
const position = ((event.timestamp - startTime) / windowMs) * 100;
return `<div class="signal-timeline-tick"
style="left: ${position}%;"
data-strength="${event.strength}"></div>`;
}).join('');
// Stats
const recentCount = visibleEvents.length;
return `
<div class="signal-timeline-lane ${isBaseline ? 'baseline' : ''}"
data-frequency="${signal.frequency}"
data-status="${signal.status}">
<div class="signal-timeline-status" data-status="${signal.status}"></div>
<div class="signal-timeline-label">
<span class="signal-timeline-freq">${signal.frequency}</span>
<span class="signal-timeline-name">${signal.name}</span>
</div>
<div class="signal-timeline-track">
<div class="signal-timeline-track-bg">
${barsHtml}
</div>
<div class="signal-timeline-ticks">
${ticksHtml}
</div>
</div>
<div class="signal-timeline-stats">
<span class="signal-timeline-stat-count">${recentCount}</span>
<span class="signal-timeline-stat-label">events</span>
</div>
</div>
`;
}
/**
* Aggregate events into bars and render
*/
function aggregateAndRenderBars(events, startTime, windowMs) {
if (events.length === 0) return '';
// Group nearby events into bars
const bars = [];
let currentBar = null;
const minGap = windowMs / 100; // Merge events within 1% of window
events.sort((a, b) => a.timestamp - b.timestamp);
for (const event of events) {
if (!currentBar) {
currentBar = {
start: event.timestamp,
end: event.timestamp + event.duration,
maxStrength: event.strength,
count: 1
};
} else if (event.timestamp - currentBar.end <= minGap) {
// Extend current bar
currentBar.end = Math.max(currentBar.end, event.timestamp + event.duration);
currentBar.maxStrength = Math.max(currentBar.maxStrength, event.strength);
currentBar.count++;
} else {
// Start new bar
bars.push(currentBar);
currentBar = {
start: event.timestamp,
end: event.timestamp + event.duration,
maxStrength: event.strength,
count: 1
};
}
}
if (currentBar) bars.push(currentBar);
// Determine status for bars based on count
return bars.map(bar => {
const left = ((bar.start - startTime) / windowMs) * 100;
const width = Math.max(
config.barMinWidth / 8, // Convert px to approximate %
((bar.end - bar.start) / windowMs) * 100
);
const status = bar.count >= config.burstThreshold ? 'burst' :
bar.count > 1 ? 'repeated' : 'new';
return `<div class="signal-timeline-bar"
style="left: ${left}%; width: ${width}%;"
data-strength="${bar.maxStrength}"
data-status="${status}"></div>`;
}).join('');
}
/**
* Render annotations
*/
function renderAnnotations(container) {
if (!container) return;
const recentAnnotations = state.annotations.slice(0, 5);
if (recentAnnotations.length === 0) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
container.innerHTML = recentAnnotations.map(ann => {
const iconFuncs = {
new: () => Icons.newBadge('icon--sm'),
burst: () => Icons.meter('icon--sm'),
pattern: () => Icons.refresh('icon--sm'),
flagged: () => Icons.flag('icon--sm'),
gone: () => Icons.offline('icon--sm')
};
const iconHtml = iconFuncs[ann.type] ? iconFuncs[ann.type]() : Icons.sensor('icon--sm');
return `
<div class="signal-timeline-annotation" data-type="${ann.type}">
<span class="signal-timeline-annotation-icon">${iconHtml}</span>
<span>${ann.message}</span>
<span style="margin-left: auto; opacity: 0.6;">${formatTimeAgo(ann.timestamp)}</span>
</div>
`;
}).join('');
}
/**
* Format time for display
*/
function formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
/**
* Format short time for axis
*/
function formatTimeShort(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
/**
* Format time ago
*/
function formatTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
/**
* Format duration
*/
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
/**
* Clear all data
*/
function clear() {
state.signals.clear();
state.annotations = [];
render();
}
/**
* Export data for reports
*/
function exportData() {
const signals = Array.from(state.signals.values()).map(s => ({
frequency: s.frequency,
name: s.name,
status: s.status,
pattern: s.pattern,
firstSeen: new Date(s.firstSeen).toISOString(),
lastSeen: new Date(s.lastSeen).toISOString(),
transmissionCount: s.transmissionCount,
flagged: s.flagged
}));
return {
exportTime: new Date().toISOString(),
timeWindow: state.timeWindow,
signals: signals,
annotations: state.annotations.map(a => ({
...a,
timestamp: new Date(a.timestamp).toISOString()
}))
};
}
/**
* Get summary stats
*/
function getStats() {
const signals = Array.from(state.signals.values());
return {
total: signals.length,
new: signals.filter(s => s.status === 'new').length,
baseline: signals.filter(s => s.status === 'baseline').length,
burst: signals.filter(s => s.status === 'burst').length,
flagged: signals.filter(s => s.flagged).length,
withPattern: signals.filter(s => s.pattern).length
};
}
/**
* Destroy the timeline
*/
function destroy() {
stopUpdateTimer();
if (state.tooltip) {
state.tooltip.remove();
state.tooltip = null;
}
const timeline = document.getElementById('signalTimeline');
if (timeline) {
timeline.remove();
}
}
// Public API
return {
// Initialization
create: createTimeline,
destroy: destroy,
// Data management
addEvent: addEvent,
flagSignal: flagSignal,
markGone: markGone,
clear: clear,
// Rendering
render: render,
// Data access
getSignals: () => Array.from(state.signals.values()),
getAnnotations: () => state.annotations,
getStats: getStats,
exportData: exportData,
// Configuration
setTimeWindow: (window) => {
if (config.timeWindows[window]) {
state.timeWindow = window;
render();
}
},
// Filter controls
setFilter: (filter, value) => {
if (state.filters.hasOwnProperty(filter)) {
state.filters[filter] = value;
render();
}
}
};
})();
// Make globally available
window.SignalTimeline = SignalTimeline;
@@ -0,0 +1,288 @@
/**
* Bluetooth Timeline Adapter
* Normalizes Bluetooth device data for the Activity Timeline component
* Used by: Bluetooth mode, TSCM (Bluetooth detections)
*/
const BluetoothTimelineAdapter = (function() {
'use strict';
/**
* RSSI to strength category mapping for Bluetooth
* Bluetooth RSSI typically ranges from -30 (very close) to -100 (far)
*/
const RSSI_THRESHOLDS = {
VERY_STRONG: -45, // 5 - device likely within 1m
STRONG: -60, // 4 - device likely within 3m
MODERATE: -75, // 3 - device likely within 10m
WEAK: -90, // 2 - device at edge of range
MINIMAL: -100 // 1 - barely detectable
};
/**
* Known device type patterns
*/
const DEVICE_PATTERNS = {
// Apple devices
AIRPODS: /airpods/i,
IPHONE: /iphone/i,
IPAD: /ipad/i,
MACBOOK: /macbook|mac\s*pro|imac/i,
APPLE_WATCH: /apple\s*watch/i,
AIRTAG: /airtag/i,
// Trackers
TILE: /tile/i,
CHIPOLO: /chipolo/i,
SAMSUNG_TAG: /smarttag|galaxy\s*tag/i,
// Audio
HEADPHONES: /headphone|earphone|earbud|bose|sony|beats|jabra|sennheiser/i,
SPEAKER: /speaker|soundbar|echo|homepod|sonos/i,
// Wearables
FITBIT: /fitbit/i,
GARMIN: /garmin/i,
SMARTWATCH: /watch|band|mi\s*band|galaxy\s*fit/i,
// Input devices
KEYBOARD: /keyboard/i,
MOUSE: /mouse|trackpad|magic/i,
CONTROLLER: /controller|gamepad|xbox|playstation|dualshock/i,
// Vehicles
CAR: /car\s*kit|handsfree|obd|vehicle|toyota|honda|ford|bmw|mercedes/i
};
/**
* Convert RSSI to strength category
*/
function rssiToStrength(rssi) {
if (rssi === null || rssi === undefined) return 3;
const r = parseFloat(rssi);
if (isNaN(r)) return 3;
if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5;
if (r > RSSI_THRESHOLDS.STRONG) return 4;
if (r > RSSI_THRESHOLDS.MODERATE) return 3;
if (r > RSSI_THRESHOLDS.WEAK) return 2;
return 1;
}
/**
* Classify device type from name
*/
function classifyDevice(name) {
if (!name) return { type: 'unknown', category: 'device' };
for (const [pattern, regex] of Object.entries(DEVICE_PATTERNS)) {
if (regex.test(name)) {
return {
type: pattern.toLowerCase(),
category: getCategoryForType(pattern)
};
}
}
return { type: 'unknown', category: 'device' };
}
/**
* Get category for device type
*/
function getCategoryForType(type) {
const categories = {
AIRPODS: 'audio',
IPHONE: 'phone',
IPAD: 'tablet',
MACBOOK: 'computer',
APPLE_WATCH: 'wearable',
AIRTAG: 'tracker',
TILE: 'tracker',
CHIPOLO: 'tracker',
SAMSUNG_TAG: 'tracker',
HEADPHONES: 'audio',
SPEAKER: 'audio',
FITBIT: 'wearable',
GARMIN: 'wearable',
SMARTWATCH: 'wearable',
KEYBOARD: 'input',
MOUSE: 'input',
CONTROLLER: 'input',
CAR: 'vehicle'
};
return categories[type] || 'device';
}
/**
* Format MAC address for display (truncated)
*/
function formatMac(mac, full = false) {
if (!mac) return 'Unknown';
if (full) return mac.toUpperCase();
return mac.substring(0, 8).toUpperCase() + '...';
}
/**
* Determine if device is a tracker type
*/
function isTracker(device) {
if (device.is_tracker) return true;
const name = device.name || '';
return /airtag|tile|chipolo|smarttag|tracker/i.test(name);
}
/**
* Normalize a Bluetooth device detection for the timeline
*/
function normalizeDevice(device) {
const mac = device.mac || device.address || device.id;
const name = device.name || device.device_name || formatMac(mac);
const classification = classifyDevice(name);
const tags = [device.type || 'ble'];
tags.push(classification.category);
if (isTracker(device)) tags.push('tracker');
if (device.is_beacon) tags.push('beacon');
if (device.is_connectable) tags.push('connectable');
if (device.manufacturer) tags.push('identified');
return {
id: mac,
label: name,
strength: rssiToStrength(device.rssi),
duration: device.scan_duration || device.duration || 1000,
type: classification.type,
tags: tags,
metadata: {
mac: mac,
rssi: device.rssi,
device_type: device.type,
manufacturer: device.manufacturer,
services: device.services,
is_tracker: isTracker(device),
classification: classification
}
};
}
/**
* Normalize for TSCM context (includes threat assessment)
*/
function normalizeTscmDevice(device) {
const normalized = normalizeDevice(device);
// Add TSCM-specific tags
if (device.is_new) normalized.tags.push('new');
if (device.threat_level) normalized.tags.push(`threat-${device.threat_level}`);
if (device.baseline_known === false) normalized.tags.push('unknown');
normalized.metadata.threat_level = device.threat_level;
normalized.metadata.first_seen = device.first_seen;
normalized.metadata.appearance_count = device.appearance_count;
return normalized;
}
/**
* Batch normalize multiple devices
*/
function normalizeDevices(devices, context = 'scan') {
const normalizer = context === 'tscm' ? normalizeTscmDevice : normalizeDevice;
return devices.map(normalizer);
}
/**
* Create timeline configuration for Bluetooth mode
*/
function getBluetoothConfig() {
return {
title: 'Device Activity',
mode: 'bluetooth',
visualMode: 'enriched',
collapsed: false,
showAnnotations: true,
showLegend: true,
defaultWindow: '15m',
availableWindows: ['5m', '15m', '30m', '1h'],
filters: {
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
showOnlyNew: { enabled: true, label: 'New Only', default: false },
showOnlyBurst: { enabled: false, label: 'Bursts', default: false }
},
customFilters: [
{
key: 'showOnlyTrackers',
label: 'Trackers Only',
default: false,
predicate: (item) => item.tags.includes('tracker')
},
{
key: 'hideWearables',
label: 'Hide Wearables',
default: false,
predicate: (item) => !item.tags.includes('wearable')
}
],
maxItems: 75,
maxDisplayedLanes: 12,
labelGenerator: (id) => formatMac(id)
};
}
/**
* Create compact timeline configuration (for sidebar use)
*/
function getCompactConfig() {
return {
title: 'BT Devices',
mode: 'bluetooth',
visualMode: 'compact',
collapsed: false,
showAnnotations: false,
showLegend: false,
defaultWindow: '15m',
availableWindows: ['5m', '15m', '30m'],
filters: {
hideBaseline: { enabled: false },
showOnlyNew: { enabled: true, label: 'New', default: false },
showOnlyBurst: { enabled: false }
},
customFilters: [],
maxItems: 30,
maxDisplayedLanes: 8
};
}
// Public API
return {
// Normalization
normalizeDevice: normalizeDevice,
normalizeTscmDevice: normalizeTscmDevice,
normalizeDevices: normalizeDevices,
// Utilities
rssiToStrength: rssiToStrength,
classifyDevice: classifyDevice,
formatMac: formatMac,
isTracker: isTracker,
// Configuration presets
getBluetoothConfig: getBluetoothConfig,
getCompactConfig: getCompactConfig,
// Constants
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
DEVICE_PATTERNS: DEVICE_PATTERNS
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = BluetoothTimelineAdapter;
}
window.BluetoothTimelineAdapter = BluetoothTimelineAdapter;
@@ -0,0 +1,241 @@
/**
* RF Signal Timeline Adapter
* Normalizes RF signal data for the Activity Timeline component
* Used by: Listening Post, TSCM
*/
const RFTimelineAdapter = (function() {
'use strict';
/**
* RSSI to strength category mapping
* Uses confidence-safe thresholds
*/
const RSSI_THRESHOLDS = {
VERY_STRONG: -40, // 5 - indicates likely nearby source
STRONG: -55, // 4 - probable close proximity
MODERATE: -70, // 3 - likely in proximity
WEAK: -85, // 2 - potentially distant or obstructed
MINIMAL: -100 // 1 - may be ambient noise or distant source
};
/**
* Frequency band categorization
*/
const FREQUENCY_BANDS = [
{ min: 2400, max: 2500, label: 'Wi-Fi 2.4GHz', type: 'wifi' },
{ min: 5150, max: 5850, label: 'Wi-Fi 5GHz', type: 'wifi' },
{ min: 5925, max: 7125, label: 'Wi-Fi 6E', type: 'wifi' },
{ min: 2402, max: 2480, label: 'Bluetooth', type: 'bluetooth' },
{ min: 433, max: 434, label: '433MHz ISM', type: 'ism' },
{ min: 868, max: 869, label: '868MHz ISM', type: 'ism' },
{ min: 902, max: 928, label: '915MHz ISM', type: 'ism' },
{ min: 315, max: 316, label: '315MHz', type: 'keyfob' },
{ min: 144, max: 148, label: 'VHF Ham', type: 'amateur' },
{ min: 420, max: 450, label: 'UHF Ham', type: 'amateur' },
{ min: 462.5625, max: 467.7125, label: 'FRS/GMRS', type: 'personal' },
{ min: 151, max: 159, label: 'VHF Business', type: 'commercial' },
{ min: 450, max: 470, label: 'UHF Business', type: 'commercial' },
{ min: 88, max: 108, label: 'FM Broadcast', type: 'broadcast' },
{ min: 118, max: 137, label: 'Airband', type: 'aviation' },
{ min: 156, max: 162, label: 'Marine VHF', type: 'marine' }
];
/**
* Convert RSSI (dBm) to strength category (1-5)
*/
function rssiToStrength(rssi) {
if (rssi === null || rssi === undefined) return 3;
const r = parseFloat(rssi);
if (isNaN(r)) return 3;
if (r > RSSI_THRESHOLDS.VERY_STRONG) return 5;
if (r > RSSI_THRESHOLDS.STRONG) return 4;
if (r > RSSI_THRESHOLDS.MODERATE) return 3;
if (r > RSSI_THRESHOLDS.WEAK) return 2;
return 1;
}
/**
* Categorize frequency into human-readable band name
*/
function categorizeFrequency(freqMHz) {
const f = parseFloat(freqMHz);
if (isNaN(f)) return { label: String(freqMHz), type: 'unknown' };
for (const band of FREQUENCY_BANDS) {
if (f >= band.min && f <= band.max) {
return { label: band.label, type: band.type };
}
}
// Generic labeling by range
if (f < 30) return { label: `${f.toFixed(3)} MHz HF`, type: 'hf' };
if (f < 300) return { label: `${f.toFixed(3)} MHz VHF`, type: 'vhf' };
if (f < 3000) return { label: `${f.toFixed(3)} MHz UHF`, type: 'uhf' };
return { label: `${f.toFixed(3)} MHz`, type: 'unknown' };
}
/**
* Normalize a scanner signal detection for the timeline
*/
function normalizeSignal(signalData) {
const freq = signalData.frequency || signalData.freq;
const category = categorizeFrequency(freq);
return {
id: String(freq),
label: signalData.name || category.label,
strength: rssiToStrength(signalData.rssi || signalData.signal_strength),
duration: signalData.duration || 1000,
type: category.type,
tags: buildTags(signalData, category),
metadata: {
frequency: freq,
rssi: signalData.rssi,
modulation: signalData.modulation,
bandwidth: signalData.bandwidth
}
};
}
/**
* Normalize a TSCM RF detection
*/
function normalizeTscmSignal(detection) {
const freq = detection.frequency;
const category = categorizeFrequency(freq);
const tags = buildTags(detection, category);
// Add TSCM-specific tags
if (detection.is_new) tags.push('new');
if (detection.baseline_deviation) tags.push('deviation');
if (detection.threat_level) tags.push(`threat-${detection.threat_level}`);
return {
id: String(freq),
label: detection.name || category.label,
strength: rssiToStrength(detection.rssi),
duration: detection.duration || 1000,
type: category.type,
tags: tags,
metadata: {
frequency: freq,
rssi: detection.rssi,
threat_level: detection.threat_level,
source: detection.source
}
};
}
/**
* Build tags array from signal data
*/
function buildTags(data, category) {
const tags = [];
if (category.type) tags.push(category.type);
if (data.modulation) {
tags.push(data.modulation.toLowerCase());
}
if (data.is_burst) tags.push('burst');
if (data.is_continuous) tags.push('continuous');
if (data.is_periodic) tags.push('periodic');
return tags;
}
/**
* Batch normalize multiple signals
*/
function normalizeSignals(signals, type = 'scanner') {
const normalizer = type === 'tscm' ? normalizeTscmSignal : normalizeSignal;
return signals.map(normalizer);
}
/**
* Create timeline configuration for Listening Post mode
*/
function getListeningPostConfig() {
return {
title: 'Signal Activity',
mode: 'listening-post',
visualMode: 'enriched',
collapsed: false,
showAnnotations: true,
showLegend: true,
defaultWindow: '15m',
availableWindows: ['5m', '15m', '30m', '1h'],
filters: {
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
showOnlyNew: { enabled: true, label: 'New Only', default: false },
showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
},
customFilters: [
{
key: 'hideIsm',
label: 'Hide ISM',
default: false,
predicate: (item) => !item.tags.includes('ism')
}
],
maxItems: 50,
maxDisplayedLanes: 12
};
}
/**
* Create timeline configuration for TSCM mode
*/
function getTscmConfig() {
return {
title: 'Signal Activity Timeline',
mode: 'tscm',
visualMode: 'enriched',
collapsed: true,
showAnnotations: true,
showLegend: true,
defaultWindow: '30m',
availableWindows: ['5m', '15m', '30m', '1h', '2h'],
filters: {
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
showOnlyNew: { enabled: true, label: 'New Only', default: false },
showOnlyBurst: { enabled: true, label: 'Bursts', default: false }
},
customFilters: [],
maxItems: 100,
maxDisplayedLanes: 15
};
}
// Public API
return {
// Normalization
normalizeSignal: normalizeSignal,
normalizeTscmSignal: normalizeTscmSignal,
normalizeSignals: normalizeSignals,
// Utilities
rssiToStrength: rssiToStrength,
categorizeFrequency: categorizeFrequency,
// Configuration presets
getListeningPostConfig: getListeningPostConfig,
getTscmConfig: getTscmConfig,
// Constants
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
FREQUENCY_BANDS: FREQUENCY_BANDS
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = RFTimelineAdapter;
}
window.RFTimelineAdapter = RFTimelineAdapter;
@@ -0,0 +1,319 @@
/**
* WiFi Timeline Adapter
* Normalizes WiFi network data for the Activity Timeline component
* Used by: WiFi mode, TSCM (WiFi detections)
*/
const WiFiTimelineAdapter = (function() {
'use strict';
/**
* RSSI to strength category mapping for WiFi
*/
const RSSI_THRESHOLDS = {
EXCELLENT: -50, // 5 - excellent signal
GOOD: -60, // 4 - good signal
FAIR: -70, // 3 - fair signal
WEAK: -80, // 2 - weak signal
POOR: -90 // 1 - very weak
};
/**
* WiFi channel to frequency band mapping
*/
const CHANNEL_BANDS = {
// 2.4 GHz (channels 1-14)
'2.4GHz': { min: 1, max: 14 },
// 5 GHz (channels 32-177)
'5GHz': { min: 32, max: 177 },
// 6 GHz (channels 1-233, WiFi 6E)
'6GHz': { min: 1, max: 233, is6e: true }
};
/**
* Security type classifications
*/
const SECURITY_TYPES = {
OPEN: 'open',
WEP: 'wep',
WPA: 'wpa',
WPA2: 'wpa2',
WPA3: 'wpa3',
ENTERPRISE: 'enterprise'
};
/**
* Convert RSSI to strength category
*/
function rssiToStrength(rssi) {
if (rssi === null || rssi === undefined) return 3;
const r = parseFloat(rssi);
if (isNaN(r)) return 3;
if (r > RSSI_THRESHOLDS.EXCELLENT) return 5;
if (r > RSSI_THRESHOLDS.GOOD) return 4;
if (r > RSSI_THRESHOLDS.FAIR) return 3;
if (r > RSSI_THRESHOLDS.WEAK) return 2;
return 1;
}
/**
* Determine frequency band from channel
*/
function getBandFromChannel(channel, frequency) {
if (frequency) {
const f = parseFloat(frequency);
if (f >= 5925) return '6GHz';
if (f >= 5000) return '5GHz';
if (f >= 2400) return '2.4GHz';
}
const ch = parseInt(channel);
if (isNaN(ch)) return 'unknown';
// This is simplified - in practice 6GHz also uses channels 1+
// but typically reported with frequency
if (ch <= 14) return '2.4GHz';
if (ch >= 32 && ch <= 177) return '5GHz';
return 'unknown';
}
/**
* Classify security type
*/
function classifySecurity(network) {
const security = (network.security || network.encryption || '').toLowerCase();
const auth = (network.auth || '').toLowerCase();
if (!security || security === 'none' || security === 'open') {
return SECURITY_TYPES.OPEN;
}
if (security.includes('wep')) return SECURITY_TYPES.WEP;
if (security.includes('wpa3')) return SECURITY_TYPES.WPA3;
if (security.includes('wpa2') || security.includes('rsn')) {
if (auth.includes('eap') || auth.includes('802.1x') || auth.includes('enterprise')) {
return SECURITY_TYPES.ENTERPRISE;
}
return SECURITY_TYPES.WPA2;
}
if (security.includes('wpa')) return SECURITY_TYPES.WPA;
return 'unknown';
}
/**
* Truncate SSID for display
*/
function formatSsid(ssid, maxLength = 20) {
if (!ssid) return '[Hidden]';
if (ssid.length <= maxLength) return ssid;
return ssid.substring(0, maxLength - 3) + '...';
}
/**
* Identify potentially interesting network characteristics
*/
function identifyCharacteristics(network) {
const characteristics = [];
const ssid = (network.ssid || '').toLowerCase();
// Hidden network
if (!network.ssid || network.is_hidden) {
characteristics.push('hidden');
}
// Open network
if (classifySecurity(network) === SECURITY_TYPES.OPEN) {
characteristics.push('open');
}
// Weak security
if (classifySecurity(network) === SECURITY_TYPES.WEP) {
characteristics.push('weak-security');
}
// Potential hotspot
if (/hotspot|mobile|tether|android|iphone/i.test(ssid)) {
characteristics.push('hotspot');
}
// Guest network
if (/guest|visitor|public/i.test(ssid)) {
characteristics.push('guest');
}
// IoT device
if (/ring|nest|ecobee|smartthings|wyze|arlo|hue|lifx/i.test(ssid)) {
characteristics.push('iot');
}
return characteristics;
}
/**
* Normalize a WiFi network detection for the timeline
*/
function normalizeNetwork(network) {
const ssid = network.ssid || network.essid || '';
const bssid = network.bssid || network.mac || '';
const band = getBandFromChannel(network.channel, network.frequency);
const security = classifySecurity(network);
const characteristics = identifyCharacteristics(network);
const tags = [band, security, ...characteristics];
return {
id: bssid || ssid,
label: formatSsid(ssid) || formatMac(bssid),
strength: rssiToStrength(network.rssi || network.signal),
duration: network.duration || 1000,
type: 'wifi',
tags: tags.filter(Boolean),
metadata: {
ssid: ssid,
bssid: bssid,
channel: network.channel,
frequency: network.frequency,
rssi: network.rssi || network.signal,
security: security,
band: band,
characteristics: characteristics
}
};
}
/**
* Normalize for TSCM context
*/
function normalizeTscmNetwork(network) {
const normalized = normalizeNetwork(network);
// Add TSCM-specific tags
if (network.is_new) normalized.tags.push('new');
if (network.threat_level) normalized.tags.push(`threat-${network.threat_level}`);
if (network.is_rogue) normalized.tags.push('rogue');
if (network.is_deauth_target) normalized.tags.push('targeted');
normalized.metadata.threat_level = network.threat_level;
normalized.metadata.first_seen = network.first_seen;
normalized.metadata.client_count = network.client_count;
return normalized;
}
/**
* Format MAC/BSSID for display
*/
function formatMac(mac) {
if (!mac) return 'Unknown';
return mac.toUpperCase();
}
/**
* Batch normalize multiple networks
*/
function normalizeNetworks(networks, context = 'scan') {
const normalizer = context === 'tscm' ? normalizeTscmNetwork : normalizeNetwork;
return networks.map(normalizer);
}
/**
* Create timeline configuration for WiFi mode
*/
function getWiFiConfig() {
return {
title: 'Network Activity',
mode: 'wifi',
visualMode: 'enriched',
collapsed: false,
showAnnotations: true,
showLegend: true,
defaultWindow: '15m',
availableWindows: ['5m', '15m', '30m', '1h'],
filters: {
hideBaseline: { enabled: true, label: 'Hide Known', default: false },
showOnlyNew: { enabled: true, label: 'New Only', default: false },
showOnlyBurst: { enabled: false, label: 'Bursts', default: false }
},
customFilters: [
{
key: 'showOnlyOpen',
label: 'Open Only',
default: false,
predicate: (item) => item.tags.includes('open')
},
{
key: 'hideHidden',
label: 'Hide Hidden',
default: false,
predicate: (item) => !item.tags.includes('hidden')
},
{
key: 'show5GHz',
label: '5GHz Only',
default: false,
predicate: (item) => item.tags.includes('5GHz')
}
],
maxItems: 100,
maxDisplayedLanes: 15,
labelGenerator: (id) => formatSsid(id)
};
}
/**
* Create compact configuration for sidebar
*/
function getCompactConfig() {
return {
title: 'Networks',
mode: 'wifi',
visualMode: 'compact',
collapsed: false,
showAnnotations: false,
showLegend: false,
defaultWindow: '15m',
availableWindows: ['5m', '15m', '30m'],
filters: {
hideBaseline: { enabled: false },
showOnlyNew: { enabled: true, label: 'New', default: false },
showOnlyBurst: { enabled: false }
},
customFilters: [],
maxItems: 30,
maxDisplayedLanes: 8
};
}
// Public API
return {
// Normalization
normalizeNetwork: normalizeNetwork,
normalizeTscmNetwork: normalizeTscmNetwork,
normalizeNetworks: normalizeNetworks,
// Utilities
rssiToStrength: rssiToStrength,
getBandFromChannel: getBandFromChannel,
classifySecurity: classifySecurity,
formatSsid: formatSsid,
identifyCharacteristics: identifyCharacteristics,
// Configuration presets
getWiFiConfig: getWiFiConfig,
getCompactConfig: getCompactConfig,
// Constants
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
SECURITY_TYPES: SECURITY_TYPES
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = WiFiTimelineAdapter;
}
window.WiFiTimelineAdapter = WiFiTimelineAdapter;
+409
View File
@@ -0,0 +1,409 @@
/**
* Timeline Heatmap Component
*
* Displays RSSI signal history as a heatmap grid.
* Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength
*/
const TimelineHeatmap = (function() {
'use strict';
// Configuration
const CONFIG = {
cellWidth: 8,
cellHeight: 20,
labelWidth: 120,
maxDevices: 20,
refreshInterval: 5000,
// RSSI color scale (green = strong, red = weak)
colorScale: [
{ rssi: -40, color: '#22c55e' }, // Strong - green
{ rssi: -55, color: '#84cc16' }, // Good - lime
{ rssi: -65, color: '#eab308' }, // Medium - yellow
{ rssi: -75, color: '#f97316' }, // Weak - orange
{ rssi: -90, color: '#ef4444' }, // Very weak - red
],
noDataColor: '#2a2a3e',
};
// State
let container = null;
let contentEl = null;
let controlsEl = null;
let data = null;
let isPaused = false;
let refreshTimer = null;
let selectedDeviceKey = null;
let onDeviceSelect = null;
// Settings
let settings = {
windowMinutes: 10,
bucketSeconds: 10,
sortBy: 'recency',
topN: 20,
};
/**
* Initialize the heatmap component
*/
function init(containerId, options = {}) {
container = document.getElementById(containerId);
if (!container) {
console.error('[TimelineHeatmap] Container not found:', containerId);
return;
}
if (options.onDeviceSelect) {
onDeviceSelect = options.onDeviceSelect;
}
// Merge options into settings
Object.assign(settings, options);
createStructure();
startAutoRefresh();
}
/**
* Create the heatmap DOM structure
*/
function createStructure() {
container.innerHTML = `
<div class="timeline-heatmap-controls">
<div class="heatmap-control-group">
<label>Window:</label>
<select id="heatmapWindow" class="heatmap-select">
<option value="10" ${settings.windowMinutes === 10 ? 'selected' : ''}>10 min</option>
<option value="30" ${settings.windowMinutes === 30 ? 'selected' : ''}>30 min</option>
<option value="60" ${settings.windowMinutes === 60 ? 'selected' : ''}>60 min</option>
</select>
</div>
<div class="heatmap-control-group">
<label>Bucket:</label>
<select id="heatmapBucket" class="heatmap-select">
<option value="10" ${settings.bucketSeconds === 10 ? 'selected' : ''}>10s</option>
<option value="30" ${settings.bucketSeconds === 30 ? 'selected' : ''}>30s</option>
<option value="60" ${settings.bucketSeconds === 60 ? 'selected' : ''}>60s</option>
</select>
</div>
<div class="heatmap-control-group">
<label>Sort:</label>
<select id="heatmapSort" class="heatmap-select">
<option value="recency" ${settings.sortBy === 'recency' ? 'selected' : ''}>Recent</option>
<option value="strength" ${settings.sortBy === 'strength' ? 'selected' : ''}>Strength</option>
<option value="activity" ${settings.sortBy === 'activity' ? 'selected' : ''}>Activity</option>
</select>
</div>
<button id="heatmapPauseBtn" class="heatmap-btn ${isPaused ? 'active' : ''}">
${isPaused ? 'Resume' : 'Pause'}
</button>
</div>
<div class="timeline-heatmap-content">
<div class="heatmap-loading">Loading signal history...</div>
</div>
<div class="heatmap-legend">
<span class="legend-label">Signal:</span>
<span class="legend-item"><span class="legend-color" style="background: #22c55e;"></span>Strong</span>
<span class="legend-item"><span class="legend-color" style="background: #eab308;"></span>Medium</span>
<span class="legend-item"><span class="legend-color" style="background: #ef4444;"></span>Weak</span>
<span class="legend-item"><span class="legend-color" style="background: ${CONFIG.noDataColor};"></span>No data</span>
</div>
`;
contentEl = container.querySelector('.timeline-heatmap-content');
controlsEl = container.querySelector('.timeline-heatmap-controls');
// Attach event listeners
attachEventListeners();
}
/**
* Attach event listeners to controls
*/
function attachEventListeners() {
const windowSelect = container.querySelector('#heatmapWindow');
const bucketSelect = container.querySelector('#heatmapBucket');
const sortSelect = container.querySelector('#heatmapSort');
const pauseBtn = container.querySelector('#heatmapPauseBtn');
windowSelect?.addEventListener('change', (e) => {
settings.windowMinutes = parseInt(e.target.value, 10);
refresh();
});
bucketSelect?.addEventListener('change', (e) => {
settings.bucketSeconds = parseInt(e.target.value, 10);
refresh();
});
sortSelect?.addEventListener('change', (e) => {
settings.sortBy = e.target.value;
refresh();
});
pauseBtn?.addEventListener('click', () => {
isPaused = !isPaused;
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('active', isPaused);
});
}
/**
* Start auto-refresh timer
*/
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (!isPaused) {
refresh();
}
}, CONFIG.refreshInterval);
}
/**
* Fetch and render heatmap data
*/
async function refresh() {
if (!container) return;
try {
const params = new URLSearchParams({
top_n: settings.topN,
window_minutes: settings.windowMinutes,
bucket_seconds: settings.bucketSeconds,
sort_by: settings.sortBy,
});
const response = await fetch(`/api/bluetooth/heatmap/data?${params}`);
if (!response.ok) throw new Error('Failed to fetch heatmap data');
data = await response.json();
render();
} catch (err) {
console.error('[TimelineHeatmap] Refresh error:', err);
contentEl.innerHTML = '<div class="heatmap-error">Failed to load data</div>';
}
}
/**
* Render the heatmap grid
*/
function render() {
if (!data || !data.devices || data.devices.length === 0) {
contentEl.innerHTML = '<div class="heatmap-empty">No signal history available yet</div>';
return;
}
// Calculate time buckets
const windowMs = settings.windowMinutes * 60 * 1000;
const bucketMs = settings.bucketSeconds * 1000;
const numBuckets = Math.ceil(windowMs / bucketMs);
const now = new Date();
// Generate time labels
const timeLabels = [];
for (let i = 0; i < numBuckets; i++) {
const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
if (i % Math.ceil(numBuckets / 6) === 0) {
timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
} else {
timeLabels.push('');
}
}
// Build heatmap HTML
let html = '<div class="heatmap-grid">';
// Time axis header
html += `<div class="heatmap-row heatmap-header">
<div class="heatmap-label"></div>
<div class="heatmap-cells">
${timeLabels.map(label =>
`<div class="heatmap-time-label" style="width: ${CONFIG.cellWidth}px;">${label}</div>`
).join('')}
</div>
</div>`;
// Device rows
data.devices.forEach(device => {
const isSelected = device.device_key === selectedDeviceKey;
const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row';
// Create lookup for timeseries data
const tsLookup = new Map();
device.timeseries.forEach(point => {
const ts = new Date(point.timestamp).getTime();
tsLookup.set(ts, point.rssi);
});
// Generate cells for each time bucket
const cells = [];
for (let i = 0; i < numBuckets; i++) {
const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs;
// Find closest timestamp in data
let rssi = null;
const tolerance = bucketMs;
tsLookup.forEach((val, ts) => {
if (Math.abs(ts - bucketKey) < tolerance) {
rssi = val;
}
});
const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor;
const title = rssi !== null ? `${rssi} dBm` : 'No data';
cells.push(`<div class="heatmap-cell" style="width: ${CONFIG.cellWidth}px; height: ${CONFIG.cellHeight}px; background: ${color};" title="${title}"></div>`);
}
const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12);
const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--';
html += `
<div class="${rowClass}" data-device-key="${escapeAttr(device.device_key)}">
<div class="heatmap-label" title="${escapeHtml(device.name || device.address || '')}">
<span class="device-name">${escapeHtml(displayName)}</span>
<span class="device-rssi">${rssiDisplay}</span>
</div>
<div class="heatmap-cells">${cells.join('')}</div>
</div>
`;
});
html += '</div>';
contentEl.innerHTML = html;
// Attach row click handlers
contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => {
row.addEventListener('click', () => {
const deviceKey = row.getAttribute('data-device-key');
selectDevice(deviceKey);
});
});
}
/**
* Get color for RSSI value
*/
function getRssiColor(rssi) {
const scale = CONFIG.colorScale;
// Find the appropriate color from scale
for (let i = 0; i < scale.length; i++) {
if (rssi >= scale[i].rssi) {
return scale[i].color;
}
}
return scale[scale.length - 1].color;
}
/**
* Format MAC address for display
*/
function formatAddress(address) {
if (!address) return null;
const parts = address.split(':');
if (parts.length === 6) {
return `${parts[0]}:${parts[1]}:..${parts[5]}`;
}
return address;
}
/**
* Select a device row
*/
function selectDevice(deviceKey) {
selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey;
// Update row highlighting
contentEl.querySelectorAll('.heatmap-row').forEach(row => {
const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey;
row.classList.toggle('selected', isSelected);
});
// Callback
if (onDeviceSelect && selectedDeviceKey) {
const device = data?.devices?.find(d => d.device_key === selectedDeviceKey);
onDeviceSelect(selectedDeviceKey, device);
}
}
/**
* Update with new data directly (for SSE integration)
*/
function updateData(newData) {
if (isPaused) return;
data = newData;
render();
}
/**
* Set paused state
*/
function setPaused(paused) {
isPaused = paused;
const pauseBtn = container?.querySelector('#heatmapPauseBtn');
if (pauseBtn) {
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('active', isPaused);
}
}
/**
* Destroy the component
*/
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (container) {
container.innerHTML = '';
}
}
/**
* Escape HTML for safe rendering
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Escape attribute value
*/
function escapeAttr(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Public API
return {
init,
refresh,
updateData,
setPaused,
destroy,
selectDevice,
getSelectedDevice: () => selectedDeviceKey,
isPaused: () => isPaused,
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = TimelineHeatmap;
}
window.TimelineHeatmap = TimelineHeatmap;
File diff suppressed because it is too large Load Diff
+93 -136
View File
@@ -78,37 +78,6 @@ function updateHeaderClock() {
document.getElementById('headerUtcTime').textContent = utc;
}
// ============== HEADER STATS SYNC ==============
function syncHeaderStats() {
// Pager stats
document.getElementById('headerMsgCount').textContent = msgCount;
document.getElementById('headerPocsagCount').textContent = pocsagCount;
document.getElementById('headerFlexCount').textContent = flexCount;
// Sensor stats
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
// WiFi stats
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
// Bluetooth stats
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
// Aircraft stats
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
// Satellite stats
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
}
// ============== MODE SWITCHING ==============
function switchMode(mode) {
@@ -126,7 +95,7 @@ function switchMode(mode) {
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening'
'listening': 'listening', 'meshtastic': 'meshtastic'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
@@ -138,11 +107,16 @@ function switchMode(mode) {
// Toggle mode content visibility
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
document.getElementById('aircraftMode')?.classList.toggle('active', mode === 'aircraft');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
// Toggle stats visibility
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
@@ -150,19 +124,10 @@ function switchMode(mode) {
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Hide signal meter - individual panels show signal strength where needed
document.getElementById('signalMeter').style.display = 'none';
// Update header stats groups
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
// Show/hide dashboard buttons in nav bar
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
@@ -175,10 +140,21 @@ function switchMode(mode) {
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST'
'listening': 'LISTENING POST',
'tscm': 'TSCM',
'aprs': 'APRS',
'meshtastic': 'MESHTASTIC'
};
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
// Update mobile nav buttons
updateMobileNavButtons(mode);
// Close mobile drawer when mode is switched (on mobile)
if (window.innerWidth < 1024 && typeof window.closeMobileDrawer === 'function') {
window.closeMobileDrawer();
}
// Toggle layout containers
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
@@ -197,7 +173,8 @@ function switchMode(mode) {
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post'
'listening': 'Listening Post',
'meshtastic': 'Meshtastic Mesh Monitor'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
@@ -227,10 +204,10 @@ function switchMode(mode) {
// Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display =
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.getElementById('output').style.display =
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
@@ -251,6 +228,8 @@ function switchMode(mode) {
if (typeof checkAudioTools === 'function') checkAudioTools();
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
} else if (mode === 'meshtastic') {
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
}
}
@@ -410,94 +389,68 @@ function showError(text) {
output.insertBefore(errorEl, output.firstChild);
}
// ============== OBSERVER LOCATION ==============
function saveObserverLocation() {
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
if (!isNaN(lat) && !isNaN(lon)) {
observerLocation = { lat, lon };
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Sync both input sets
const adsbLat = document.getElementById('adsbObsLat');
const adsbLon = document.getElementById('adsbObsLon');
const satLat = document.getElementById('obsLat');
const satLon = document.getElementById('obsLon');
if (adsbLat) adsbLat.value = lat.toFixed(4);
if (adsbLon) adsbLon.value = lon.toFixed(4);
if (satLat) satLat.value = lat.toFixed(4);
if (satLon) satLon.value = lon.toFixed(4);
}
}
function useGeolocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
observerLocation = { lat, lon };
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Update all input fields
const adsbLat = document.getElementById('adsbObsLat');
const adsbLon = document.getElementById('adsbObsLon');
const satLat = document.getElementById('obsLat');
const satLon = document.getElementById('obsLon');
if (adsbLat) adsbLat.value = lat.toFixed(4);
if (adsbLon) adsbLon.value = lon.toFixed(4);
if (satLat) satLat.value = lat.toFixed(4);
if (satLon) satLon.value = lon.toFixed(4);
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
},
(error) => {
showError('Geolocation failed: ' + error.message);
}
);
} else {
showError('Geolocation not supported by browser');
}
}
// ============== EXPORT FUNCTIONS ==============
function exportCSV() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
const csv = [headers.join(',')];
allMessages.forEach(msg => {
const row = [
msg.timestamp || '',
msg.protocol || '',
msg.address || '',
msg.function || '',
msg.msg_type || '',
'"' + (msg.message || '').replace(/"/g, '""') + '"'
];
csv.push(row.join(','));
});
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
}
function exportJSON() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
}
// ============== INITIALIZATION ==============
// ============== MOBILE NAVIGATION ==============
function initMobileNav() {
const hamburgerBtn = document.getElementById('hamburgerBtn');
const sidebar = document.getElementById('mainSidebar');
const overlay = document.getElementById('drawerOverlay');
if (!hamburgerBtn || !sidebar || !overlay) return;
function openDrawer() {
sidebar.classList.add('open');
overlay.classList.add('visible');
hamburgerBtn.classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
hamburgerBtn.classList.remove('active');
document.body.style.overflow = '';
}
function toggleDrawer() {
if (sidebar.classList.contains('open')) {
closeDrawer();
} else {
openDrawer();
}
}
hamburgerBtn.addEventListener('click', toggleDrawer);
overlay.addEventListener('click', closeDrawer);
// Close drawer when resizing to desktop
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024) {
closeDrawer();
}
});
// Expose for external use
window.toggleMobileDrawer = toggleDrawer;
window.closeMobileDrawer = closeDrawer;
}
function setViewportHeight() {
// Fix for iOS Safari address bar height
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
function updateMobileNavButtons(mode) {
// Update mobile nav bar buttons
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
const btnMode = btn.getAttribute('data-mode');
btn.classList.toggle('active', btnMode === mode);
});
}
function initApp() {
// Check disclaimer
checkDisclaimer();
@@ -509,9 +462,6 @@ function initApp() {
updateHeaderClock();
setInterval(updateHeaderClock, 1000);
// Start stats sync
setInterval(syncHeaderStats, 500);
// Load bias-T setting
loadBiasTSetting();
@@ -541,6 +491,13 @@ function initApp() {
section.classList.add('collapsed');
}
});
// Initialize mobile navigation
initMobileNav();
// Set viewport height for mobile browsers
setViewportHeight();
window.addEventListener('resize', setViewportHeight);
}
// Run initialization when DOM is ready
+2 -2
View File
@@ -211,7 +211,7 @@ function isMuted() {
function updateMuteButton() {
const btn = document.getElementById('muteBtn');
if (btn) {
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
btn.innerHTML = audioMuted ? Icons.volumeOff('icon--sm') + ' UNMUTE' : Icons.volumeOn('icon--sm') + ' MUTE';
btn.classList.toggle('muted', audioMuted);
}
}
@@ -226,7 +226,7 @@ function requestNotificationPermission() {
Notification.requestPermission().then(permission => {
notificationsEnabled = permission === 'granted';
if (notificationsEnabled && typeof showInfo === 'function') {
showInfo('🔔 Desktop notifications enabled');
showInfo('Desktop notifications enabled');
}
});
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Handles the visual transition and submission lock for the authorization terminal.
* @param {Event} event - The click event from the submission button.
*/
function login(event) {
const btn = event.currentTarget;
const form = btn.closest('form');
// Validate form requirements before triggering visual effects
if (!form.checkValidity()) {
return; // Allow the browser to handle native "required" field alerts
}
// 1. Visual Feedback: Transition to "Processing" state
btn.style.color = "#ff4d4d";
btn.style.borderColor = "#ff4d4d";
btn.style.textShadow = "0 0 10px #ff4d4d";
btn.style.transform = "scale(0.95)";
// Update button text to reflect terminal status
const btnText = btn.querySelector('.btn-text');
if (btnText) {
btnText.innerText = "AUTHORIZING...";
}
// 2. Security Lock: Prevent redundant requests (Double-click spam)
// A 10ms delay ensures the browser successfully dispatches the POST request
// before the UI element becomes non-interactive.
setTimeout(() => {
btn.style.pointerEvents = "none";
btn.style.opacity = "0.7";
btn.style.cursor = "not-allowed";
}, 10);
}
+399
View File
@@ -0,0 +1,399 @@
/**
* Settings Manager - Handles offline mode and application settings
*/
const Settings = {
// Default settings
defaults: {
'offline.enabled': false,
'offline.assets_source': 'cdn',
'offline.fonts_source': 'cdn',
'offline.tile_provider': 'openstreetmap',
'offline.tile_server_url': ''
},
// Tile provider configurations
tileProviders: {
openstreetmap: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
subdomains: 'abc'
},
cartodb_dark: {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd'
},
cartodb_light: {
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd'
},
esri_world: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
subdomains: null
}
},
// Current settings cache
_cache: {},
/**
* Initialize settings - load from server/localStorage
*/
async init() {
try {
const response = await fetch('/offline/settings');
if (response.ok) {
const data = await response.json();
this._cache = { ...this.defaults, ...data.settings };
} else {
// Fall back to localStorage
this._loadFromLocalStorage();
}
} catch (e) {
console.warn('Failed to load settings from server, using localStorage:', e);
this._loadFromLocalStorage();
}
this._updateUI();
return this._cache;
},
/**
* Load settings from localStorage
*/
_loadFromLocalStorage() {
const stored = localStorage.getItem('intercept_settings');
if (stored) {
try {
this._cache = { ...this.defaults, ...JSON.parse(stored) };
} catch (e) {
this._cache = { ...this.defaults };
}
} else {
this._cache = { ...this.defaults };
}
},
/**
* Save a setting to server and localStorage
*/
async _save(key, value) {
this._cache[key] = value;
// Save to localStorage as backup
localStorage.setItem('intercept_settings', JSON.stringify(this._cache));
// Save to server
try {
await fetch('/offline/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
} catch (e) {
console.warn('Failed to save setting to server:', e);
}
},
/**
* Get a setting value
*/
get(key) {
return this._cache[key] ?? this.defaults[key];
},
/**
* Toggle offline mode master switch
*/
async toggleOfflineMode(enabled) {
await this._save('offline.enabled', enabled);
if (enabled) {
// When enabling offline mode, also switch assets and fonts to local
await this._save('offline.assets_source', 'local');
await this._save('offline.fonts_source', 'local');
}
this._updateUI();
this._showReloadPrompt();
},
/**
* Set asset source (cdn or local)
*/
async setAssetSource(source) {
await this._save('offline.assets_source', source);
this._showReloadPrompt();
},
/**
* Set fonts source (cdn or local)
*/
async setFontsSource(source) {
await this._save('offline.fonts_source', source);
this._showReloadPrompt();
},
/**
* Set tile provider
*/
async setTileProvider(provider) {
await this._save('offline.tile_provider', provider);
// Show/hide custom URL input
const customRow = document.getElementById('customTileUrlRow');
if (customRow) {
customRow.style.display = provider === 'custom' ? 'block' : 'none';
}
// If not custom and we have a map, update tiles immediately
if (provider !== 'custom') {
this._updateMapTiles();
}
},
/**
* Set custom tile server URL
*/
async setCustomTileUrl(url) {
await this._save('offline.tile_server_url', url);
this._updateMapTiles();
},
/**
* Get current tile configuration
*/
getTileConfig() {
const provider = this.get('offline.tile_provider');
if (provider === 'custom') {
const customUrl = this.get('offline.tile_server_url');
return {
url: customUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: 'Custom Tile Server',
subdomains: 'abc'
};
}
return this.tileProviders[provider] || this.tileProviders.openstreetmap;
},
/**
* Check if local assets are available
*/
async checkAssets() {
const assets = {
leaflet: [
'/static/vendor/leaflet/leaflet.js',
'/static/vendor/leaflet/leaflet.css'
],
chartjs: [
'/static/vendor/chartjs/chart.umd.min.js'
],
inter: [
'/static/vendor/fonts/Inter-Regular.woff2'
],
jetbrains: [
'/static/vendor/fonts/JetBrainsMono-Regular.woff2'
]
};
const results = {};
for (const [name, urls] of Object.entries(assets)) {
const statusEl = document.getElementById(`status${name.charAt(0).toUpperCase() + name.slice(1)}`);
if (statusEl) {
statusEl.textContent = 'Checking...';
statusEl.className = 'asset-badge checking';
}
let available = true;
for (const url of urls) {
try {
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
available = false;
break;
}
} catch (e) {
available = false;
break;
}
}
results[name] = available;
if (statusEl) {
statusEl.textContent = available ? 'Available' : 'Missing';
statusEl.className = `asset-badge ${available ? 'available' : 'missing'}`;
}
}
return results;
},
/**
* Update UI elements to reflect current settings
*/
_updateUI() {
// Offline mode toggle
const offlineEnabled = document.getElementById('offlineEnabled');
if (offlineEnabled) {
offlineEnabled.checked = this.get('offline.enabled');
}
// Assets source
const assetsSource = document.getElementById('assetsSource');
if (assetsSource) {
assetsSource.value = this.get('offline.assets_source');
}
// Fonts source
const fontsSource = document.getElementById('fontsSource');
if (fontsSource) {
fontsSource.value = this.get('offline.fonts_source');
}
// Tile provider
const tileProvider = document.getElementById('tileProvider');
if (tileProvider) {
tileProvider.value = this.get('offline.tile_provider');
}
// Custom tile URL
const customTileUrl = document.getElementById('customTileUrl');
if (customTileUrl) {
customTileUrl.value = this.get('offline.tile_server_url') || '';
}
// Show/hide custom URL row
const customRow = document.getElementById('customTileUrlRow');
if (customRow) {
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
}
},
/**
* Update map tiles if a map exists
*/
_updateMapTiles() {
// Look for common map variable names
const maps = [
window.map,
window.leafletMap,
window.aprsMap,
window.adsbMap
].filter(m => m && typeof m.eachLayer === 'function');
if (maps.length === 0) return;
const config = this.getTileConfig();
maps.forEach(map => {
// Remove existing tile layers
map.eachLayer(layer => {
if (layer instanceof L.TileLayer) {
map.removeLayer(layer);
}
});
// Add new tile layer
const options = {
attribution: config.attribution
};
if (config.subdomains) {
options.subdomains = config.subdomains;
}
L.tileLayer(config.url, options).addTo(map);
});
},
/**
* Show reload prompt
*/
_showReloadPrompt() {
// Create or update reload prompt
let prompt = document.getElementById('settingsReloadPrompt');
if (!prompt) {
prompt = document.createElement('div');
prompt.id = 'settingsReloadPrompt';
prompt.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-dark, #0a0a0f);
border: 1px solid var(--accent-cyan, #00d4ff);
border-radius: 8px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
z-index: 10001;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
`;
prompt.innerHTML = `
<span style="color: var(--text-primary, #e0e0e0); font-size: 13px;">
Reload to apply changes
</span>
<button onclick="location.reload()" style="
background: var(--accent-cyan, #00d4ff);
border: none;
color: #000;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
">Reload</button>
<button onclick="this.parentElement.remove()" style="
background: none;
border: none;
color: var(--text-muted, #666);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
">&times;</button>
`;
document.body.appendChild(prompt);
}
}
};
// Settings modal functions
function showSettings() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.add('active');
Settings.init().then(() => {
Settings.checkAssets();
});
}
}
function hideSettings() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.classList.remove('active');
}
}
function switchSettingsTab(tabName) {
// Update tab buttons
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
// Update sections
document.querySelectorAll('.settings-section').forEach(section => {
section.classList.toggle('active', section.id === `settings-${tabName}`);
});
}
// Initialize settings on page load
document.addEventListener('DOMContentLoaded', () => {
Settings.init();
});
+173
View File
@@ -271,3 +271,176 @@ function clamp(num, min, max) {
function mapRange(value, inMin, inMax, outMin, outMax) {
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
}
// ============== ICON SYSTEM ==============
// Minimal SVG icons. Each returns HTML string.
// Designed for screenshot legibility - standard symbols only.
const Icons = {
// ===== Signal Type Icons =====
wifi: function(className) {
return `<span class="icon icon-wifi ${className || ''}" aria-label="WiFi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>`;
},
bluetooth: function(className) {
return `<span class="icon icon-bluetooth ${className || ''}" aria-label="Bluetooth"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>`;
},
cellular: function(className) {
return `<span class="icon icon-cellular ${className || ''}" aria-label="Cellular"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="2" y="16" width="4" height="6" rx="1"/><rect x="8" y="12" width="4" height="10" rx="1"/><rect x="14" y="8" width="4" height="14" rx="1"/><rect x="20" y="4" width="4" height="18" rx="1" opacity="0.3"/></svg></span>`;
},
radio: function(className) {
return `<span class="icon icon-radio ${className || ''}" aria-label="Radio"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>`;
},
// ===== Mode Icons =====
pager: function(className) {
return `<span class="icon icon-pager ${className || ''}" aria-label="Pager"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>`;
},
sensor: function(className) {
return `<span class="icon icon-sensor ${className || ''}" aria-label="Sensor"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>`;
},
aircraft: function(className) {
return `<span class="icon icon-aircraft ${className || ''}" aria-label="Aircraft"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>`;
},
satellite: function(className) {
return `<span class="icon icon-satellite ${className || ''}" aria-label="Satellite"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>`;
},
location: function(className) {
return `<span class="icon icon-location ${className || ''}" aria-label="Location"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>`;
},
search: function(className) {
return `<span class="icon icon-search ${className || ''}" aria-label="Search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>`;
},
meter: function(className) {
return `<span class="icon icon-meter ${className || ''}" aria-label="Meter"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>`;
},
scanner: function(className) {
return `<span class="icon icon-scanner ${className || ''}" aria-label="Scanner"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span>`;
},
// ===== Status Icons =====
warning: function(className) {
return `<span class="icon icon-warning ${className || ''}" aria-label="Warning"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>`;
},
check: function(className) {
return `<span class="icon icon-check ${className || ''}" aria-label="Check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;
},
x: function(className) {
return `<span class="icon icon-x ${className || ''}" aria-label="X"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`;
},
recording: function(className) {
return `<span class="icon icon-recording ${className || ''}" aria-label="Recording"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="8"/></svg></span>`;
},
anomaly: function(className) {
return `<span class="icon icon-anomaly ${className || ''}" aria-label="Anomaly"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>`;
},
flag: function(className) {
return `<span class="icon icon-flag ${className || ''}" aria-label="Flag"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg></span>`;
},
newBadge: function(className) {
return `<span class="icon icon-new ${className || ''}" aria-label="New"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>`;
},
offline: function(className) {
return `<span class="icon icon-offline ${className || ''}" aria-label="Offline"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></span>`;
},
// ===== Device Type Icons =====
user: function(className) {
return `<span class="icon icon-user ${className || ''}" aria-label="User"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>`;
},
drone: function(className) {
return `<span class="icon icon-drone ${className || ''}" aria-label="Drone"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M3 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M9 9l-4 -1"/><path d="M15 9l4 -1"/><path d="M9 15l-4 1"/><path d="M15 15l4 1"/></svg></span>`;
},
military: function(className) {
return `<span class="icon icon-military ${className || ''}" aria-label="Military"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>`;
},
handshake: function(className) {
return `<span class="icon icon-handshake ${className || ''}" aria-label="Handshake"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg></span>`;
},
// ===== Action Icons =====
refresh: function(className) {
return `<span class="icon icon-refresh ${className || ''}" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span>`;
},
download: function(className) {
return `<span class="icon icon-download ${className || ''}" aria-label="Download"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>`;
},
export: function(className) {
return `<span class="icon icon-export ${className || ''}" aria-label="Export"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></span>`;
},
copy: function(className) {
return `<span class="icon icon-copy ${className || ''}" aria-label="Copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>`;
},
link: function(className) {
return `<span class="icon icon-link ${className || ''}" aria-label="Link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>`;
},
chart: function(className) {
return `<span class="icon icon-chart ${className || ''}" aria-label="Chart"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span>`;
},
star: function(className) {
return `<span class="icon icon-star ${className || ''}" aria-label="Star"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>`;
},
target: function(className) {
return `<span class="icon icon-target ${className || ''}" aria-label="Target"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg></span>`;
},
settings: function(className) {
return `<span class="icon icon-settings ${className || ''}" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>`;
},
// ===== Playback Controls =====
play: function(className) {
return `<span class="icon icon-play ${className || ''}" aria-label="Play"><svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></span>`;
},
pause: function(className) {
return `<span class="icon icon-pause ${className || ''}" aria-label="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg></span>`;
},
stop: function(className) {
return `<span class="icon icon-stop ${className || ''}" aria-label="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg></span>`;
},
headphones: function(className) {
return `<span class="icon icon-headphones ${className || ''}" aria-label="Headphones"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg></span>`;
},
volumeOn: function(className) {
return `<span class="icon icon-volume-on ${className || ''}" aria-label="Volume On"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>`;
},
volumeOff: function(className) {
return `<span class="icon icon-volume-off ${className || ''}" aria-label="Volume Off"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg></span>`;
},
// ===== UI Icons =====
sun: function(className) {
return `<span class="icon icon-sun ${className || ''}" aria-label="Light mode"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>`;
},
moon: function(className) {
return `<span class="icon icon-moon ${className || ''}" aria-label="Dark mode"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>`;
},
arrowDown: function(className) {
return `<span class="icon icon-arrow-down ${className || ''}" aria-label="Arrow down"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></span>`;
},
chevronDown: function(className) {
return `<span class="icon icon-chevron-down ${className || ''}" aria-label="Expand"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>`;
},
chevronRight: function(className) {
return `<span class="icon icon-chevron-right ${className || ''}" aria-label="Right"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></span>`;
},
chevronLeft: function(className) {
return `<span class="icon icon-chevron-left ${className || ''}" aria-label="Left"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></span>`;
},
mail: function(className) {
return `<span class="icon icon-mail ${className || ''}" aria-label="Mail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span>`;
},
loader: function(className) {
return `<span class="icon icon-loader ${className || ''}" aria-label="Loading"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg></span>`;
},
bell: function(className) {
return `<span class="icon icon-bell ${className || ''}" aria-label="Notifications"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>`;
},
// ===== Helper function =====
forSignalType: function(type, className) {
const t = (type || '').toLowerCase();
if (t.includes('wifi') || t.includes('802.11')) return this.wifi(className);
if (t.includes('bluetooth') || t.includes('bt') || t.includes('ble')) return this.bluetooth(className);
if (t.includes('cellular') || t.includes('lte') || t.includes('gsm') || t.includes('5g')) return this.cellular(className);
return this.radio(className);
}
};

Some files were not shown because too many files have changed in this diff Show More