mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge upstream/main: sync fork with conflict resolution
Resolve conflicts keeping local GSM tools in kill_all() process list and weather satellite config settings while merging upstream changes including GSM spy removal, DMR fixes, USB device probe, APRS crash fix, and cross-module frequency routing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
28
Dockerfile
28
Dockerfile
@@ -57,39 +57,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
airspy \
|
||||
limesuite \
|
||||
hackrf \
|
||||
# GSM Intelligence (tshark for packet parsing)
|
||||
tshark \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# GSM Intelligence: gr-gsm (grgsm_scanner, grgsm_livemon)
|
||||
# Install from apt if available, otherwise build from source
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gnuradio gr-osmosdr gr-gsm 2>/dev/null \
|
||||
|| ( \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gnuradio gnuradio-dev gr-osmosdr \
|
||||
git cmake libboost-all-dev libcppunit-dev swig \
|
||||
doxygen liblog4cpp5-dev python3-scipy python3-numpy \
|
||||
libvolk-dev libfftw3-dev build-essential \
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/bkerler/gr-gsm.git \
|
||||
&& cd gr-gsm \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/gr-gsm \
|
||||
&& apt-get remove -y gnuradio-dev libcppunit-dev swig doxygen \
|
||||
liblog4cpp5-dev libvolk-dev build-essential git cmake \
|
||||
&& apt-get autoremove -y \
|
||||
) \
|
||||
&& 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 \
|
||||
|
||||
70
app.py
70
app.py
@@ -39,7 +39,6 @@ from utils.constants import (
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||
MAX_DEAUTH_ALERTS_AGE_SECONDS,
|
||||
MAX_GSM_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
@@ -192,16 +191,6 @@ deauth_detector = None
|
||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
deauth_detector_lock = threading.Lock()
|
||||
|
||||
# GSM Spy
|
||||
gsm_spy_scanner_running = False # Flag: scanner thread active
|
||||
gsm_spy_livemon_process = None # For grgsm_livemon process
|
||||
gsm_spy_monitor_process = None # For tshark monitoring process
|
||||
gsm_spy_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
gsm_spy_lock = threading.Lock()
|
||||
gsm_spy_active_device = None
|
||||
gsm_spy_selected_arfcn = None
|
||||
gsm_spy_region = 'Americas' # Default band
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE DICTIONARIES
|
||||
# ============================================
|
||||
@@ -234,16 +223,6 @@ dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_
|
||||
# Deauth alerts - using DataStore for automatic cleanup
|
||||
deauth_alerts = DataStore(max_age_seconds=MAX_DEAUTH_ALERTS_AGE_SECONDS, name='deauth_alerts')
|
||||
|
||||
# GSM Spy data stores
|
||||
gsm_spy_towers = DataStore(
|
||||
max_age_seconds=MAX_GSM_AGE_SECONDS,
|
||||
name='gsm_spy_towers'
|
||||
)
|
||||
gsm_spy_devices = DataStore(
|
||||
max_age_seconds=MAX_GSM_AGE_SECONDS,
|
||||
name='gsm_spy_devices'
|
||||
)
|
||||
|
||||
# Satellite state
|
||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||
|
||||
@@ -256,8 +235,6 @@ cleanup_manager.register(adsb_aircraft)
|
||||
cleanup_manager.register(ais_vessels)
|
||||
cleanup_manager.register(dsc_messages)
|
||||
cleanup_manager.register(deauth_alerts)
|
||||
cleanup_manager.register(gsm_spy_towers)
|
||||
cleanup_manager.register(gsm_spy_devices)
|
||||
|
||||
# ============================================
|
||||
# SDR DEVICE REGISTRY
|
||||
@@ -271,6 +248,10 @@ sdr_device_registry_lock = threading.Lock()
|
||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
"""Claim an SDR device for a mode.
|
||||
|
||||
Checks the in-app registry first, then probes the USB device to
|
||||
catch stale handles held by external processes (e.g. a leftover
|
||||
rtl_fm from a previous crash).
|
||||
|
||||
Args:
|
||||
device_index: The SDR device index to claim
|
||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||
@@ -282,6 +263,16 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||
if device_index in sdr_device_registry:
|
||||
in_use_by = sdr_device_registry[device_index]
|
||||
return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.'
|
||||
|
||||
# Probe the USB device to catch external processes holding the handle
|
||||
try:
|
||||
from utils.sdr.detection import probe_rtlsdr_device
|
||||
usb_error = probe_rtlsdr_device(device_index)
|
||||
if usb_error:
|
||||
return usb_error
|
||||
except Exception:
|
||||
pass # If probe fails, let the caller proceed normally
|
||||
|
||||
sdr_device_registry[device_index] = mode_name
|
||||
return None
|
||||
|
||||
@@ -693,8 +684,6 @@ def kill_all() -> Response:
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
global dmr_process, dmr_rtl_process
|
||||
global gsm_spy_livemon_process, gsm_spy_monitor_process
|
||||
global gsm_spy_scanner_running, gsm_spy_active_device, gsm_spy_selected_arfcn, gsm_spy_region
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
@@ -777,29 +766,6 @@ def kill_all() -> Response:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset GSM Spy state
|
||||
with gsm_spy_lock:
|
||||
gsm_spy_scanner_running = False
|
||||
gsm_spy_active_device = None
|
||||
gsm_spy_selected_arfcn = None
|
||||
gsm_spy_region = 'Americas'
|
||||
|
||||
if gsm_spy_livemon_process:
|
||||
try:
|
||||
if safe_terminate(gsm_spy_livemon_process):
|
||||
killed.append('grgsm_livemon')
|
||||
except Exception:
|
||||
pass
|
||||
gsm_spy_livemon_process = None
|
||||
|
||||
if gsm_spy_monitor_process:
|
||||
try:
|
||||
if safe_terminate(gsm_spy_monitor_process):
|
||||
killed.append('tshark')
|
||||
except Exception:
|
||||
pass
|
||||
gsm_spy_monitor_process = None
|
||||
|
||||
# Clear SDR device registry
|
||||
with sdr_device_registry_lock:
|
||||
sdr_device_registry.clear()
|
||||
@@ -891,19 +857,11 @@ def main() -> None:
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_gsm_signals,
|
||||
cleanup_old_gsm_tmsi_log,
|
||||
cleanup_old_gsm_velocity_log,
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
# GSM cleanups: signals (60 days), TMSI log (24 hours), velocity (1 hour)
|
||||
# Interval multiplier: cleanup every N cycles (60s interval = 1 cleanup per hour at multiplier 60)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_gsm_tmsi_log, interval_multiplier=60) # Every hour
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_gsm_velocity_log, interval_multiplier=60) # Every hour
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_gsm_signals, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
@@ -228,11 +228,6 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||
|
||||
# GSM Spy settings
|
||||
GSM_OPENCELLID_API_KEY = _get_env('GSM_OPENCELLID_API_KEY', '')
|
||||
GSM_OPENCELLID_API_URL = _get_env('GSM_OPENCELLID_API_URL', 'https://opencellid.org/cell/get')
|
||||
GSM_API_DAILY_LIMIT = _get_env_int('GSM_API_DAILY_LIMIT', 1000)
|
||||
GSM_TA_METERS_PER_UNIT = _get_env_int('GSM_TA_METERS_PER_UNIT', 554)
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
|
||||
210
gp.php
Normal file
210
gp.php
Normal file
@@ -0,0 +1,210 @@
|
||||
DMSP 5D-3 F16 (USA 172)
|
||||
1 28054U 03048A 26037.66410905 .00000171 00000+0 11311-3 0 9991
|
||||
2 28054 99.0018 60.5544 0007736 150.6435 318.8272 14.14449870151032
|
||||
METEOSAT-9 (MSG-2)
|
||||
1 28912U 05049B 26037.20698824 .00000122 00000+0 00000+0 0 9990
|
||||
2 28912 9.0646 55.4438 0001292 220.3216 340.7358 1.00280364 5681
|
||||
DMSP 5D-3 F17 (USA 191)
|
||||
1 29522U 06050A 26037.63495522 .00000221 00000+0 13641-3 0 9997
|
||||
2 29522 98.7406 46.8646 0011088 71.3269 288.9107 14.14949568993957
|
||||
FENGYUN 3A
|
||||
1 32958U 08026A 26037.29889977 .00000162 00000+0 97205-4 0 9995
|
||||
2 32958 98.6761 340.6748 0009336 139.4536 220.7337 14.19536323916838
|
||||
GOES 14
|
||||
1 35491U 09033A 26037.59737599 .00000128 00000+0 00000+0 0 9998
|
||||
2 35491 1.3510 84.7861 0001663 279.3774 203.6871 1.00112472 5283
|
||||
DMSP 5D-3 F18 (USA 210)
|
||||
1 35951U 09057A 26037.59574243 .00000344 00000+0 20119-3 0 9997
|
||||
2 35951 98.8912 18.7405 0010014 262.2671 97.7365 14.14814612841124
|
||||
EWS-G2 (GOES 15)
|
||||
1 36411U 10008A 26037.42417604 .00000037 00000+0 00000+0 0 9998
|
||||
2 36411 0.9477 85.6904 0004764 200.6178 64.5237 1.00275731 58322
|
||||
COMS 1
|
||||
1 36744U 10032A 26037.66884865 -.00000343 00000+0 00000+0 0 9998
|
||||
2 36744 4.4730 77.2684 0001088 239.9858 188.4845 1.00274368 49786
|
||||
FENGYUN 3B
|
||||
1 37214U 10059A 26037.62488625 .00000510 00000+0 28715-3 0 9992
|
||||
2 37214 98.9821 82.9728 0021838 194.4193 280.6049 14.14810700788968
|
||||
SUOMI NPP
|
||||
1 37849U 11061A 26037.58885771 .00000151 00000+0 92735-4 0 9993
|
||||
2 37849 98.7835 339.4455 0001677 23.1332 336.9919 14.19534335739918
|
||||
METEOSAT-10 (MSG-3)
|
||||
1 38552U 12035B 26037.34062893 -.00000007 00000+0 00000+0 0 9993
|
||||
2 38552 4.3618 61.5789 0002324 286.1065 271.3938 1.00272839 49549
|
||||
METOP-B
|
||||
1 38771U 12049A 26037.61376690 .00000161 00000+0 93652-4 0 9994
|
||||
2 38771 98.6708 91.6029 0002456 28.4142 331.7169 14.21434029694718
|
||||
INSAT-3D
|
||||
1 39216U 13038B 26037.58021591 -.00000338 00000+0 00000+0 0 9998
|
||||
2 39216 1.5890 84.3012 0001719 220.0673 170.6954 1.00273812 45771
|
||||
FENGYUN 3C
|
||||
1 39260U 13052A 26037.57879946 .00000181 00000+0 11337-3 0 9991
|
||||
2 39260 98.4839 17.5531 0015475 42.6626 317.5748 14.15718213640089
|
||||
METEOR-M 2
|
||||
1 40069U 14037A 26037.57010537 .00000364 00000+0 18579-3 0 9995
|
||||
2 40069 98.4979 18.0359 0006835 60.5067 299.6792 14.21415164600761
|
||||
HIMAWARI-8
|
||||
1 40267U 14060A 26037.58238259 -.00000273 00000+0 00000+0 0 9991
|
||||
2 40267 0.0457 252.0286 0000958 31.3580 203.5957 1.00278490 41450
|
||||
FENGYUN 2G
|
||||
1 40367U 14090A 26037.64556289 -.00000299 00000+0 00000+0 0 9996
|
||||
2 40367 5.3089 74.4184 0001565 198.1345 195.9683 1.00263067 40698
|
||||
METEOSAT-11 (MSG-4)
|
||||
1 40732U 15034A 26037.62779616 .00000065 00000+0 00000+0 0 9990
|
||||
2 40732 2.8728 71.8294 0001180 241.7344 58.8290 1.00268087 5909
|
||||
ELEKTRO-L 2
|
||||
1 41105U 15074A 26037.40900929 -.00000118 00000+0 00000+0 0 9998
|
||||
2 41105 6.3653 72.1489 0003612 229.0998 328.0297 1.00272232 37198
|
||||
INSAT-3DR
|
||||
1 41752U 16054A 26037.65505200 -.00000075 00000+0 00000+0 0 9997
|
||||
2 41752 0.0554 93.8053 0013744 184.8269 167.9427 1.00271627 34504
|
||||
HIMAWARI-9
|
||||
1 41836U 16064A 26037.58238259 -.00000273 00000+0 00000+0 0 9990
|
||||
2 41836 0.0124 137.0088 0001068 210.1850 139.9064 1.00271322 33905
|
||||
GOES 16
|
||||
1 41866U 16071A 26037.60517604 -.00000089 00000+0 00000+0 0 9993
|
||||
2 41866 0.1490 94.1417 0002832 199.6896 316.0413 1.00271854 33798
|
||||
FENGYUN 4A
|
||||
1 41882U 16077A 26037.65041625 -.00000356 00000+0 00000+0 0 9994
|
||||
2 41882 1.9907 81.7886 0006284 132.9819 279.8453 1.00276098 33627
|
||||
CYGFM05
|
||||
1 41884U 16078A 26037.42561482 .00027408 00000+0 46309-3 0 9992
|
||||
2 41884 34.9596 42.6579 0007295 332.2973 27.7361 15.50585086508404
|
||||
CYGFM04
|
||||
1 41885U 16078B 26037.34428483 .00032519 00000+0 49575-3 0 9994
|
||||
2 41885 34.9348 16.2836 0005718 359.2189 0.8525 15.53424088508589
|
||||
CYGFM02
|
||||
1 41886U 16078C 26037.35007768 .00035591 00000+0 50564-3 0 9998
|
||||
2 41886 34.9436 13.7490 0006836 2.8379 357.2383 15.55324468508720
|
||||
CYGFM01
|
||||
1 41887U 16078D 26037.39685921 .00028560 00000+0 47572-3 0 9999
|
||||
2 41887 34.9425 44.8029 0007415 323.1915 36.8298 15.50976884508344
|
||||
CYGFM08
|
||||
1 41888U 16078E 26037.34185185 .00031327 00000+0 49606-3 0 9997
|
||||
2 41888 34.9457 27.4597 0008083 350.5361 9.5208 15.52364941508578
|
||||
CYGFM07
|
||||
1 41890U 16078G 26037.32199955 .00032204 00000+0 49829-3 0 9990
|
||||
2 41890 34.9475 16.2411 0005914 7.0804 353.0002 15.53017084508593
|
||||
CYGFM03
|
||||
1 41891U 16078H 26037.35550653 .00031487 00000+0 48940-3 0 9995
|
||||
2 41891 34.9430 17.9804 0005939 349.1458 10.9136 15.52895386508574
|
||||
FENGYUN 3D
|
||||
1 43010U 17072A 26037.62659924 .00000092 00000+0 65298-4 0 9990
|
||||
2 43010 98.9980 9.7978 0002479 69.6779 290.4663 14.19704535426460
|
||||
NOAA 20 (JPSS-1)
|
||||
1 43013U 17073A 26037.60336371 .00000124 00000+0 79520-4 0 9999
|
||||
2 43013 98.7658 338.3064 0000377 14.6433 345.4754 14.19527655425942
|
||||
GOES 17
|
||||
1 43226U 18022A 26037.60794939 -.00000180 00000+0 00000+0 0 9993
|
||||
2 43226 0.6016 88.1527 0002754 213.0089 324.8756 1.00269924 29115
|
||||
FENGYUN 2H
|
||||
1 43491U 18050A 26037.66161282 -.00000125 00000+0 00000+0 0 9992
|
||||
2 43491 2.6948 80.6967 0002145 171.8276 201.3055 1.00274855 28134
|
||||
METOP-C
|
||||
1 43689U 18087A 26037.63948662 .00000167 00000+0 96262-4 0 9998
|
||||
2 43689 98.6834 99.5280 0001629 143.8933 216.2355 14.21510040376280
|
||||
GEO-KOMPSAT-2A
|
||||
1 43823U 18100A 26037.57995591 .00000000 00000+0 00000+0 0 9996
|
||||
2 43823 0.0152 95.1913 0001141 313.4173 65.1318 1.00271011 26327
|
||||
METEOR-M2 2
|
||||
1 44387U 19038A 26037.58492015 .00000244 00000+0 12531-3 0 9993
|
||||
2 44387 98.9044 23.0180 0002141 55.2566 304.8814 14.24320728342700
|
||||
ARKTIKA-M 1
|
||||
1 47719U 21016A 26035.90384421 -.00000136 00000+0 00000+0 0 9994
|
||||
2 47719 63.1930 76.4940 7230705 269.3476 15.2984 2.00623094 36131
|
||||
FENGYUN 3E
|
||||
1 49008U 21062A 26037.62586080 .00000245 00000+0 13631-3 0 9992
|
||||
2 49008 98.7499 42.4910 0002627 96.2819 263.8657 14.19890127238058
|
||||
GOES 18
|
||||
1 51850U 22021A 26037.59876267 .00000098 00000+0 00000+0 0 9999
|
||||
2 51850 0.0198 91.3546 0000843 290.2366 193.6737 1.00273310 5288
|
||||
NOAA 21 (JPSS-2)
|
||||
1 54234U 22150A 26037.56792604 .00000152 00000+0 92800-4 0 9995
|
||||
2 54234 98.7521 338.1972 0001388 169.8161 190.3044 14.19543641168012
|
||||
METEOSAT-12 (MTG-I1)
|
||||
1 54743U 22170C 26037.62580281 -.00000006 00000+0 00000+0 0 9990
|
||||
2 54743 0.7119 25.1556 0002027 273.4388 63.0828 1.00270670 11667
|
||||
TIANMU-1 03
|
||||
1 55973U 23039A 26037.63298084 .00025307 00000+0 57478-3 0 9994
|
||||
2 55973 97.5143 206.9374 0002852 198.5193 161.5950 15.43014921160671
|
||||
TIANMU-1 04
|
||||
1 55974U 23039B 26037.59957323 .00027172 00000+0 60888-3 0 9999
|
||||
2 55974 97.5075 206.0729 0003605 196.0743 164.0390 15.43399931160675
|
||||
TIANMU-1 05
|
||||
1 55975U 23039C 26037.60840428 .00024975 00000+0 56836-3 0 9995
|
||||
2 55975 97.5122 206.5750 0002421 224.3240 135.7814 15.42959696160653
|
||||
TIANMU-1 06
|
||||
1 55976U 23039D 26037.60004198 .00024821 00000+0 55598-3 0 9996
|
||||
2 55976 97.5133 207.0788 0002810 218.0193 142.0857 15.43432906160673
|
||||
FENGYUN 3G
|
||||
1 56232U 23055A 26037.30935013 .00046475 00000+0 74423-3 0 9993
|
||||
2 56232 49.9940 300.8928 0009962 237.3703 122.6303 15.52544991159665
|
||||
METEOR-M2 3
|
||||
1 57166U 23091A 26037.62090481 .00000022 00000+0 28455-4 0 9999
|
||||
2 57166 98.6282 95.1607 0004003 174.5474 185.5750 14.24034408135931
|
||||
TIANMU-1 07
|
||||
1 57399U 23101A 26037.63242936 .00011510 00000+0 41012-3 0 9991
|
||||
2 57399 97.2786 91.2606 0002747 218.4597 141.6448 15.29074661141694
|
||||
TIANMU-1 08
|
||||
1 57400U 23101B 26037.66743594 .00011474 00000+0 41016-3 0 9996
|
||||
2 57400 97.2774 91.0783 0004440 227.8102 132.2762 15.28966110141699
|
||||
TIANMU-1 09
|
||||
1 57401U 23101C 26037.65072558 .00011360 00000+0 40433-3 0 9997
|
||||
2 57401 97.2732 90.5514 0003773 229.5297 130.5615 15.29113177141698
|
||||
TIANMU-1 10
|
||||
1 57402U 23101D 26037.61974057 .00011836 00000+0 42113-3 0 9994
|
||||
2 57402 97.2810 91.4302 0005461 233.7620 126.3116 15.29106286141685
|
||||
FENGYUN 3F
|
||||
1 57490U 23111A 26037.61228373 .00000135 00000+0 84019-4 0 9997
|
||||
2 57490 98.6988 109.9815 0001494 99.6638 260.4707 14.19912110130332
|
||||
ARKTIKA-M 2
|
||||
1 58584U 23198A 26037.15964049 .00000160 00000+0 00000+0 0 9994
|
||||
2 58584 63.2225 168.8508 6872222 267.8808 18.8364 2.00612776 15698
|
||||
TIANMU-1 11
|
||||
1 58645U 23205A 26037.58628093 .00009545 00000+0 37951-3 0 9999
|
||||
2 58645 97.3574 61.2485 0010997 103.8713 256.3749 15.25445149117601
|
||||
TIANMU-1 12
|
||||
1 58646U 23205B 26037.61705312 .00010066 00000+0 40129-3 0 9995
|
||||
2 58646 97.3561 61.0663 0009308 89.8253 270.4052 15.25355570117590
|
||||
TIANMU-1 13
|
||||
1 58647U 23205C 26037.64894829 .00010029 00000+0 39925-3 0 9992
|
||||
2 58647 97.3589 61.3229 0009456 74.8265 285.4018 15.25403883117592
|
||||
TIANMU-1 14
|
||||
1 58648U 23205D 26037.63305929 .00009719 00000+0 38718-3 0 9993
|
||||
2 58648 97.3523 60.6045 0010314 77.9995 282.2399 15.25381326117592
|
||||
TIANMU-1 19
|
||||
1 58660U 23208A 26037.58812600 .00016491 00000+0 58449-3 0 9991
|
||||
2 58660 97.4377 153.5627 0006125 66.0574 294.1307 15.29155961117352
|
||||
TIANMU-1 20
|
||||
1 58661U 23208B 26037.59661536 .00016638 00000+0 56823-3 0 9990
|
||||
2 58661 97.4315 154.0738 0008420 72.4906 287.7255 15.30347593117439
|
||||
TIANMU-1 21
|
||||
1 58662U 23208C 26037.56944589 .00017161 00000+0 55253-3 0 9998
|
||||
2 58662 97.4367 156.2063 0008160 67.8039 292.4068 15.32247056117540
|
||||
TIANMU-1 22
|
||||
1 58663U 23208D 26037.59847459 .00015396 00000+0 55169-3 0 9994
|
||||
2 58663 97.4371 153.6033 0005010 87.2275 272.9538 15.28818503117364
|
||||
TIANMU-1 15
|
||||
1 58700U 24004A 26037.63062994 .00009739 00000+0 38850-3 0 9991
|
||||
2 58700 97.4651 223.9243 0008449 88.7599 271.4607 15.25356935115862
|
||||
TIANMU-1 16
|
||||
1 58701U 24004B 26037.61474986 .00010691 00000+0 42590-3 0 9993
|
||||
2 58701 97.4590 223.2544 0006831 91.0928 269.1093 15.25387104115863
|
||||
TIANMU-1 17
|
||||
1 58702U 24004C 26037.59783649 .00011079 00000+0 44078-3 0 9994
|
||||
2 58702 97.4624 223.6760 0006020 92.0871 268.1056 15.25425175115852
|
||||
TIANMU-1 18
|
||||
1 58703U 24004D 26037.64767373 .00010786 00000+0 42976-3 0 9996
|
||||
2 58703 97.4642 223.9320 0005432 91.0134 269.1726 15.25387870115860
|
||||
INSAT-3DS
|
||||
1 58990U 24033A 26037.64159978 -.00000153 00000+0 00000+0 0 9998
|
||||
2 58990 0.0277 242.2492 0001855 99.2205 108.3003 1.00271452 45758
|
||||
METEOR-M2 4
|
||||
1 59051U 24039A 26037.62796654 .00000070 00000+0 51194-4 0 9991
|
||||
2 59051 98.6849 358.6843 0006923 178.9165 181.2029 14.22412185100701
|
||||
GOES 19
|
||||
1 60133U 24119A 26037.61098274 -.00000246 00000+0 00000+0 0 9996
|
||||
2 60133 0.0027 288.6290 0001204 74.2636 278.5881 1.00270967 5651
|
||||
FENGYUN 3H
|
||||
1 65815U 25219A 26037.60879211 .00000151 00000+0 91464-4 0 9990
|
||||
2 65815 98.6649 341.0050 0001596 86.5100 273.6260 14.19924132 18857
|
||||
@@ -32,7 +32,6 @@ def register_blueprints(app):
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .gsm_spy import gsm_spy_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -64,7 +63,6 @@ def register_blueprints(app):
|
||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(gsm_spy_bp) # GSM cellular intelligence
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
@@ -53,6 +53,7 @@ aprs_packet_count = 0
|
||||
aprs_station_count = 0
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {} # callsign -> station data
|
||||
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
||||
|
||||
# Meter rate limiting
|
||||
_last_meter_time = 0.0
|
||||
@@ -1371,6 +1372,13 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
aprs_stations,
|
||||
key=lambda k: aprs_stations[k].get('last_seen', ''),
|
||||
)
|
||||
del aprs_stations[oldest]
|
||||
|
||||
app_module.aprs_queue.put(packet)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import register_process, unregister_process
|
||||
from utils.validation import validate_frequency, validate_gain, validate_device_index
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
@@ -56,12 +57,12 @@ _DSD_PROTOCOL_FLAGS = {
|
||||
|
||||
# dsd-fme uses different flag names
|
||||
_DSD_FME_PROTOCOL_FLAGS = {
|
||||
'auto': ['-ft'],
|
||||
'dmr': ['-fs'],
|
||||
'p25': ['-f1'],
|
||||
'nxdn': ['-fi'],
|
||||
'dstar': [],
|
||||
'provoice': ['-fp'],
|
||||
'auto': [],
|
||||
'dmr': ['-fd'],
|
||||
'p25': ['-fp'],
|
||||
'nxdn': ['-fn'],
|
||||
'dstar': ['-fi'],
|
||||
'provoice': ['-fv'],
|
||||
}
|
||||
|
||||
# ============================================
|
||||
@@ -321,16 +322,13 @@ def start_dmr() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 462.5625))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||
gain = int(validate_gain(data.get('gain', 40)))
|
||||
device = validate_device_index(data.get('device', 0))
|
||||
protocol = str(data.get('protocol', 'auto')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
if frequency <= 0:
|
||||
return jsonify({'status': 'error', 'message': 'Frequency must be positive'}), 400
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
|
||||
@@ -402,6 +400,21 @@ def start_dmr() -> Response:
|
||||
if dmr_dsd_process.stderr:
|
||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
|
||||
# Terminate surviving process and unregister both
|
||||
for proc in [dmr_dsd_process, dmr_rtl_process]:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
if proc:
|
||||
unregister_process(proc)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
1730
routes/gsm_spy.py
1730
routes/gsm_spy.py
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,9 @@ ASSET_PATHS = {
|
||||
'static/vendor/leaflet/images/marker-shadow.png',
|
||||
'static/vendor/leaflet/images/layers.png',
|
||||
'static/vendor/leaflet/images/layers-2x.png'
|
||||
],
|
||||
'leaflet_heat': [
|
||||
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
138
setup.sh
138
setup.sh
@@ -243,12 +243,6 @@ check_tools() {
|
||||
check_required "hcitool" "Bluetooth scan utility" hcitool
|
||||
check_required "hciconfig" "Bluetooth adapter config" hciconfig
|
||||
|
||||
echo
|
||||
info "GSM Intelligence:"
|
||||
check_recommended "grgsm_scanner" "GSM tower scanner (gr-gsm)" grgsm_scanner
|
||||
check_recommended "grgsm_livemon" "GSM live monitor (gr-gsm)" grgsm_livemon
|
||||
check_recommended "tshark" "Packet analysis (Wireshark)" tshark
|
||||
|
||||
echo
|
||||
info "SoapySDR:"
|
||||
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
|
||||
@@ -713,47 +707,6 @@ install_macos_packages() {
|
||||
progress "Installing gpsd"
|
||||
brew_install gpsd
|
||||
|
||||
# gr-gsm for GSM Intelligence
|
||||
progress "Installing gr-gsm"
|
||||
if ! cmd_exists grgsm_scanner; then
|
||||
brew_install gnuradio
|
||||
(brew_install gr-gsm) || {
|
||||
warn "gr-gsm not available in Homebrew, building from source..."
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning gr-gsm repository..."
|
||||
git clone --depth 1 https://github.com/bkerler/gr-gsm.git "$tmp_dir/gr-gsm" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone gr-gsm. GSM Spy feature will not work."; exit 1; }
|
||||
|
||||
cd "$tmp_dir/gr-gsm"
|
||||
mkdir -p build && cd build
|
||||
info "Compiling gr-gsm (this may take several minutes)..."
|
||||
if cmake .. >/dev/null 2>&1 && make -j$(sysctl -n hw.ncpu) >/dev/null 2>&1; then
|
||||
if [[ -w /usr/local/lib ]]; then
|
||||
make install >/dev/null 2>&1
|
||||
else
|
||||
sudo make install >/dev/null 2>&1
|
||||
fi
|
||||
ok "gr-gsm installed successfully from source"
|
||||
else
|
||||
warn "Failed to build gr-gsm. GSM Spy feature will not work."
|
||||
fi
|
||||
)
|
||||
}
|
||||
else
|
||||
ok "gr-gsm already installed"
|
||||
fi
|
||||
|
||||
# Wireshark (tshark) for GSM packet analysis
|
||||
progress "Installing tshark"
|
||||
if ! cmd_exists tshark; then
|
||||
brew_install wireshark
|
||||
else
|
||||
ok "tshark already installed"
|
||||
fi
|
||||
|
||||
progress "Installing Ubertooth tools (optional)"
|
||||
if ! cmd_exists ubertooth-btle; then
|
||||
echo
|
||||
@@ -1164,82 +1117,6 @@ install_debian_packages() {
|
||||
progress "Installing gpsd"
|
||||
apt_install gpsd gpsd-clients || true
|
||||
|
||||
# gr-gsm for GSM Intelligence
|
||||
progress "Installing GNU Radio and gr-gsm"
|
||||
if ! cmd_exists grgsm_scanner; then
|
||||
# Try to install gr-gsm directly from package repositories
|
||||
apt_install gnuradio gnuradio-dev gr-osmosdr gr-gsm || {
|
||||
warn "gr-gsm package not available in repositories. Attempting source build..."
|
||||
|
||||
# Fallback: Build from source
|
||||
progress "Building gr-gsm from source"
|
||||
apt_install git cmake libboost-all-dev libcppunit-dev swig \
|
||||
doxygen liblog4cpp5-dev python3-scipy python3-numpy \
|
||||
libvolk-dev libuhd-dev libfftw3-dev || true
|
||||
|
||||
info "Cloning gr-gsm repository..."
|
||||
if [ -d /tmp/gr-gsm ]; then
|
||||
rm -rf /tmp/gr-gsm
|
||||
fi
|
||||
|
||||
git clone https://github.com/bkerler/gr-gsm.git /tmp/gr-gsm || {
|
||||
warn "Failed to clone gr-gsm repository. GSM Spy will not be available."
|
||||
return 0
|
||||
}
|
||||
|
||||
cd /tmp/gr-gsm
|
||||
mkdir -p build && cd build
|
||||
|
||||
# Try to find GNU Radio cmake files
|
||||
if [ -d /usr/lib/x86_64-linux-gnu/cmake/gnuradio ]; then
|
||||
export CMAKE_PREFIX_PATH="/usr/lib/x86_64-linux-gnu/cmake/gnuradio:$CMAKE_PREFIX_PATH"
|
||||
fi
|
||||
|
||||
info "Running CMake configuration..."
|
||||
if cmake .. 2>/dev/null; then
|
||||
info "Compiling gr-gsm (this may take several minutes)..."
|
||||
if make -j$(nproc) 2>/dev/null; then
|
||||
$SUDO make install
|
||||
$SUDO ldconfig
|
||||
cd ~
|
||||
rm -rf /tmp/gr-gsm
|
||||
ok "gr-gsm built and installed successfully"
|
||||
else
|
||||
warn "gr-gsm compilation failed. GSM Spy feature will not work."
|
||||
cd ~
|
||||
rm -rf /tmp/gr-gsm
|
||||
fi
|
||||
else
|
||||
warn "gr-gsm CMake configuration failed. GNU Radio 3.8+ may not be available."
|
||||
cd ~
|
||||
rm -rf /tmp/gr-gsm
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify installation
|
||||
if cmd_exists grgsm_scanner; then
|
||||
ok "gr-gsm installed successfully"
|
||||
else
|
||||
warn "gr-gsm installation incomplete. GSM Spy feature will not work."
|
||||
fi
|
||||
else
|
||||
ok "gr-gsm already installed"
|
||||
fi
|
||||
|
||||
# Wireshark (tshark) for GSM packet analysis
|
||||
progress "Installing tshark"
|
||||
if ! cmd_exists tshark; then
|
||||
# Pre-accept non-root capture prompt for non-interactive install
|
||||
echo 'wireshark-common wireshark-common/install-setuid boolean true' | $SUDO debconf-set-selections
|
||||
apt_install tshark || true
|
||||
# Allow non-root capture
|
||||
$SUDO dpkg-reconfigure wireshark-common 2>/dev/null || true
|
||||
$SUDO usermod -a -G wireshark $USER 2>/dev/null || true
|
||||
ok "tshark installed. You may need to re-login for wireshark group permissions."
|
||||
else
|
||||
ok "tshark already installed"
|
||||
fi
|
||||
|
||||
progress "Installing Python packages"
|
||||
apt_install python3-venv python3-pip || true
|
||||
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
||||
@@ -1327,7 +1204,7 @@ final_summary_and_hard_fail() {
|
||||
warn "Missing RECOMMENDED tools (some features will not work):"
|
||||
for t in "${missing_recommended[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
warn "Install these for full functionality (GSM Intelligence, etc.)"
|
||||
warn "Install these for full functionality"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1375,6 +1252,19 @@ main() {
|
||||
fi
|
||||
|
||||
install_python_deps
|
||||
|
||||
# Download leaflet-heat plugin (offline mode)
|
||||
if [ ! -f "static/vendor/leaflet-heat/leaflet-heat.js" ]; then
|
||||
info "Downloading leaflet-heat plugin..."
|
||||
mkdir -p static/vendor/leaflet-heat
|
||||
if curl -sL "https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" \
|
||||
-o static/vendor/leaflet-heat/leaflet-heat.js; then
|
||||
ok "leaflet-heat plugin downloaded"
|
||||
else
|
||||
warn "Failed to download leaflet-heat plugin. Heatmap will use CDN."
|
||||
fi
|
||||
fi
|
||||
|
||||
final_summary_and_hard_fail
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
@@ -115,6 +118,9 @@
|
||||
|
||||
.settings-section.active {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
|
||||
@@ -930,5 +930,56 @@ function switchSettingsTab(tabName) {
|
||||
if (typeof RecordingUI !== 'undefined') {
|
||||
RecordingUI.refresh();
|
||||
}
|
||||
} else if (tabName === 'apikeys') {
|
||||
loadApiKeyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load API key status into the API Keys settings tab
|
||||
*/
|
||||
function loadApiKeyStatus() {
|
||||
const badge = document.getElementById('apiKeyStatusBadge');
|
||||
const desc = document.getElementById('apiKeyStatusDesc');
|
||||
const usage = document.getElementById('apiKeyUsageCount');
|
||||
const bar = document.getElementById('apiKeyUsageBar');
|
||||
|
||||
if (!badge) return;
|
||||
|
||||
badge.textContent = 'Not available';
|
||||
badge.className = 'asset-badge missing';
|
||||
desc.textContent = 'GSM feature removed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API key from the settings input
|
||||
*/
|
||||
function saveApiKey() {
|
||||
const input = document.getElementById('apiKeyInput');
|
||||
const result = document.getElementById('apiKeySaveResult');
|
||||
if (!input || !result) return;
|
||||
|
||||
const key = input.value.trim();
|
||||
if (!key) {
|
||||
result.style.display = 'block';
|
||||
result.style.color = 'var(--accent-red)';
|
||||
result.textContent = 'Please enter an API key.';
|
||||
return;
|
||||
}
|
||||
|
||||
result.style.display = 'block';
|
||||
result.style.color = 'var(--text-dim)';
|
||||
result.textContent = 'Saving...';
|
||||
|
||||
result.style.color = 'var(--accent-red)';
|
||||
result.textContent = 'GSM feature has been removed.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle API key input visibility
|
||||
*/
|
||||
function toggleApiKeyVisibility() {
|
||||
const input = document.getElementById('apiKeyInput');
|
||||
if (!input) return;
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
@@ -91,6 +91,21 @@ function startDmr() {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
||||
}
|
||||
} else if (data.status === 'error' && data.message === 'Already running') {
|
||||
// Backend has an active session the frontend lost track of — resync
|
||||
isDmrRunning = true;
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('DMR', 'Reconnected to active session');
|
||||
}
|
||||
} else {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error', data.message || 'Failed to start DMR');
|
||||
@@ -496,9 +511,43 @@ function stopDmrSynthesizer() {
|
||||
|
||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
||||
|
||||
// ============== STATUS SYNC ==============
|
||||
|
||||
function checkDmrStatus() {
|
||||
fetch('/dmr/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running && !isDmrRunning) {
|
||||
// Backend is running but frontend lost track — resync
|
||||
isDmrRunning = true;
|
||||
updateDmrUI();
|
||||
connectDmrSSE();
|
||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
||||
dmrEventType = 'idle';
|
||||
dmrActivityTarget = 0.1;
|
||||
dmrLastEventTime = Date.now();
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'DECODING';
|
||||
} else if (!data.running && isDmrRunning) {
|
||||
// Backend stopped but frontend didn't know
|
||||
isDmrRunning = false;
|
||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
||||
updateDmrUI();
|
||||
dmrEventType = 'stopped';
|
||||
dmrActivityTarget = 0;
|
||||
updateDmrSynthStatus();
|
||||
const statusEl = document.getElementById('dmrStatus');
|
||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ============== EXPORTS ==============
|
||||
|
||||
window.startDmr = startDmr;
|
||||
window.stopDmr = stopDmr;
|
||||
window.checkDmrTools = checkDmrTools;
|
||||
window.checkDmrStatus = checkDmrStatus;
|
||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||
|
||||
@@ -1018,8 +1018,16 @@ function addSignalHit(data) {
|
||||
<td style="padding: 4px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)}</td>
|
||||
<td style="padding: 4px; color: ${snrColor}; font-weight: bold; font-size: 9px;">${snrText}</td>
|
||||
<td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td>
|
||||
<td style="padding: 4px; text-align: center;">
|
||||
<td style="padding: 4px; text-align: center; white-space: nowrap;">
|
||||
<button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 6px; font-size: 9px; background: var(--accent-green); border: none; color: #000; cursor: pointer; border-radius: 3px;">Listen</button>
|
||||
<span style="position:relative;display:inline-block;">
|
||||
<button class="preset-btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'block' ? 'none' : 'block'" style="padding:2px 5px; font-size:9px; background:var(--accent-cyan); border:none; color:#000; cursor:pointer; border-radius:3px; margin-left:3px;" title="Send frequency to decoder">▶</button>
|
||||
<div style="display:none; position:absolute; right:0; top:100%; background:var(--bg-primary); border:1px solid var(--border-color); border-radius:4px; z-index:100; min-width:90px; padding:2px; box-shadow:0 2px 8px rgba(0,0,0,0.4);">
|
||||
<div onclick="sendFrequencyToMode(${data.frequency}, 'pager'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Pager</div>
|
||||
<div onclick="sendFrequencyToMode(${data.frequency}, 'sensor'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">433 Sensor</div>
|
||||
<div onclick="sendFrequencyToMode(${data.frequency}, 'rtlamr'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">RTLAMR</div>
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.insertBefore(row, tbody.firstChild);
|
||||
@@ -3056,6 +3064,27 @@ function renderSignalGuess(result) {
|
||||
altsEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
const sendToEl = document.getElementById('signalGuessSendTo');
|
||||
if (sendToEl) {
|
||||
const freqInput = document.getElementById('signalGuessFreqInput');
|
||||
const freq = freqInput ? parseFloat(freqInput.value) : NaN;
|
||||
if (!isNaN(freq) && freq > 0) {
|
||||
const tags = (result.tags || []).map(t => t.toLowerCase());
|
||||
const modes = [
|
||||
{ key: 'pager', label: 'Pager', highlight: tags.some(t => t.includes('pager') || t.includes('pocsag') || t.includes('flex')) },
|
||||
{ key: 'sensor', label: '433 Sensor', highlight: tags.some(t => t.includes('ism') || t.includes('433') || t.includes('sensor') || t.includes('iot')) },
|
||||
{ key: 'rtlamr', label: 'RTLAMR', highlight: tags.some(t => t.includes('meter') || t.includes('amr') || t.includes('utility')) }
|
||||
];
|
||||
sendToEl.style.display = 'block';
|
||||
sendToEl.innerHTML = '<div style="font-size:9px; color:var(--text-muted); margin-bottom:4px;">Send to:</div><div style="display:flex; gap:4px;">' +
|
||||
modes.map(m =>
|
||||
`<button class="preset-btn" onclick="sendFrequencyToMode(${freq}, '${m.key}')" style="padding:2px 8px; font-size:9px; border:none; color:#000; cursor:pointer; border-radius:3px; background:${m.highlight ? 'var(--accent-green)' : 'var(--accent-cyan)'}; ${m.highlight ? 'font-weight:bold;' : ''}">${m.label}</button>`
|
||||
).join('') + '</div>';
|
||||
} else {
|
||||
sendToEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function manualSignalGuess() {
|
||||
@@ -4023,21 +4052,88 @@ function bindWaterfallInteraction() {
|
||||
tooltip.style.display = 'none';
|
||||
};
|
||||
|
||||
// Right-click context menu for "Send to" decoder
|
||||
let ctxMenu = document.getElementById('waterfallCtxMenu');
|
||||
if (!ctxMenu) {
|
||||
ctxMenu = document.createElement('div');
|
||||
ctxMenu.id = 'waterfallCtxMenu';
|
||||
ctxMenu.style.cssText = 'position:fixed;display:none;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:4px;z-index:10000;min-width:120px;padding:4px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-size:11px;';
|
||||
document.body.appendChild(ctxMenu);
|
||||
document.addEventListener('click', () => { ctxMenu.style.display = 'none'; });
|
||||
}
|
||||
|
||||
const contextHandler = (event) => {
|
||||
if (waterfallMode === 'audio') return;
|
||||
event.preventDefault();
|
||||
const canvas = event.currentTarget;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||
|
||||
const modes = [
|
||||
{ key: 'pager', label: 'Pager' },
|
||||
{ key: 'sensor', label: '433 Sensor' },
|
||||
{ key: 'rtlamr', label: 'RTLAMR' }
|
||||
];
|
||||
|
||||
ctxMenu.innerHTML = `<div style="padding:4px 10px; color:var(--text-muted); font-size:9px; border-bottom:1px solid var(--border-color); margin-bottom:2px;">${freq.toFixed(3)} MHz →</div>` +
|
||||
modes.map(m =>
|
||||
`<div onclick="sendFrequencyToMode(${freq}, '${m.key}')" style="padding:4px 10px; cursor:pointer; color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Send to ${m.label}</div>`
|
||||
).join('');
|
||||
|
||||
ctxMenu.style.left = event.clientX + 'px';
|
||||
ctxMenu.style.top = event.clientY + 'px';
|
||||
ctxMenu.style.display = 'block';
|
||||
};
|
||||
|
||||
if (waterfallCanvas) {
|
||||
waterfallCanvas.style.cursor = 'crosshair';
|
||||
waterfallCanvas.addEventListener('click', handler);
|
||||
waterfallCanvas.addEventListener('mousemove', hoverHandler);
|
||||
waterfallCanvas.addEventListener('mouseleave', leaveHandler);
|
||||
waterfallCanvas.addEventListener('contextmenu', contextHandler);
|
||||
}
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.style.cursor = 'crosshair';
|
||||
spectrumCanvas.addEventListener('click', handler);
|
||||
spectrumCanvas.addEventListener('mousemove', hoverHandler);
|
||||
spectrumCanvas.addEventListener('mouseleave', leaveHandler);
|
||||
spectrumCanvas.addEventListener('contextmenu', contextHandler);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============== CROSS-MODULE FREQUENCY ROUTING ==============
|
||||
|
||||
function sendFrequencyToMode(freqMhz, targetMode) {
|
||||
const inputMap = {
|
||||
pager: 'frequency',
|
||||
sensor: 'sensorFrequency',
|
||||
rtlamr: 'rtlamrFrequency'
|
||||
};
|
||||
|
||||
const inputId = inputMap[targetMode];
|
||||
if (!inputId) return;
|
||||
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode(targetMode);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.value = freqMhz.toFixed(4);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
const modeLabels = { pager: 'Pager', sensor: '433 Sensor', rtlamr: 'RTLAMR' };
|
||||
showNotification('Frequency Sent', `${freqMhz.toFixed(3)} MHz → ${modeLabels[targetMode] || targetMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.sendFrequencyToMode = sendFrequencyToMode;
|
||||
window.stopDirectListen = stopDirectListen;
|
||||
window.toggleScanner = toggleScanner;
|
||||
window.startScanner = startScanner;
|
||||
|
||||
11
static/vendor/leaflet-heat/leaflet-heat.js
vendored
Normal file
11
static/vendor/leaflet-heat/leaflet-heat.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
|
||||
https://github.com/mourner/simpleheat
|
||||
*/
|
||||
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
|
||||
(c) 2014, Vladimir Agafonkin
|
||||
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
|
||||
https://github.com/Leaflet/Leaflet.heat
|
||||
*/
|
||||
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -174,10 +174,6 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/></svg></span>
|
||||
<span class="mode-name">Vessels</span>
|
||||
</a>
|
||||
<a href="/gsm_spy/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/><path d="M8 6h8M8 10h8M8 14h8"/></svg></span>
|
||||
<span class="mode-name">GSM SPY</span>
|
||||
</a>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
|
||||
<span class="mode-icon icon"><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>
|
||||
<span class="mode-name">APRS</span>
|
||||
@@ -918,7 +914,7 @@
|
||||
</div>
|
||||
|
||||
<!-- APRS Visualizations -->
|
||||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px;">
|
||||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px; overflow: hidden; min-height: 0;">
|
||||
<!-- APRS Function Bar -->
|
||||
<div class="aprs-strip">
|
||||
<div class="aprs-strip-inner">
|
||||
@@ -993,7 +989,7 @@
|
||||
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
||||
<!-- Map Panel (larger) -->
|
||||
<div class="wifi-visual-panel"
|
||||
style="flex: 2; display: flex; flex-direction: column; min-width: 0;">
|
||||
style="flex: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;">
|
||||
<h5
|
||||
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
|
||||
APRS STATION MAP</h5>
|
||||
@@ -1012,12 +1008,12 @@
|
||||
|
||||
<!-- Station List Panel -->
|
||||
<div class="wifi-visual-panel"
|
||||
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column;">
|
||||
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||||
<h5
|
||||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px;">
|
||||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
|
||||
STATION LIST</h5>
|
||||
<div id="aprsFilterBarContainer"></div>
|
||||
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px;">
|
||||
<div id="aprsFilterBarContainer" style="flex-shrink: 0;"></div>
|
||||
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px; min-height: 0;">
|
||||
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
|
||||
No stations received yet
|
||||
</div>
|
||||
@@ -1026,7 +1022,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: Packet Log -->
|
||||
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px;">
|
||||
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px; flex-shrink: 0;">
|
||||
<h5
|
||||
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
||||
PACKET LOG</h5>
|
||||
@@ -3397,6 +3393,7 @@
|
||||
SSTVGeneral.init();
|
||||
} else if (mode === 'dmr') {
|
||||
if (typeof checkDmrTools === 'function') checkDmrTools();
|
||||
if (typeof checkDmrStatus === 'function') checkDmrStatus();
|
||||
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
|
||||
} else if (mode === 'websdr') {
|
||||
if (typeof initWebSDR === 'function') initWebSDR();
|
||||
@@ -9543,10 +9540,10 @@
|
||||
listEl.insertBefore(newCard, listEl.firstChild);
|
||||
}
|
||||
|
||||
// Keep list manageable
|
||||
const cards = listEl.querySelectorAll('.signal-card');
|
||||
while (cards.length > 50) {
|
||||
listEl.removeChild(listEl.lastChild);
|
||||
// Keep list manageable (use live childElementCount, not static NodeList)
|
||||
const MAX_APRS_STATION_CARDS = 200;
|
||||
while (listEl.childElementCount > MAX_APRS_STATION_CARDS && listEl.lastElementChild) {
|
||||
listEl.removeChild(listEl.lastElementChild);
|
||||
}
|
||||
|
||||
// Update filter counts if filter bar exists
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
||||
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
{{ mode_item('rtlamr', 'Meters', '<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>') }}
|
||||
{{ mode_item('adsb', '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>', '/adsb/dashboard') }}
|
||||
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
|
||||
{{ mode_item('gsm', 'GSM SPY', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/><path d="M8 6h8M8 10h8M8 14h8"/></svg>', '/gsm_spy/dashboard') }}
|
||||
{{ mode_item('aprs', 'APRS', '<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>') }}
|
||||
{{ mode_item('listening', 'Listening Post', '<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>') }}
|
||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>') }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from routes.dmr import parse_dsd_output
|
||||
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -98,6 +98,21 @@ def test_parse_unrecognized():
|
||||
assert result['text'] == 'some random text'
|
||||
|
||||
|
||||
def test_dsd_fme_protocol_flags_match_classic():
|
||||
"""dsd-fme flags must match classic DSD flags (same fork, same CLI)."""
|
||||
assert _DSD_FME_PROTOCOL_FLAGS == _DSD_PROTOCOL_FLAGS
|
||||
|
||||
|
||||
def test_dsd_protocol_flags_known_values():
|
||||
"""Protocol flags should map to the correct DSD -f flags."""
|
||||
assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd']
|
||||
assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp']
|
||||
assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
||||
assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi']
|
||||
assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv']
|
||||
assert _DSD_PROTOCOL_FLAGS['auto'] == []
|
||||
|
||||
|
||||
# ============================================
|
||||
# Endpoint tests
|
||||
# ============================================
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Unit tests for GSM Spy parsing and validation functions."""
|
||||
|
||||
import pytest
|
||||
from routes.gsm_spy import (
|
||||
parse_grgsm_scanner_output,
|
||||
parse_tshark_output,
|
||||
arfcn_to_frequency,
|
||||
validate_band_names,
|
||||
REGIONAL_BANDS
|
||||
)
|
||||
|
||||
|
||||
class TestParseGrgsmScannerOutput:
|
||||
"""Tests for parse_grgsm_scanner_output()."""
|
||||
|
||||
def test_valid_output_line(self):
|
||||
"""Test parsing a valid grgsm_scanner output line."""
|
||||
line = "ARFCN: 23, Freq: 940.6M, CID: 31245, LAC: 1234, MCC: 214, MNC: 01, Pwr: -48"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
|
||||
assert result is not None
|
||||
assert result['type'] == 'tower'
|
||||
assert result['arfcn'] == 23
|
||||
assert result['frequency'] == 940.6
|
||||
assert result['cid'] == 31245
|
||||
assert result['lac'] == 1234
|
||||
assert result['mcc'] == 214
|
||||
assert result['mnc'] == 1
|
||||
assert result['signal_strength'] == -48.0
|
||||
assert 'timestamp' in result
|
||||
|
||||
def test_freq_without_suffix(self):
|
||||
"""Test parsing frequency without M suffix."""
|
||||
line = "ARFCN: 975, Freq: 925.2, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is not None
|
||||
assert result['frequency'] == 925.2
|
||||
|
||||
def test_config_line(self):
|
||||
"""Test that configuration lines are skipped."""
|
||||
line = " Configuration: 1 CCCH, not combined"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_neighbour_line(self):
|
||||
"""Test that neighbour cell lines are skipped."""
|
||||
line = " Neighbour Cells: 57, 61, 70, 71, 72, 86"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_cell_arfcn_line(self):
|
||||
"""Test that cell ARFCN lines are skipped."""
|
||||
line = " Cell ARFCNs: 63, 76"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_progress_line(self):
|
||||
"""Test that progress/status lines are skipped."""
|
||||
line = "Scanning GSM900 band..."
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_empty_line(self):
|
||||
"""Test handling of empty lines."""
|
||||
result = parse_grgsm_scanner_output("")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""Test handling of non-numeric values."""
|
||||
line = "ARFCN: abc, Freq: xyz, CID: bad, LAC: data, MCC: bad, MNC: bad, Pwr: bad"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_no_identity_filtered(self):
|
||||
"""Test that MCC=0/MNC=0 entries (no network identity) are filtered out."""
|
||||
line = "ARFCN: 115, Freq: 925.0M, CID: 0, LAC: 0, MCC: 0, MNC: 0, Pwr: -100"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_mcc_zero_mnc_zero_filtered(self):
|
||||
"""Test that MCC=0/MNC=0 even with valid CID is filtered out."""
|
||||
line = "ARFCN: 113, Freq: 924.6M, CID: 1234, LAC: 5678, MCC: 0, MNC: 0, Pwr: -90"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_cid_zero_valid_mcc_passes(self):
|
||||
"""Test that CID=0 with valid MCC/MNC passes (partially decoded cell)."""
|
||||
line = "ARFCN: 115, Freq: 958.0M, CID: 0, LAC: 21864, MCC: 234, MNC: 10, Pwr: -51"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is not None
|
||||
assert result['cid'] == 0
|
||||
assert result['mcc'] == 234
|
||||
assert result['signal_strength'] == -51.0
|
||||
|
||||
def test_valid_cid_nonzero(self):
|
||||
"""Test that valid non-zero CID/MCC entries pass through."""
|
||||
line = "ARFCN: 115, Freq: 925.0M, CID: 19088, LAC: 21864, MCC: 234, MNC: 10, Pwr: -58"
|
||||
result = parse_grgsm_scanner_output(line)
|
||||
assert result is not None
|
||||
assert result['cid'] == 19088
|
||||
assert result['signal_strength'] == -58.0
|
||||
|
||||
|
||||
class TestParseTsharkOutput:
|
||||
"""Tests for parse_tshark_output()."""
|
||||
|
||||
def test_valid_full_output(self):
|
||||
"""Test parsing tshark output with all fields."""
|
||||
line = "5\t0xABCD1234\t123456789012345\t1234\t31245"
|
||||
result = parse_tshark_output(line)
|
||||
|
||||
assert result is not None
|
||||
assert result['type'] == 'device'
|
||||
assert result['ta_value'] == 5
|
||||
assert result['tmsi'] == '0xABCD1234'
|
||||
assert result['imsi'] == '123456789012345'
|
||||
assert result['lac'] == 1234
|
||||
assert result['cid'] == 31245
|
||||
assert result['distance_meters'] == 5 * 554 # TA * 554 meters
|
||||
assert 'timestamp' in result
|
||||
|
||||
def test_missing_optional_fields(self):
|
||||
"""Test parsing with missing optional fields (empty tabs)."""
|
||||
line = "3\t\t\t1234\t31245"
|
||||
result = parse_tshark_output(line)
|
||||
|
||||
assert result is not None
|
||||
assert result['ta_value'] == 3
|
||||
assert result['tmsi'] is None
|
||||
assert result['imsi'] is None
|
||||
assert result['lac'] == 1234
|
||||
assert result['cid'] == 31245
|
||||
|
||||
def test_no_ta_value(self):
|
||||
"""Test parsing without TA value (empty field)."""
|
||||
# When TA is empty, int('') will fail, so the parse returns None
|
||||
# This is the current behavior - the function expects valid integers or valid empty handling
|
||||
line = "\t0xABCD1234\t123456789012345\t1234\t31245"
|
||||
result = parse_tshark_output(line)
|
||||
# Current implementation will fail to parse this due to int('') failing
|
||||
assert result is None
|
||||
|
||||
def test_invalid_line(self):
|
||||
"""Test handling of invalid tshark output."""
|
||||
line = "invalid data"
|
||||
result = parse_tshark_output(line)
|
||||
assert result is None
|
||||
|
||||
def test_empty_line(self):
|
||||
"""Test handling of empty lines."""
|
||||
result = parse_tshark_output("")
|
||||
assert result is None
|
||||
|
||||
def test_partial_fields(self):
|
||||
"""Test with fewer than 5 fields."""
|
||||
line = "5\t0xABCD1234" # Only 2 fields
|
||||
result = parse_tshark_output(line)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestArfcnToFrequency:
|
||||
"""Tests for arfcn_to_frequency()."""
|
||||
|
||||
def test_gsm850_arfcn(self):
|
||||
"""Test ARFCN in GSM850 band."""
|
||||
# GSM850: ARFCN 128-251, 869-894 MHz
|
||||
arfcn = 128
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
assert freq == 869000000 # 869 MHz
|
||||
|
||||
arfcn = 251
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
assert freq == 893600000 # 893.6 MHz
|
||||
|
||||
def test_egsm900_arfcn(self):
|
||||
"""Test ARFCN in EGSM900 band."""
|
||||
# EGSM900: ARFCN 0-124, 925-960 MHz
|
||||
arfcn = 0
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
assert freq == 925000000 # 925 MHz
|
||||
|
||||
arfcn = 124
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
assert freq == 949800000 # 949.8 MHz
|
||||
|
||||
def test_dcs1800_arfcn(self):
|
||||
"""Test ARFCN in DCS1800 band."""
|
||||
# DCS1800: ARFCN 512-885, 1805-1880 MHz
|
||||
# Note: ARFCN 512 also exists in PCS1900 and will match that first
|
||||
# Use ARFCN 811+ which is only in DCS1800
|
||||
arfcn = 811 # Beyond PCS1900 range (512-810)
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
# 811 is ARFCN offset from 512, so freq = 1805MHz + (811-512)*200kHz
|
||||
expected = 1805000000 + (811 - 512) * 200000
|
||||
assert freq == expected
|
||||
|
||||
arfcn = 885
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
assert freq == 1879600000 # 1879.6 MHz
|
||||
|
||||
def test_pcs1900_arfcn(self):
|
||||
"""Test ARFCN in PCS1900 band."""
|
||||
# PCS1900: ARFCN 512-810, 1930-1990 MHz
|
||||
# Note: overlaps with DCS1800 ARFCN range, but different frequencies
|
||||
arfcn = 512
|
||||
freq = arfcn_to_frequency(arfcn)
|
||||
# Will match first band (DCS1800 in Europe config)
|
||||
assert freq > 0
|
||||
|
||||
def test_invalid_arfcn(self):
|
||||
"""Test ARFCN outside known ranges."""
|
||||
with pytest.raises(ValueError, match="not found in any known GSM band"):
|
||||
arfcn_to_frequency(9999)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
arfcn_to_frequency(-1)
|
||||
|
||||
def test_arfcn_200khz_spacing(self):
|
||||
"""Test that ARFCNs are 200kHz apart."""
|
||||
arfcn1 = 128
|
||||
arfcn2 = 129
|
||||
freq1 = arfcn_to_frequency(arfcn1)
|
||||
freq2 = arfcn_to_frequency(arfcn2)
|
||||
assert freq2 - freq1 == 200000 # 200 kHz
|
||||
|
||||
|
||||
class TestValidateBandNames:
|
||||
"""Tests for validate_band_names()."""
|
||||
|
||||
def test_valid_americas_bands(self):
|
||||
"""Test valid band names for Americas region."""
|
||||
bands = ['GSM850', 'PCS1900']
|
||||
result, error = validate_band_names(bands, 'Americas')
|
||||
assert result == bands
|
||||
assert error is None
|
||||
|
||||
def test_valid_europe_bands(self):
|
||||
"""Test valid band names for Europe region."""
|
||||
# Note: Europe uses EGSM900, not GSM900
|
||||
bands = ['EGSM900', 'DCS1800', 'GSM850', 'GSM800']
|
||||
result, error = validate_band_names(bands, 'Europe')
|
||||
assert result == bands
|
||||
assert error is None
|
||||
|
||||
def test_valid_asia_bands(self):
|
||||
"""Test valid band names for Asia region."""
|
||||
# Note: Asia uses EGSM900, not GSM900
|
||||
bands = ['EGSM900', 'DCS1800']
|
||||
result, error = validate_band_names(bands, 'Asia')
|
||||
assert result == bands
|
||||
assert error is None
|
||||
|
||||
def test_invalid_band_for_region(self):
|
||||
"""Test invalid band name for a region."""
|
||||
bands = ['GSM900', 'INVALID_BAND']
|
||||
result, error = validate_band_names(bands, 'Americas')
|
||||
assert result == []
|
||||
assert error is not None
|
||||
assert 'Invalid bands' in error
|
||||
assert 'INVALID_BAND' in error
|
||||
|
||||
def test_invalid_region(self):
|
||||
"""Test invalid region name."""
|
||||
bands = ['GSM900']
|
||||
result, error = validate_band_names(bands, 'InvalidRegion')
|
||||
assert result == []
|
||||
assert error is not None
|
||||
assert 'Invalid region' in error
|
||||
|
||||
def test_empty_bands_list(self):
|
||||
"""Test with empty bands list."""
|
||||
result, error = validate_band_names([], 'Americas')
|
||||
assert result == []
|
||||
assert error is None
|
||||
|
||||
def test_single_valid_band(self):
|
||||
"""Test with single valid band."""
|
||||
bands = ['GSM850']
|
||||
result, error = validate_band_names(bands, 'Americas')
|
||||
assert result == ['GSM850']
|
||||
assert error is None
|
||||
|
||||
def test_case_sensitive_band_names(self):
|
||||
"""Test that band names are case-sensitive."""
|
||||
bands = ['gsm850'] # lowercase
|
||||
result, error = validate_band_names(bands, 'Americas')
|
||||
assert result == []
|
||||
assert error is not None
|
||||
|
||||
def test_multiple_invalid_bands(self):
|
||||
"""Test with multiple invalid bands."""
|
||||
bands = ['INVALID1', 'GSM850', 'INVALID2']
|
||||
result, error = validate_band_names(bands, 'Americas')
|
||||
assert result == []
|
||||
assert error is not None
|
||||
assert 'INVALID1' in error
|
||||
assert 'INVALID2' in error
|
||||
|
||||
|
||||
class TestRegionalBandsConfig:
|
||||
"""Tests for REGIONAL_BANDS configuration."""
|
||||
|
||||
def test_all_regions_defined(self):
|
||||
"""Test that all expected regions are defined."""
|
||||
assert 'Americas' in REGIONAL_BANDS
|
||||
assert 'Europe' in REGIONAL_BANDS
|
||||
assert 'Asia' in REGIONAL_BANDS
|
||||
|
||||
def test_all_bands_have_required_fields(self):
|
||||
"""Test that all bands have required configuration fields."""
|
||||
for region, bands in REGIONAL_BANDS.items():
|
||||
for band_name, band_config in bands.items():
|
||||
assert 'start' in band_config
|
||||
assert 'end' in band_config
|
||||
assert 'arfcn_start' in band_config
|
||||
assert 'arfcn_end' in band_config
|
||||
|
||||
def test_frequency_ranges_valid(self):
|
||||
"""Test that frequency ranges are positive and start < end."""
|
||||
for region, bands in REGIONAL_BANDS.items():
|
||||
for band_name, band_config in bands.items():
|
||||
assert band_config['start'] > 0
|
||||
assert band_config['end'] > 0
|
||||
assert band_config['start'] < band_config['end']
|
||||
|
||||
def test_arfcn_ranges_valid(self):
|
||||
"""Test that ARFCN ranges are valid."""
|
||||
for region, bands in REGIONAL_BANDS.items():
|
||||
for band_name, band_config in bands.items():
|
||||
assert band_config['arfcn_start'] >= 0
|
||||
assert band_config['arfcn_end'] >= 0
|
||||
assert band_config['arfcn_start'] <= band_config['arfcn_end']
|
||||
@@ -275,13 +275,3 @@ MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
|
||||
# Deauth detector sniff timeout (seconds)
|
||||
DEAUTH_SNIFF_TIMEOUT = 0.5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GSM SPY (Cellular Intelligence)
|
||||
# =============================================================================
|
||||
|
||||
# Maximum age for GSM tower/device data in DataStore (seconds)
|
||||
MAX_GSM_AGE_SECONDS = 300 # 5 minutes
|
||||
|
||||
# Timing Advance conversion to meters
|
||||
GSM_TA_METERS_PER_UNIT = 554
|
||||
|
||||
@@ -453,134 +453,6 @@ def init_db() -> None:
|
||||
ON tscm_cases(status, created_at)
|
||||
''')
|
||||
|
||||
# =====================================================================
|
||||
# GSM (Global System for Mobile) Intelligence Tables
|
||||
# =====================================================================
|
||||
|
||||
# gsm_cells - Known cell towers (OpenCellID cache)
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS gsm_cells (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mcc INTEGER NOT NULL,
|
||||
mnc INTEGER NOT NULL,
|
||||
lac INTEGER NOT NULL,
|
||||
cid INTEGER NOT NULL,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
azimuth INTEGER,
|
||||
range_meters INTEGER,
|
||||
samples INTEGER,
|
||||
radio TEXT,
|
||||
operator TEXT,
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
UNIQUE(mcc, mnc, lac, cid)
|
||||
)
|
||||
''')
|
||||
|
||||
# gsm_rogues - Detected rogue towers / IMSI catchers
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS gsm_rogues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
arfcn INTEGER NOT NULL,
|
||||
mcc INTEGER,
|
||||
mnc INTEGER,
|
||||
lac INTEGER,
|
||||
cid INTEGER,
|
||||
signal_strength REAL,
|
||||
reason TEXT NOT NULL,
|
||||
threat_level TEXT DEFAULT 'medium',
|
||||
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
location_lat REAL,
|
||||
location_lon REAL,
|
||||
acknowledged BOOLEAN DEFAULT 0,
|
||||
notes TEXT,
|
||||
metadata TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# gsm_signals - 60-day archive of signal observations
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS gsm_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imsi TEXT,
|
||||
tmsi TEXT,
|
||||
mcc INTEGER,
|
||||
mnc INTEGER,
|
||||
lac INTEGER,
|
||||
cid INTEGER,
|
||||
ta_value INTEGER,
|
||||
signal_strength REAL,
|
||||
arfcn INTEGER,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# gsm_tmsi_log - 24-hour raw pings for crowd density
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS gsm_tmsi_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tmsi TEXT NOT NULL,
|
||||
lac INTEGER,
|
||||
cid INTEGER,
|
||||
ta_value INTEGER,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# gsm_velocity_log - 1-hour buffer for movement tracking
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS gsm_velocity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
prev_ta INTEGER,
|
||||
curr_ta INTEGER,
|
||||
prev_cid INTEGER,
|
||||
curr_cid INTEGER,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
estimated_velocity REAL,
|
||||
metadata TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# GSM indexes for performance
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_cells_location
|
||||
ON gsm_cells(lat, lon)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_cells_identity
|
||||
ON gsm_cells(mcc, mnc, lac, cid)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_rogues_severity
|
||||
ON gsm_rogues(threat_level, detected_at)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_signals_cell_time
|
||||
ON gsm_signals(cid, lac, timestamp)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_signals_device
|
||||
ON gsm_signals(imsi, tmsi, timestamp)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_tmsi_log_time
|
||||
ON gsm_tmsi_log(timestamp)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_gsm_velocity_log_device
|
||||
ON gsm_velocity_log(device_id, timestamp)
|
||||
''')
|
||||
|
||||
# =====================================================================
|
||||
# DSC (Digital Selective Calling) Tables
|
||||
# =====================================================================
|
||||
@@ -2298,60 +2170,3 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
|
||||
''', (f'-{max_age_hours} hours',))
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GSM Cleanup Functions
|
||||
# =============================================================================
|
||||
|
||||
def cleanup_old_gsm_signals(max_age_days: int = 60) -> int:
|
||||
"""
|
||||
Remove old GSM signal observations (60-day archive).
|
||||
|
||||
Args:
|
||||
max_age_days: Maximum age in days (default: 60)
|
||||
|
||||
Returns:
|
||||
Number of deleted entries
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
DELETE FROM gsm_signals
|
||||
WHERE timestamp < datetime('now', ?)
|
||||
''', (f'-{max_age_days} days',))
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def cleanup_old_gsm_tmsi_log(max_age_hours: int = 24) -> int:
|
||||
"""
|
||||
Remove old TMSI log entries (24-hour buffer for crowd density).
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours (default: 24)
|
||||
|
||||
Returns:
|
||||
Number of deleted entries
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
DELETE FROM gsm_tmsi_log
|
||||
WHERE timestamp < datetime('now', ?)
|
||||
''', (f'-{max_age_hours} hours',))
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def cleanup_old_gsm_velocity_log(max_age_hours: int = 1) -> int:
|
||||
"""
|
||||
Remove old velocity log entries (1-hour buffer for movement tracking).
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age in hours (default: 1)
|
||||
|
||||
Returns:
|
||||
Number of deleted entries
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
DELETE FROM gsm_velocity_log
|
||||
WHERE timestamp < datetime('now', ?)
|
||||
''', (f'-{max_age_hours} hours',))
|
||||
return cursor.rowcount
|
||||
|
||||
@@ -444,38 +444,6 @@ TOOL_DEPENDENCIES = {
|
||||
}
|
||||
}
|
||||
},
|
||||
'gsm': {
|
||||
'name': 'GSM Intelligence',
|
||||
'tools': {
|
||||
'grgsm_scanner': {
|
||||
'required': True,
|
||||
'description': 'gr-gsm scanner for finding GSM towers',
|
||||
'install': {
|
||||
'apt': 'Build gr-gsm from source: https://github.com/bkerler/gr-gsm',
|
||||
'brew': 'brew install gr-gsm (may require manual build)',
|
||||
'manual': 'https://github.com/bkerler/gr-gsm'
|
||||
}
|
||||
},
|
||||
'grgsm_livemon': {
|
||||
'required': True,
|
||||
'description': 'gr-gsm live monitor for decoding GSM signals',
|
||||
'install': {
|
||||
'apt': 'Included with gr-gsm package',
|
||||
'brew': 'Included with gr-gsm',
|
||||
'manual': 'Included with gr-gsm'
|
||||
}
|
||||
},
|
||||
'tshark': {
|
||||
'required': True,
|
||||
'description': 'Wireshark CLI for parsing GSM packets',
|
||||
'install': {
|
||||
'apt': 'sudo apt-get install tshark',
|
||||
'brew': 'brew install wireshark',
|
||||
'manual': 'https://www.wireshark.org/download.html'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
"""GSM Cell Tower Geocoding Service.
|
||||
|
||||
Provides hybrid cache-first geocoding with async API fallback for cell towers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
from utils.database import get_db
|
||||
|
||||
logger = logging.getLogger('intercept.gsm_geocoding')
|
||||
|
||||
# Queue for pending geocoding requests
|
||||
_geocoding_queue = queue.Queue(maxsize=100)
|
||||
|
||||
|
||||
def lookup_cell_coordinates(mcc: int, mnc: int, lac: int, cid: int) -> dict[str, Any] | None:
|
||||
"""
|
||||
Lookup cell tower coordinates with cache-first strategy.
|
||||
|
||||
Strategy:
|
||||
1. Check gsm_cells table (cache) - fast synchronous lookup
|
||||
2. If not found, return None (caller decides whether to use API)
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
lac: Location Area Code
|
||||
cid: Cell ID
|
||||
|
||||
Returns:
|
||||
dict with keys: lat, lon, source='cache', azimuth (optional),
|
||||
range_meters (optional), operator (optional), radio (optional)
|
||||
Returns None if not found in cache.
|
||||
"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
result = conn.execute('''
|
||||
SELECT lat, lon, azimuth, range_meters, operator, radio
|
||||
FROM gsm_cells
|
||||
WHERE mcc = ? AND mnc = ? AND lac = ? AND cid = ?
|
||||
''', (mcc, mnc, lac, cid)).fetchone()
|
||||
|
||||
if result:
|
||||
return {
|
||||
'lat': result['lat'],
|
||||
'lon': result['lon'],
|
||||
'source': 'cache',
|
||||
'azimuth': result['azimuth'],
|
||||
'range_meters': result['range_meters'],
|
||||
'operator': result['operator'],
|
||||
'radio': result['radio']
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up coordinates from cache: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def lookup_cell_from_api(mcc: int, mnc: int, lac: int, cid: int) -> dict[str, Any] | None:
|
||||
"""
|
||||
Lookup cell tower from OpenCellID API and cache result.
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
lac: Location Area Code
|
||||
cid: Cell ID
|
||||
|
||||
Returns:
|
||||
dict with keys: lat, lon, source='api', azimuth (optional),
|
||||
range_meters (optional), operator (optional), radio (optional)
|
||||
Returns None if API call fails or cell not found.
|
||||
"""
|
||||
try:
|
||||
api_url = config.GSM_OPENCELLID_API_URL
|
||||
params = {
|
||||
'key': config.GSM_OPENCELLID_API_KEY,
|
||||
'mcc': mcc,
|
||||
'mnc': mnc,
|
||||
'lac': lac,
|
||||
'cellid': cid,
|
||||
'format': 'json'
|
||||
}
|
||||
|
||||
response = requests.get(api_url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
cell_data = response.json()
|
||||
|
||||
# Cache the result
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT OR REPLACE INTO gsm_cells
|
||||
(mcc, mnc, lac, cid, lat, lon, azimuth, range_meters, samples, radio, operator, last_verified)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
''', (
|
||||
mcc, mnc, lac, cid,
|
||||
cell_data.get('lat'),
|
||||
cell_data.get('lon'),
|
||||
cell_data.get('azimuth'),
|
||||
cell_data.get('range'),
|
||||
cell_data.get('samples'),
|
||||
cell_data.get('radio'),
|
||||
cell_data.get('operator')
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Cached cell tower from API: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
|
||||
return {
|
||||
'lat': cell_data.get('lat'),
|
||||
'lon': cell_data.get('lon'),
|
||||
'source': 'api',
|
||||
'azimuth': cell_data.get('azimuth'),
|
||||
'range_meters': cell_data.get('range'),
|
||||
'operator': cell_data.get('operator'),
|
||||
'radio': cell_data.get('radio')
|
||||
}
|
||||
else:
|
||||
logger.warning(f"OpenCellID API returned {response.status_code} for MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling OpenCellID API: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def enrich_tower_data(tower_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Enrich tower data with coordinates using cache-first strategy.
|
||||
|
||||
If coordinates found in cache, adds them immediately.
|
||||
If not found, marks as 'pending' and queues for background API lookup.
|
||||
|
||||
Args:
|
||||
tower_data: Dictionary with keys mcc, mnc, lac, cid (and other tower data)
|
||||
|
||||
Returns:
|
||||
Enriched tower_data dict with added fields:
|
||||
- lat, lon (if found in cache)
|
||||
- status='pending' (if needs API lookup)
|
||||
- source='cache' (if from cache)
|
||||
"""
|
||||
mcc = tower_data.get('mcc')
|
||||
mnc = tower_data.get('mnc')
|
||||
lac = tower_data.get('lac')
|
||||
cid = tower_data.get('cid')
|
||||
|
||||
# Validate required fields
|
||||
if not all([mcc is not None, mnc is not None, lac is not None, cid is not None]):
|
||||
logger.warning(f"Tower data missing required fields: {tower_data}")
|
||||
return tower_data
|
||||
|
||||
# Try cache lookup
|
||||
coords = lookup_cell_coordinates(mcc, mnc, lac, cid)
|
||||
|
||||
if coords:
|
||||
# Found in cache - add coordinates immediately
|
||||
tower_data['lat'] = coords['lat']
|
||||
tower_data['lon'] = coords['lon']
|
||||
tower_data['source'] = 'cache'
|
||||
|
||||
# Add optional fields if available
|
||||
if coords.get('azimuth') is not None:
|
||||
tower_data['azimuth'] = coords['azimuth']
|
||||
if coords.get('range_meters') is not None:
|
||||
tower_data['range_meters'] = coords['range_meters']
|
||||
if coords.get('operator'):
|
||||
tower_data['operator'] = coords['operator']
|
||||
if coords.get('radio'):
|
||||
tower_data['radio'] = coords['radio']
|
||||
|
||||
logger.debug(f"Cache hit for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
else:
|
||||
# Not in cache - mark as pending and queue for API lookup
|
||||
tower_data['status'] = 'pending'
|
||||
tower_data['source'] = 'unknown'
|
||||
|
||||
# Queue for background geocoding (non-blocking)
|
||||
try:
|
||||
_geocoding_queue.put_nowait(tower_data.copy())
|
||||
logger.debug(f"Queued tower for geocoding: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}")
|
||||
except queue.Full:
|
||||
logger.warning("Geocoding queue full, dropping tower")
|
||||
|
||||
return tower_data
|
||||
|
||||
|
||||
def get_geocoding_queue() -> queue.Queue:
|
||||
"""Get the geocoding queue for the background worker."""
|
||||
return _geocoding_queue
|
||||
@@ -28,4 +28,3 @@ wifi_logger = get_logger('intercept.wifi')
|
||||
bluetooth_logger = get_logger('intercept.bluetooth')
|
||||
adsb_logger = get_logger('intercept.adsb')
|
||||
satellite_logger = get_logger('intercept.satellite')
|
||||
gsm_spy_logger = get_logger('intercept.gsm_spy')
|
||||
|
||||
@@ -85,11 +85,13 @@ atexit.register(cleanup_all_processes)
|
||||
|
||||
# Handle signals for graceful shutdown
|
||||
def _signal_handler(signum, frame):
|
||||
"""Handle termination signals."""
|
||||
"""Handle termination signals.
|
||||
|
||||
Keep this minimal — logging and lock acquisition in signal handlers
|
||||
can deadlock when another thread holds the logging or process lock.
|
||||
Process cleanup is handled by the atexit handler registered above.
|
||||
"""
|
||||
import sys
|
||||
logger.info(f"Received signal {signum}, cleaning up...")
|
||||
cleanup_all_processes()
|
||||
# Re-raise KeyboardInterrupt for SIGINT so Flask can handle shutdown
|
||||
if signum == signal.SIGINT:
|
||||
raise KeyboardInterrupt()
|
||||
sys.exit(0)
|
||||
|
||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
from .detection import detect_all_devices
|
||||
from .detection import detect_all_devices, probe_rtlsdr_device
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
@@ -229,4 +229,6 @@ __all__ = [
|
||||
'validate_device_index',
|
||||
'validate_squelch',
|
||||
'get_capabilities_for_type',
|
||||
# Device probing
|
||||
'probe_rtlsdr_device',
|
||||
]
|
||||
|
||||
@@ -348,6 +348,68 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
return devices
|
||||
|
||||
|
||||
def probe_rtlsdr_device(device_index: int) -> str | None:
|
||||
"""Probe whether an RTL-SDR device is available at the USB level.
|
||||
|
||||
Runs a quick ``rtl_test`` invocation targeting a single device to
|
||||
check for USB claim errors that indicate the device is held by an
|
||||
external process (or a stale handle from a previous crash).
|
||||
|
||||
Args:
|
||||
device_index: The RTL-SDR device index to probe.
|
||||
|
||||
Returns:
|
||||
An error message string if the device cannot be opened,
|
||||
or ``None`` if the device is available.
|
||||
"""
|
||||
if not _check_tool('rtl_test'):
|
||||
# Can't probe without rtl_test — let the caller proceed and
|
||||
# surface errors from the actual decoder process instead.
|
||||
return None
|
||||
|
||||
try:
|
||||
import os
|
||||
import platform
|
||||
env = os.environ.copy()
|
||||
|
||||
if platform.system() == 'Darwin':
|
||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||
env['DYLD_LIBRARY_PATH'] = ':'.join(
|
||||
lib_paths + [current_ld] if current_ld else lib_paths
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-d', str(device_index), '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
env=env,
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
if 'usb_claim_interface' in output or 'Failed to open' in output:
|
||||
logger.warning(
|
||||
f"RTL-SDR device {device_index} USB probe failed: "
|
||||
f"device busy or unavailable"
|
||||
)
|
||||
return (
|
||||
f'SDR device {device_index} is busy at the USB level — '
|
||||
f'another process outside INTERCEPT may be using it. '
|
||||
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
|
||||
f'or try a different device.'
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# rtl_test opened the device successfully and is running the
|
||||
# test — that means the device *is* available.
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_all_devices() -> list[SDRDevice]:
|
||||
"""
|
||||
Detect all connected SDR devices across all supported hardware types.
|
||||
|
||||
Reference in New Issue
Block a user