mirror of
				https://github.com/f4exb/sdrangel.git
				synced 2025-11-04 05:30:32 -05:00 
			
		
		
		
	
		
			
	
	
		
			454 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			454 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| 
								 | 
							
								#!/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
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								from datetime import datetime
							 | 
						||
| 
								 | 
							
								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):
							 | 
						||
| 
								 | 
							
								    t = datetime.utcnow()
							 | 
						||
| 
								 | 
							
								    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()
							 |