diff --git a/Dockerfile b/Dockerfile index 85be1df..5e1a7fa 100644 --- a/Dockerfile +++ b/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 \ diff --git a/app.py b/app.py index 000c061..8d4cbc4 100644 --- a/app.py +++ b/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 diff --git a/config.py b/config.py index 80b0a2d..67e7d3e 100644 --- a/config.py +++ b/config.py @@ -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.""" diff --git a/gp.php b/gp.php new file mode 100644 index 0000000..c901b45 --- /dev/null +++ b/gp.php @@ -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 diff --git a/routes/__init__.py b/routes/__init__.py index f6a81ce..0cb7e1a 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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 diff --git a/routes/aprs.py b/routes/aprs.py index fbbced7..cfd346a 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -21,8 +21,8 @@ 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.event_pipeline import process_event +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, SSE_KEEPALIVE_INTERVAL, @@ -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) @@ -1726,13 +1734,13 @@ def stream_aprs() -> Response: while True: try: - msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) - last_keepalive = time.time() - try: - process_event('aprs', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) + msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + try: + process_event('aprs', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: diff --git a/routes/dmr.py b/routes/dmr.py index d993614..e8d91fa 100644 --- a/routes/dmr.py +++ b/routes/dmr.py @@ -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 diff --git a/routes/gsm_spy.py b/routes/gsm_spy.py deleted file mode 100644 index b1b5607..0000000 --- a/routes/gsm_spy.py +++ /dev/null @@ -1,1730 +0,0 @@ -"""GSM Spy route handlers for cellular tower and device tracking.""" - -from __future__ import annotations - -import json -import logging -import os -import queue -import re -import shutil -import subprocess -import threading -import time -from datetime import datetime, timedelta -from typing import Any - -import requests -from flask import Blueprint, Response, jsonify, render_template, request - -import app as app_module -import config -from config import SHARED_OBSERVER_LOCATION_ENABLED -from utils.database import get_db -from utils.process import register_process, safe_terminate, unregister_process -from utils.sse import format_sse -from utils.validation import validate_device_index - -from utils.logging import get_logger -logger = get_logger('intercept.gsm_spy') -logger.setLevel(logging.DEBUG) # GSM Spy needs verbose logging for diagnostics - -gsm_spy_bp = Blueprint('gsm_spy', __name__, url_prefix='/gsm_spy') - -# Regional band configurations (G-01) -REGIONAL_BANDS = { - 'Americas': { - 'GSM850': {'start': 869e6, 'end': 894e6, 'arfcn_start': 128, 'arfcn_end': 251}, - 'PCS1900': {'start': 1930e6, 'end': 1990e6, 'arfcn_start': 512, 'arfcn_end': 810} - }, - 'Europe': { - 'GSM800': {'start': 832e6, 'end': 862e6, 'arfcn_start': 438, 'arfcn_end': 511}, # E-GSM800 downlink - 'GSM850': {'start': 869e6, 'end': 894e6, 'arfcn_start': 128, 'arfcn_end': 251}, # Also used in some EU countries - 'EGSM900': {'start': 925e6, 'end': 960e6, 'arfcn_start': 0, 'arfcn_end': 124}, - 'DCS1800': {'start': 1805e6, 'end': 1880e6, 'arfcn_start': 512, 'arfcn_end': 885} - }, - 'Asia': { - 'EGSM900': {'start': 925e6, 'end': 960e6, 'arfcn_start': 0, 'arfcn_end': 124}, - 'DCS1800': {'start': 1805e6, 'end': 1880e6, 'arfcn_start': 512, 'arfcn_end': 885} - } -} - -# Module state tracking -gsm_using_service = False -gsm_connected = False -gsm_towers_found = 0 -gsm_devices_tracked = 0 - -# Geocoding worker state -_geocoding_worker_thread = None - - -# ============================================ -# API Usage Tracking Helper Functions -# ============================================ - -def get_api_usage_today(): - """Get OpenCellID API usage count for today.""" - from utils.database import get_setting - today = datetime.now().date().isoformat() - usage_date = get_setting('gsm.opencellid.usage_date', '') - - # Reset counter if new day - if usage_date != today: - from utils.database import set_setting - set_setting('gsm.opencellid.usage_date', today) - set_setting('gsm.opencellid.usage_count', 0) - return 0 - - return get_setting('gsm.opencellid.usage_count', 0) - - -def increment_api_usage(): - """Increment OpenCellID API usage counter.""" - from utils.database import set_setting - current = get_api_usage_today() - set_setting('gsm.opencellid.usage_count', current + 1) - return current + 1 - - -def can_use_api(): - """Check if we can make an API call within daily limit.""" - current_usage = get_api_usage_today() - return current_usage < config.GSM_API_DAILY_LIMIT - - -# ============================================ -# Background Geocoding Worker -# ============================================ - -def start_geocoding_worker(): - """Start background thread for async geocoding.""" - global _geocoding_worker_thread - if _geocoding_worker_thread is None or not _geocoding_worker_thread.is_alive(): - _geocoding_worker_thread = threading.Thread( - target=geocoding_worker, - daemon=True, - name='gsm-geocoding-worker' - ) - _geocoding_worker_thread.start() - logger.info("Started geocoding worker thread") - - -def geocoding_worker(): - """Worker thread processes pending geocoding requests.""" - from utils.gsm_geocoding import lookup_cell_from_api, get_geocoding_queue - - geocoding_queue = get_geocoding_queue() - - while True: - try: - # Wait for pending tower with timeout - tower_data = geocoding_queue.get(timeout=5) - - # Check rate limit - if not can_use_api(): - current_usage = get_api_usage_today() - logger.warning(f"OpenCellID API rate limit reached ({current_usage}/{config.GSM_API_DAILY_LIMIT})") - geocoding_queue.task_done() - continue - - # Call API - mcc = tower_data.get('mcc') - mnc = tower_data.get('mnc') - lac = tower_data.get('lac') - cid = tower_data.get('cid') - - logger.debug(f"Geocoding tower via API: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}") - - coords = lookup_cell_from_api(mcc, mnc, lac, cid) - - if coords: - # Update tower data with coordinates - tower_data['lat'] = coords['lat'] - tower_data['lon'] = coords['lon'] - tower_data['source'] = 'api' - tower_data['status'] = 'resolved' - tower_data['type'] = 'tower_update' - - # 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'] - - # Update DataStore - key = f"{mcc}_{mnc}_{lac}_{cid}" - app_module.gsm_spy_towers[key] = tower_data - - # Send update to SSE stream - try: - app_module.gsm_spy_queue.put_nowait(tower_data) - logger.info(f"Resolved coordinates for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}") - except queue.Full: - logger.warning("SSE queue full, dropping tower update") - - # Increment API usage counter - usage_count = increment_api_usage() - logger.info(f"OpenCellID API call #{usage_count} today") - - else: - logger.warning(f"Could not resolve coordinates for tower: MCC={mcc} MNC={mnc} LAC={lac} CID={cid}") - - geocoding_queue.task_done() - - # Rate limiting between API calls (be nice to OpenCellID) - time.sleep(1) - - except queue.Empty: - # No pending towers, continue waiting - continue - except Exception as e: - logger.error(f"Geocoding worker error: {e}", exc_info=True) - time.sleep(1) - - -def arfcn_to_frequency(arfcn): - """Convert ARFCN to downlink frequency in Hz. - - Uses REGIONAL_BANDS to determine the correct band and conversion formula. - Returns frequency in Hz (e.g., 925800000 for 925.8 MHz). - """ - arfcn = int(arfcn) - - # Search all bands to find which one this ARFCN belongs to - for region_bands in REGIONAL_BANDS.values(): - for band_name, band_info in region_bands.items(): - arfcn_start = band_info['arfcn_start'] - arfcn_end = band_info['arfcn_end'] - - if arfcn_start <= arfcn <= arfcn_end: - # Found the right band, calculate frequency - # Downlink frequency = band_start + (arfcn - arfcn_start) * 200kHz - freq_hz = band_info['start'] + (arfcn - arfcn_start) * 200000 - return int(freq_hz) - - # If ARFCN not found in any band, raise error - raise ValueError(f"ARFCN {arfcn} not found in any known GSM band") - - -def validate_band_names(bands: list[str], region: str) -> tuple[list[str], str | None]: - """Validate band names against REGIONAL_BANDS whitelist. - - Args: - bands: List of band names from user input - region: Region name (Americas, Europe, Asia) - - Returns: - Tuple of (validated_bands, error_message) - """ - if not bands: - return [], None - - region_bands = REGIONAL_BANDS.get(region) - if not region_bands: - return [], f"Invalid region: {region}" - - valid_band_names = set(region_bands.keys()) - invalid_bands = [b for b in bands if b not in valid_band_names] - - if invalid_bands: - return [], (f"Invalid bands for {region}: {', '.join(invalid_bands)}. " - f"Valid bands: {', '.join(sorted(valid_band_names))}") - - return bands, None - - -def _start_monitoring_processes(arfcn: int, device_index: int) -> tuple[subprocess.Popen, subprocess.Popen]: - """Start grgsm_livemon and tshark processes for monitoring an ARFCN. - - Returns: - Tuple of (grgsm_process, tshark_process) - - Raises: - FileNotFoundError: If grgsm_livemon or tshark not found - RuntimeError: If grgsm_livemon exits immediately - """ - frequency_hz = arfcn_to_frequency(arfcn) - frequency_mhz = frequency_hz / 1e6 - - # Check prerequisites - if not shutil.which('grgsm_livemon'): - raise FileNotFoundError('grgsm_livemon not found. Please install gr-gsm.') - - # Start grgsm_livemon - grgsm_cmd = [ - 'grgsm_livemon', - '--args', f'rtl={device_index}', - '-f', f'{frequency_mhz}M' - ] - env = dict(os.environ, - OSMO_FSM_DUP_CHECK_DISABLED='1', - PYTHONUNBUFFERED='1', - QT_QPA_PLATFORM='offscreen') - logger.info(f"Starting grgsm_livemon: {' '.join(grgsm_cmd)}") - grgsm_proc = subprocess.Popen( - grgsm_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - env=env - ) - register_process(grgsm_proc) - logger.info(f"Started grgsm_livemon (PID: {grgsm_proc.pid})") - - # Wait and check it didn't die immediately - time.sleep(2) - - if grgsm_proc.poll() is not None: - # Process already exited - capture stderr for diagnostics - stderr_output = '' - try: - stderr_output = grgsm_proc.stderr.read() - except Exception: - pass - exit_code = grgsm_proc.returncode - logger.error( - f"grgsm_livemon exited immediately (code: {exit_code}). " - f"stderr: {stderr_output[:500]}" - ) - unregister_process(grgsm_proc) - raise RuntimeError( - f'grgsm_livemon failed (exit code {exit_code}): {stderr_output[:200]}' - ) - - # Start stderr reader thread for grgsm_livemon diagnostics - def read_livemon_stderr(): - try: - for line in iter(grgsm_proc.stderr.readline, ''): - if line: - logger.debug(f"grgsm_livemon stderr: {line.strip()}") - except Exception: - pass - threading.Thread(target=read_livemon_stderr, daemon=True).start() - - # Start tshark - if not shutil.which('tshark'): - safe_terminate(grgsm_proc) - unregister_process(grgsm_proc) - raise FileNotFoundError('tshark not found. Please install wireshark/tshark.') - - tshark_cmd = [ - 'tshark', '-i', 'lo', - '-Y', 'gsm_a.rr.timing_advance || gsm_a.tmsi || gsm_a.imsi', - '-T', 'fields', - '-e', 'gsm_a.rr.timing_advance', - '-e', 'gsm_a.tmsi', - '-e', 'gsm_a.imsi', - '-e', 'gsm_a.lac', - '-e', 'gsm_a.cellid' - ] - logger.info(f"Starting tshark: {' '.join(tshark_cmd)}") - tshark_proc = subprocess.Popen( - tshark_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - bufsize=1 - ) - register_process(tshark_proc) - logger.info(f"Started tshark (PID: {tshark_proc.pid})") - - return grgsm_proc, tshark_proc - - -def _start_and_register_monitor(arfcn: int, device_index: int) -> None: - """Start monitoring processes and register them in global state. - - This is shared logic between start_monitor() and auto_start_monitor(). - Must be called within gsm_spy_lock context. - - Args: - arfcn: ARFCN to monitor - device_index: SDR device index - """ - # Start monitoring processes - grgsm_proc, tshark_proc = _start_monitoring_processes(arfcn, device_index) - app_module.gsm_spy_livemon_process = grgsm_proc - app_module.gsm_spy_monitor_process = tshark_proc - app_module.gsm_spy_selected_arfcn = arfcn - - # Start monitoring thread - monitor_thread_obj = threading.Thread( - target=monitor_thread, - args=(tshark_proc,), - daemon=True - ) - monitor_thread_obj.start() - - -@gsm_spy_bp.route('/dashboard') -def dashboard(): - """Render GSM Spy dashboard.""" - return render_template( - 'gsm_spy_dashboard.html', - shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED - ) - - -@gsm_spy_bp.route('/start', methods=['POST']) -def start_scanner(): - """Start GSM scanner (G-01 BTS Scanner).""" - global gsm_towers_found, gsm_connected - - with app_module.gsm_spy_lock: - if app_module.gsm_spy_scanner_running: - return jsonify({'error': 'Scanner already running'}), 400 - - data = request.get_json() or {} - device_index = data.get('device', 0) - region = data.get('region', 'Americas') - selected_bands = data.get('bands', []) # Get user-selected bands - - # Validate device index - try: - device_index = validate_device_index(device_index) - except ValueError as e: - return jsonify({'error': str(e)}), 400 - - # Claim SDR device to prevent conflicts - from app import claim_sdr_device - claim_error = claim_sdr_device(device_index, 'GSM Spy') - if claim_error: - return jsonify({ - 'error': claim_error, - 'error_type': 'DEVICE_BUSY' - }), 409 - - # If no bands selected, use all bands for the region (backwards compatibility) - if selected_bands: - validated_bands, error = validate_band_names(selected_bands, region) - if error: - from app import release_sdr_device - release_sdr_device(device_index) - return jsonify({'error': error}), 400 - selected_bands = validated_bands - else: - region_bands = REGIONAL_BANDS.get(region, REGIONAL_BANDS['Americas']) - selected_bands = list(region_bands.keys()) - logger.warning(f"No bands specified, using all bands for {region}: {selected_bands}") - - # Build grgsm_scanner command - # Example: grgsm_scanner --args="rtl=0" -b GSM900 - if not shutil.which('grgsm_scanner'): - from app import release_sdr_device - release_sdr_device(device_index) - return jsonify({'error': 'grgsm_scanner not found. Please install gr-gsm.'}), 500 - - try: - cmd = ['grgsm_scanner'] - - # Add device argument (--args for RTL-SDR device selection) - cmd.extend(['--args', f'rtl={device_index}']) - - # Add selected band arguments - # Map EGSM900 to GSM900 since that's what grgsm_scanner expects - for band_name in selected_bands: - # Normalize band name (EGSM900 -> GSM900, remove EGSM prefix) - normalized_band = band_name.replace('EGSM', 'GSM') - cmd.extend(['-b', normalized_band]) - - logger.info(f"Starting GSM scanner: {' '.join(cmd)}") - - # Set a flag to indicate scanner should run - app_module.gsm_spy_active_device = device_index - app_module.gsm_spy_region = region - app_module.gsm_spy_scanner_running = True # Use as flag initially - - # Reset counters for new session - gsm_towers_found = 0 - gsm_devices_tracked = 0 - - # Start geocoding worker (if not already running) - start_geocoding_worker() - - # Start scanning thread (will run grgsm_scanner in a loop) - scanner_thread_obj = threading.Thread( - target=scanner_thread, - args=(cmd, device_index), - daemon=True - ) - scanner_thread_obj.start() - - gsm_connected = True - - return jsonify({ - 'status': 'started', - 'device': device_index, - 'region': region - }) - - except FileNotFoundError: - from app import release_sdr_device - release_sdr_device(device_index) - return jsonify({'error': 'grgsm_scanner not found. Please install gr-gsm.'}), 500 - except Exception as e: - from app import release_sdr_device - release_sdr_device(device_index) - logger.error(f"Error starting GSM scanner: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/monitor', methods=['POST']) -def start_monitor(): - """Start monitoring specific tower (G-02 Decoding).""" - with app_module.gsm_spy_lock: - if app_module.gsm_spy_monitor_process: - return jsonify({'error': 'Monitor already running'}), 400 - - data = request.get_json() or {} - arfcn = data.get('arfcn') - device_index = data.get('device', app_module.gsm_spy_active_device or 0) - - if not arfcn: - return jsonify({'error': 'ARFCN required'}), 400 - - # Validate ARFCN is valid integer and in known GSM band ranges - try: - arfcn = int(arfcn) - # This will raise ValueError if ARFCN is not in any known band - arfcn_to_frequency(arfcn) - except (ValueError, TypeError) as e: - return jsonify({'error': f'Invalid ARFCN: {e}'}), 400 - - # Validate device index - try: - device_index = validate_device_index(device_index) - except ValueError as e: - return jsonify({'error': str(e)}), 400 - - try: - # Start and register monitoring (shared logic) - _start_and_register_monitor(arfcn, device_index) - - return jsonify({ - 'status': 'monitoring', - 'arfcn': arfcn, - 'device': device_index - }) - - except FileNotFoundError as e: - return jsonify({'error': f'Tool not found: {e}'}), 500 - except Exception as e: - logger.error(f"Error starting monitor: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/stop', methods=['POST']) -def stop_scanner(): - """Stop GSM scanner and monitor.""" - global gsm_connected, gsm_towers_found, gsm_devices_tracked - - with app_module.gsm_spy_lock: - killed = [] - - # Stop scanner (now just a flag, thread will see it and exit) - if app_module.gsm_spy_scanner_running: - app_module.gsm_spy_scanner_running = False - killed.append('scanner') - - # Terminate livemon process - if app_module.gsm_spy_livemon_process: - unregister_process(app_module.gsm_spy_livemon_process) - if safe_terminate(app_module.gsm_spy_livemon_process, timeout=5): - killed.append('livemon') - app_module.gsm_spy_livemon_process = None - - # Terminate monitor process - if app_module.gsm_spy_monitor_process: - unregister_process(app_module.gsm_spy_monitor_process) - if safe_terminate(app_module.gsm_spy_monitor_process, timeout=5): - killed.append('monitor') - app_module.gsm_spy_monitor_process = None - - # Release SDR device from registry - if app_module.gsm_spy_active_device is not None: - from app import release_sdr_device - release_sdr_device(app_module.gsm_spy_active_device) - app_module.gsm_spy_active_device = None - app_module.gsm_spy_selected_arfcn = None - gsm_connected = False - gsm_towers_found = 0 - gsm_devices_tracked = 0 - - return jsonify({'status': 'stopped', 'killed': killed}) - - -@gsm_spy_bp.route('/stream') -def stream(): - """SSE stream for real-time GSM updates.""" - def generate(): - """Generate SSE events.""" - logger.info("SSE stream connected - client subscribed") - - # Send current state on connect (handles reconnects and late-joining clients) - existing_towers = dict(app_module.gsm_spy_towers.items()) - logger.info(f"SSE sending {len(existing_towers)} existing towers on connect") - for key, tower_data in existing_towers.items(): - yield format_sse(tower_data) - - last_keepalive = time.time() - idle_count = 0 # Track consecutive idle checks to handle transitions - - while True: - try: - # Check if scanner/monitor are still running - # Use idle counter to avoid disconnecting during scanner→monitor transition - if not app_module.gsm_spy_scanner_running and not app_module.gsm_spy_monitor_process: - idle_count += 1 - if idle_count >= 5: # 5 seconds grace period for mode transitions - logger.info("SSE stream: no active scanner or monitor, disconnecting") - yield format_sse({'type': 'disconnected'}) - break - else: - idle_count = 0 - - # Try to get data from queue - try: - data = app_module.gsm_spy_queue.get(timeout=1) - logger.info(f"SSE sending: type={data.get('type', '?')} keys={list(data.keys())}") - yield format_sse(data) - last_keepalive = time.time() - except queue.Empty: - # Send keepalive if needed - if time.time() - last_keepalive > 30: - yield format_sse({'type': 'keepalive'}) - last_keepalive = time.time() - - except GeneratorExit: - logger.info("SSE stream: client disconnected (GeneratorExit)") - break - except Exception as e: - logger.error(f"Error in GSM stream: {e}") - yield format_sse({'type': 'error', 'message': str(e)}) - break - - 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 - - -@gsm_spy_bp.route('/status') -def status(): - """Get current GSM Spy status.""" - api_usage = get_api_usage_today() - return jsonify({ - 'running': bool(app_module.gsm_spy_scanner_running), - 'monitoring': app_module.gsm_spy_monitor_process is not None, - 'towers_found': gsm_towers_found, - 'devices_tracked': gsm_devices_tracked, - 'device': app_module.gsm_spy_active_device, - 'region': app_module.gsm_spy_region, - 'selected_arfcn': app_module.gsm_spy_selected_arfcn, - 'api_usage_today': api_usage, - 'api_limit': config.GSM_API_DAILY_LIMIT, - 'api_remaining': config.GSM_API_DAILY_LIMIT - api_usage - }) - - -@gsm_spy_bp.route('/lookup_cell', methods=['POST']) -def lookup_cell(): - """Lookup cell tower via OpenCellID (G-05).""" - data = request.get_json() or {} - mcc = data.get('mcc') - mnc = data.get('mnc') - lac = data.get('lac') - cid = data.get('cid') - - if not all([mcc, mnc, lac, cid]): - return jsonify({'error': 'MCC, MNC, LAC, and CID required'}), 400 - - try: - # Check local cache first - 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 jsonify({ - 'source': 'cache', - 'lat': result['lat'], - 'lon': result['lon'], - 'azimuth': result['azimuth'], - 'range': result['range_meters'], - 'operator': result['operator'], - 'radio': result['radio'] - }) - - # Check API usage limit - if not can_use_api(): - current_usage = get_api_usage_today() - return jsonify({ - 'error': 'OpenCellID API daily limit reached', - 'usage_today': current_usage, - 'limit': config.GSM_API_DAILY_LIMIT - }), 429 - - # Call OpenCellID API - 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() - - # Increment API usage counter - usage_count = increment_api_usage() - logger.info(f"OpenCellID API call #{usage_count} today") - - # Cache the result - 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() - - return jsonify({ - 'source': 'api', - 'lat': cell_data.get('lat'), - 'lon': cell_data.get('lon'), - 'azimuth': cell_data.get('azimuth'), - 'range': cell_data.get('range'), - 'operator': cell_data.get('operator'), - 'radio': cell_data.get('radio') - }) - else: - return jsonify({'error': 'Cell not found in OpenCellID'}), 404 - - except Exception as e: - logger.error(f"Error looking up cell: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/detect_rogue', methods=['POST']) -def detect_rogue(): - """Analyze and flag rogue towers (G-07).""" - data = request.get_json() or {} - tower_info = data.get('tower') - - if not tower_info: - return jsonify({'error': 'Tower info required'}), 400 - - try: - is_rogue = False - reasons = [] - - # Check if tower exists in OpenCellID - mcc = tower_info.get('mcc') - mnc = tower_info.get('mnc') - lac = tower_info.get('lac') - cid = tower_info.get('cid') - - if all([mcc, mnc, lac, cid]): - with get_db() as conn: - result = conn.execute(''' - SELECT id FROM gsm_cells - WHERE mcc = ? AND mnc = ? AND lac = ? AND cid = ? - ''', (mcc, mnc, lac, cid)).fetchone() - - if not result: - is_rogue = True - reasons.append('Tower not found in OpenCellID database') - - # Check signal strength anomalies - signal = tower_info.get('signal_strength', 0) - if signal > -50: # Suspiciously strong signal - is_rogue = True - reasons.append(f'Unusually strong signal: {signal} dBm') - - # If rogue, insert into database - if is_rogue: - with get_db() as conn: - conn.execute(''' - INSERT INTO gsm_rogues - (arfcn, mcc, mnc, lac, cid, signal_strength, reason, threat_level) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - tower_info.get('arfcn'), - mcc, mnc, lac, cid, - signal, - '; '.join(reasons), - 'high' if len(reasons) > 1 else 'medium' - )) - conn.commit() - - return jsonify({ - 'is_rogue': is_rogue, - 'reasons': reasons - }) - - except Exception as e: - logger.error(f"Error detecting rogue: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/towers') -def get_towers(): - """Get all detected towers.""" - towers = [] - for key, tower_data in app_module.gsm_spy_towers.items(): - towers.append(tower_data) - return jsonify(towers) - - -@gsm_spy_bp.route('/devices') -def get_devices(): - """Get all tracked devices (IMSI/TMSI).""" - devices = [] - for key, device_data in app_module.gsm_spy_devices.items(): - devices.append(device_data) - return jsonify(devices) - - -@gsm_spy_bp.route('/rogues') -def get_rogues(): - """Get all detected rogue towers.""" - try: - with get_db() as conn: - results = conn.execute(''' - SELECT * FROM gsm_rogues - WHERE acknowledged = 0 - ORDER BY detected_at DESC - LIMIT 50 - ''').fetchall() - - rogues = [dict(row) for row in results] - return jsonify(rogues) - except Exception as e: - logger.error(f"Error fetching rogues: {e}") - return jsonify({'error': str(e)}), 500 - - -# ============================================ -# Advanced Features (G-08 through G-12) -# ============================================ - -@gsm_spy_bp.route('/velocity', methods=['GET']) -def get_velocity_data(): - """Get velocity vectoring data for tracked devices (G-08).""" - try: - device_id = request.args.get('device_id') - minutes = int(request.args.get('minutes', 60)) # Last 60 minutes by default - - with get_db() as conn: - # Get velocity log entries - query = ''' - SELECT * FROM gsm_velocity_log - WHERE timestamp >= datetime('now', '-' || ? || ' minutes') - ''' - params = [minutes] - - if device_id: - query += ' AND device_id = ?' - params.append(device_id) - - query += ' ORDER BY timestamp DESC LIMIT 100' - - results = conn.execute(query, params).fetchall() - velocity_data = [dict(row) for row in results] - - return jsonify(velocity_data) - except Exception as e: - logger.error(f"Error fetching velocity data: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/velocity/calculate', methods=['POST']) -def calculate_velocity(): - """Calculate velocity for a device based on TA transitions (G-08).""" - data = request.get_json() or {} - device_id = data.get('device_id') - - if not device_id: - return jsonify({'error': 'device_id required'}), 400 - - try: - with get_db() as conn: - # Get last two TA readings for this device - results = conn.execute(''' - SELECT ta_value, cid, timestamp - FROM gsm_signals - WHERE (imsi = ? OR tmsi = ?) - ORDER BY timestamp DESC - LIMIT 2 - ''', (device_id, device_id)).fetchall() - - if len(results) < 2: - return jsonify({'velocity': 0, 'message': 'Insufficient data'}) - - curr = dict(results[0]) - prev = dict(results[1]) - - # Calculate distance change (TA * 554 meters) - curr_distance = curr['ta_value'] * config.GSM_TA_METERS_PER_UNIT - prev_distance = prev['ta_value'] * config.GSM_TA_METERS_PER_UNIT - distance_change = abs(curr_distance - prev_distance) - - # Calculate time difference - curr_time = datetime.fromisoformat(curr['timestamp']) - prev_time = datetime.fromisoformat(prev['timestamp']) - time_diff_seconds = (curr_time - prev_time).total_seconds() - - # Calculate velocity (m/s) - if time_diff_seconds > 0: - velocity = distance_change / time_diff_seconds - else: - velocity = 0 - - # Store in velocity log - conn.execute(''' - INSERT INTO gsm_velocity_log - (device_id, prev_ta, curr_ta, prev_cid, curr_cid, estimated_velocity) - VALUES (?, ?, ?, ?, ?, ?) - ''', (device_id, prev['ta_value'], curr['ta_value'], - prev['cid'], curr['cid'], velocity)) - conn.commit() - - return jsonify({ - 'device_id': device_id, - 'velocity_mps': round(velocity, 2), - 'velocity_kmh': round(velocity * 3.6, 2), - 'distance_change_m': round(distance_change, 2), - 'time_diff_s': round(time_diff_seconds, 2) - }) - - except Exception as e: - logger.error(f"Error calculating velocity: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/crowd_density', methods=['GET']) -def get_crowd_density(): - """Get crowd density data by sector (G-09).""" - try: - hours = int(request.args.get('hours', 1)) # Last 1 hour by default - cid = request.args.get('cid') # Optional: specific cell - - with get_db() as conn: - # Count unique TMSI per cell in time window - query = ''' - SELECT - cid, - lac, - COUNT(DISTINCT tmsi) as unique_devices, - COUNT(*) as total_pings, - MIN(timestamp) as first_seen, - MAX(timestamp) as last_seen - FROM gsm_tmsi_log - WHERE timestamp >= datetime('now', '-' || ? || ' hours') - ''' - params = [hours] - - if cid: - query += ' AND cid = ?' - params.append(cid) - - query += ' GROUP BY cid, lac ORDER BY unique_devices DESC' - - results = conn.execute(query, params).fetchall() - density_data = [] - - for row in results: - density_data.append({ - 'cid': row['cid'], - 'lac': row['lac'], - 'unique_devices': row['unique_devices'], - 'total_pings': row['total_pings'], - 'first_seen': row['first_seen'], - 'last_seen': row['last_seen'], - 'density_level': 'high' if row['unique_devices'] > 20 else - 'medium' if row['unique_devices'] > 10 else 'low' - }) - - return jsonify(density_data) - - except Exception as e: - logger.error(f"Error fetching crowd density: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/life_patterns', methods=['GET']) -def get_life_patterns(): - """Get life pattern analysis for a device (G-10).""" - try: - device_id = request.args.get('device_id') - if not device_id: - # Return empty results gracefully when no device selected - return jsonify({ - 'device_id': None, - 'patterns': [], - 'message': 'No device selected' - }), 200 - - with get_db() as conn: - # Get historical signal data - results = conn.execute(''' - SELECT - strftime('%H', timestamp) as hour, - strftime('%w', timestamp) as day_of_week, - cid, - lac, - COUNT(*) as occurrences - FROM gsm_signals - WHERE (imsi = ? OR tmsi = ?) - AND timestamp >= datetime('now', '-60 days') - GROUP BY hour, day_of_week, cid, lac - ORDER BY occurrences DESC - ''', (device_id, device_id)).fetchall() - - patterns = [] - for row in results: - patterns.append({ - 'hour': int(row['hour']), - 'day_of_week': int(row['day_of_week']), - 'cid': row['cid'], - 'lac': row['lac'], - 'occurrences': row['occurrences'], - 'day_name': ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][int(row['day_of_week'])] - }) - - # Identify regular patterns - regular_locations = [] - for pattern in patterns[:5]: # Top 5 most frequent - if pattern['occurrences'] >= 3: # Seen at least 3 times - regular_locations.append({ - 'cid': pattern['cid'], - 'typical_time': f"{pattern['day_name']} {pattern['hour']:02d}:00", - 'frequency': pattern['occurrences'] - }) - - return jsonify({ - 'device_id': device_id, - 'patterns': patterns, - 'regular_locations': regular_locations, - 'total_observations': sum(p['occurrences'] for p in patterns) - }) - - except Exception as e: - logger.error(f"Error analyzing life patterns: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/neighbor_audit', methods=['GET']) -def neighbor_audit(): - """Audit neighbor cell lists for consistency (G-11).""" - try: - cid = request.args.get('cid') - if not cid: - # Return empty results gracefully when no tower selected - return jsonify({ - 'cid': None, - 'neighbors': [], - 'inconsistencies': [], - 'message': 'No tower selected' - }), 200 - - with get_db() as conn: - # Get tower info with metadata (neighbor list stored in metadata JSON) - result = conn.execute(''' - SELECT metadata FROM gsm_cells WHERE cid = ? - ''', (cid,)).fetchone() - - if not result or not result['metadata']: - return jsonify({ - 'cid': cid, - 'status': 'no_data', - 'message': 'No neighbor list data available' - }) - - # Parse metadata JSON - metadata = json.loads(result['metadata']) - neighbor_list = metadata.get('neighbors', []) - - # Audit consistency - issues = [] - for neighbor_cid in neighbor_list: - # Check if neighbor exists in database - neighbor_exists = conn.execute(''' - SELECT id FROM gsm_cells WHERE cid = ? - ''', (neighbor_cid,)).fetchone() - - if not neighbor_exists: - issues.append({ - 'type': 'missing_neighbor', - 'cid': neighbor_cid, - 'message': f'Neighbor CID {neighbor_cid} not found in database' - }) - - return jsonify({ - 'cid': cid, - 'neighbor_count': len(neighbor_list), - 'neighbors': neighbor_list, - 'issues': issues, - 'status': 'suspicious' if issues else 'normal' - }) - - except Exception as e: - logger.error(f"Error auditing neighbors: {e}") - return jsonify({'error': str(e)}), 500 - - -@gsm_spy_bp.route('/traffic_correlation', methods=['GET']) -def traffic_correlation(): - """Correlate uplink/downlink traffic for pairing analysis (G-12).""" - try: - cid = request.args.get('cid') - minutes = int(request.args.get('minutes', 5)) - - with get_db() as conn: - # Get recent signal activity for this cell - results = conn.execute(''' - SELECT - imsi, - tmsi, - ta_value, - timestamp, - metadata - FROM gsm_signals - WHERE cid = ? - AND timestamp >= datetime('now', '-' || ? || ' minutes') - ORDER BY timestamp DESC - ''', (cid, minutes)).fetchall() - - correlations = [] - seen_devices = set() - - for row in results: - device_id = row['imsi'] or row['tmsi'] - if device_id and device_id not in seen_devices: - seen_devices.add(device_id) - - # Simple correlation: count bursts - burst_count = conn.execute(''' - SELECT COUNT(*) as bursts - FROM gsm_signals - WHERE (imsi = ? OR tmsi = ?) - AND cid = ? - AND timestamp >= datetime('now', '-' || ? || ' minutes') - ''', (device_id, device_id, cid, minutes)).fetchone() - - correlations.append({ - 'device_id': device_id, - 'burst_count': burst_count['bursts'], - 'last_seen': row['timestamp'], - 'ta_value': row['ta_value'], - 'activity_level': 'high' if burst_count['bursts'] > 10 else - 'medium' if burst_count['bursts'] > 5 else 'low' - }) - - return jsonify({ - 'cid': cid, - 'time_window_minutes': minutes, - 'active_devices': len(correlations), - 'correlations': correlations - }) - - except Exception as e: - logger.error(f"Error correlating traffic: {e}") - return jsonify({'error': str(e)}), 500 - - -# ============================================ -# Helper Functions -# ============================================ - -def parse_grgsm_scanner_output(line: str) -> dict[str, Any] | None: - """Parse grgsm_scanner output line. - - Actual output format (comma-separated key-value pairs): - ARFCN: 975, Freq: 925.2M, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58 - """ - try: - line = line.strip() - - # Skip non-data lines (progress, config, neighbour info, blank) - if not line or 'ARFCN:' not in line: - return None - - # Parse "ARFCN: 975, Freq: 925.2M, CID: 13522, LAC: 38722, MCC: 262, MNC: 1, Pwr: -58" - fields = {} - for part in line.split(','): - part = part.strip() - if ':' in part: - key, _, value = part.partition(':') - fields[key.strip()] = value.strip() - - if 'ARFCN' in fields and 'CID' in fields: - cid = int(fields.get('CID', 0)) - mcc = int(fields.get('MCC', 0)) - mnc = int(fields.get('MNC', 0)) - - # Only skip entries with no network identity at all (MCC=0 AND MNC=0) - # CID=0 with valid MCC/MNC is a partially decoded cell - still useful - if mcc == 0 and mnc == 0: - logger.debug(f"Skipping unidentified ARFCN (MCC=0, MNC=0): {line}") - return None - - # Freq may have 'M' suffix (e.g. "925.2M") - freq_str = fields.get('Freq', '0').rstrip('Mm') - - data = { - 'type': 'tower', - 'arfcn': int(fields['ARFCN']), - 'frequency': float(freq_str), - 'cid': cid, - 'lac': int(fields.get('LAC', 0)), - 'mcc': mcc, - 'mnc': mnc, - 'signal_strength': float(fields.get('Pwr', -999)), - 'timestamp': datetime.now().isoformat() - } - return data - - except Exception as e: - logger.debug(f"Failed to parse scanner line: {line} - {e}") - - return None - - -def parse_tshark_output(line: str) -> dict[str, Any] | None: - """Parse tshark filtered GSM output.""" - try: - # tshark output format: ta_value\ttmsi\timsi\tlac\tcid - parts = line.strip().split('\t') - - if len(parts) >= 5: - data = { - 'type': 'device', - 'ta_value': int(parts[0]) if parts[0] else None, - 'tmsi': parts[1] if parts[1] else None, - 'imsi': parts[2] if parts[2] else None, - 'lac': int(parts[3]) if parts[3] else None, - 'cid': int(parts[4]) if parts[4] else None, - 'timestamp': datetime.now().isoformat() - } - - # Calculate distance from TA - if data['ta_value'] is not None: - data['distance_meters'] = data['ta_value'] * config.GSM_TA_METERS_PER_UNIT - - return data - - except Exception as e: - logger.debug(f"Failed to parse tshark line: {line} - {e}") - - return None - - -def auto_start_monitor(tower_data): - """Automatically start monitoring the strongest tower found.""" - try: - arfcn = tower_data.get('arfcn') - if not arfcn: - logger.warning("Cannot auto-monitor: no ARFCN in tower data") - return - - logger.info(f"Auto-monitoring strongest tower: ARFCN {arfcn}, Signal {tower_data.get('signal_strength')} dBm") - - # Brief delay to ensure scanner has stabilized - time.sleep(2) - - with app_module.gsm_spy_lock: - if app_module.gsm_spy_monitor_process: - logger.info("Monitor already running, skipping auto-start") - return - - device_index = app_module.gsm_spy_active_device or 0 - - # Start and register monitoring (shared logic) - _start_and_register_monitor(arfcn, device_index) - - # Send SSE notification - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'auto_monitor_started', - 'arfcn': arfcn, - 'tower': tower_data - }) - except queue.Full: - pass - - logger.info(f"Auto-monitoring started for ARFCN {arfcn}") - - except Exception as e: - logger.error(f"Error in auto-monitoring: {e}") - - -def scanner_thread(cmd, device_index): - """Thread to continuously run grgsm_scanner in a loop with non-blocking I/O. - - grgsm_scanner scans once and exits, so we loop it to provide - continuous updates to the dashboard. - """ - global gsm_towers_found - - strongest_tower = None - auto_monitor_triggered = False # Moved outside loop - persists across scans - scan_count = 0 - crash_count = 0 - process = None - - try: - while app_module.gsm_spy_scanner_running: # Flag check - scan_count += 1 - logger.info(f"Starting GSM scan #{scan_count}") - - try: - # Start scanner process - # Set OSMO_FSM_DUP_CHECK_DISABLED to prevent libosmocore - # abort on duplicate FSM registration (common with apt gr-gsm) - env = dict(os.environ, - OSMO_FSM_DUP_CHECK_DISABLED='1', - PYTHONUNBUFFERED='1', - QT_QPA_PLATFORM='offscreen') - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - bufsize=1, - env=env - ) - register_process(process) - logger.info(f"Started grgsm_scanner (PID: {process.pid})") - - # Standard pattern: reader threads with queue - output_queue_local = queue.Queue() - - def read_stdout(): - try: - for line in iter(process.stdout.readline, ''): - if line: - output_queue_local.put(('stdout', line)) - except Exception as e: - logger.error(f"stdout read error: {e}") - finally: - output_queue_local.put(('eof', None)) - - def read_stderr(): - try: - for line in iter(process.stderr.readline, ''): - if line: - logger.debug(f"grgsm_scanner stderr: {line.strip()}") - # grgsm_scanner outputs scan results to stderr - output_queue_local.put(('stderr', line)) - except Exception as e: - logger.error(f"stderr read error: {e}") - - stdout_thread = threading.Thread(target=read_stdout, daemon=True) - stderr_thread = threading.Thread(target=read_stderr, daemon=True) - stdout_thread.start() - stderr_thread.start() - - # Process output with timeout - scan_start = time.time() - last_output = scan_start - scan_timeout = 300 # 5 minute maximum per scan (4 bands takes ~2-3 min) - - while app_module.gsm_spy_scanner_running: - # Check if process died - if process.poll() is not None: - logger.info(f"Scanner exited (code: {process.returncode})") - break - - # Get output from queue with timeout - try: - msg_type, line = output_queue_local.get(timeout=1.0) - - if msg_type == 'eof': - break # EOF - - last_output = time.time() - stripped = line.strip() - logger.info(f"Scanner [{msg_type}]: {stripped}") - - # Forward progress and status info to frontend - progress_match = re.match(r'Scanning:\s+([\d.]+)%\s+done', stripped) - if progress_match: - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'progress', - 'percent': float(progress_match.group(1)), - 'scan': scan_count - }) - except queue.Full: - pass - continue - if stripped.startswith('Try scan CCCH'): - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'status', - 'message': stripped, - 'scan': scan_count - }) - except queue.Full: - pass - - parsed = parse_grgsm_scanner_output(line) - if parsed: - # Enrich with coordinates - from utils.gsm_geocoding import enrich_tower_data - enriched = enrich_tower_data(parsed) - - # Store in DataStore - key = f"{enriched['mcc']}_{enriched['mnc']}_{enriched['lac']}_{enriched['cid']}" - app_module.gsm_spy_towers[key] = enriched - - # Track strongest tower - signal = enriched.get('signal_strength', -999) - if strongest_tower is None or signal > strongest_tower.get('signal_strength', -999): - strongest_tower = enriched - - # Queue for SSE - try: - app_module.gsm_spy_queue.put_nowait(enriched) - except queue.Full: - logger.warning("Queue full, dropping tower update") - - # Thread-safe counter update - with app_module.gsm_spy_lock: - gsm_towers_found += 1 - except queue.Empty: - # No output, check timeout - if time.time() - last_output > scan_timeout: - logger.warning(f"Scan timeout after {scan_timeout}s") - break - - # Drain remaining queue items after process exits - while not output_queue_local.empty(): - try: - msg_type, line = output_queue_local.get_nowait() - if line: - logger.info(f"Scanner [{msg_type}] (drain): {line.strip()}") - except queue.Empty: - break - - # Clean up process with timeout - if process.poll() is None: - logger.info("Terminating scanner process") - safe_terminate(process, timeout=5) - else: - process.wait() # Reap zombie - - exit_code = process.returncode - scan_duration = time.time() - scan_start - logger.info(f"Scan #{scan_count} complete (exit code: {exit_code}, duration: {scan_duration:.1f}s)") - - # Notify frontend scan completed - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'scan_complete', - 'scan': scan_count, - 'duration': round(scan_duration, 1), - 'towers_found': gsm_towers_found - }) - except queue.Full: - pass - - # Detect crash pattern: process exits too quickly with no data - if scan_duration < 5 and exit_code != 0: - crash_count += 1 - logger.error( - f"grgsm_scanner crashed on startup (exit code: {exit_code}). " - f"Crash count: {crash_count}. Check gr-gsm/libosmocore compatibility." - ) - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'error', - 'message': f'grgsm_scanner crashed (exit code: {exit_code}). ' - 'This may be a gr-gsm/libosmocore compatibility issue. ' - 'Try rebuilding gr-gsm from source.', - 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S') - }) - except queue.Full: - pass - if crash_count >= 3: - logger.error("grgsm_scanner crashed 3 times, stopping scanner") - break - - except FileNotFoundError: - logger.error( - "grgsm_scanner not found. Please install gr-gsm: " - "https://github.com/bkerler/gr-gsm" - ) - # Send error to SSE stream so the UI knows - try: - app_module.gsm_spy_queue.put({ - 'type': 'error', - 'message': 'grgsm_scanner not found. Please install gr-gsm.', - 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S') - }) - except Exception: - pass - break # Don't retry - binary won't appear - - except Exception as e: - logger.error(f"Scanner scan error: {e}", exc_info=True) - if process and process.poll() is None: - safe_terminate(process) - - # Check if should continue - if not app_module.gsm_spy_scanner_running: - break - - # After first scan completes: auto-switch to monitoring if towers found - # Scanner process has exited so SDR is free for grgsm_livemon - if not auto_monitor_triggered and strongest_tower and scan_count >= 1: - auto_monitor_triggered = True - arfcn = strongest_tower.get('arfcn') - signal = strongest_tower.get('signal_strength', -999) - logger.info( - f"Scan complete with towers found. Auto-switching to monitor mode " - f"on ARFCN {arfcn} (signal: {signal} dBm)" - ) - - # Stop scanner loop - SDR needed for monitoring - app_module.gsm_spy_scanner_running = False - - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'status', - 'message': f'Switching to monitor mode on ARFCN {arfcn}...' - }) - except queue.Full: - pass - - # Start monitoring (SDR is free since scanner process exited) - try: - with app_module.gsm_spy_lock: - if not app_module.gsm_spy_monitor_process: - _start_and_register_monitor(arfcn, device_index) - logger.info(f"Auto-monitoring started for ARFCN {arfcn}") - - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'auto_monitor_started', - 'arfcn': arfcn, - 'tower': strongest_tower - }) - except queue.Full: - pass - except Exception as e: - logger.error(f"Error starting auto-monitor: {e}", exc_info=True) - try: - app_module.gsm_spy_queue.put_nowait({ - 'type': 'error', - 'message': f'Monitor failed: {e}' - }) - except queue.Full: - pass - # Resume scanning if monitor failed - app_module.gsm_spy_scanner_running = True - - break # Exit scanner loop (monitoring takes over) - - # Wait between scans with responsive flag checking - logger.info("Waiting 5 seconds before next scan") - for i in range(5): - if not app_module.gsm_spy_scanner_running: - break - time.sleep(1) - - except Exception as e: - logger.error(f"Scanner thread fatal error: {e}", exc_info=True) - - finally: - # Always cleanup - if process and process.poll() is None: - safe_terminate(process, timeout=5) - - logger.info("Scanner thread terminated") - - # Reset global state - but don't release SDR if monitoring took over - with app_module.gsm_spy_lock: - app_module.gsm_spy_scanner_running = False - if app_module.gsm_spy_monitor_process is None: - # No monitor running - release SDR device - if app_module.gsm_spy_active_device is not None: - from app import release_sdr_device - release_sdr_device(app_module.gsm_spy_active_device) - app_module.gsm_spy_active_device = None - else: - logger.info("Monitor is running, keeping SDR device allocated") - - -def monitor_thread(process): - """Thread to read tshark output using standard iter pattern.""" - global gsm_devices_tracked - - # Standard pattern: reader thread with queue - output_queue_local = queue.Queue() - - def read_stdout(): - try: - for line in iter(process.stdout.readline, ''): - if line: - output_queue_local.put(('stdout', line)) - except Exception as e: - logger.error(f"tshark read error: {e}") - finally: - output_queue_local.put(('eof', None)) - - stdout_thread = threading.Thread(target=read_stdout, daemon=True) - stdout_thread.start() - - try: - while app_module.gsm_spy_monitor_process: - # Check if process died - if process.poll() is not None: - logger.info(f"Monitor process exited (code: {process.returncode})") - break - - # Get output from queue with timeout - try: - msg_type, line = output_queue_local.get(timeout=1.0) - except queue.Empty: - continue # Timeout, check flag again - - if msg_type == 'eof': - break # EOF - - parsed = parse_tshark_output(line) - if parsed: - # Store in DataStore - key = parsed.get('tmsi') or parsed.get('imsi') or str(time.time()) - app_module.gsm_spy_devices[key] = parsed - - # Queue for SSE stream - try: - app_module.gsm_spy_queue.put_nowait(parsed) - except queue.Full: - pass - - # Store in database for historical analysis - try: - with get_db() as conn: - # gsm_signals table - conn.execute(''' - INSERT INTO gsm_signals - (imsi, tmsi, lac, cid, ta_value, arfcn) - VALUES (?, ?, ?, ?, ?, ?) - ''', ( - parsed.get('imsi'), - parsed.get('tmsi'), - parsed.get('lac'), - parsed.get('cid'), - parsed.get('ta_value'), - app_module.gsm_spy_selected_arfcn - )) - - # gsm_tmsi_log table for crowd density - if parsed.get('tmsi'): - conn.execute(''' - INSERT INTO gsm_tmsi_log - (tmsi, lac, cid, ta_value) - VALUES (?, ?, ?, ?) - ''', ( - parsed.get('tmsi'), - parsed.get('lac'), - parsed.get('cid'), - parsed.get('ta_value') - )) - - # Velocity calculation (G-08) - device_id = parsed.get('imsi') or parsed.get('tmsi') - if device_id and parsed.get('ta_value') is not None: - # Get previous TA reading - prev_reading = conn.execute(''' - SELECT ta_value, cid, timestamp - FROM gsm_signals - WHERE (imsi = ? OR tmsi = ?) - ORDER BY timestamp DESC - LIMIT 1 OFFSET 1 - ''', (device_id, device_id)).fetchone() - - if prev_reading: - # Calculate velocity - curr_ta = parsed.get('ta_value') - prev_ta = prev_reading['ta_value'] - curr_distance = curr_ta * config.GSM_TA_METERS_PER_UNIT - prev_distance = prev_ta * config.GSM_TA_METERS_PER_UNIT - distance_change = abs(curr_distance - prev_distance) - - # Time difference - prev_time = datetime.fromisoformat(prev_reading['timestamp']) - curr_time = datetime.now() - time_diff_seconds = (curr_time - prev_time).total_seconds() - - if time_diff_seconds > 0: - velocity = distance_change / time_diff_seconds - - # Store velocity - conn.execute(''' - INSERT INTO gsm_velocity_log - (device_id, prev_ta, curr_ta, prev_cid, curr_cid, estimated_velocity) - VALUES (?, ?, ?, ?, ?, ?) - ''', ( - device_id, - prev_ta, - curr_ta, - prev_reading['cid'], - parsed.get('cid'), - velocity - )) - - conn.commit() - except Exception as e: - logger.error(f"Error storing device data: {e}") - - # Thread-safe counter - with app_module.gsm_spy_lock: - gsm_devices_tracked += 1 - - except Exception as e: - logger.error(f"Monitor thread error: {e}", exc_info=True) - - finally: - # Reap process with timeout - try: - if process.poll() is None: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - logger.warning("Monitor process didn't terminate, killing") - process.kill() - process.wait() - else: - process.wait() - logger.info(f"Monitor process exited with code {process.returncode}") - except Exception as e: - logger.error(f"Error reaping monitor process: {e}") - - logger.info("Monitor thread terminated") diff --git a/routes/offline.py b/routes/offline.py index a35b6e9..ff25922 100644 --- a/routes/offline.py +++ b/routes/offline.py @@ -13,7 +13,7 @@ OFFLINE_DEFAULTS = { 'offline.enabled': False, 'offline.assets_source': 'cdn', 'offline.fonts_source': 'cdn', - 'offline.tile_provider': 'cartodb_dark_cyan', + 'offline.tile_provider': 'cartodb_dark_cyan', 'offline.tile_server_url': '' } @@ -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' ] } diff --git a/setup.sh b/setup.sh index f41218a..f91e2b0 100755 --- a/setup.sh +++ b/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 } diff --git a/static/css/settings.css b/static/css/settings.css index 41c6618..d626954 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -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 { diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index 3d2cb8b..35649c2 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -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'; +} diff --git a/static/js/modes/dmr.js b/static/js/modes/dmr.js index 447afaa..7504dd7 100644 --- a/static/js/modes/dmr.js +++ b/static/js/modes/dmr.js @@ -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; diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 2987e39..4273b23 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -1018,8 +1018,16 @@ function addSignalHit(data) { ${data.frequency.toFixed(3)} ${snrText} ${mod.toUpperCase()} - + + + +
+
Pager
+
433 Sensor
+
RTLAMR
+
+
`; 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 = '
Send to:
' + + modes.map(m => + `` + ).join('') + '
'; + } 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 = `
${freq.toFixed(3)} MHz →
` + + modes.map(m => + `
Send to ${m.label}
` + ).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; diff --git a/static/vendor/leaflet-heat/leaflet-heat.js b/static/vendor/leaflet-heat/leaflet-heat.js new file mode 100644 index 0000000..aa8031a --- /dev/null +++ b/static/vendor/leaflet-heat/leaflet-heat.js @@ -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)}; \ No newline at end of file diff --git a/templates/gsm_spy_dashboard.html b/templates/gsm_spy_dashboard.html deleted file mode 100644 index dc673ad..0000000 --- a/templates/gsm_spy_dashboard.html +++ /dev/null @@ -1,2530 +0,0 @@ - - - - - - GSM SPY // INTERCEPT - See the Invisible - - {% if offline_settings.fonts_source == 'local' %} - - {% else %} - - {% endif %} - - {% if offline_settings.assets_source == 'local' %} - - - {% else %} - - - {% endif %} - - - - - - - - - - - -
-
- -
- -
-
- STANDBY -
-
- - {% set active_mode = 'gsm' %} - {% include 'partials/nav.html' with context %} - - -
-
-
- 0 - TOWERS -
-
- 0 - DEVICES -
-
- 0 - ROGUES -
-
- 0 - SIGNALS -
-
- - - CROWD -
-
-
-
- STANDBY -
-
--:--:-- UTC
- -
-
- - -
-
-
-
Analytics Overview
- -
-
-
- -
-
-
📍
-
Velocity Tracking
-
-
- Track device movement by analyzing Timing Advance transitions and cell handovers. - Estimates velocity and direction based on TA delta and cell sector patterns. -
-
-
-
0
-
Devices Tracked
-
-
-
- km/h
-
Avg Velocity
-
-
-
- - -
-
-
👥
-
Crowd Density
-
-
- Aggregate TMSI pings per cell sector to estimate crowd density. - Visualizes hotspots and congestion patterns across towers. -
-
-
-
0
-
Total Devices
-
-
-
0
-
Peak Sector
-
-
-
- - -
-
-
📊
-
Life Patterns
-
-
- Analyze 60-day historical data to identify recurring patterns in device behavior. - Detects work locations, commute routes, and daily routines. -
-
-
-
0
-
Patterns Found
-
-
-
0%
-
Confidence
-
-
-
- - -
-
-
🔍
-
Neighbor Audit
-
-
- Validate neighbor cell lists against expected network topology. - Detects inconsistencies that may indicate rogue towers. -
-
-
-
0
-
Neighbors
-
-
-
0
-
Anomalies
-
-
-
- - -
-
-
📡
-
Traffic Correlation
-
-
- Correlate uplink and downlink timing to identify communication patterns. - Maps device-to-device interactions and network flows. -
-
-
-
0
-
Paired Flows
-
-
-
0
-
Active Now
-
-
-
-
-
-
-
- -
- - - - -
- -
-
- - - - - -
- -
- GPS LOCATION -
- - - -
-
- - -
- GSM SCANNER -
- - - -
-
-
-
- - - - - - - diff --git a/templates/index.html b/templates/index.html index 31305a6..8c6361d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -174,10 +174,6 @@ Vessels - - - GSM SPY -