2020-11-12 19:46:18 +01:00
#!/usr/bin/env python3
"""
Connects to spectrum server to monitor PSD and detect local increase to pilot channel(s)
"""
import requests , traceback , sys , json , time
import struct , operator
import math
import numpy as np
import websocket
try :
import thread
except ImportError :
import _thread as thread
import time
2024-07-21 14:20:48 +02:00
from datetime import datetime , timezone
2020-11-12 19:46:18 +01:00
from optparse import OptionParser
import sdrangel
OPTIONS = None
API_URI = None
WS_URI = None
PASS_INDEX = 0
PSD_FLOOR = []
CONFIG = {}
UNSERVED_FREQUENCIES = []
# ======================================================================
class SuperScannerError ( Exception ):
def __init__ ( self , message ):
self . message = message
# ======================================================================
class SuperScannerWebsocketError ( SuperScannerError ):
pass
# ======================================================================
class SuperScannerWebsocketClosed ( SuperScannerError ):
pass
# ======================================================================
class SuperScannerOptionsError ( SuperScannerError ):
pass
# ======================================================================
class SuperScannerAPIError ( SuperScannerError ):
pass
# ======================================================================
def log_with_timestamp ( message ):
2024-07-21 14:20:48 +02:00
t = datetime . now ( timezone . utc )
2020-11-12 19:46:18 +01:00
print ( f ' { t . isoformat () } { message } ' )
# ======================================================================
def get_input_options ( args = None ):
if args is None :
args = sys . argv [ 1 :]
parser = OptionParser ( usage = "usage: %% prog [-t] \n " )
parser . add_option ( "-a" , "--address" , dest = "address" , help = "SDRangel web base address. Default: 127.0.0.1" , metavar = "ADDRESS" , type = "string" )
parser . add_option ( "-p" , "--api-port" , dest = "api_port" , help = "SDRangel API port. Default: 8091" , metavar = "PORT" , type = "int" )
parser . add_option ( "-w" , "--ws-port" , dest = "ws_port" , help = "SDRangel websocket spectrum server port. Default: 8887" , metavar = "PORT" , type = "int" )
parser . add_option ( "-c" , "--config-file" , dest = "config_file" , help = "JSON configuration file. Mandatory" , metavar = "FILE" , type = "string" )
parser . add_option ( "-j" , "--psd-in" , dest = "psd_input_file" , help = "JSON file containing PSD floor information." , metavar = "FILE" , type = "string" )
parser . add_option ( "-J" , "--psd-out" , dest = "psd_output_file" , help = "Write PSD floor information to JSON file." , metavar = "FILE" , type = "string" )
parser . add_option ( "-n" , "--nb-passes" , dest = "passes" , help = "Number of passes for PSD floor estimation. Default: 10" , metavar = "NUM" , type = "int" )
parser . add_option ( "-m" , "--margin" , dest = "margin" , help = "Margin in dB above PSD floor to detect acivity. Default: 3" , metavar = "DB" , type = "int" )
parser . add_option ( "-f" , "--psd-level" , dest = "psd_fixed" , help = "Use a fixed PSD floor value." , metavar = "DB" , type = "float" )
parser . add_option ( "-X" , "--psd-exclude-higher" , dest = "psd_exclude_higher" , help = "Level above which to exclude bin scan." , metavar = "DB" , type = "float" )
parser . add_option ( "-x" , "--psd-exclude-lower" , dest = "psd_exclude_lower" , help = "Level below which to exclude bin scan." , metavar = "DB" , type = "float" )
parser . add_option ( "-N" , "--hotspots-noise" , dest = "hotspots_noise" , help = "Number of hotspots above which detection is considered as noise. Default 8" , metavar = "NUM" , type = "int" )
parser . add_option ( "-G" , "--psd-graph" , dest = "psd_graph" , help = "Show PSD floor graphs. Requires matplotlib" , action = "store_true" )
parser . add_option ( "-g" , "--group-tolerance" , dest = "group_tolerance" , help = "Radius (1D) tolerance in points (bins) for hotspots grouping. Default 1." , metavar = "NUM" , type = "int" )
parser . add_option ( "-r" , "--freq-round" , dest = "freq_round" , help = "Frequency rounding value in Hz. Default: 1 (no rounding)" , metavar = "NUM" , type = "int" )
parser . add_option ( "-o" , "--freq-offset" , dest = "freq_offset" , help = "Frequency rounding offset in Hz. Default: 0 (no offset)" , metavar = "NUM" , type = "int" )
( options , args ) = parser . parse_args ( args )
if ( options . config_file == None ):
raise SuperScannerOptionsError ( 'A configuration file is required. Option -c or --config-file' )
if ( options . address == None ):
options . address = "127.0.0.1"
if ( options . api_port == None ):
options . api_port = 8091
if ( options . ws_port == None ):
options . ws_port = 8887
if ( options . passes == None ):
options . passes = 10
elif options . passes < 1 :
options . passes = 1
if ( options . margin == None ):
options . margin = 3
if ( options . hotspots_noise == None ):
options . hotspots_noise = 8
if ( options . group_tolerance == None ):
options . group_tolerance = 1
if ( options . freq_round == None ):
options . freq_round = 1
if ( options . freq_offset == None ):
options . freq_offset = 0
return options
# ======================================================================
def on_ws_message ( ws , message ):
global PASS_INDEX
try :
struct_message = decode_message ( message )
if OPTIONS . psd_fixed is not None and OPTIONS . passes > 0 :
compute_fixed_floor ( struct_message )
OPTIONS . passes = 0 # done
elif OPTIONS . psd_input_file is not None and OPTIONS . passes > 0 :
global PSD_FLOOR
with open ( OPTIONS . psd_input_file ) as json_file :
PSD_FLOOR = json . load ( json_file )
OPTIONS . passes = 0 # done
elif OPTIONS . passes > 0 :
compute_floor ( struct_message )
OPTIONS . passes -= 1
PASS_INDEX += 1
print ( f 'PSD floor pass no { PASS_INDEX } ' )
elif OPTIONS . passes == 0 :
OPTIONS . passes -= 1
if OPTIONS . psd_output_file :
with open ( OPTIONS . psd_output_file , 'w' ) as outfile :
json . dump ( PSD_FLOOR , outfile )
if OPTIONS . psd_graph :
show_floor ()
else :
scan ( struct_message )
except Exception as ex :
tb = traceback . format_exc ()
print ( tb , file = sys . stderr )
# ======================================================================
def on_ws_error ( ws , error ):
raise SuperScannerWebsocketError ( f ' { error } ' )
# ======================================================================
def on_ws_close ( ws ):
raise SuperScannerWebsocketClosed ( 'websocket closed' )
# ======================================================================
def on_ws_open ( ws ):
log_with_timestamp ( 'Web socket opened starting...' )
def run ( * args ):
pass
thread . start_new_thread ( run , ())
# ======================================================================
def decode_message ( byte_message ):
struct_message = {}
struct_message [ 'cf' ] = int . from_bytes ( byte_message [ 0 : 8 ], byteorder = 'little' , signed = False )
struct_message [ 'elasped' ] = int . from_bytes ( byte_message [ 8 : 16 ], byteorder = 'little' , signed = False )
struct_message [ 'ts' ] = int . from_bytes ( byte_message [ 16 : 24 ], byteorder = 'little' , signed = False )
struct_message [ 'fft_size' ] = int . from_bytes ( byte_message [ 24 : 28 ], byteorder = 'little' , signed = False )
struct_message [ 'fft_bw' ] = int . from_bytes ( byte_message [ 28 : 32 ], byteorder = 'little' , signed = False )
indicators = int . from_bytes ( byte_message [ 32 : 36 ], byteorder = 'little' , signed = False )
struct_message [ 'linear' ] = ( indicators & 1 ) == 1
struct_message [ 'ssb' ] = (( indicators & 2 ) >> 1 ) == 1
struct_message [ 'usb' ] = (( indicators & 4 ) >> 2 ) == 1
struct_message [ 'samples' ] = []
for sample_index in range ( struct_message [ 'fft_size' ]):
psd = struct . unpack ( 'f' , byte_message [ 36 + 4 * sample_index : 40 + 4 * sample_index ])[ 0 ]
struct_message [ 'samples' ] . append ( psd )
return struct_message
# ======================================================================
def compute_fixed_floor ( struct_message ):
global PSD_FLOOR
nb_samples = len ( struct_message [ 'samples' ])
PSD_FLOOR = [( OPTIONS . psd_fixed , False )] * nb_samples
# ======================================================================
def compute_floor ( struct_message ):
global PSD_FLOOR
fft_size = struct_message [ 'fft_size' ]
psd_samples = struct_message [ 'samples' ]
for psd_index , psd in enumerate ( psd_samples ):
exclude = False
if OPTIONS . psd_exclude_higher :
exclude = psd > OPTIONS . psd_exclude_higher
if OPTIONS . psd_exclude_lower :
exclude = psd < OPTIONS . psd_exclude_lower
if psd_index < len ( PSD_FLOOR ):
PSD_FLOOR [ psd_index ][ 1 ] = exclude or PSD_FLOOR [ psd_index ][ 1 ]
if psd > PSD_FLOOR [ psd_index ][ 0 ]:
PSD_FLOOR [ psd_index ][ 0 ] = psd
else :
PSD_FLOOR . append ([])
PSD_FLOOR [ psd_index ] . append ( psd )
PSD_FLOOR [ psd_index ] . append ( exclude )
# ======================================================================
def show_floor ():
import matplotlib
import matplotlib.pyplot as plt
print ( 'show_floor' )
plt . figure ( 1 )
plt . subplot ( 211 )
plt . plot ([ x [ 1 ] for x in PSD_FLOOR ])
plt . ylabel ( 'PSD exclusion' )
plt . subplot ( 212 )
plt . plot ([ x [ 0 ] for x in PSD_FLOOR ])
plt . ylabel ( 'PSD floor' )
plt . show ()
# ======================================================================
def freq_rounding ( freq , round_freq , round_offset ):
shifted_freq = freq - round_offset
return round ( shifted_freq / round_freq ) * round_freq + round_offset
# ======================================================================
def scan ( struct_message ):
ts = struct_message [ 'ts' ]
freq_density = struct_message [ 'fft_bw' ] / struct_message [ 'fft_size' ]
hotspots = []
hotspot = {}
last_hotspot_index = 0
if struct_message [ 'ssb' ]:
freq_start = struct_message [ 'cf' ]
freq_stop = struct_message [ 'cf' ] + struct_message [ 'fft_bw' ]
else :
freq_start = struct_message [ 'cf' ] - ( struct_message [ 'fft_bw' ] / 2 )
freq_stop = struct_message [ 'cf' ] + ( struct_message [ 'fft_bw' ] / 2 )
psd_samples = struct_message [ 'samples' ]
psd_sum = 0
psd_count = 1
for psd_index , psd in enumerate ( psd_samples ):
freq = freq_start + psd_index * freq_density
if PSD_FLOOR [ psd_index ][ 1 ]: # exclusion zone
continue
if psd > PSD_FLOOR [ psd_index ][ 0 ] + OPTIONS . margin : # detection
psd_sum += 10 ** ( psd / 10 )
psd_count += 1
if psd_index > last_hotspot_index + OPTIONS . group_tolerance : # new hotspot
if hotspot . get ( "begin" ): # finalize previous hotspot
hotspot [ "end" ] = hotspot_end
hotspot [ "power" ] = psd_sum / psd_count
hotspots . append ( hotspot )
hotspot = { "begin" : freq }
psd_sum = 10 ** ( psd / 10 )
psd_count = 1
hotspot_end = freq
last_hotspot_index = psd_index
if hotspot . get ( "begin" ): # finalize last hotspot
hotspot [ "end" ] = hotspot_end
hotspot [ "power" ] = psd_sum / psd_count
hotspots . append ( hotspot )
process_hotspots ( hotspots )
# ======================================================================
def allocate_channel ():
channels = CONFIG [ 'channel_info' ]
for channel in channels :
if channel [ 'usage' ] == 0 :
return channel
return None
# ======================================================================
def freq_in_ranges_check ( freq ):
freqrange_inclusions = CONFIG . get ( 'freqrange_inclusions' , [])
freqrange_exclusions = CONFIG . get ( 'freqrange_exclusions' , [])
for freqrange in freqrange_exclusions :
if freqrange [ 0 ] <= freq <= freqrange [ 1 ]:
return False
for freqrange in freqrange_inclusions :
if freqrange [ 0 ] <= freq <= freqrange [ 1 ]:
return True
return False
# ======================================================================
def get_hotspot_frequency ( channel , hotspot ):
fc_pos = channel . get ( 'fc_pos' , 'center' )
if fc_pos == 'lsb' :
channel_frequency = freq_rounding ( hotspot [ 'end' ], OPTIONS . freq_round , OPTIONS . freq_offset )
elif fc_pos == 'usb' :
channel_frequency = freq_rounding ( hotspot [ 'begin' ], OPTIONS . freq_round , OPTIONS . freq_offset )
else :
channel_frequency = freq_rounding ( hotspot [ 'fc' ], OPTIONS . freq_round , OPTIONS . freq_offset )
fc_shift = channel . get ( 'fc_shift' , 0 )
return channel_frequency + fc_shift
# ======================================================================
def process_hotspots ( scanned_hotspots ):
global CONFIG
global UNSERVED_FREQUENCIES
if len ( scanned_hotspots ) > OPTIONS . hotspots_noise :
return
# calculate frequency for each hotspot and create list of valid hotspots
hotspots = []
for hotspot in scanned_hotspots :
width = hotspot [ 'end' ] - hotspot [ 'begin' ]
fc = hotspot [ 'begin' ] + width / 2
if not freq_in_ranges_check ( fc ):
continue
hotspot [ 'fc' ] = fc
hotspot [ 'begin' ] = fc - ( width / 2 ) # re-center around fc
hotspot [ 'end' ] = fc + ( width / 2 )
hotspots . append ( hotspot )
# calculate hotspot distances for each used channel and reuse the channel for the closest hotspot
channels = CONFIG [ 'channel_info' ]
used_channels = [ channel for channel in channels if channel [ 'usage' ] == 1 ]
consolidated_distances = []
for channel in used_channels : # loop on used channels
distances = [[ abs ( channel [ 'frequency' ] - get_hotspot_frequency ( channel , hotspot )), hotspot ] for hotspot in hotspots ]
distances = sorted ( distances , key = operator . itemgetter ( 0 ))
if distances :
consolidated_distances . append ([ distances [ 0 ][ 0 ], channel , distances [ 0 ][ 1 ]]) # [distance, channel, hotspot]
consolidated_distances = sorted ( consolidated_distances , key = operator . itemgetter ( 0 )) # get (channel, hotspot) pair with shortest distance first
# reallocate used channels on their closest hotspot
for distance in consolidated_distances :
channel = distance [ 1 ]
hotspot = distance [ 2 ]
if hotspot in hotspots : # hotspot is not processed yet
channel_frequency = get_hotspot_frequency ( channel , hotspot )
channel [ 'usage' ] = 2 # mark channel used on this pass
if channel [ 'frequency' ] != channel_frequency : # optimization: do not move to same frequency
channel [ 'frequency' ] = channel_frequency
channel_index = channel [ 'index' ]
set_channel_frequency ( channel )
log_with_timestamp ( f 'Moved channel { channel_index } to frequency { channel_frequency } Hz' )
hotspots . remove ( hotspot ) # done with this hotspot
# for remaining hotspots we need to allocate new channels
for hotspot in hotspots :
channel = allocate_channel ()
if channel :
channel_index = channel [ 'index' ]
channel_frequency = get_hotspot_frequency ( channel , hotspot )
channel [ 'usage' ] = 2 # mark channel used on this pass
channel [ 'frequency' ] = channel_frequency
set_channel_frequency ( channel )
log_with_timestamp ( f 'Allocated channel { channel_index } on frequency { channel_frequency } Hz' )
else :
fc = hotspot [ 'fc' ]
if fc not in UNSERVED_FREQUENCIES :
UNSERVED_FREQUENCIES . append ( fc )
log_with_timestamp ( f 'All channels allocated. Cannot process signal at { fc } Hz' )
# cleanup
for channel in CONFIG [ 'channel_info' ]:
if channel [ 'usage' ] == 1 : # channel unused on this pass
channel [ 'usage' ] = 0 # release it
channel_index = channel [ 'index' ]
fc = channel [ 'frequency' ]
set_channel_mute ( channel )
UNSERVED_FREQUENCIES . clear () # at least one channel is able to serve next time
log_with_timestamp ( f 'Released channel { channel_index } on frequency { fc } Hz' )
elif channel [ 'usage' ] == 2 : # channel used on this pass
channel [ 'usage' ] = 1 # reset usage for next pass
# ======================================================================
def set_channel_frequency ( channel ):
deviceset_index = CONFIG [ 'deviceset_index' ]
channel_index = channel [ 'index' ]
channel_id = channel [ 'id' ]
df = channel [ 'frequency' ] - CONFIG [ 'device_frequency' ]
url = f ' { API_URI } /sdrangel/deviceset/ { deviceset_index } /channel/ { channel_index } /settings'
payload = {
sdrangel . CHANNEL_TYPES [ channel_id ][ 'settings' ]: {
sdrangel . CHANNEL_TYPES [ channel_id ][ 'df_key' ]: df ,
sdrangel . CHANNEL_TYPES [ channel_id ][ 'mute_key' ]: 0
},
'channelType' : channel_id ,
'direction' : 0
}
r = requests . patch ( url = url , json = payload )
if r . status_code // 100 != 2 :
raise SuperScannerAPIError ( f 'Set channel { channel_index } frequency failed' )
# ======================================================================
def set_channel_mute ( channel ):
deviceset_index = CONFIG [ 'deviceset_index' ]
channel_index = channel [ 'index' ]
channel_id = channel [ 'id' ]
url = f ' { API_URI } /sdrangel/deviceset/ { deviceset_index } /channel/ { channel_index } /settings'
payload = {
sdrangel . CHANNEL_TYPES [ channel_id ][ 'settings' ]: {
sdrangel . CHANNEL_TYPES [ channel_id ][ 'mute_key' ]: 1
},
'channelType' : channel_id ,
'direction' : 0
}
r = requests . patch ( url = url , json = payload )
if r . status_code // 100 != 2 :
raise SuperScannerAPIError ( f 'Set channel { channel_index } mute failed' )
# ======================================================================
def get_deviceset_info ( deviceset_index ):
url = f ' { API_URI } /sdrangel/deviceset/ { deviceset_index } '
r = requests . get ( url = url )
if r . status_code // 100 != 2 :
raise SuperScannerAPIError ( f 'Get deviceset { deviceset_index } info failed' )
return r . json ()
# ======================================================================
def make_config ():
global CONFIG
deviceset_index = CONFIG [ 'deviceset_index' ]
deviceset_info = get_deviceset_info ( deviceset_index )
device_frequency = deviceset_info [ "samplingDevice" ][ "centerFrequency" ]
CONFIG [ 'device_frequency' ] = device_frequency
for channel_info in CONFIG [ 'channel_info' ]:
channel_index = channel_info [ 'index' ]
if channel_index < deviceset_info [ 'channelcount' ]:
channel_offset = deviceset_info [ 'channels' ][ channel_index ][ 'deltaFrequency' ]
channel_id = deviceset_info [ 'channels' ][ channel_index ][ 'id' ]
channel_info [ 'id' ] = channel_id
channel_info [ 'usage' ] = 0 # 0: unused 1: used 2: reused in current allocation step (temporary state)
channel_info [ 'frequency' ] = device_frequency + channel_offset
else :
raise SuperScannerAPIError ( f 'There is no channel with index { channel_index } in deviceset { deviceset_index } ' )
# ======================================================================
def main ():
try :
global OPTIONS
global CONFIG
global API_URI
global WS_URI
OPTIONS = get_input_options ()
log_with_timestamp ( f 'Start with options: { OPTIONS } ' )
with open ( OPTIONS . config_file ) as json_file : # get base config
CONFIG = json . load ( json_file )
log_with_timestamp ( f 'Initial configuration: { CONFIG } ' )
API_URI = f 'http:// { OPTIONS . address } : { OPTIONS . api_port } '
WS_URI = f 'ws:// { OPTIONS . address } : { OPTIONS . ws_port } '
make_config () # complete config with device set information from SDRangel
ws = websocket . WebSocketApp ( WS_URI ,
on_message = on_ws_message ,
on_error = on_ws_error ,
on_close = on_ws_close )
ws . on_open = on_ws_open
ws . run_forever ()
except SuperScannerWebsocketError as ex :
print ( ex . message )
except SuperScannerWebsocketClosed :
print ( "Spectrum websocket closed" )
except Exception as ex :
tb = traceback . format_exc ()
print ( tb , file = sys . stderr )
# ======================================================================
if __name__ == "__main__" :
main ()