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()}
-
+
Listen
+
+ ▶
+
+
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 =>
+ `${m.label} `
+ ).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 %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% set active_mode = 'gsm' %}
- {% include 'partials/nav.html' with context %}
-
-
-
-
-
- 0
- TOWERS
-
-
- 0
- DEVICES
-
-
- 0
- ROGUES
-
-
- 0
- SIGNALS
-
-
- -
- CROWD
-
-
-
-
--:--:-- UTC
-
- Analytics Overview
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Track device movement by analyzing Timing Advance transitions and cell handovers.
- Estimates velocity and direction based on TA delta and cell sector patterns.
-
-
-
-
-
- km/h
-
Avg Velocity
-
-
-
-
-
-
-
-
- Aggregate TMSI pings per cell sector to estimate crowd density.
- Visualizes hotspots and congestion patterns across towers.
-
-
-
-
-
-
-
-
- Analyze 60-day historical data to identify recurring patterns in device behavior.
- Detects work locations, commute routes, and daily routines.
-
-
-
-
-
-
-
-
- Validate neighbor cell lists against expected network topology.
- Detects inconsistencies that may indicate rogue towers.
-
-
-
-
-
-
-
-
- Correlate uplink and downlink timing to identify communication patterns.
- Maps device-to-device interactions and network flows.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
GSM SCANNER
-
-
- Americas
- Europe
- Asia
-
-
-
-
- START
-
-
-
-
-
-
-
-
-
-
-
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
-
APRS
@@ -918,7 +914,7 @@
-
+
@@ -993,7 +989,7 @@
+ style="flex: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;">
APRS STATION MAP
@@ -1012,12 +1008,12 @@
+ style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
+ style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
STATION LIST
-
-