Compare commits

..

302 Commits

Author SHA1 Message Date
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
171 changed files with 79286 additions and 9749 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
+15 -4
View File
@@ -10,9 +10,17 @@ venv/
ENV/
uv.lock
# Logs
*.log
pager_messages.log
# Logs
*.log
pager_messages.log
# Local data
downloads/
pgdata/
# Local data
downloads/
pgdata/
# IDE
.idea/
@@ -30,5 +38,8 @@ dist/
build/
*.egg-info/
# Package manager lock files
# Package manager lock files & DB files
uv.lock
*.db
*.sqlite3
intercept.db
+60
View File
@@ -2,6 +2,66 @@
All notable changes to iNTERCEPT will be documented in this file.
## [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 .
+39 -6
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,14 @@
- **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
- **Spy Stations** - Number stations and diplomatic HF network database
---
@@ -38,7 +49,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 +57,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,9 +105,10 @@ 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
@@ -121,9 +146,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"
}
+281 -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
@@ -103,6 +125,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 +179,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 +194,68 @@ 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']
# 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 +270,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 +522,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 +543,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 +579,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 +650,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 +710,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 "$@"
+70 -4
View File
@@ -7,7 +7,61 @@ import os
import sys
# Application version
VERSION = "2.9.0"
VERSION = "2.10.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"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:
@@ -72,15 +126,27 @@ AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
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)
# 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
+97 -14
View File
@@ -16,25 +16,74 @@ Complete feature list for all modules.
- **Doorbells, remotes, and IoT devices**
- **Smart meters** and utility monitors
## ADS-B Aircraft Tracking
## AIS Vessel Tracking
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
- **Full-screen dashboard** - dedicated popout with virtual radar scope
- **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)
- **Aircraft trails** - optional flight path history visualization
- **Range rings** - distance reference circles from observer position
- **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
- **Observer location** - manual input or GPS geolocation
- **Audio alerts** - notifications for military and emergency aircraft
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
- **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
- **Full-screen dashboard** - dedicated popout with virtual radar scope
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
- **Aircraft trails** - optional flight path history visualization
- **Range rings** - distance reference circles from observer position
- **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
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
<p align="center">
<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,47 @@ 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)
## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode
+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
+37 -5
View File
@@ -74,10 +74,42 @@ INTERCEPT automatically detects known trackers:
### Emergency Squawks
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **7600** - Radio failure
- **7700** - General emergency
The system highlights aircraft transmitting emergency squawks:
- **7500** - Hijack
- **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
@@ -110,7 +142,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
+17
View File
@@ -0,0 +1,17 @@
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 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

+307
View File
@@ -0,0 +1,307 @@
<!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" 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>
</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>
</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="USAGE.html">Documentation</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.
+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]
+13 -1
View File
@@ -1,10 +1,22 @@
# 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
@@ -14,4 +26,4 @@ pyserial>=3.5
# ruff>=0.1.0
# black>=23.0.0
# mypy>=1.0.0
flask-sock
flask-sock
+23
View File
@@ -4,22 +4,45 @@ 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 .tscm import tscm_bp, init_tscm_state
from .spy_stations import spy_stations_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(tscm_bp)
app.register_blueprint(spy_stations_bp)
# 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)
+353
View File
@@ -0,0 +1,353 @@
"""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.
Version 4.0+ uses -j for JSON stdout.
Version 3.x uses -o 4 for JSON stdout.
"""
try:
# Get version by running acarsdec with no args (shows usage with version)
result = subprocess.run(
[acarsdec_path],
capture_output=True,
text=True,
timeout=5
)
output = result.stdout + result.stderr
# Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7"
import re
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 (modern standard for current builds from source)
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
# acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
# Note: -j is JSON stdout (newer forks), -o 4 was the old syntax
# gain/ppm must come BEFORE -r
json_flag = get_acarsdec_json_flag(acarsdec_path)
cmd = [acarsdec_path]
if json_flag == '-j':
cmd.append('-j') # JSON output (newer TLeconte fork)
else:
cmd.extend(['-o', '4']) # JSON output (older versions)
# 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 (-r takes device, remaining args are frequencies)
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
# ============================================
+480
View File
@@ -0,0 +1,480 @@
"""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
if 'lat' in msg and 'lon' in msg:
try:
lat = float(msg['lat'])
lon = float(msg['lon'])
# 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
+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(),
+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
}
})
+3281
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
+485 -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=14
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
@@ -304,6 +479,7 @@ install_macos_packages() {
brew_install gpsd
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 +510,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 +557,109 @@ 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_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 +677,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=19
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
@@ -427,7 +819,9 @@ install_debian_packages() {
apt_install bluez bluetooth || true
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 +831,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 +884,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 +942,8 @@ final_summary_and_hard_fail() {
# ----------------------------
main() {
detect_os
detect_dragonos
show_install_summary
if [[ "$OS" == "macos" ]]; then
install_macos_packages
@@ -494,4 +956,3 @@ main() {
}
main "$@"
File diff suppressed because it is too large Load Diff
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;
}
}
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;
}
+1618 -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);
}
+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;
}
}
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;
+79 -131
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) {
@@ -150,19 +119,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 +135,20 @@ function switchMode(mode) {
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST'
'listening': 'LISTENING POST',
'tscm': 'TSCM',
'aprs': 'APRS'
};
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';
@@ -230,7 +200,7 @@ function switchMode(mode) {
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? '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';
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
@@ -410,94 +380,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 +453,6 @@ function initApp() {
updateHeaderClock();
setInterval(updateHeaderClock, 1000);
// Start stats sync
setInterval(syncHeaderStats, 500);
// Load bias-T setting
loadBiasTSetting();
@@ -541,6 +482,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);
}
+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);
}
};
File diff suppressed because it is too large Load Diff
+128 -14
View File
@@ -216,7 +216,7 @@ function startScanner() {
// Update radio scan button to show STOP
const radioScanBtn = document.getElementById('radioScanBtn');
if (radioScanBtn) {
radioScanBtn.innerHTML = ' STOP';
radioScanBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
radioScanBtn.style.background = 'var(--accent-red)';
radioScanBtn.style.borderColor = 'var(--accent-red)';
}
@@ -268,7 +268,7 @@ function stopScanner() {
const pauseBtn = document.getElementById('scannerPauseBtn');
if (pauseBtn) {
pauseBtn.disabled = true;
pauseBtn.textContent = ' Pause';
pauseBtn.innerHTML = Icons.pause('icon--sm') + ' Pause';
}
// Update radio scan button
@@ -338,7 +338,7 @@ function pauseScanner() {
.then(data => {
isScannerPaused = !isScannerPaused;
const pauseBtn = document.getElementById('scannerPauseBtn');
if (pauseBtn) pauseBtn.textContent = isScannerPaused ? '▶ Resume' : '⏸ Pause';
if (pauseBtn) pauseBtn.innerHTML = isScannerPaused ? Icons.play('icon--sm') + ' Resume' : Icons.pause('icon--sm') + ' Pause';
const statusText = document.getElementById('scannerStatusText');
if (statusText) {
statusText.textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
@@ -516,6 +516,14 @@ function handleSignalFound(data) {
const streamUrl = getStreamUrl(data.frequency, data.modulation);
console.log('[SCANNER] Starting audio for signal:', data.frequency, 'MHz');
scannerAudio.src = streamUrl;
// Apply current volume from knob
const volumeKnob = document.getElementById('radioVolumeKnob');
if (volumeKnob && volumeKnob._knob) {
scannerAudio.volume = volumeKnob._knob.getValue() / 100;
} else if (volumeKnob) {
const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
scannerAudio.volume = knobValue / 100;
}
scannerAudio.play().catch(e => console.warn('[SCANNER] Audio autoplay blocked:', e));
}
}
@@ -683,6 +691,25 @@ function addSignalHit(data) {
const hitCount = document.getElementById('scannerHitCount');
if (hitCount) hitCount.textContent = `${tbody.children.length} signals found`;
// Feed to activity timeline if available
if (typeof addTimelineEvent === 'function') {
const normalized = typeof RFTimelineAdapter !== 'undefined'
? RFTimelineAdapter.normalizeSignal({
frequency: data.frequency,
rssi: data.rssi || data.signal_strength,
duration: data.duration || 2000,
modulation: data.modulation
})
: {
id: String(data.frequency),
label: `${data.frequency.toFixed(3)} MHz`,
strength: 3,
duration: 2000,
type: 'rf'
};
addTimelineEvent('listening', normalized);
}
}
function clearScannerLog() {
@@ -692,6 +719,12 @@ function clearScannerLog() {
scannerCycles = 0;
recentSignalHits.clear();
// Clear the timeline if available
const timeline = typeof getTimeline === 'function' ? getTimeline('listening') : null;
if (timeline) {
timeline.clear();
}
const signalCount = document.getElementById('scannerSignalCount');
if (signalCount) signalCount.textContent = '0';
@@ -865,7 +898,7 @@ function startAudio() {
}
});
document.getElementById('audioStartBtn').textContent = ' Stop Audio';
document.getElementById('audioStartBtn').innerHTML = Icons.stop('icon--sm') + ' Stop Audio';
document.getElementById('audioStartBtn').classList.add('active');
document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')';
document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device;
@@ -888,7 +921,7 @@ async function stopAudio() {
await fetch('/listening/audio/stop', { method: 'POST' });
if (typeof releaseDevice === 'function') releaseDevice('audio');
isAudioPlaying = false;
document.getElementById('audioStartBtn').textContent = ' Play Audio';
document.getElementById('audioStartBtn').innerHTML = Icons.play('icon--sm') + ' Play Audio';
document.getElementById('audioStartBtn').classList.remove('active');
document.getElementById('audioStatus').textContent = 'STOPPED';
document.getElementById('audioStatus').style.color = 'var(--text-muted)';
@@ -991,7 +1024,7 @@ async function tuneToFrequency(freq, mod) {
// ============== AUDIO VISUALIZER ==============
function initAudioVisualizer() {
const audioPlayer = document.getElementById('audioPlayer');
const audioPlayer = document.getElementById('scannerAudioPlayer');
if (!audioPlayer) return;
if (!visualizerContext) {
@@ -1163,10 +1196,7 @@ function initRadioKnobControls() {
const audioPlayer = document.getElementById('scannerAudioPlayer');
if (audioPlayer) {
audioPlayer.volume = e.detail.value / 100;
}
const manualPlayer = document.getElementById('audioPlayer');
if (manualPlayer) {
manualPlayer.volume = e.detail.value / 100;
console.log('[VOLUME] Set to', Math.round(e.detail.value) + '%');
}
// Update knob value display
const valueDisplay = document.getElementById('radioVolumeValue');
@@ -1512,6 +1542,78 @@ function initListeningPost() {
audioReconnectAttempts = 0;
});
}
// Keyboard controls for frequency tuning
document.addEventListener('keydown', function(e) {
// Only active in listening mode
if (typeof currentMode !== 'undefined' && currentMode !== 'listening') {
return;
}
// Don't intercept if user is typing in an input
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'SELECT')) {
return;
}
// Arrow keys for tuning
// Up/Down: fine tuning (Shift for ultra-fine)
// Left/Right: coarse tuning (Shift for very coarse)
let delta = 0;
switch (e.key) {
case 'ArrowUp':
delta = e.shiftKey ? 0.005 : 0.05;
break;
case 'ArrowDown':
delta = e.shiftKey ? -0.005 : -0.05;
break;
case 'ArrowRight':
delta = e.shiftKey ? 1 : 0.1;
break;
case 'ArrowLeft':
delta = e.shiftKey ? -1 : -0.1;
break;
default:
return; // Not a tuning key
}
e.preventDefault();
tuneFreq(delta);
});
// Check if we arrived from Spy Stations with a tune request
checkIncomingTuneRequest();
}
/**
* Check for incoming tune request from Spy Stations or other pages
*/
function checkIncomingTuneRequest() {
const tuneFreq = sessionStorage.getItem('tuneFrequency');
const tuneMode = sessionStorage.getItem('tuneMode');
if (tuneFreq) {
// Clear the session storage first
sessionStorage.removeItem('tuneFrequency');
sessionStorage.removeItem('tuneMode');
// Parse and validate frequency
const freq = parseFloat(tuneFreq);
if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) {
console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default');
// Determine modulation (default to USB for HF/number stations)
const mod = tuneMode || (freq < 30 ? 'usb' : 'am');
// Use quickTune to set frequency and modulation
quickTune(freq, mod);
// Show notification
if (typeof showNotification === 'function') {
showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode');
}
}
}
}
// Initialize when DOM is ready
@@ -1736,7 +1838,7 @@ async function _startDirectListenInternal() {
const listenBtn = document.getElementById('radioListenBtn');
if (listenBtn) {
listenBtn.innerHTML = ' TUNING...';
listenBtn.innerHTML = Icons.loader('icon--sm') + ' TUNING...';
listenBtn.style.background = 'var(--accent-orange)';
listenBtn.style.borderColor = 'var(--accent-orange)';
}
@@ -1787,6 +1889,15 @@ async function _startDirectListenInternal() {
console.log('[LISTEN] Connecting to stream:', streamUrl);
audioPlayer.src = streamUrl;
// Apply current volume from knob
const volumeKnob = document.getElementById('radioVolumeKnob');
if (volumeKnob && volumeKnob._knob) {
audioPlayer.volume = volumeKnob._knob.getValue() / 100;
} else if (volumeKnob) {
const knobValue = parseFloat(volumeKnob.dataset.value) || 80;
audioPlayer.volume = knobValue / 100;
}
// Wait for audio to be ready then play
audioPlayer.oncanplay = () => {
console.log('[LISTEN] Audio can play');
@@ -1865,11 +1976,11 @@ function updateDirectListenUI(isPlaying, freq) {
if (listenBtn) {
if (isPlaying) {
listenBtn.innerHTML = ' STOP';
listenBtn.innerHTML = Icons.stop('icon--sm') + ' STOP';
listenBtn.style.background = 'var(--accent-red)';
listenBtn.style.borderColor = 'var(--accent-red)';
} else {
listenBtn.innerHTML = '🎧 LISTEN';
listenBtn.innerHTML = Icons.headphones('icon--sm') + ' LISTEN';
listenBtn.style.background = 'var(--accent-purple)';
listenBtn.style.borderColor = 'var(--accent-purple)';
}
@@ -1901,8 +2012,10 @@ function tuneFreq(delta) {
const freqInput = document.getElementById('radioScanStart');
if (freqInput) {
let newFreq = parseFloat(freqInput.value) + delta;
// Round to 3 decimal places to avoid floating-point precision issues
newFreq = Math.round(newFreq * 1000) / 1000;
newFreq = Math.max(24, Math.min(1800, newFreq));
freqInput.value = newFreq.toFixed(1);
freqInput.value = newFreq.toFixed(3);
// Update display
const freqDisplay = document.getElementById('mainScannerFreq');
@@ -2186,6 +2299,7 @@ window.skipSignal = skipSignal;
window.setBand = setBand;
window.tuneFreq = tuneFreq;
window.quickTune = quickTune;
window.checkIncomingTuneRequest = checkIncomingTuneRequest;
window.addFrequencyBookmark = addFrequencyBookmark;
window.removeBookmark = removeBookmark;
window.tuneToFrequency = tuneToFrequency;
+530
View File
@@ -0,0 +1,530 @@
/**
* Spy Stations Mode
* Number stations and diplomatic HF radio networks
*/
const SpyStations = (function() {
// State
let stations = [];
let filteredStations = [];
let activeFilters = {
types: ['number', 'diplomatic'],
countries: [],
modes: []
};
// Country flag emoji map
const countryFlags = {
'RU': '\u{1F1F7}\u{1F1FA}',
'CU': '\u{1F1E8}\u{1F1FA}',
'BG': '\u{1F1E7}\u{1F1EC}',
'CZ': '\u{1F1E8}\u{1F1FF}',
'EG': '\u{1F1EA}\u{1F1EC}',
'KP': '\u{1F1F0}\u{1F1F5}',
'TN': '\u{1F1F9}\u{1F1F3}',
'US': '\u{1F1FA}\u{1F1F8}',
'PL': '\u{1F1F5}\u{1F1F1}',
'IL': '\u{1F1EE}\u{1F1F1}',
'CN': '\u{1F1E8}\u{1F1F3}',
'MA': '\u{1F1F2}\u{1F1E6}',
'FR': '\u{1F1EB}\u{1F1F7}',
'RO': '\u{1F1F7}\u{1F1F4}',
'DZ': '\u{1F1E9}\u{1F1FF}'
};
/**
* Initialize the spy stations mode
*/
function init() {
fetchStations();
checkTuneFrequency();
}
/**
* Fetch stations from the API
*/
async function fetchStations() {
try {
const response = await fetch('/spy-stations/stations');
const data = await response.json();
if (data.status === 'success') {
stations = data.stations;
initFilters();
applyFilters();
updateStats();
}
} catch (err) {
console.error('Failed to fetch spy stations:', err);
}
}
/**
* Initialize filter checkboxes
*/
function initFilters() {
// Get unique countries and modes
const countries = [...new Set(stations.map(s => JSON.stringify({name: s.country, code: s.country_code})))].map(s => JSON.parse(s));
const modes = [...new Set(stations.map(s => s.mode.split('/')[0]))].sort();
// Populate country filters
const countryContainer = document.getElementById('countryFilters');
if (countryContainer) {
countryContainer.innerHTML = countries.map(c => `
<label class="inline-checkbox">
<input type="checkbox" data-country="${c.code}" checked onchange="SpyStations.applyFilters()">
<span>${countryFlags[c.code] || ''} ${c.name}</span>
</label>
`).join('');
}
// Populate mode filters
const modeContainer = document.getElementById('modeFilters');
if (modeContainer) {
modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox">
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
</label>
`).join('');
}
// Set initial filter states
activeFilters.countries = countries.map(c => c.code);
activeFilters.modes = modes;
}
/**
* Apply filters and render stations
*/
function applyFilters() {
// Read type filters
const typeNumber = document.getElementById('filterTypeNumber');
const typeDiplomatic = document.getElementById('filterTypeDiplomatic');
activeFilters.types = [];
if (typeNumber && typeNumber.checked) activeFilters.types.push('number');
if (typeDiplomatic && typeDiplomatic.checked) activeFilters.types.push('diplomatic');
// Read country filters
activeFilters.countries = [];
document.querySelectorAll('#countryFilters input[data-country]:checked').forEach(cb => {
activeFilters.countries.push(cb.dataset.country);
});
// Read mode filters
activeFilters.modes = [];
document.querySelectorAll('#modeFilters input[data-mode]:checked').forEach(cb => {
activeFilters.modes.push(cb.dataset.mode);
});
// Apply filters
filteredStations = stations.filter(s => {
if (!activeFilters.types.includes(s.type)) return false;
if (!activeFilters.countries.includes(s.country_code)) return false;
const stationMode = s.mode.split('/')[0];
if (!activeFilters.modes.includes(stationMode)) return false;
return true;
});
renderStations();
updateStats(true);
}
/**
* Render station cards
*/
function renderStations() {
const container = document.getElementById('spyStationsGrid');
if (!container) return;
if (filteredStations.length === 0) {
container.innerHTML = `
<div class="spy-station-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 12px;">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
<circle cx="12" cy="12" r="2"/>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
</svg>
<p>No stations match your filters</p>
</div>
`;
return;
}
container.innerHTML = filteredStations.map(station => renderStationCard(station)).join('');
}
/**
* Render a single station card
*/
function renderStationCard(station) {
const flag = countryFlags[station.country_code] || '';
const typeBadgeClass = station.type === 'number' ? 'spy-badge-number' : 'spy-badge-diplomatic';
const typeBadgeText = station.type === 'number' ? 'NUMBER' : 'DIPLOMATIC';
const primaryFreq = station.frequencies.find(f => f.primary) || station.frequencies[0];
const freqList = station.frequencies.slice(0, 4).map(f => formatFrequency(f.freq_khz)).join(', ');
const moreFreqs = station.frequencies.length > 4 ? ` +${station.frequencies.length - 4} more` : '';
// Build tune button with frequency selector if multiple frequencies
let tuneSection;
if (station.frequencies.length > 1) {
const options = station.frequencies.map(f => {
const label = formatFrequency(f.freq_khz) + (f.primary ? ' (primary)' : '');
return `<option value="${f.freq_khz}">${label}</option>`;
}).join('');
tuneSection = `
<div class="spy-tune-group">
<select class="spy-freq-select" id="freq-select-${station.id}">
${options}
</select>
<button class="spy-tune-btn" onclick="SpyStations.tuneToSelectedFreq('${station.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<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>
Tune In
</button>
</div>
`;
} else {
tuneSection = `
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${primaryFreq.freq_khz})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<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>
Tune In
</button>
`;
}
return `
<div class="spy-station-card" data-station-id="${station.id}">
<div class="spy-station-header">
<div class="spy-station-title">
<span class="spy-station-flag">${flag}</span>
<span class="spy-station-name">${station.name}</span>
${station.nickname ? `<span class="spy-station-nickname">- ${station.nickname}</span>` : ''}
</div>
<span class="spy-station-badge ${typeBadgeClass}">${typeBadgeText}</span>
</div>
<div class="spy-station-body">
<div class="spy-station-meta">
<div class="spy-station-meta-item">
<span class="spy-meta-label">Origin</span>
<span class="spy-meta-value">${station.country}</span>
</div>
<div class="spy-station-meta-item">
<span class="spy-meta-label">Mode</span>
<span class="spy-meta-value spy-meta-mode">${station.mode}</span>
</div>
</div>
<div class="spy-station-freqs">
<span class="spy-meta-label">Frequencies</span>
<span class="spy-freq-list">${freqList}${moreFreqs}</span>
</div>
<div class="spy-station-desc">${station.description}</div>
</div>
<div class="spy-station-footer">
${tuneSection}
<button class="spy-details-btn" onclick="SpyStations.showDetails('${station.id}')">
Details
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
`;
}
/**
* Format frequency for display
*/
function formatFrequency(freqKhz) {
if (freqKhz >= 1000) {
return (freqKhz / 1000).toFixed(3) + ' MHz';
}
return freqKhz + ' kHz';
}
/**
* Get appropriate SDR mode from station mode string
*/
function getModeFromStation(stationMode) {
const mode = stationMode.toLowerCase();
if (mode.includes('am') || mode.includes('ofdm')) return 'am';
if (mode.includes('lsb')) return 'lsb';
if (mode.includes('fm')) return 'fm';
// Default to USB for most number stations and digital modes
return 'usb';
}
/**
* Tune to a station frequency
*/
function tuneToStation(stationId, freqKhz) {
const freqMhz = freqKhz / 1000;
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
// Find the station and determine mode
const station = stations.find(s => s.id === stationId);
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
sessionStorage.setItem('tuneMode', tuneMode);
const stationName = station ? station.name : 'Station';
if (typeof showNotification === 'function') {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
}
// Switch to listening post mode
if (typeof selectMode === 'function') {
selectMode('listening');
} else if (typeof switchMode === 'function') {
switchMode('listening');
}
}
/**
* Tune to selected frequency from dropdown
*/
function tuneToSelectedFreq(stationId) {
const select = document.getElementById('freq-select-' + stationId);
if (select) {
const freqKhz = parseInt(select.value, 10);
tuneToStation(stationId, freqKhz);
}
}
/**
* Check if we arrived from another page with a tune request
*/
function checkTuneFrequency() {
// This is for the listening post to check - spy stations sets, listening post reads
}
/**
* Show station details modal
*/
function showDetails(stationId) {
const station = stations.find(s => s.id === stationId);
if (!station) return;
let modal = document.getElementById('spyStationDetailsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'spyStationDetailsModal';
modal.className = 'signal-details-modal';
document.body.appendChild(modal);
}
const flag = countryFlags[station.country_code] || '';
const allFreqs = station.frequencies.map(f => {
const label = f.primary ? ' (primary)' : '';
return `<span class="spy-freq-item spy-freq-clickable" onclick="SpyStations.tuneToStation('${station.id}', ${f.freq_khz}); SpyStations.closeDetails();">${formatFrequency(f.freq_khz)}${label}</span>`;
}).join('');
modal.innerHTML = `
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeDetails()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>${flag} ${station.name} ${station.nickname ? '- ' + station.nickname : ''}</h3>
<button class="signal-details-modal-close" onclick="SpyStations.closeDetails()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div class="signal-details-section">
<div class="signal-details-title">Overview</div>
<div class="signal-details-grid">
<div class="signal-details-item">
<span class="signal-details-label">Type</span>
<span class="signal-details-value">${station.type === 'number' ? 'Number Station' : 'Diplomatic Network'}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Country</span>
<span class="signal-details-value">${station.country}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Mode</span>
<span class="signal-details-value">${station.mode}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Operator</span>
<span class="signal-details-value">${station.operator || 'Unknown'}</span>
</div>
</div>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Description</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">${station.description}</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Frequencies (${station.frequencies.length})</div>
<div class="spy-freq-grid">${allFreqs}</div>
</div>
${station.schedule ? `
<div class="signal-details-section">
<div class="signal-details-title">Schedule</div>
<p style="color: var(--text-secondary); font-size: 12px;">${station.schedule}</p>
</div>
` : ''}
${station.source_url ? `
<div class="signal-details-section">
<div class="signal-details-title">Source</div>
<a href="${station.source_url}" target="_blank" rel="noopener" style="color: var(--accent-cyan); font-size: 12px;">${station.source_url}</a>
</div>
` : ''}
</div>
<div class="signal-details-modal-footer">
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${station.frequencies[0].freq_khz}); SpyStations.closeDetails();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<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>
Tune In
</button>
</div>
</div>
`;
modal.classList.add('show');
}
/**
* Close details modal
*/
function closeDetails() {
const modal = document.getElementById('spyStationDetailsModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* Show help modal
*/
function showHelp() {
let modal = document.getElementById('spyStationsHelpModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'spyStationsHelpModal';
modal.className = 'signal-details-modal';
document.body.appendChild(modal);
}
modal.innerHTML = `
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeHelp()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>About Spy Stations</h3>
<button class="signal-details-modal-close" onclick="SpyStations.closeHelp()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div class="signal-details-section">
<div class="signal-details-title">Number Stations</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Number stations are shortwave radio transmissions believed to be used by intelligence agencies
to communicate with spies in the field. They typically broadcast strings of numbers, letters,
or words read by synthesized or live voices. These one-way broadcasts are encrypted using
one-time pads, making them virtually unbreakable.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Diplomatic Networks</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Foreign ministries maintain HF radio networks to communicate with embassies worldwide,
especially in regions where satellite or internet connectivity may be unreliable or
compromised. These networks use various digital modes like PACTOR, ALE, and proprietary
protocols for encrypted diplomatic traffic.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">How to Listen</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Best Practices</div>
<ul style="color: var(--text-secondary); font-size: 12px; line-height: 1.6; padding-left: 20px;">
<li>HF propagation varies with time of day and solar conditions</li>
<li>Use a long wire or loop antenna for best results</li>
<li>Check schedules on priyom.org for transmission times</li>
<li>Night time generally offers better long-distance reception</li>
</ul>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Data Sources</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Station data sourced from <a href="https://priyom.org" target="_blank" rel="noopener" style="color: var(--accent-cyan);">priyom.org</a>,
a community-maintained database of number stations and related transmissions.
</p>
</div>
</div>
</div>
`;
modal.classList.add('show');
}
/**
* Close help modal
*/
function closeHelp() {
const modal = document.getElementById('spyStationsHelpModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* Update sidebar stats
* @param {boolean} useFiltered - If true, use filtered stations instead of all stations
*/
function updateStats(useFiltered) {
const stationList = useFiltered ? filteredStations : stations;
const numberCount = stationList.filter(s => s.type === 'number').length;
const diplomaticCount = stationList.filter(s => s.type === 'diplomatic').length;
const countryCount = new Set(stationList.map(s => s.country_code)).size;
const freqCount = stationList.reduce((sum, s) => sum + s.frequencies.length, 0);
const numberEl = document.getElementById('spyStatsNumber');
const diplomaticEl = document.getElementById('spyStatsDiplomatic');
const countriesEl = document.getElementById('spyStatsCountries');
const freqsEl = document.getElementById('spyStatsFreqs');
if (numberEl) numberEl.textContent = numberCount;
if (diplomaticEl) diplomaticEl.textContent = diplomaticCount;
if (countriesEl) countriesEl.textContent = countryCount;
if (freqsEl) freqsEl.textContent = freqCount;
// Update visible count in header if element exists
const visibleCountEl = document.getElementById('spyStationsVisibleCount');
if (visibleCountEl) {
visibleCountEl.textContent = stationList.length;
}
}
// Public API
return {
init,
applyFilters,
tuneToStation,
tuneToSelectedFreq,
showDetails,
closeDetails,
showHelp,
closeHelp
};
})();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Will be initialized when mode is switched to spy stations
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ADS-B History // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
ADS-B HISTORY
<span>// INTERCEPT REPORTING</span>
</div>
<div class="status-bar">
<a href="/adsb/dashboard" class="back-link">Live Radar</a>
</div>
</header>
<main class="history-shell">
<section class="summary-strip">
<div class="summary-card">
<div class="summary-label">Messages</div>
<div class="summary-value" id="summaryMessages">--</div>
</div>
<div class="summary-card">
<div class="summary-label">Snapshots</div>
<div class="summary-value" id="summarySnapshots">--</div>
</div>
<div class="summary-card">
<div class="summary-label">Aircraft</div>
<div class="summary-value" id="summaryAircraft">--</div>
</div>
<div class="summary-card">
<div class="summary-label">First Seen</div>
<div class="summary-value" id="summaryFirstSeen">--</div>
</div>
<div class="summary-card">
<div class="summary-label">Last Seen</div>
<div class="summary-value" id="summaryLastSeen">--</div>
</div>
</section>
<section class="session-strip">
<div class="session-status">
<div class="status-dot" id="sessionStatusDot"></div>
<div>
<div class="session-label">Tracking</div>
<div class="session-value" id="sessionStatusText">--</div>
</div>
</div>
<div class="session-metric">
<div class="session-label">Uptime</div>
<div class="session-value mono" id="sessionUptime">--:--:--</div>
</div>
<div class="session-metric">
<div class="session-label">Started</div>
<div class="session-value" id="sessionStartedAt">--</div>
</div>
<div class="session-metric">
<div class="session-label">Status</div>
<div class="session-value" id="sessionNotice">Ready</div>
</div>
<div class="session-controls">
<select id="sessionDeviceSelect"></select>
<button class="primary-btn" id="sessionToggleBtn" type="button" onclick="toggleSession()">Start Tracking</button>
</div>
</section>
<section class="controls">
<div class="control-group">
<label for="windowSelect">Window</label>
<select id="windowSelect">
<option value="15">15 minutes</option>
<option value="60" selected>1 hour</option>
<option value="360">6 hours</option>
<option value="1440">24 hours</option>
<option value="10080">7 days</option>
</select>
</div>
<div class="control-group">
<label for="searchInput">Search</label>
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
</div>
<div class="control-group">
<label for="limitSelect">Limit</label>
<select id="limitSelect">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
</div>
<button class="primary-btn" id="refreshBtn">Refresh</button>
<div class="status-pill" id="historyStatus">
{% if history_enabled %}
HISTORY ONLINE
{% else %}
HISTORY DISABLED
{% endif %}
</div>
</section>
<section class="content-grid">
<div class="panel aircraft-panel">
<div class="panel-header">
<span>RECENT AIRCRAFT</span>
<span class="panel-meta" id="aircraftCount">0</span>
</div>
<div class="panel-body">
<table class="aircraft-table">
<thead>
<tr>
<th>ICAO</th>
<th>Callsign</th>
<th>Alt</th>
<th>Speed</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody id="aircraftTableBody">
<tr class="empty-row">
<td colspan="5">No aircraft in this window</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel detail-panel">
<div class="panel-header">
<span>DETAIL TIMELINE</span>
<span class="panel-meta" id="detailIcao">--</span>
</div>
<div class="panel-body">
<div class="detail-card">
<div class="detail-title" id="detailTitle">Select an aircraft</div>
<div class="detail-meta" id="detailMeta">---</div>
</div>
<div class="chart-grid">
<div class="chart-card">
<div class="chart-title">Altitude (ft)</div>
<canvas id="altitudeChart"></canvas>
</div>
<div class="chart-card">
<div class="chart-title">Speed (kt)</div>
<canvas id="speedChart"></canvas>
</div>
<div class="chart-card">
<div class="chart-title">Heading (deg)</div>
<canvas id="headingChart"></canvas>
</div>
<div class="chart-card">
<div class="chart-title">Vertical Rate (fpm)</div>
<canvas id="verticalChart"></canvas>
</div>
</div>
<div class="timeline-list" id="timelineList">
<div class="empty-row">No timeline data</div>
</div>
<div class="squawk-list" id="squawkList">
<div class="empty-row">No squawk changes</div>
</div>
</div>
</div>
</section>
</main>
<div class="modal-backdrop" id="aircraftModalBackdrop" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true">
<button class="modal-close" id="aircraftModalClose" aria-label="Close">×</button>
<div class="modal-header">
<div>
<div class="modal-title" id="modalTitle">Aircraft</div>
<div class="modal-subtitle" id="modalSubtitle">--</div>
</div>
<div class="modal-actions">
<button class="nav-btn" id="modalPrev"></button>
<button class="nav-btn" id="modalNext"></button>
</div>
</div>
<div class="modal-body">
<div class="modal-photo">
<img id="modalPhoto" alt="Aircraft photo">
<div class="photo-fallback" id="modalPhotoFallback">No photo</div>
</div>
<div class="modal-details">
<div class="detail-row">
<span>ICAO</span>
<strong id="modalIcao">--</strong>
</div>
<div class="detail-row">
<span>Callsign</span>
<strong id="modalCallsign">--</strong>
</div>
<div class="detail-row">
<span>Registration</span>
<strong id="modalRegistration">--</strong>
</div>
<div class="detail-row">
<span>Type</span>
<strong id="modalType">--</strong>
</div>
<div class="detail-row">
<span>Altitude</span>
<strong id="modalAltitude">--</strong>
</div>
<div class="detail-row">
<span>Speed</span>
<strong id="modalSpeed">--</strong>
</div>
<div class="detail-row">
<span>Heading</span>
<strong id="modalHeading">--</strong>
</div>
<div class="detail-row">
<span>Vertical Rate</span>
<strong id="modalVerticalRate">--</strong>
</div>
<div class="detail-row">
<span>Squawk</span>
<strong id="modalSquawk">--</strong>
</div>
<div class="detail-row">
<span>Position</span>
<strong id="modalPosition">--</strong>
</div>
<div class="detail-row">
<span>Last Seen</span>
<strong id="modalLastSeen">--</strong>
</div>
</div>
</div>
</div>
</div>
<script>
const historyEnabled = {{ 'true' if history_enabled else 'false' }};
const summaryMessages = document.getElementById('summaryMessages');
const summarySnapshots = document.getElementById('summarySnapshots');
const summaryAircraft = document.getElementById('summaryAircraft');
const summaryFirstSeen = document.getElementById('summaryFirstSeen');
const summaryLastSeen = document.getElementById('summaryLastSeen');
const aircraftTableBody = document.getElementById('aircraftTableBody');
const aircraftCount = document.getElementById('aircraftCount');
const detailIcao = document.getElementById('detailIcao');
const detailTitle = document.getElementById('detailTitle');
const detailMeta = document.getElementById('detailMeta');
const timelineList = document.getElementById('timelineList');
const squawkList = document.getElementById('squawkList');
const altitudeChart = document.getElementById('altitudeChart');
const speedChart = document.getElementById('speedChart');
const headingChart = document.getElementById('headingChart');
const verticalChart = document.getElementById('verticalChart');
const refreshBtn = document.getElementById('refreshBtn');
const sessionStatusDot = document.getElementById('sessionStatusDot');
const sessionStatusText = document.getElementById('sessionStatusText');
const sessionUptime = document.getElementById('sessionUptime');
const sessionStartedAt = document.getElementById('sessionStartedAt');
const sessionNotice = document.getElementById('sessionNotice');
const sessionDeviceSelect = document.getElementById('sessionDeviceSelect');
const sessionToggleBtn = document.getElementById('sessionToggleBtn');
const windowSelect = document.getElementById('windowSelect');
const searchInput = document.getElementById('searchInput');
const limitSelect = document.getElementById('limitSelect');
let selectedIcao = '';
let sessionStartAt = null;
let sessionTimer = null;
let recentAircraft = [];
let selectedIndex = -1;
const photoCache = new Map();
const modalBackdrop = document.getElementById('aircraftModalBackdrop');
const modalClose = document.getElementById('aircraftModalClose');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
const modalTitle = document.getElementById('modalTitle');
const modalSubtitle = document.getElementById('modalSubtitle');
const modalPhoto = document.getElementById('modalPhoto');
const modalPhotoFallback = document.getElementById('modalPhotoFallback');
const modalIcao = document.getElementById('modalIcao');
const modalCallsign = document.getElementById('modalCallsign');
const modalRegistration = document.getElementById('modalRegistration');
const modalType = document.getElementById('modalType');
const modalAltitude = document.getElementById('modalAltitude');
const modalSpeed = document.getElementById('modalSpeed');
const modalHeading = document.getElementById('modalHeading');
const modalVerticalRate = document.getElementById('modalVerticalRate');
const modalSquawk = document.getElementById('modalSquawk');
const modalPosition = document.getElementById('modalPosition');
const modalLastSeen = document.getElementById('modalLastSeen');
function formatNumber(value) {
if (value === null || value === undefined) {
return '--';
}
return Number(value).toLocaleString();
}
function formatTime(value) {
if (!value) {
return '--';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '--';
}
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatDateTime(value) {
if (!value) {
return '--';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '--';
}
return date.toLocaleString();
}
function valueOrDash(value) {
if (value === null || value === undefined || value === '') {
return '--';
}
return value;
}
function formatUptime(seconds) {
if (seconds === null || seconds === undefined) {
return '--:--:--';
}
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
async function loadSummary() {
const sinceMinutes = windowSelect.value;
const resp = await fetch(`/adsb/history/summary?since_minutes=${sinceMinutes}`);
if (!resp.ok) {
return;
}
const data = await resp.json();
summaryMessages.textContent = formatNumber(data.message_count);
summarySnapshots.textContent = formatNumber(data.snapshot_count);
summaryAircraft.textContent = formatNumber(data.aircraft_count);
summaryFirstSeen.textContent = formatTime(data.first_seen);
summaryLastSeen.textContent = formatTime(data.last_seen);
}
async function loadAircraft() {
const sinceMinutes = windowSelect.value;
const limit = limitSelect.value;
const search = encodeURIComponent(searchInput.value.trim());
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
if (!resp.ok) {
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">History database unavailable</td></tr>';
return;
}
const data = await resp.json();
const rows = data.aircraft || [];
recentAircraft = rows;
aircraftCount.textContent = rows.length;
if (!rows.length) {
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">No aircraft in this window</td></tr>';
return;
}
aircraftTableBody.innerHTML = rows.map(row => `
<tr class="aircraft-row" data-icao="${row.icao}">
<td class="mono">${row.icao}</td>
<td>${valueOrDash(row.callsign)}</td>
<td>${valueOrDash(row.altitude)}</td>
<td>${valueOrDash(row.speed)}</td>
<td>${formatTime(row.last_seen)}</td>
</tr>
`).join('');
document.querySelectorAll('.aircraft-row').forEach((row, index) => {
row.addEventListener('click', () => {
selectAircraft(row.dataset.icao);
openModal(index);
});
});
}
async function selectAircraft(icao) {
selectedIcao = icao;
detailIcao.textContent = icao || '--';
if (!icao) {
detailTitle.textContent = 'Select an aircraft';
detailMeta.textContent = '---';
timelineList.innerHTML = '<div class="empty-row">No timeline data</div>';
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
drawMetricChart(altitudeChart, [], 'altitude', 'Altitude', 'ft');
drawMetricChart(speedChart, [], 'speed', 'Speed', 'kt');
drawMetricChart(headingChart, [], 'heading', 'Heading', 'deg');
drawMetricChart(verticalChart, [], 'vertical_rate', 'Vertical Rate', 'fpm');
return;
}
await loadTimeline(icao);
}
async function loadTimeline(icao) {
const sinceMinutes = windowSelect.value;
const resp = await fetch(`/adsb/history/timeline?icao=${icao}&since_minutes=${sinceMinutes}`);
if (!resp.ok) {
timelineList.innerHTML = '<div class="empty-row">Timeline unavailable</div>';
return;
}
const data = await resp.json();
const timeline = data.timeline || [];
if (!timeline.length) {
timelineList.innerHTML = '<div class="empty-row">No timeline data</div>';
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
drawMetricChart(altitudeChart, [], 'altitude', 'Altitude', 'ft');
drawMetricChart(speedChart, [], 'speed', 'Speed', 'kt');
drawMetricChart(headingChart, [], 'heading', 'Heading', 'deg');
drawMetricChart(verticalChart, [], 'vertical_rate', 'Vertical Rate', 'fpm');
return;
}
const latest = timeline[timeline.length - 1];
detailTitle.textContent = `${icao} Timeline`;
detailMeta.textContent = `Alt ${valueOrDash(latest.altitude)} ft | Spd ${valueOrDash(latest.speed)} kt | Head ${valueOrDash(latest.heading)}`;
timelineList.innerHTML = timeline.slice(-30).reverse().map(point => `
<div class="timeline-row">
<span>${formatTime(point.captured_at)}</span>
<span>Alt ${valueOrDash(point.altitude)} ft</span>
<span>Spd ${valueOrDash(point.speed)} kt</span>
<span>Hdg ${valueOrDash(point.heading)}</span>
<span>V/S ${valueOrDash(point.vertical_rate)} fpm</span>
</div>
`).join('');
updateSquawkChanges(timeline);
drawMetricChart(altitudeChart, timeline, 'altitude', 'Altitude', 'ft');
drawMetricChart(speedChart, timeline, 'speed', 'Speed', 'kt');
drawMetricChart(headingChart, timeline, 'heading', 'Heading', 'deg');
drawMetricChart(verticalChart, timeline, 'vertical_rate', 'Vertical Rate', 'fpm');
}
function drawMetricChart(canvas, points, field, label, unit) {
const ctx = canvas.getContext('2d');
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = width;
canvas.height = height;
ctx.clearRect(0, 0, width, height);
if (!points.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return;
}
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
if (!series.length) {
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
ctx.font = '12px "JetBrains Mono", monospace';
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
return;
}
const minVal = Math.min(...series);
const maxVal = Math.max(...series);
const range = maxVal - minVal || 1;
const padding = 20;
ctx.strokeStyle = 'rgba(74, 158, 255, 0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, height - padding);
ctx.lineTo(width - padding, height - padding);
ctx.stroke();
ctx.strokeStyle = 'rgba(74, 158, 255, 0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
let started = false;
const span = Math.max(1, points.length - 1);
points.forEach((point, index) => {
if (point[field] === null || point[field] === undefined) {
return;
}
const x = padding + (index / span) * (width - padding * 2);
const y = height - padding - ((point[field] - minVal) / range) * (height - padding * 2);
if (!started) {
ctx.moveTo(x, y);
started = true;
} else {
ctx.lineTo(x, y);
}
});
if (started) {
ctx.stroke();
}
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
}
function updateSquawkChanges(points) {
const changes = [];
let lastSquawk = null;
points.forEach(point => {
if (point.squawk && point.squawk !== lastSquawk) {
changes.push({ time: point.captured_at, squawk: point.squawk });
lastSquawk = point.squawk;
}
});
if (!changes.length) {
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
return;
}
squawkList.innerHTML = changes.slice(-10).reverse().map(change => `
<div class="timeline-row">
<span>${formatTime(change.time)}</span>
<span>Squawk ${change.squawk}</span>
</div>
`).join('');
}
async function loadSessionDevices() {
if (!historyEnabled) {
sessionDeviceSelect.innerHTML = '<option value="0">History disabled</option>';
sessionDeviceSelect.disabled = true;
sessionToggleBtn.disabled = true;
return;
}
const resp = await fetch('/devices');
if (!resp.ok) {
sessionDeviceSelect.innerHTML = '<option value="0">No SDR</option>';
return;
}
const devices = await resp.json();
sessionDeviceSelect.innerHTML = '';
if (!devices.length) {
sessionDeviceSelect.innerHTML = '<option value="0">No SDR</option>';
sessionDeviceSelect.disabled = true;
return;
}
devices.forEach((dev, idx) => {
const index = dev.index !== undefined ? dev.index : idx;
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
const opt = document.createElement('option');
opt.value = index;
opt.textContent = `${type} #${index}${serial}`;
sessionDeviceSelect.appendChild(opt);
});
sessionDeviceSelect.disabled = false;
}
async function loadSessionStatus() {
const resp = await fetch('/adsb/session');
if (!resp.ok) {
sessionStatusText.textContent = 'UNKNOWN';
sessionStatusDot.classList.remove('active');
sessionNotice.textContent = 'Session unavailable';
return;
}
const data = await resp.json();
if (data.tracking_active) {
sessionStatusText.textContent = 'TRACKING';
sessionStatusDot.classList.add('active');
sessionToggleBtn.textContent = 'Stop Tracking';
sessionToggleBtn.classList.add('stop');
sessionNotice.textContent = 'Live';
} else {
sessionStatusText.textContent = 'STANDBY';
sessionStatusDot.classList.remove('active');
sessionToggleBtn.textContent = 'Start Tracking';
sessionToggleBtn.classList.remove('stop');
sessionNotice.textContent = 'Idle';
}
if (data.session && data.session.started_at) {
sessionStartAt = new Date(data.session.started_at);
sessionStartedAt.textContent = formatDateTime(data.session.started_at);
sessionUptime.textContent = formatUptime(data.uptime_seconds);
} else {
sessionStartAt = null;
sessionStartedAt.textContent = '--';
sessionUptime.textContent = '--:--:--';
}
}
function startSessionTimer() {
if (sessionTimer) {
clearInterval(sessionTimer);
}
sessionTimer = setInterval(() => {
if (!sessionStartAt) {
sessionUptime.textContent = '--:--:--';
return;
}
const seconds = Math.floor((Date.now() - sessionStartAt.getTime()) / 1000);
sessionUptime.textContent = formatUptime(seconds);
}, 1000);
}
async function toggleSession() {
if (!historyEnabled) {
return;
}
sessionToggleBtn.disabled = true;
sessionNotice.textContent = 'Working...';
if (sessionStatusText.textContent === 'TRACKING') {
const resp = await fetch('/adsb/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ source: 'adsb_history' })
});
if (!resp.ok) {
sessionNotice.textContent = 'Stop failed';
}
} else {
const device = parseInt(sessionDeviceSelect.value, 10) || 0;
const resp = await fetch('/adsb/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ device, source: 'adsb_history' })
});
if (!resp.ok) {
sessionNotice.textContent = 'Start failed';
}
}
await loadSessionStatus();
sessionToggleBtn.disabled = false;
}
async function openModal(index) {
if (index < 0 || index >= recentAircraft.length) {
return;
}
selectedIndex = index;
const ac = recentAircraft[index];
modalTitle.textContent = ac.callsign || ac.icao || 'Aircraft';
modalSubtitle.textContent = `${valueOrDash(ac.registration)} • ${valueOrDash(ac.type_code)}`;
modalIcao.textContent = valueOrDash(ac.icao);
modalCallsign.textContent = valueOrDash(ac.callsign);
modalRegistration.textContent = valueOrDash(ac.registration);
modalType.textContent = [ac.type_desc, ac.type_code].filter(Boolean).join(' • ') || '--';
modalAltitude.textContent = ac.altitude ? `${ac.altitude} ft` : '--';
modalSpeed.textContent = ac.speed ? `${ac.speed} kt` : '--';
modalHeading.textContent = ac.heading !== null && ac.heading !== undefined ? `${ac.heading}°` : '--';
modalVerticalRate.textContent = ac.vertical_rate ? `${ac.vertical_rate} fpm` : '--';
modalSquawk.textContent = valueOrDash(ac.squawk);
if (ac.lat !== null && ac.lat !== undefined && ac.lon !== null && ac.lon !== undefined) {
modalPosition.textContent = `${ac.lat.toFixed(4)}, ${ac.lon.toFixed(4)}`;
} else {
modalPosition.textContent = '--';
}
modalLastSeen.textContent = formatDateTime(ac.last_seen);
await loadPhoto(ac.registration);
modalBackdrop.classList.add('open');
}
function closeModal() {
modalBackdrop.classList.remove('open');
}
async function loadPhoto(registration) {
if (!registration) {
modalPhoto.style.display = 'none';
modalPhotoFallback.style.display = 'flex';
return;
}
if (photoCache.has(registration)) {
const url = photoCache.get(registration);
if (url) {
modalPhoto.src = url;
modalPhoto.style.display = 'block';
modalPhotoFallback.style.display = 'none';
} else {
modalPhoto.style.display = 'none';
modalPhotoFallback.style.display = 'flex';
}
return;
}
try {
const resp = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
const data = await resp.json();
const url = data && data.thumbnail;
photoCache.set(registration, url || null);
if (url) {
modalPhoto.src = url;
modalPhoto.onerror = () => {
modalPhoto.style.display = 'none';
modalPhotoFallback.style.display = 'flex';
};
modalPhoto.style.display = 'block';
modalPhotoFallback.style.display = 'none';
} else {
modalPhoto.style.display = 'none';
modalPhotoFallback.style.display = 'flex';
}
} catch (err) {
modalPhoto.style.display = 'none';
modalPhotoFallback.style.display = 'flex';
}
}
function moveModal(offset) {
const nextIndex = selectedIndex + offset;
if (nextIndex < 0 || nextIndex >= recentAircraft.length) {
return;
}
openModal(nextIndex);
}
async function refreshAll() {
if (!historyEnabled) {
return;
}
await Promise.all([loadSummary(), loadAircraft(), loadSessionStatus()]);
if (selectedIcao) {
await loadTimeline(selectedIcao);
}
}
refreshBtn.addEventListener('click', refreshAll);
windowSelect.addEventListener('change', refreshAll);
limitSelect.addEventListener('change', refreshAll);
searchInput.addEventListener('input', () => {
clearTimeout(searchInput._debounce);
searchInput._debounce = setTimeout(refreshAll, 350);
});
refreshAll();
loadSessionDevices();
startSessionTimer();
modalClose.addEventListener('click', closeModal);
modalBackdrop.addEventListener('click', (event) => {
if (event.target === modalBackdrop) {
closeModal();
}
});
modalPrev.addEventListener('click', () => moveModal(-1));
modalNext.addEventListener('click', () => moveModal(1));
window.addEventListener('resize', () => {
if (selectedIcao) {
loadTimeline(selectedIcao);
}
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+11084 -8740
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iNTERCEPT // Restricted Access</title>
<script src="{{ url_for('static', filename='js/core/login.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}" />
</head>
<body>
<div class="landing-overlay">
<div class="landing-scanline"></div>
<div class="landing-content">
<div class="landing-logo">
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" class="signal-wave signal-wave-3"/>
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" class="signal-wave signal-wave-3"/>
<circle cx="50" cy="22" r="6" fill="#00ff88" class="logo-dot" />
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</svg>
</div>
<h1 class="landing-title">SECURE LOGIN</h1>
<p class="landing-tagline">// Restricted Terminal Access</p>
<div class="login-box">
<div class="flash-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-error">
<span class="error-prefix">SIGNAL_ERR:</span>
<span class="error-message">{{ message|upper }}</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<form method="POST">
<input type="text" name="username" placeholder="OPERATOR ID" class="form-input" required autofocus autocomplete="off" />
<input type="password" name="password" placeholder="ENCRYPTION KEY" class="form-input" required />
<button type="submit" class="landing-enter-btn" onclick="login(event)">
<span class="btn-text">INITIALIZE SESSION</span>
</button>
</form>
</div>
<p class="landing-version">SYSTEM AUTH v{{ version }}</p>
<p class="landing-tagline" style="font-size: 0.6rem; opacity: 0.4; margin-top: 10px;">
Unauthorized access is logged. IP: {{ request.remote_addr }}
</p>
</div>
</div>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
<!-- AIS VESSEL TRACKING MODE -->
<div id="aisMode" class="mode-content" style="display: none;">
<div class="section">
<h3>AIS Vessel Tracking</h3>
<div class="info-text" style="margin-bottom: 15px;">
Track ships and vessels via AIS (Automatic Identification System) on 161.975 / 162.025 MHz.
</div>
<a href="/ais/dashboard" target="_blank" class="run-btn" style="display: inline-block; text-decoration: none; text-align: center; margin-bottom: 15px;">
Open AIS Dashboard
</a>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="number" id="aisGainInput" value="40" min="0" max="50" placeholder="0-50">
</div>
</div>
<div class="section">
<h3>Status</h3>
<div id="aisStatusDisplay" class="info-text">
<p>Status: <span id="aisStatusText" style="color: var(--accent-yellow);">Standby</span></p>
<p>Vessels: <span id="aisVesselCount">0</span></p>
</div>
</div>
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
Start AIS Tracking
</button>
<button class="stop-btn" id="stopAisBtn" onclick="stopAisTracking()" style="display: none;">
Stop AIS Tracking
</button>
</div>
<script>
let aisEventSource = null;
let aisVessels = {};
function startAisTracking() {
const gain = document.getElementById('aisGainInput').value || '40';
const device = document.getElementById('deviceSelect')?.value || '0';
fetch('/ais/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started' || data.status === 'already_running') {
document.getElementById('startAisBtn').style.display = 'none';
document.getElementById('stopAisBtn').style.display = 'block';
document.getElementById('aisStatusText').textContent = 'Tracking';
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
startAisSSE();
} else {
alert(data.message || 'Failed to start AIS tracking');
}
})
.catch(err => alert('Error: ' + err.message));
}
function stopAisTracking() {
fetch('/ais/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('startAisBtn').style.display = 'block';
document.getElementById('stopAisBtn').style.display = 'none';
document.getElementById('aisStatusText').textContent = 'Standby';
document.getElementById('aisStatusText').style.color = 'var(--accent-yellow)';
document.getElementById('aisVesselCount').textContent = '0';
if (aisEventSource) {
aisEventSource.close();
aisEventSource = null;
}
aisVessels = {};
});
}
function startAisSSE() {
if (aisEventSource) aisEventSource.close();
aisEventSource = new EventSource('/ais/stream');
aisEventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'vessel') {
aisVessels[data.mmsi] = data;
document.getElementById('aisVesselCount').textContent = Object.keys(aisVessels).length;
}
} catch (err) {}
};
aisEventSource.onerror = function() {
setTimeout(() => {
if (document.getElementById('stopAisBtn').style.display === 'block') {
startAisSSE();
}
}, 2000);
};
}
// Check initial status
fetch('/ais/status')
.then(r => r.json())
.then(data => {
if (data.tracking_active) {
document.getElementById('startAisBtn').style.display = 'none';
document.getElementById('stopAisBtn').style.display = 'block';
document.getElementById('aisStatusText').textContent = 'Tracking';
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
document.getElementById('aisVesselCount').textContent = data.vessel_count || 0;
startAisSSE();
}
})
.catch(() => {});
</script>
+16
View File
@@ -0,0 +1,16 @@
<!-- APRS MODE -->
<div id="aprsMode" class="mode-content">
<div class="section">
<h3>APRS Tracking</h3>
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
Decode APRS (Automatic Packet Reporting System) amateur radio position reports on VHF.
</p>
<div style="background: rgba(255,193,7,0.1); border: 1px solid var(--accent-yellow); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px;">
<strong style="color: var(--accent-yellow);">Amateur Radio</strong><br>
<span style="color: var(--text-secondary);">APRS operates on 144.390 MHz (N. America) or 144.800 MHz (Europe). Decodes position, weather, and messages from ham radio operators.</span>
</div>
<div style="background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; padding: 8px; font-size: 10px;">
<span style="color: var(--accent-cyan);">Controls in function bar above map</span>
</div>
</div>
</div>
+67
View File
@@ -0,0 +1,67 @@
<!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content">
<!-- Capability Status -->
<div id="btCapabilityStatus" class="section" style="display: none;">
<!-- Populated by JavaScript with capability warnings -->
</div>
<div class="section">
<h3>Scanner Configuration</h3>
<div class="form-group">
<label>Adapter</label>
<select id="btAdapterSelect">
<option value="">Detecting adapters...</option>
</select>
</div>
<div class="form-group">
<label>Scan Mode</label>
<select id="btScanMode">
<option value="auto">Auto (Recommended)</option>
<option value="bleak">Bleak Library</option>
<option value="hcitool">hcitool (Linux)</option>
<option value="bluetoothctl">bluetoothctl (Linux)</option>
</select>
</div>
<div class="form-group">
<label>Transport</label>
<select id="btTransport">
<option value="auto">Auto (BLE + Classic)</option>
<option value="le">BLE Only</option>
<option value="br_edr">Classic Only</option>
</select>
</div>
<div class="form-group">
<label>Duration (seconds, 0 = continuous)</label>
<input type="number" id="btScanDuration" value="0" min="0" max="300" placeholder="0">
</div>
<div class="form-group">
<label>Min RSSI Filter (dBm)</label>
<input type="number" id="btMinRssi" value="-100" min="-100" max="-20" placeholder="-100">
</div>
<button class="preset-btn" onclick="btCheckCapabilities()" style="width: 100%;">
Check Capabilities
</button>
</div>
<!-- Message Container for status cards -->
<div id="btMessageContainer"></div>
<button class="run-btn" id="startBtBtn" onclick="btStartScan()">
Start Scanning
</button>
<button class="stop-btn" id="stopBtBtn" onclick="btStopScan()" style="display: none;">
Stop Scanning
</button>
<div class="section" style="margin-top: 10px;">
<h3>Export</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
Export CSV
</button>
<button class="preset-btn" onclick="btExport('json')" style="flex: 1;">
Export JSON
</button>
</div>
</div>
</div>
@@ -0,0 +1,49 @@
<!-- LISTENING POST MODE -->
<div id="listeningPostMode" class="mode-content">
<div class="section">
<h3>Status</h3>
<!-- Dependency Warning -->
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
<strong>Missing:</strong><br>
<span id="scannerToolsWarningText"></span>
</p>
</div>
<!-- Quick Status -->
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
<span id="lpQuickFreq" style="font-size: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">---.--- MHz</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
</div>
</div>
<div class="section">
<h3>Bookmarks</h3>
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
</div>
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
</div>
</div>
<div class="section">
<h3>Recent Signals</h3>
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
</div>
</div>
</div>

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