mirror of
https://github.com/smittix/intercept.git
synced 2026-07-08 09:38:12 -07:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51aba87852 | |||
| 4c13e98091 | |||
| b96eb8ccba | |||
| b8a80460bf | |||
| 7130c2d4c4 | |||
| 62c34c1e95 | |||
| e413f54651 | |||
| 1a4af214bf | |||
| c2891938ab | |||
| 2bed35dd64 | |||
| 0c656cff2b | |||
| e03ba3f5ed | |||
| c6ff8abf11 | |||
| eff6ca3e87 | |||
| 1a5b076a8d | |||
| 90e88fc469 | |||
| 98f6d18bea | |||
| 7d69cac7e7 | |||
| c6a8a4a492 | |||
| 391aff52ce | |||
| 3dc16b392b | |||
| 4d7be047da | |||
| 182e1f3239 | |||
| 87782319f2 | |||
| 6b7f817aa6 | |||
| 82f442ffb8 | |||
| f18ed26005 | |||
| 897cea5b54 | |||
| cd2d51ee40 | |||
| 39ed4bffba | |||
| 6010c7d589 | |||
| 01978730ba | |||
| 451eff83a8 | |||
| 7cb2efca30 | |||
| 33953fcf2b | |||
| 1eec4a2342 | |||
| 2dc4940ca2 | |||
| cd5f1464b6 | |||
| 4aeb51a973 | |||
| 15efe56762 | |||
| 995bc17418 | |||
| c3dcf1401a | |||
| 6f9873d47f | |||
| 28185727e3 | |||
| 48795f6ec3 | |||
| f5021a0fdf | |||
| 7312f330ed | |||
| 2115bc551d | |||
| f6c19af33a | |||
| ebd9eb81f2 | |||
| c87c01cdfe | |||
| 19a94d4a84 | |||
| cca04918a9 | |||
| 777b83f6e0 | |||
| 455bc05c69 | |||
| 37842dc1ef | |||
| 01f3cc845b | |||
| bdba56bef1 | |||
| a5ea632cc2 | |||
| a3b81bead8 | |||
| 44b1a74838 | |||
| 297f971bd5 | |||
| 18aa7fe669 | |||
| 8409a4469d | |||
| b75492ec18 | |||
| fef8db6c00 | |||
| a70502fb77 | |||
| e8a9afa221 | |||
| 8e9588c4ff | |||
| 7bc1d5b643 | |||
| ef14f5f1a1 | |||
| 7caa7247ef | |||
| 04d9d2fd56 |
@@ -2,6 +2,39 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.15.0] - 2026-02-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||||
|
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||||
|
- Shared waterfall UI across SDR modes with function bar controls
|
||||||
|
- WebSocket frame serialization and connection reuse
|
||||||
|
- **Cross-Module Frequency Routing** - Tune from Listening Post directly to decoders
|
||||||
|
- **Pure Python SSTV Decoder** - Replaces broken slowrx C dependency
|
||||||
|
- Real-time decode progress with partial image streaming
|
||||||
|
- VIS detector state in signal monitor diagnostics
|
||||||
|
- Image gallery with delete and download functionality
|
||||||
|
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||||
|
- **SSTV Image Gallery** - Delete and download decoded images
|
||||||
|
- **USB Device Probe** - Detect broken SDR devices before rtl_fm crashes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- DMR dsd-fme protocol flags, device label, and tuning controls
|
||||||
|
- DMR frontend/backend state desync causing 409 on start
|
||||||
|
- Digital voice decoder producing no output due to wrong dsd-fme flags
|
||||||
|
- SDR device lock-up from unreleased device registry on process crash
|
||||||
|
- APRS crash on large station count and station list overflow
|
||||||
|
- Settings modal overflowing viewport on smaller screens
|
||||||
|
- Waterfall crash on zoom by reusing WebSocket and adding USB release retry
|
||||||
|
- PD120 SSTV decode hang and false leader tone detection
|
||||||
|
- WebSocket waterfall blocked by login redirect
|
||||||
|
- TSCM sweep KeyError on RiskLevel.NEEDS_REVIEW
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- GSM Spy functionality removed for legal compliance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.14.0] - 2026-02-06
|
## [2.14.0] - 2026-02-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ LABEL description="Signal Intelligence Platform for SDR monitoring"
|
|||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Pre-accept tshark non-root capture prompt for non-interactive install
|
||||||
|
RUN echo 'wireshark-common wireshark-common/install-setuid boolean true' | debconf-set-selections
|
||||||
|
|
||||||
# Install system dependencies for SDR tools
|
# Install system dependencies for SDR tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
|
|||||||
@@ -244,6 +244,10 @@ sdr_device_registry_lock = threading.Lock()
|
|||||||
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
||||||
"""Claim an SDR device for a mode.
|
"""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:
|
Args:
|
||||||
device_index: The SDR device index to claim
|
device_index: The SDR device index to claim
|
||||||
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr')
|
||||||
@@ -255,6 +259,16 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None:
|
|||||||
if device_index in sdr_device_registry:
|
if device_index in sdr_device_registry:
|
||||||
in_use_by = sdr_device_registry[device_index]
|
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.'
|
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
|
sdr_device_registry[device_index] = mode_name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -292,6 +306,10 @@ def require_login():
|
|||||||
if request.path.startswith('/listening/audio/'):
|
if request.path.startswith('/listening/audio/'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Allow WebSocket upgrade requests (page load already required auth)
|
||||||
|
if request.path.startswith('/ws/'):
|
||||||
|
return None
|
||||||
|
|
||||||
# Controller API endpoints use API key auth, not session auth
|
# Controller API endpoints use API key auth, not session auth
|
||||||
# Allow agent push/pull endpoints without session login
|
# Allow agent push/pull endpoints without session login
|
||||||
if request.path.startswith('/controller/'):
|
if request.path.startswith('/controller/'):
|
||||||
@@ -672,7 +690,7 @@ def kill_all() -> Response:
|
|||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'dsd',
|
'hcitool', 'bluetoothctl', 'dsd',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
@@ -830,6 +848,18 @@ def main() -> None:
|
|||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Register database cleanup functions
|
||||||
|
from utils.database import (
|
||||||
|
cleanup_old_signal_history,
|
||||||
|
cleanup_old_timeline_entries,
|
||||||
|
cleanup_old_dsc_alerts,
|
||||||
|
cleanup_old_payloads
|
||||||
|
)
|
||||||
|
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
|
||||||
|
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||||
|
|
||||||
# Start automatic cleanup of stale data entries
|
# Start automatic cleanup of stale data entries
|
||||||
cleanup_manager.start()
|
cleanup_manager.start()
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,23 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.14.0"
|
VERSION = "2.15.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.15.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Real-time WebSocket waterfall with I/Q capture and server-side FFT",
|
||||||
|
"Cross-module frequency routing from Listening Post to decoders",
|
||||||
|
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||||
|
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||||
|
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||||
|
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||||
|
"SDR device lock-up fix from unreleased device registry on crash",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.14.0",
|
"version": "2.14.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -218,6 +231,7 @@ ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
|||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure application logging."""
|
"""Configure application logging."""
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.14.0"
|
version = "2.15.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ aprs_packet_count = 0
|
|||||||
aprs_station_count = 0
|
aprs_station_count = 0
|
||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {} # callsign -> station data
|
aprs_stations = {} # callsign -> station data
|
||||||
|
APRS_MAX_STATIONS = 500 # Limit tracked stations to prevent memory growth
|
||||||
|
|
||||||
# Meter rate limiting
|
# Meter rate limiting
|
||||||
_last_meter_time = 0.0
|
_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'),
|
'last_seen': packet.get('timestamp'),
|
||||||
'packet_type': packet.get('packet_type'),
|
'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)
|
app_module.aprs_queue.put(packet)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""WebSocket-based audio streaming for SDR."""
|
"""WebSocket-based audio streaming for SDR."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import shutil
|
|
||||||
import json
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
# Try to import flask-sock
|
# Try to import flask-sock
|
||||||
@@ -251,4 +252,19 @@ def init_audio_websocket(app: Flask):
|
|||||||
finally:
|
finally:
|
||||||
with process_lock:
|
with process_lock:
|
||||||
kill_audio_processes()
|
kill_audio_processes()
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream.
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("WebSocket audio client disconnected")
|
logger.info("WebSocket audio client disconnected")
|
||||||
|
|||||||
+53
-17
@@ -20,6 +20,7 @@ from utils.logging import get_logger
|
|||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
|
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -54,14 +55,24 @@ _DSD_PROTOCOL_FLAGS = {
|
|||||||
'provoice': ['-fv'],
|
'provoice': ['-fv'],
|
||||||
}
|
}
|
||||||
|
|
||||||
# dsd-fme uses different flag names
|
# dsd-fme remapped several flags from classic DSD:
|
||||||
|
# -fp = ProVoice (NOT P25), -fi = NXDN48 (NOT D-Star),
|
||||||
|
# -f1 = P25 Phase 1, -ft = XDMA multi-protocol decoder
|
||||||
_DSD_FME_PROTOCOL_FLAGS = {
|
_DSD_FME_PROTOCOL_FLAGS = {
|
||||||
'auto': ['-ft'],
|
'auto': ['-ft'], # XDMA: auto-detect DMR/P25/YSF
|
||||||
'dmr': ['-fs'],
|
'dmr': ['-fd'], # DMR (classic flag, works in dsd-fme)
|
||||||
'p25': ['-f1'],
|
'p25': ['-f1'], # P25 Phase 1 (-fp is ProVoice in dsd-fme!)
|
||||||
'nxdn': ['-fi'],
|
'nxdn': ['-fn'], # NXDN96
|
||||||
'dstar': [],
|
'dstar': [], # No dedicated flag in dsd-fme; auto-detect
|
||||||
'provoice': ['-fp'],
|
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Modulation hints: force C4FM for protocols that use it, improving
|
||||||
|
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||||
|
_DSD_FME_MODULATION = {
|
||||||
|
'dmr': ['-mc'], # C4FM
|
||||||
|
'p25': ['-mc'], # C4FM (Phase 1; Phase 2 would use -mq)
|
||||||
|
'nxdn': ['-mc'], # C4FM
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -321,16 +332,14 @@ def start_dmr() -> Response:
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frequency = float(data.get('frequency', 462.5625))
|
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||||
gain = int(data.get('gain', 40))
|
gain = int(validate_gain(data.get('gain', 40)))
|
||||||
device = int(data.get('device', 0))
|
device = validate_device_index(data.get('device', 0))
|
||||||
protocol = str(data.get('protocol', 'auto')).lower()
|
protocol = str(data.get('protocol', 'auto')).lower()
|
||||||
|
ppm = validate_ppm(data.get('ppm', 0))
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
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:
|
if protocol not in VALID_PROTOCOLS:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||||
|
|
||||||
@@ -341,8 +350,10 @@ def start_dmr() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Claim SDR device
|
# Claim SDR device — use protocol name so the device panel shows
|
||||||
error = app_module.claim_sdr_device(device, 'dmr')
|
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||||
|
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||||
|
error = app_module.claim_sdr_device(device, mode_label)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||||
|
|
||||||
@@ -350,7 +361,10 @@ def start_dmr() -> Response:
|
|||||||
|
|
||||||
freq_hz = int(frequency * 1e6)
|
freq_hz = int(frequency * 1e6)
|
||||||
|
|
||||||
# Build rtl_fm command (48kHz sample rate for DSD)
|
# Build rtl_fm command (48kHz sample rate for DSD).
|
||||||
|
# Squelch disabled (-l 0): rtl_fm's squelch chops the bitstream
|
||||||
|
# mid-frame, destroying DSD sync. The decoder handles silence
|
||||||
|
# internally via its own frame-sync detection.
|
||||||
rtl_cmd = [
|
rtl_cmd = [
|
||||||
rtl_fm_path,
|
rtl_fm_path,
|
||||||
'-M', 'fm',
|
'-M', 'fm',
|
||||||
@@ -358,8 +372,10 @@ def start_dmr() -> Response:
|
|||||||
'-s', '48000',
|
'-s', '48000',
|
||||||
'-g', str(gain),
|
'-g', str(gain),
|
||||||
'-d', str(device),
|
'-d', str(device),
|
||||||
'-l', '1', # squelch level
|
'-l', '0',
|
||||||
]
|
]
|
||||||
|
if ppm != 0:
|
||||||
|
rtl_cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Build DSD command
|
# Build DSD command
|
||||||
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
|
# Use -o - to send decoded audio to stdout (piped to DEVNULL)
|
||||||
@@ -367,6 +383,11 @@ def start_dmr() -> Response:
|
|||||||
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
|
dsd_cmd = [dsd_path, '-i', '-', '-o', '-']
|
||||||
if is_fme:
|
if is_fme:
|
||||||
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
||||||
|
# Relax CRC checks for marginal signals — lets more frames
|
||||||
|
# through at the cost of occasional decode errors.
|
||||||
|
if data.get('relaxCrc', False):
|
||||||
|
dsd_cmd.append('-F')
|
||||||
else:
|
else:
|
||||||
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
||||||
|
|
||||||
@@ -402,6 +423,21 @@ def start_dmr() -> Response:
|
|||||||
if dmr_dsd_process.stderr:
|
if dmr_dsd_process.stderr:
|
||||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
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}")
|
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:
|
if dmr_active_device is not None:
|
||||||
app_module.release_sdr_device(dmr_active_device)
|
app_module.release_sdr_device(dmr_active_device)
|
||||||
dmr_active_device = None
|
dmr_active_device = None
|
||||||
|
|||||||
@@ -1305,11 +1305,20 @@ def start_audio() -> Response:
|
|||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
if waterfall_running and waterfall_active_device == device:
|
if waterfall_running and waterfall_active_device == device:
|
||||||
_stop_waterfall_internal()
|
_stop_waterfall_internal()
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Release waterfall device claim if the WebSocket waterfall is still
|
||||||
|
# holding it. The JS client sends a stop command and closes the
|
||||||
|
# WebSocket before requesting audio, but the backend handler may not
|
||||||
|
# have finished its cleanup yet.
|
||||||
|
device_status = app_module.get_sdr_device_status()
|
||||||
|
if device_status.get(device) == 'waterfall':
|
||||||
|
app_module.release_sdr_device(device)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
# Claim device for listening audio
|
# Claim device for listening audio
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if listening_active_device is None or listening_active_device != device:
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ ASSET_PATHS = {
|
|||||||
'static/vendor/leaflet/images/marker-shadow.png',
|
'static/vendor/leaflet/images/marker-shadow.png',
|
||||||
'static/vendor/leaflet/images/layers.png',
|
'static/vendor/leaflet/images/layers.png',
|
||||||
'static/vendor/leaflet/images/layers-2x.png'
|
'static/vendor/leaflet/images/layers-2x.png'
|
||||||
|
],
|
||||||
|
'leaflet_heat': [
|
||||||
|
'static/vendor/leaflet-heat/leaflet-heat.js'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -95,6 +95,14 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
sensor_active_device = None
|
sensor_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
@sensor_bp.route('/sensor/status')
|
||||||
|
def sensor_status() -> Response:
|
||||||
|
"""Check if sensor decoder is currently running."""
|
||||||
|
with app_module.sensor_lock:
|
||||||
|
running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None
|
||||||
|
return jsonify({'running': running})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||||
def start_sensor() -> Response:
|
def start_sensor() -> Response:
|
||||||
global sensor_active_device
|
global sensor_active_device
|
||||||
@@ -174,7 +182,8 @@ def start_sensor() -> Response:
|
|||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
||||||
cmd.extend(['-M', 'level'])
|
# Disable stats reporting to suppress "row count limit 50 reached" warnings
|
||||||
|
cmd.extend(['-M', 'level', '-M', 'stats:0'])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.sensor_process = subprocess.Popen(
|
app_module.sensor_process = subprocess.Popen(
|
||||||
|
|||||||
@@ -551,6 +551,12 @@ def _start_sweep_internal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tscm_bp.route('/status')
|
||||||
|
def tscm_status():
|
||||||
|
"""Check if any TSCM operation is currently running."""
|
||||||
|
return jsonify({'running': _sweep_running})
|
||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||||
def start_sweep():
|
def start_sweep():
|
||||||
"""Start a TSCM sweep."""
|
"""Start a TSCM sweep."""
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import queue
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -85,24 +87,41 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
# Drain send queue first (non-blocking)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
outgoing = send_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
ws.send(outgoing)
|
||||||
|
except Exception:
|
||||||
|
stop_event.set()
|
||||||
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = ws.receive(timeout=0.1)
|
msg = ws.receive(timeout=0.1)
|
||||||
except TimeoutError:
|
|
||||||
if stop_event.is_set():
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "closed" in str(e).lower():
|
err = str(e).lower()
|
||||||
|
if "closed" in err:
|
||||||
break
|
break
|
||||||
if "timed out" not in str(e).lower():
|
if "timed out" not in err:
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
logger.error(f"WebSocket receive error: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if msg is None:
|
if msg is None:
|
||||||
break
|
# simple-websocket returns None on timeout AND on
|
||||||
|
# close; check ws.connected to tell them apart.
|
||||||
|
if not ws.connected:
|
||||||
|
break
|
||||||
|
if stop_event.is_set():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(msg)
|
data = json.loads(msg)
|
||||||
@@ -113,6 +132,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
if cmd == 'start':
|
if cmd == 'start':
|
||||||
# Stop any existing capture
|
# Stop any existing capture
|
||||||
|
was_restarting = iq_process is not None
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
reader_thread.join(timeout=2)
|
reader_thread.join(timeout=2)
|
||||||
@@ -124,6 +144,15 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
|
# Flush stale frames from previous capture
|
||||||
|
while not send_queue.empty():
|
||||||
|
try:
|
||||||
|
send_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
# Allow USB device to be released by the kernel
|
||||||
|
if was_restarting:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
center_freq = float(data.get('center_freq', 100.0))
|
center_freq = float(data.get('center_freq', 100.0))
|
||||||
@@ -187,25 +216,39 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Spawn I/Q capture process
|
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||||
|
max_attempts = 3 if was_restarting else 1
|
||||||
try:
|
try:
|
||||||
logger.info(
|
for attempt in range(max_attempts):
|
||||||
f"Starting I/Q capture: {center_freq} MHz, "
|
logger.info(
|
||||||
f"span={effective_span_mhz:.1f} MHz, "
|
f"Starting I/Q capture: {center_freq} MHz, "
|
||||||
f"sr={sample_rate}, fft={fft_size}"
|
f"span={effective_span_mhz:.1f} MHz, "
|
||||||
)
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
iq_process = subprocess.Popen(
|
)
|
||||||
iq_cmd,
|
iq_process = subprocess.Popen(
|
||||||
stdout=subprocess.PIPE,
|
iq_cmd,
|
||||||
stderr=subprocess.DEVNULL,
|
stdout=subprocess.PIPE,
|
||||||
bufsize=0,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
bufsize=0,
|
||||||
register_process(iq_process)
|
)
|
||||||
|
register_process(iq_process)
|
||||||
|
|
||||||
# Brief check that process started
|
# Brief check that process started
|
||||||
time.sleep(0.2)
|
time.sleep(0.3)
|
||||||
if iq_process.poll() is not None:
|
if iq_process.poll() is not None:
|
||||||
raise RuntimeError("I/Q capture process exited immediately")
|
unregister_process(iq_process)
|
||||||
|
iq_process = None
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
logger.info(
|
||||||
|
f"I/Q process exited immediately, "
|
||||||
|
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
raise RuntimeError(
|
||||||
|
"I/Q capture process exited immediately"
|
||||||
|
)
|
||||||
|
break # Process started successfully
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start I/Q capture: {e}")
|
logger.error(f"Failed to start I/Q capture: {e}")
|
||||||
if iq_process:
|
if iq_process:
|
||||||
@@ -229,13 +272,13 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
'sample_rate': sample_rate,
|
'sample_rate': sample_rate,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# Start reader thread
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
def fft_reader(
|
def fft_reader(
|
||||||
proc, ws_ref, stop_evt,
|
proc, _send_q, stop_evt,
|
||||||
_fft_size, _avg_count, _fps,
|
_fft_size, _avg_count, _fps,
|
||||||
_start_freq, _end_freq,
|
_start_freq, _end_freq,
|
||||||
):
|
):
|
||||||
"""Read I/Q from subprocess, compute FFT, send binary frames."""
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
bytes_per_frame = _fft_size * _avg_count * 2
|
bytes_per_frame = _fft_size * _avg_count * 2
|
||||||
frame_interval = 1.0 / _fps
|
frame_interval = 1.0 / _fps
|
||||||
|
|
||||||
@@ -272,9 +315,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ws_ref.send(frame)
|
_send_q.put_nowait(frame)
|
||||||
except Exception:
|
except queue.Full:
|
||||||
break
|
# Drop frame if main loop can't keep up
|
||||||
|
pass
|
||||||
|
|
||||||
# Pace to target FPS
|
# Pace to target FPS
|
||||||
elapsed = time.monotonic() - frame_start
|
elapsed = time.monotonic() - frame_start
|
||||||
@@ -288,7 +332,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = threading.Thread(
|
reader_thread = threading.Thread(
|
||||||
target=fft_reader,
|
target=fft_reader,
|
||||||
args=(
|
args=(
|
||||||
iq_process, ws, stop_event,
|
iq_process, send_queue, stop_event,
|
||||||
fft_size, avg_count, fps,
|
fft_size, avg_count, fps,
|
||||||
start_freq, end_freq,
|
start_freq, end_freq,
|
||||||
),
|
),
|
||||||
@@ -323,4 +367,20 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
# Complete WebSocket close handshake, then shut down the
|
||||||
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
|
# on top of the WebSocket stream (which browsers see as
|
||||||
|
# "Invalid frame header").
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ws.sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("WebSocket waterfall client disconnected")
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ detect_dragonos() {
|
|||||||
# Required tool checks (with alternates)
|
# Required tool checks (with alternates)
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
missing_required=()
|
missing_required=()
|
||||||
|
missing_recommended=()
|
||||||
|
|
||||||
check_required() {
|
check_required() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
@@ -178,6 +179,18 @@ check_required() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_recommended() {
|
||||||
|
local label="$1"; shift
|
||||||
|
local desc="$1"; shift
|
||||||
|
|
||||||
|
if have_any "$@"; then
|
||||||
|
ok "${label} - ${desc}"
|
||||||
|
else
|
||||||
|
warn "${label} - ${desc} (missing, recommended)"
|
||||||
|
missing_recommended+=("$label")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
check_optional() {
|
check_optional() {
|
||||||
local label="$1"; shift
|
local label="$1"; shift
|
||||||
local desc="$1"; shift
|
local desc="$1"; shift
|
||||||
@@ -605,7 +618,7 @@ install_aiscatcher_from_source_macos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=17
|
TOTAL_STEPS=18
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -979,7 +992,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=22
|
TOTAL_STEPS=25
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1185,6 +1198,14 @@ final_summary_and_hard_fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${#missing_recommended[@]}" -gt 0 ]]; then
|
||||||
|
echo
|
||||||
|
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"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -1231,6 +1252,19 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
install_python_deps
|
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
|
final_summary_and_hard_fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Strip title badge */
|
||||||
|
.function-strip .strip-title {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.function-strip .strip-stat {
|
.function-strip .strip-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,9 @@
|
|||||||
|
|
||||||
.settings-section.active {
|
.settings-section.active {
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-group {
|
.settings-group {
|
||||||
|
|||||||
@@ -930,5 +930,56 @@ function switchSettingsTab(tabName) {
|
|||||||
if (typeof RecordingUI !== 'undefined') {
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
RecordingUI.refresh();
|
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';
|
||||||
|
}
|
||||||
|
|||||||
+67
-10
@@ -10,6 +10,7 @@ let dmrCallCount = 0;
|
|||||||
let dmrSyncCount = 0;
|
let dmrSyncCount = 0;
|
||||||
let dmrCallHistory = [];
|
let dmrCallHistory = [];
|
||||||
let dmrCurrentProtocol = '--';
|
let dmrCurrentProtocol = '--';
|
||||||
|
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
||||||
|
|
||||||
// ============== SYNTHESIZER STATE ==============
|
// ============== SYNTHESIZER STATE ==============
|
||||||
let dmrSynthCanvas = null;
|
let dmrSynthCanvas = null;
|
||||||
@@ -57,17 +58,22 @@ function startDmr() {
|
|||||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
||||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
||||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
||||||
|
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
||||||
|
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
||||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
|
||||||
|
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
|
||||||
|
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
||||||
|
|
||||||
// Check device availability before starting
|
// Check device availability before starting
|
||||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('dmr')) {
|
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/dmr/start', {
|
fetch('/dmr/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ frequency, protocol, gain, device })
|
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -86,10 +92,25 @@ function startDmr() {
|
|||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
if (statusEl) statusEl.textContent = 'DECODING';
|
||||||
if (typeof reserveDevice === 'function') {
|
if (typeof reserveDevice === 'function') {
|
||||||
reserveDevice(parseInt(device), 'dmr');
|
reserveDevice(parseInt(device), dmrModeLabel);
|
||||||
}
|
}
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('DMR', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
showNotification('Digital Voice', `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 {
|
} else {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
@@ -113,7 +134,7 @@ function stopDmr() {
|
|||||||
const statusEl = document.getElementById('dmrStatus');
|
const statusEl = document.getElementById('dmrStatus');
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
if (typeof releaseDevice === 'function') {
|
if (typeof releaseDevice === 'function') {
|
||||||
releaseDevice('dmr');
|
releaseDevice(dmrModeLabel);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[DMR] Stop error:', err));
|
.catch(err => console.error('[DMR] Stop error:', err));
|
||||||
@@ -215,7 +236,7 @@ function handleDmrMessage(msg) {
|
|||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
if (statusEl) statusEl.textContent = 'CRASHED';
|
||||||
if (typeof releaseDevice === 'function') releaseDevice('dmr');
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('DMR Error', detail);
|
showNotification('DMR Error', detail);
|
||||||
@@ -227,7 +248,7 @@ function handleDmrMessage(msg) {
|
|||||||
dmrActivityTarget = 0;
|
dmrActivityTarget = 0;
|
||||||
updateDmrSynthStatus();
|
updateDmrSynthStatus();
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
if (statusEl) statusEl.textContent = 'STOPPED';
|
||||||
if (typeof releaseDevice === 'function') releaseDevice('dmr');
|
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,10 +327,12 @@ function drawDmrSynthesizer() {
|
|||||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
dmrSynthCtx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
// Decay activity toward target
|
// Decay activity toward target. Window must exceed the backend
|
||||||
|
// heartbeat interval (3s) so the status doesn't flip-flop between
|
||||||
|
// LISTENING and IDLE on every heartbeat cycle.
|
||||||
const timeSinceEvent = now - dmrLastEventTime;
|
const timeSinceEvent = now - dmrLastEventTime;
|
||||||
if (timeSinceEvent > 2000) {
|
if (timeSinceEvent > 5000) {
|
||||||
// No events for 2s — decay target toward idle
|
// No events for 5s — decay target toward idle
|
||||||
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
||||||
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
||||||
dmrEventType = 'idle';
|
dmrEventType = 'idle';
|
||||||
@@ -496,9 +519,43 @@ function stopDmrSynthesizer() {
|
|||||||
|
|
||||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
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 ==============
|
// ============== EXPORTS ==============
|
||||||
|
|
||||||
window.startDmr = startDmr;
|
window.startDmr = startDmr;
|
||||||
window.stopDmr = stopDmr;
|
window.stopDmr = stopDmr;
|
||||||
window.checkDmrTools = checkDmrTools;
|
window.checkDmrTools = checkDmrTools;
|
||||||
|
window.checkDmrStatus = checkDmrStatus;
|
||||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
window.initDmrSynthesizer = initDmrSynthesizer;
|
||||||
|
|||||||
@@ -1018,8 +1018,16 @@ function addSignalHit(data) {
|
|||||||
<td style="padding: 4px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)}</td>
|
<td style="padding: 4px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)}</td>
|
||||||
<td style="padding: 4px; color: ${snrColor}; font-weight: bold; font-size: 9px;">${snrText}</td>
|
<td style="padding: 4px; color: ${snrColor}; font-weight: bold; font-size: 9px;">${snrText}</td>
|
||||||
<td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td>
|
<td style="padding: 4px; color: var(--text-secondary);">${mod.toUpperCase()}</td>
|
||||||
<td style="padding: 4px; text-align: center;">
|
<td style="padding: 4px; text-align: center; white-space: nowrap;">
|
||||||
<button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 6px; font-size: 9px; background: var(--accent-green); border: none; color: #000; cursor: pointer; border-radius: 3px;">Listen</button>
|
<button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 6px; font-size: 9px; background: var(--accent-green); border: none; color: #000; cursor: pointer; border-radius: 3px;">Listen</button>
|
||||||
|
<span style="position:relative;display:inline-block;">
|
||||||
|
<button class="preset-btn" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'block' ? 'none' : 'block'" style="padding:2px 5px; font-size:9px; background:var(--accent-cyan); border:none; color:#000; cursor:pointer; border-radius:3px; margin-left:3px;" title="Send frequency to decoder">▶</button>
|
||||||
|
<div style="display:none; position:absolute; right:0; top:100%; background:var(--bg-primary); border:1px solid var(--border-color); border-radius:4px; z-index:100; min-width:90px; padding:2px; box-shadow:0 2px 8px rgba(0,0,0,0.4);">
|
||||||
|
<div onclick="sendFrequencyToMode(${data.frequency}, 'pager'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Pager</div>
|
||||||
|
<div onclick="sendFrequencyToMode(${data.frequency}, 'sensor'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">433 Sensor</div>
|
||||||
|
<div onclick="sendFrequencyToMode(${data.frequency}, 'rtlamr'); this.parentElement.style.display='none'" style="padding:3px 8px; cursor:pointer; font-size:9px; color:var(--text-primary); border-radius:3px;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">RTLAMR</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tbody.insertBefore(row, tbody.firstChild);
|
tbody.insertBefore(row, tbody.firstChild);
|
||||||
@@ -3056,6 +3064,27 @@ function renderSignalGuess(result) {
|
|||||||
altsEl.innerHTML = '';
|
altsEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendToEl = document.getElementById('signalGuessSendTo');
|
||||||
|
if (sendToEl) {
|
||||||
|
const freqInput = document.getElementById('signalGuessFreqInput');
|
||||||
|
const freq = freqInput ? parseFloat(freqInput.value) : NaN;
|
||||||
|
if (!isNaN(freq) && freq > 0) {
|
||||||
|
const tags = (result.tags || []).map(t => t.toLowerCase());
|
||||||
|
const modes = [
|
||||||
|
{ key: 'pager', label: 'Pager', highlight: tags.some(t => t.includes('pager') || t.includes('pocsag') || t.includes('flex')) },
|
||||||
|
{ key: 'sensor', label: '433 Sensor', highlight: tags.some(t => t.includes('ism') || t.includes('433') || t.includes('sensor') || t.includes('iot')) },
|
||||||
|
{ key: 'rtlamr', label: 'RTLAMR', highlight: tags.some(t => t.includes('meter') || t.includes('amr') || t.includes('utility')) }
|
||||||
|
];
|
||||||
|
sendToEl.style.display = 'block';
|
||||||
|
sendToEl.innerHTML = '<div style="font-size:9px; color:var(--text-muted); margin-bottom:4px;">Send to:</div><div style="display:flex; gap:4px;">' +
|
||||||
|
modes.map(m =>
|
||||||
|
`<button class="preset-btn" onclick="sendFrequencyToMode(${freq}, '${m.key}')" style="padding:2px 8px; font-size:9px; border:none; color:#000; cursor:pointer; border-radius:3px; background:${m.highlight ? 'var(--accent-green)' : 'var(--accent-cyan)'}; ${m.highlight ? 'font-weight:bold;' : ''}">${m.label}</button>`
|
||||||
|
).join('') + '</div>';
|
||||||
|
} else {
|
||||||
|
sendToEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function manualSignalGuess() {
|
function manualSignalGuess() {
|
||||||
@@ -3172,8 +3201,12 @@ function setWaterfallControlButtons(running) {
|
|||||||
const startBtn = document.getElementById('startWaterfallBtn');
|
const startBtn = document.getElementById('startWaterfallBtn');
|
||||||
const stopBtn = document.getElementById('stopWaterfallBtn');
|
const stopBtn = document.getElementById('stopWaterfallBtn');
|
||||||
if (!startBtn || !stopBtn) return;
|
if (!startBtn || !stopBtn) return;
|
||||||
startBtn.style.display = running ? 'none' : 'block';
|
startBtn.style.display = running ? 'none' : 'inline-block';
|
||||||
stopBtn.style.display = running ? 'block' : 'none';
|
stopBtn.style.display = running ? 'inline-block' : 'none';
|
||||||
|
const dot = document.getElementById('waterfallStripDot');
|
||||||
|
if (dot) {
|
||||||
|
dot.className = running ? 'status-dot sweeping' : 'status-dot inactive';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWaterfallRangeFromInputs() {
|
function getWaterfallRangeFromInputs() {
|
||||||
@@ -3250,6 +3283,26 @@ async function syncWaterfallToFrequency(freq, options = {}) {
|
|||||||
if (isDirectListening || waterfallMode === 'audio') return { started: false };
|
if (isDirectListening || waterfallMode === 'audio') return { started: false };
|
||||||
|
|
||||||
if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) {
|
if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) {
|
||||||
|
// Reuse existing WebSocket to avoid USB device release race
|
||||||
|
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||||
|
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||||
|
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||||
|
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||||
|
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
waterfallWebSocket.send(JSON.stringify({
|
||||||
|
cmd: 'start',
|
||||||
|
center_freq: (sf + ef) / 2,
|
||||||
|
span_mhz: Math.max(0.1, ef - sf),
|
||||||
|
gain: g,
|
||||||
|
device: dev,
|
||||||
|
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||||
|
fft_size: fft,
|
||||||
|
fps: 25,
|
||||||
|
avg_count: 4,
|
||||||
|
}));
|
||||||
|
return { started: true };
|
||||||
|
}
|
||||||
await stopWaterfall();
|
await stopWaterfall();
|
||||||
return await startWaterfall({ silent: silent });
|
return await startWaterfall({ silent: silent });
|
||||||
}
|
}
|
||||||
@@ -3275,8 +3328,28 @@ async function zoomWaterfall(direction) {
|
|||||||
setWaterfallRange(center, newSpan);
|
setWaterfallRange(center, newSpan);
|
||||||
|
|
||||||
if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) {
|
if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) {
|
||||||
await stopWaterfall();
|
// Reuse existing WebSocket to avoid USB device release race
|
||||||
await startWaterfall({ silent: true });
|
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||||
|
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||||
|
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||||
|
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||||
|
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||||
|
waterfallWebSocket.send(JSON.stringify({
|
||||||
|
cmd: 'start',
|
||||||
|
center_freq: (sf + ef) / 2,
|
||||||
|
span_mhz: Math.max(0.1, ef - sf),
|
||||||
|
gain: g,
|
||||||
|
device: dev,
|
||||||
|
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||||
|
fft_size: fft,
|
||||||
|
fps: 25,
|
||||||
|
avg_count: 4,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
await stopWaterfall();
|
||||||
|
await startWaterfall({ silent: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3885,6 +3958,8 @@ async function stopWaterfall() {
|
|||||||
if (typeof releaseDevice === 'function') {
|
if (typeof releaseDevice === 'function') {
|
||||||
releaseDevice('waterfall');
|
releaseDevice('waterfall');
|
||||||
}
|
}
|
||||||
|
// Allow backend WebSocket handler to finish cleanup and release SDR
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3977,21 +4052,88 @@ function bindWaterfallInteraction() {
|
|||||||
tooltip.style.display = 'none';
|
tooltip.style.display = 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Right-click context menu for "Send to" decoder
|
||||||
|
let ctxMenu = document.getElementById('waterfallCtxMenu');
|
||||||
|
if (!ctxMenu) {
|
||||||
|
ctxMenu = document.createElement('div');
|
||||||
|
ctxMenu.id = 'waterfallCtxMenu';
|
||||||
|
ctxMenu.style.cssText = 'position:fixed;display:none;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:4px;z-index:10000;min-width:120px;padding:4px 0;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-size:11px;';
|
||||||
|
document.body.appendChild(ctxMenu);
|
||||||
|
document.addEventListener('click', () => { ctxMenu.style.display = 'none'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextHandler = (event) => {
|
||||||
|
if (waterfallMode === 'audio') return;
|
||||||
|
event.preventDefault();
|
||||||
|
const canvas = event.currentTarget;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
{ key: 'pager', label: 'Pager' },
|
||||||
|
{ key: 'sensor', label: '433 Sensor' },
|
||||||
|
{ key: 'rtlamr', label: 'RTLAMR' }
|
||||||
|
];
|
||||||
|
|
||||||
|
ctxMenu.innerHTML = `<div style="padding:4px 10px; color:var(--text-muted); font-size:9px; border-bottom:1px solid var(--border-color); margin-bottom:2px;">${freq.toFixed(3)} MHz →</div>` +
|
||||||
|
modes.map(m =>
|
||||||
|
`<div onclick="sendFrequencyToMode(${freq}, '${m.key}')" style="padding:4px 10px; cursor:pointer; color:var(--text-primary);" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'">Send to ${m.label}</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
ctxMenu.style.left = event.clientX + 'px';
|
||||||
|
ctxMenu.style.top = event.clientY + 'px';
|
||||||
|
ctxMenu.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
if (waterfallCanvas) {
|
if (waterfallCanvas) {
|
||||||
waterfallCanvas.style.cursor = 'crosshair';
|
waterfallCanvas.style.cursor = 'crosshair';
|
||||||
waterfallCanvas.addEventListener('click', handler);
|
waterfallCanvas.addEventListener('click', handler);
|
||||||
waterfallCanvas.addEventListener('mousemove', hoverHandler);
|
waterfallCanvas.addEventListener('mousemove', hoverHandler);
|
||||||
waterfallCanvas.addEventListener('mouseleave', leaveHandler);
|
waterfallCanvas.addEventListener('mouseleave', leaveHandler);
|
||||||
|
waterfallCanvas.addEventListener('contextmenu', contextHandler);
|
||||||
}
|
}
|
||||||
if (spectrumCanvas) {
|
if (spectrumCanvas) {
|
||||||
spectrumCanvas.style.cursor = 'crosshair';
|
spectrumCanvas.style.cursor = 'crosshair';
|
||||||
spectrumCanvas.addEventListener('click', handler);
|
spectrumCanvas.addEventListener('click', handler);
|
||||||
spectrumCanvas.addEventListener('mousemove', hoverHandler);
|
spectrumCanvas.addEventListener('mousemove', hoverHandler);
|
||||||
spectrumCanvas.addEventListener('mouseleave', leaveHandler);
|
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.stopDirectListen = stopDirectListen;
|
||||||
window.toggleScanner = toggleScanner;
|
window.toggleScanner = toggleScanner;
|
||||||
window.startScanner = startScanner;
|
window.startScanner = startScanner;
|
||||||
|
|||||||
+11
@@ -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)};
|
||||||
+77
-63
@@ -61,6 +61,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -505,42 +506,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared Waterfall Controls -->
|
|
||||||
<div class="section" id="waterfallControlsSection" style="display: none;">
|
|
||||||
<h3>Waterfall</h3>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Start (MHz)</label>
|
|
||||||
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">End (MHz)</label>
|
|
||||||
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Zoom</label>
|
|
||||||
<div style="display: flex; gap: 6px; align-items: center;">
|
|
||||||
<button class="tune-btn" type="button" onclick="zoomWaterfall('out')" style="padding: 4px 8px;">-</button>
|
|
||||||
<button class="tune-btn" type="button" onclick="zoomWaterfall('in')" style="padding: 4px 8px;">+</button>
|
|
||||||
<span id="waterfallZoomSpan" style="font-size: 10px; color: var(--text-muted);">20.0 MHz</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">FFT Size</label>
|
|
||||||
<select id="waterfallFftSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<option value="512">512</option>
|
|
||||||
<option value="1024" selected>1024</option>
|
|
||||||
<option value="2048">2048</option>
|
|
||||||
<option value="4096">4096</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 8px;">
|
|
||||||
<label style="font-size: 10px;">Gain</label>
|
|
||||||
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
|
|
||||||
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'partials/modes/pager.html' %}
|
{% include 'partials/modes/pager.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/sensor.html' %}
|
{% include 'partials/modes/sensor.html' %}
|
||||||
@@ -607,16 +572,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WATERFALL / SPECTROGRAM PANEL -->
|
|
||||||
<div id="waterfallPanel" class="radio-module-box" style="padding: 10px; display: none; margin-bottom: 12px;">
|
|
||||||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
|
||||||
<span>WATERFALL / SPECTROGRAM</span>
|
|
||||||
<span id="waterfallFreqRange" style="font-size: 9px; color: var(--accent-cyan);"></span>
|
|
||||||
</div>
|
|
||||||
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
|
|
||||||
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Layout Container -->
|
<!-- WiFi Layout Container -->
|
||||||
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
||||||
<!-- Status Bar -->
|
<!-- Status Bar -->
|
||||||
@@ -950,7 +905,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- APRS Visualizations -->
|
<!-- APRS Visualizations -->
|
||||||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px;">
|
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px; overflow: hidden; min-height: 0;">
|
||||||
<!-- APRS Function Bar -->
|
<!-- APRS Function Bar -->
|
||||||
<div class="aprs-strip">
|
<div class="aprs-strip">
|
||||||
<div class="aprs-strip-inner">
|
<div class="aprs-strip-inner">
|
||||||
@@ -1025,7 +980,7 @@
|
|||||||
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
||||||
<!-- Map Panel (larger) -->
|
<!-- Map Panel (larger) -->
|
||||||
<div class="wifi-visual-panel"
|
<div class="wifi-visual-panel"
|
||||||
style="flex: 2; display: flex; flex-direction: column; min-width: 0;">
|
style="flex: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden;">
|
||||||
<h5
|
<h5
|
||||||
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
|
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
|
||||||
APRS STATION MAP</h5>
|
APRS STATION MAP</h5>
|
||||||
@@ -1044,12 +999,12 @@
|
|||||||
|
|
||||||
<!-- Station List Panel -->
|
<!-- Station List Panel -->
|
||||||
<div class="wifi-visual-panel"
|
<div class="wifi-visual-panel"
|
||||||
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column;">
|
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||||||
<h5
|
<h5
|
||||||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px;">
|
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
|
||||||
STATION LIST</h5>
|
STATION LIST</h5>
|
||||||
<div id="aprsFilterBarContainer"></div>
|
<div id="aprsFilterBarContainer" style="flex-shrink: 0;"></div>
|
||||||
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px;">
|
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px; min-height: 0;">
|
||||||
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
|
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
|
||||||
No stations received yet
|
No stations received yet
|
||||||
</div>
|
</div>
|
||||||
@@ -1058,7 +1013,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom row: Packet Log -->
|
<!-- Bottom row: Packet Log -->
|
||||||
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px;">
|
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px; flex-shrink: 0;">
|
||||||
<h5
|
<h5
|
||||||
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
|
||||||
PACKET LOG</h5>
|
PACKET LOG</h5>
|
||||||
@@ -1072,6 +1027,68 @@
|
|||||||
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
|
||||||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
||||||
|
|
||||||
|
<!-- WATERFALL FUNCTION BAR -->
|
||||||
|
<div class="function-strip listening-strip" style="grid-column: span 4;">
|
||||||
|
<div class="function-strip-inner">
|
||||||
|
<span class="strip-title">WATERFALL</span>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<!-- Span display -->
|
||||||
|
<div class="strip-stat">
|
||||||
|
<span class="strip-value" id="waterfallZoomSpan">20.0 MHz</span>
|
||||||
|
<span class="strip-label">SPAN</span>
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<!-- Frequency inputs -->
|
||||||
|
<div class="strip-control">
|
||||||
|
<span class="strip-input-label">START</span>
|
||||||
|
<input type="number" id="waterfallStartFreq" class="strip-input wide" value="88" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="strip-control">
|
||||||
|
<span class="strip-input-label">END</span>
|
||||||
|
<input type="number" id="waterfallEndFreq" class="strip-input wide" value="108" step="0.1">
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<!-- Zoom buttons -->
|
||||||
|
<button type="button" class="strip-btn" onclick="zoomWaterfall('out')">−</button>
|
||||||
|
<button type="button" class="strip-btn" onclick="zoomWaterfall('in')">+</button>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<!-- FFT Size -->
|
||||||
|
<div class="strip-control">
|
||||||
|
<span class="strip-input-label">FFT</span>
|
||||||
|
<select id="waterfallFftSize" class="strip-select">
|
||||||
|
<option value="512">512</option>
|
||||||
|
<option value="1024" selected>1024</option>
|
||||||
|
<option value="2048">2048</option>
|
||||||
|
<option value="4096">4096</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Gain -->
|
||||||
|
<div class="strip-control">
|
||||||
|
<span class="strip-input-label">GAIN</span>
|
||||||
|
<input type="number" id="waterfallGain" class="strip-input" value="40" min="0" max="50">
|
||||||
|
</div>
|
||||||
|
<div class="strip-divider"></div>
|
||||||
|
<!-- Start / Stop -->
|
||||||
|
<button type="button" class="strip-btn primary" id="startWaterfallBtn" onclick="startWaterfall()">▶ START</button>
|
||||||
|
<button type="button" class="strip-btn stop" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none;">◼ STOP</button>
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="strip-status">
|
||||||
|
<div class="status-dot inactive" id="waterfallStripDot"></div>
|
||||||
|
<span id="waterfallFreqRange">STANDBY</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WATERFALL / SPECTROGRAM PANEL -->
|
||||||
|
<div id="waterfallPanel" class="radio-module-box" style="grid-column: span 4; padding: 10px; display: none;">
|
||||||
|
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||||||
|
<span>WATERFALL / SPECTROGRAM</span>
|
||||||
|
<span id="waterfallFreqRangeHeader" style="font-size: 9px; color: var(--accent-cyan);"></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
|
||||||
|
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TOP: FREQUENCY DISPLAY PANEL -->
|
<!-- TOP: FREQUENCY DISPLAY PANEL -->
|
||||||
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
|
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
|
||||||
<div style="display: flex; gap: 15px; align-items: stretch;">
|
<div style="display: flex; gap: 15px; align-items: stretch;">
|
||||||
@@ -3122,15 +3139,11 @@
|
|||||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||||
|
|
||||||
// Show shared waterfall controls for supported modes
|
// Show waterfall panel if running in listening mode
|
||||||
const waterfallControlsSection = document.getElementById('waterfallControlsSection');
|
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||||
const waterfallModes = ['listening'];
|
|
||||||
const waterfallSupported = waterfallModes.includes(mode);
|
|
||||||
if (waterfallControlsSection) waterfallControlsSection.style.display = waterfallSupported ? 'block' : 'none';
|
|
||||||
if (waterfallPanel) {
|
if (waterfallPanel) {
|
||||||
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
|
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
|
||||||
waterfallPanel.style.display = (waterfallSupported && running) ? 'block' : 'none';
|
waterfallPanel.style.display = (mode === 'listening' && running) ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
@@ -3194,6 +3207,7 @@
|
|||||||
SSTVGeneral.init();
|
SSTVGeneral.init();
|
||||||
} else if (mode === 'dmr') {
|
} else if (mode === 'dmr') {
|
||||||
if (typeof checkDmrTools === 'function') checkDmrTools();
|
if (typeof checkDmrTools === 'function') checkDmrTools();
|
||||||
|
if (typeof checkDmrStatus === 'function') checkDmrStatus();
|
||||||
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
|
if (typeof initDmrSynthesizer === 'function') setTimeout(initDmrSynthesizer, 100);
|
||||||
} else if (mode === 'websdr') {
|
} else if (mode === 'websdr') {
|
||||||
if (typeof initWebSDR === 'function') initWebSDR();
|
if (typeof initWebSDR === 'function') initWebSDR();
|
||||||
@@ -9340,10 +9354,10 @@
|
|||||||
listEl.insertBefore(newCard, listEl.firstChild);
|
listEl.insertBefore(newCard, listEl.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep list manageable
|
// Keep list manageable (use live childElementCount, not static NodeList)
|
||||||
const cards = listEl.querySelectorAll('.signal-card');
|
const MAX_APRS_STATION_CARDS = 200;
|
||||||
while (cards.length > 50) {
|
while (listEl.childElementCount > MAX_APRS_STATION_CARDS && listEl.lastElementChild) {
|
||||||
listEl.removeChild(listEl.lastChild);
|
listEl.removeChild(listEl.lastElementChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update filter counts if filter bar exists
|
// Update filter counts if filter bar exists
|
||||||
|
|||||||
@@ -32,6 +32,22 @@
|
|||||||
<label>Gain</label>
|
<label>Gain</label>
|
||||||
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PPM Correction</label>
|
||||||
|
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
|
||||||
|
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 4px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||||
|
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
|
||||||
|
<span>Relax CRC (weak signals)</span>
|
||||||
|
</label>
|
||||||
|
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
||||||
|
Allows more frames through on marginal signals at the cost of occasional errors
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
||||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
||||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
||||||
|
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+36
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import pytest
|
import pytest
|
||||||
from routes.dmr import parse_dsd_output
|
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -98,6 +98,41 @@ def test_parse_unrecognized():
|
|||||||
assert result['text'] == 'some random text'
|
assert result['text'] == 'some random text'
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_fme_flags_differ_from_classic():
|
||||||
|
"""dsd-fme remapped several flags; tables must NOT be identical."""
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_fme_protocol_flags_known_values():
|
||||||
|
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-ft'] # XDMA
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fd']
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-f1'] # NOT -fp (ProVoice in fme)
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == [] # No dedicated flag
|
||||||
|
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_protocol_flags_known_values():
|
||||||
|
"""Classic DSD protocol flags should map to the correct -f flags."""
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv']
|
||||||
|
assert _DSD_PROTOCOL_FLAGS['auto'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsd_fme_modulation_hints():
|
||||||
|
"""C4FM modulation hints should be set for C4FM protocols."""
|
||||||
|
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
|
||||||
|
assert _DSD_FME_MODULATION['p25'] == ['-mc']
|
||||||
|
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
|
||||||
|
# D-Star and ProVoice should not have forced modulation
|
||||||
|
assert 'dstar' not in _DSD_FME_MODULATION
|
||||||
|
assert 'provoice' not in _DSD_FME_MODULATION
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Endpoint tests
|
# Endpoint tests
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
+30
-2
@@ -142,7 +142,7 @@ class DataStore:
|
|||||||
|
|
||||||
|
|
||||||
class CleanupManager:
|
class CleanupManager:
|
||||||
"""Manages periodic cleanup of multiple data stores."""
|
"""Manages periodic cleanup of multiple data stores and database tables."""
|
||||||
|
|
||||||
def __init__(self, interval: float = 60.0):
|
def __init__(self, interval: float = 60.0):
|
||||||
"""
|
"""
|
||||||
@@ -152,9 +152,11 @@ class CleanupManager:
|
|||||||
interval: Cleanup interval in seconds
|
interval: Cleanup interval in seconds
|
||||||
"""
|
"""
|
||||||
self.stores: list[DataStore] = []
|
self.stores: list[DataStore] = []
|
||||||
|
self.db_cleanup_funcs: list[tuple[callable, int]] = [] # (func, interval_multiplier)
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self._timer: threading.Timer | None = None
|
self._timer: threading.Timer | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._cleanup_count = 0
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def register(self, store: DataStore) -> None:
|
def register(self, store: DataStore) -> None:
|
||||||
@@ -169,6 +171,17 @@ class CleanupManager:
|
|||||||
if store in self.stores:
|
if store in self.stores:
|
||||||
self.stores.remove(store)
|
self.stores.remove(store)
|
||||||
|
|
||||||
|
def register_db_cleanup(self, func: callable, interval_multiplier: int = 60) -> None:
|
||||||
|
"""
|
||||||
|
Register a database cleanup function.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Cleanup function to call (should return number of deleted rows)
|
||||||
|
interval_multiplier: How many cleanup cycles to wait between calls (default: 60 = 1 hour if interval is 60s)
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.db_cleanup_funcs.append((func, interval_multiplier))
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start the cleanup timer."""
|
"""Start the cleanup timer."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -194,11 +207,15 @@ class CleanupManager:
|
|||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
def _run_cleanup(self) -> None:
|
def _run_cleanup(self) -> None:
|
||||||
"""Run cleanup on all registered stores."""
|
"""Run cleanup on all registered stores and database tables."""
|
||||||
total_cleaned = 0
|
total_cleaned = 0
|
||||||
|
|
||||||
|
# Cleanup in-memory data stores
|
||||||
with self._lock:
|
with self._lock:
|
||||||
stores = list(self.stores)
|
stores = list(self.stores)
|
||||||
|
db_funcs = list(self.db_cleanup_funcs)
|
||||||
|
self._cleanup_count += 1
|
||||||
|
current_count = self._cleanup_count
|
||||||
|
|
||||||
for store in stores:
|
for store in stores:
|
||||||
try:
|
try:
|
||||||
@@ -206,6 +223,17 @@ class CleanupManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cleaning up {store.name}: {e}")
|
logger.error(f"Error cleaning up {store.name}: {e}")
|
||||||
|
|
||||||
|
# Cleanup database tables (less frequently)
|
||||||
|
for func, interval_multiplier in db_funcs:
|
||||||
|
if current_count % interval_multiplier == 0:
|
||||||
|
try:
|
||||||
|
deleted = func()
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Database cleanup: {func.__name__} removed {deleted} rows")
|
||||||
|
total_cleaned += deleted
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in database cleanup {func.__name__}: {e}")
|
||||||
|
|
||||||
if total_cleaned > 0:
|
if total_cleaned > 0:
|
||||||
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
|
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
|
||||||
|
|
||||||
|
|||||||
@@ -274,3 +274,4 @@ MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes
|
|||||||
|
|
||||||
# Deauth detector sniff timeout (seconds)
|
# Deauth detector sniff timeout (seconds)
|
||||||
DEAUTH_SNIFF_TIMEOUT = 0.5
|
DEAUTH_SNIFF_TIMEOUT = 0.5
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,52 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Alert rules
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
mode TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
match TEXT,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
notify TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Alert events
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_id INTEGER,
|
||||||
|
mode TEXT,
|
||||||
|
event_type TEXT,
|
||||||
|
severity TEXT DEFAULT 'medium',
|
||||||
|
title TEXT,
|
||||||
|
message TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Session recordings
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS recording_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
stopped_at TIMESTAMP,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
event_count INTEGER DEFAULT 0,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
# Users table for authentication
|
# Users table for authentication
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -2123,3 +2169,4 @@ def cleanup_old_payloads(max_age_hours: int = 24) -> int:
|
|||||||
WHERE received_at < datetime('now', ?)
|
WHERE received_at < datetime('now', ?)
|
||||||
''', (f'-{max_age_hours} hours',))
|
''', (f'-{max_age_hours} hours',))
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ TOOL_DEPENDENCIES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -85,11 +85,13 @@ atexit.register(cleanup_all_processes)
|
|||||||
|
|
||||||
# Handle signals for graceful shutdown
|
# Handle signals for graceful shutdown
|
||||||
def _signal_handler(signum, frame):
|
def _signal_handler(signum, frame):
|
||||||
"""Handle termination signals."""
|
"""Handle termination signals.
|
||||||
|
|
||||||
|
Keep this minimal — logging and lock acquisition in signal handlers
|
||||||
|
can deadlock when another thread holds the logging or process lock.
|
||||||
|
Process cleanup is handled by the atexit handler registered above.
|
||||||
|
"""
|
||||||
import sys
|
import sys
|
||||||
logger.info(f"Received signal {signum}, cleaning up...")
|
|
||||||
cleanup_all_processes()
|
|
||||||
# Re-raise KeyboardInterrupt for SIGINT so Flask can handle shutdown
|
|
||||||
if signum == signal.SIGINT:
|
if signum == signal.SIGINT:
|
||||||
raise KeyboardInterrupt()
|
raise KeyboardInterrupt()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||||
from .detection import detect_all_devices
|
from .detection import detect_all_devices, probe_rtlsdr_device
|
||||||
from .rtlsdr import RTLSDRCommandBuilder
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
from .limesdr import LimeSDRCommandBuilder
|
from .limesdr import LimeSDRCommandBuilder
|
||||||
from .hackrf import HackRFCommandBuilder
|
from .hackrf import HackRFCommandBuilder
|
||||||
@@ -229,4 +229,6 @@ __all__ = [
|
|||||||
'validate_device_index',
|
'validate_device_index',
|
||||||
'validate_squelch',
|
'validate_squelch',
|
||||||
'get_capabilities_for_type',
|
'get_capabilities_for_type',
|
||||||
|
# Device probing
|
||||||
|
'probe_rtlsdr_device',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -348,6 +348,68 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def probe_rtlsdr_device(device_index: int) -> str | None:
|
||||||
|
"""Probe whether an RTL-SDR device is available at the USB level.
|
||||||
|
|
||||||
|
Runs a quick ``rtl_test`` invocation targeting a single device to
|
||||||
|
check for USB claim errors that indicate the device is held by an
|
||||||
|
external process (or a stale handle from a previous crash).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_index: The RTL-SDR device index to probe.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An error message string if the device cannot be opened,
|
||||||
|
or ``None`` if the device is available.
|
||||||
|
"""
|
||||||
|
if not _check_tool('rtl_test'):
|
||||||
|
# Can't probe without rtl_test — let the caller proceed and
|
||||||
|
# surface errors from the actual decoder process instead.
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||||
|
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||||
|
env['DYLD_LIBRARY_PATH'] = ':'.join(
|
||||||
|
lib_paths + [current_ld] if current_ld else lib_paths
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
['rtl_test', '-d', str(device_index), '-t'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
output = result.stderr + result.stdout
|
||||||
|
|
||||||
|
if 'usb_claim_interface' in output or 'Failed to open' in output:
|
||||||
|
logger.warning(
|
||||||
|
f"RTL-SDR device {device_index} USB probe failed: "
|
||||||
|
f"device busy or unavailable"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'SDR device {device_index} is busy at the USB level — '
|
||||||
|
f'another process outside INTERCEPT may be using it. '
|
||||||
|
f'Check for stale rtl_fm/rtl_433/dump1090 processes, '
|
||||||
|
f'or try a different device.'
|
||||||
|
)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
# rtl_test opened the device successfully and is running the
|
||||||
|
# test — that means the device *is* available.
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"RTL-SDR probe error for device {device_index}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def detect_all_devices() -> list[SDRDevice]:
|
def detect_all_devices() -> list[SDRDevice]:
|
||||||
"""
|
"""
|
||||||
Detect all connected SDR devices across all supported hardware types.
|
Detect all connected SDR devices across all supported hardware types.
|
||||||
|
|||||||
+18
-4
@@ -72,19 +72,33 @@ def compute_power_spectrum(
|
|||||||
|
|
||||||
def quantize_to_uint8(
|
def quantize_to_uint8(
|
||||||
power_db: np.ndarray,
|
power_db: np.ndarray,
|
||||||
db_min: float = -90.0,
|
db_min: float | None = None,
|
||||||
db_max: float = -20.0,
|
db_max: float | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Clamp and scale dB values to 0-255.
|
"""Clamp and scale dB values to 0-255.
|
||||||
|
|
||||||
|
When *db_min* / *db_max* are ``None`` (the default) the range is
|
||||||
|
derived from the data so the full colour palette is always used.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
power_db: Float32 array of power values in dB.
|
power_db: Float32 array of power values in dB.
|
||||||
db_min: Value mapped to 0.
|
db_min: Value mapped to 0 (auto if None).
|
||||||
db_max: Value mapped to 255.
|
db_max: Value mapped to 255 (auto if None).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Bytes of length len(power_db), each in [0, 255].
|
Bytes of length len(power_db), each in [0, 255].
|
||||||
"""
|
"""
|
||||||
|
if db_min is None or db_max is None:
|
||||||
|
actual_min = float(np.min(power_db))
|
||||||
|
actual_max = float(np.max(power_db))
|
||||||
|
# Guarantee at least 1 dB of dynamic range
|
||||||
|
if actual_max - actual_min < 1.0:
|
||||||
|
actual_max = actual_min + 1.0
|
||||||
|
if db_min is None:
|
||||||
|
db_min = actual_min
|
||||||
|
if db_max is None:
|
||||||
|
db_max = actual_max
|
||||||
|
|
||||||
db_range = db_max - db_min
|
db_range = db_max - db_min
|
||||||
if db_range <= 0:
|
if db_range <= 0:
|
||||||
db_range = 1.0
|
db_range = 1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user