diff --git a/scriptsapi/Readme.md b/scriptsapi/Readme.md
index 81c94a936..36a3a09b9 100644
--- a/scriptsapi/Readme.md
+++ b/scriptsapi/Readme.md
@@ -3,6 +3,7 @@
These scripts are designed to work in Python 3 preferably with version 3.6 or higher. Dependencies are installed with pip in a virtual environment. The sequence of operations is the following:
```
+sudo apt-get install virtualenv gcc g++ gfortran python3-dev
virtualenv -p /usr/bin/python3 venv # Create virtual environment
. ./venv/bin/activate # Activate virtual environment
pip install -r requirements.txt # Install requirements
@@ -188,4 +189,104 @@ If you have presets defined you may also use presets instead of having to set up
"msg": "Start device on deviceset R0"
}
]
-```
\ No newline at end of file
+```
+
+
superscanner.py
+
+Connects to spectrum server to monitor PSD and detect local PSD hotspots to pilot channel(s). Thus channels can follow band activity. This effectively implements a "scanner" feature with parallel tracking of any number of channels. It is FFT based so can effectively track spectrum hotspots simultaneously. Therefore the "super" superlative.
+
+It requires SDRangel version 5.6 or above. On SDRangel instance baseband spectrum should be set in log mode and the spectrum server activated with an accessible address and a port that matches the port given to `superscanner.py`. Please refer to SDRangel documentation for details.
+
+The script runs in daemon mode and is stopped using `Ctl-C`.
+
+Options
+
+ - `-a` or `--address` SDRangel web base address. Default: `127.0.0.1`
+ - `-p` or `--api-port` SDRangel API port. Default: `8091`
+ - `-w` or `--ws-port` SDRangel websocket spectrum server port. Default: `8887`
+ - `-c` or `--config-file` JSON configuration file. Mandatory. See next for format details
+ - `-j` or `--psd-in` JSON file containing PSD floor information previously saved with the `-J` option
+ - `-J` or `--psd-out` Write PSD floor information to JSON file
+ - `-n` or `--nb-passes` Number of passes for PSD floor estimation. Default: `10`
+ - `-f` or `--psd-level` Use a fixed PSD floor value therefore do not perform PSD floor estimaton
+ - `-X` or `--psd-exclude-higher` Level above which to exclude bin scan during PSD floor estimation
+ - `-x` or `--psd-exclude-lower` Level below which to exclude bin scan during PSD floor estimation
+ - `-G` or `--psd-graph` Show PSD floor graphs. Requires `matplotlib`
+ - `-N` or `--hotspots-noise` Number of hotspots above which detection is considered as noise. Default `8`
+ - `-m` or `--margin` Margin in dB above PSD floor to detect acivity. Default: `3`
+ - `-g` or `--group-tolerance` Radius (1D) tolerance in points (bins) for hotspot aggregation. Default `1`
+ - `-r` or `--freq-round` Frequency rounding value in Hz. Default: `1` (no rounding)
+ - `-o` or `--freq-offset` Frequency rounding offset in Hz. Default: `0` (no offset)
+
+Command examples:
+ - `python ./superscanner.py -a 127.0.0.1 -p 8889 -w 8886 -c 446M.json -g 10 -r 12500 -o 6250 -J psd_pmr.json`
+ - `python ./superscanner.py -a 192.168.0.3 -j psd.json -c 145M.json -g 10 -r 2500`
+
+Configuration file
+
+This file drives how channels in the connected SDRangel instance are managed.
+
+```json
+{
+ "deviceset_index": 0, // SDRangel instance deviceset index addressed - required
+ "freqrange_inclusions": [
+ [145170000, 145900000] // List of frequency ranges in Hz to include in processing - optional
+ ],
+ "freqrange_exclusions": [ // List of frequency ranges in Hz to exclude from processing - optional
+ [145000000, 145170000],
+ [145290000, 145335000],
+ [145800000, 146000000]
+ ],
+ "channel_info": [ // List of controlled channels - required
+ { // Channel information - at least one required
+ "index": 0, // Index of channel in deviceset - required
+ "fc_pos": "usb", // Center frequency position in hotspot - optional: default center
+ // lsb: center frequency at end of hotspot (higer frequency)
+ // usb: center frequency at beginning of hotspot (lower frequency)
+ // canter: center frequency at mid-point of hotspot (center frequency)
+ "fc_shift": -300 // Center frequency constant shift from computed frequency - optional
+ },
+ {
+ "index": 2
+ },
+ {
+ "index": 3
+ }
+ ]
+}
+```
+
+Run with supervisord
+
+Refer to supervisord documentation.
+
+Esample of `superscanner.conf` file to put in your `/etc//etc/supervisor/conf.d/` folder (add it in the `[incude]` section of `/etc/supervisor/supervisord.conf`). Environment variable `PYTHONUNBUFFERED=1` is important for the log tail to work correctly.
+
+```
+[program:superscanner]
+command = /opt/build/sdrangel/scriptsapi/venv/bin/python /opt/build/sdrangel/scriptsapi/superscanner.py -a 192.168.0.24 -c /home/f4exb/145M_scan.config.json -g 4 -r 3125 -f -65
+process_name = superscanner
+user = f4exb
+stopsignal = INT
+autostart = false
+autorestart = false
+environment =
+ USER=f4exb,
+ PATH="/home/f4exb/bin:/home/f4exb/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games",
+ HOME="/home/f4exb",
+ PYTHONUNBUFFERED=1
+stdout_logfile = /home/f4exb/log/superscanner.log
+stdout_logfile_maxbytes = 10MB
+stdout_logfile_backups = 3
+redirect_stderr=true
+```
+
+sdrangel.py
+
+Holds constants related to SDRangel software required by other scripts
+
+Unit tests
+
+Run as `python ` in the virtual environment
+
+ - `test_superscanner.py` is testing `superscanner.py`
\ No newline at end of file
diff --git a/scriptsapi/requirements.txt b/scriptsapi/requirements.txt
index b914feac2..836866ef8 100644
--- a/scriptsapi/requirements.txt
+++ b/scriptsapi/requirements.txt
@@ -1,2 +1,6 @@
requests
-Flask
\ No newline at end of file
+Flask
+numpy
+websockets
+websocket-client
+mock
\ No newline at end of file
diff --git a/scriptsapi/sdrangel.py b/scriptsapi/sdrangel.py
new file mode 100644
index 000000000..26667e71b
--- /dev/null
+++ b/scriptsapi/sdrangel.py
@@ -0,0 +1,60 @@
+"""
+Constants that refer to SDRangel software
+"""
+
+# Device keys depending on hardware type (deviceHwType)
+DEVICE_TYPES = {
+ "AirspyHF": {
+ "settings": "airspyHFSettings",
+ "cf_key": "centerFrequency",
+ }
+}
+
+# Channel keys depending on channel type (id)
+CHANNEL_TYPES = {
+ "AMDemod": {
+ "settings": "AMDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "audioMute"
+ },
+ "BFMDemod": {
+ "settings": "BFMDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "volume"
+ },
+ "ChirpChatDemod": {
+ "settings": "ChirpChatDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "decodeActive"
+ },
+ "DSDDemod": {
+ "settings": "DSDDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "audioMute"
+ },
+ "FreeDVDemod": {
+ "settings": "FreeDVDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "audioMute"
+ },
+ "NFMDemod": {
+ "settings": "NFMDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "audioMute"
+ },
+ "SSBDemod": {
+ "settings": "SSBDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "audioMute"
+ },
+ "UDPSink": {
+ "settings": "UDPSinkSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "channelMute"
+ },
+ "WFMDemod": {
+ "settings": "WFMDemodSettings",
+ "df_key": "inputFrequencyOffset",
+ "mute_key": "audioMute"
+ }
+}
\ No newline at end of file
diff --git a/scriptsapi/superscanner.py b/scriptsapi/superscanner.py
new file mode 100644
index 000000000..7c8cedc64
--- /dev/null
+++ b/scriptsapi/superscanner.py
@@ -0,0 +1,453 @@
+#!/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()
diff --git a/scriptsapi/test_superscanner.py b/scriptsapi/test_superscanner.py
new file mode 100644
index 000000000..bd849f219
--- /dev/null
+++ b/scriptsapi/test_superscanner.py
@@ -0,0 +1,211 @@
+import unittest
+import mock
+import superscanner
+
+# ======================================================================
+def print_hex(bytestring):
+ print('\\x' + '\\x'.join('{:02x}'.format(x) for x in bytestring))
+
+# ======================================================================
+def get_deviceset_info(deviceset_index):
+ return {
+ "channelcount": 4,
+ "channels": [
+ {
+ "deltaFrequency": 170000,
+ "direction": 0,
+ "id": "NFMDemod",
+ "index": 0,
+ "title": "NFM Demodulator",
+ "uid": 1590355926650308
+ },
+ {
+ "deltaFrequency": -155000,
+ "direction": 0,
+ "id": "DSDDemod",
+ "index": 1,
+ "title": "DSD Demodulator",
+ "uid": 1590355926718405
+ },
+ {
+ "deltaFrequency": 170000,
+ "direction": 0,
+ "id": "NFMDemod",
+ "index": 2,
+ "title": "NFM Demodulator",
+ "uid": 1590355926939766
+ },
+ {
+ "deltaFrequency": -95000,
+ "direction": 0,
+ "id": "NFMDemod",
+ "index": 3,
+ "title": "NFM Demodulator",
+ "uid": 1590355926945674
+ }
+ ],
+ "samplingDevice": {
+ "bandwidth": 768000,
+ "centerFrequency": 145480000,
+ "deviceNbStreams": 1,
+ "deviceStreamIndex": 0,
+ "direction": 0,
+ "hwType": "AirspyHF",
+ "index": 0,
+ "sequence": 0,
+ "serial": "c852a98040c73f93",
+ "state": "running"
+ }
+ }
+
+# ======================================================================
+def set_channel_frequency(channel):
+ pass
+
+# ======================================================================
+def set_channel_mute(channel):
+ pass
+
+# ======================================================================
+class TestStringMethods(unittest.TestCase):
+
+ def test_upper(self):
+ self.assertEqual('foo'.upper(), 'FOO')
+
+ def test_isupper(self):
+ self.assertTrue('FOO'.isupper())
+ self.assertFalse('Foo'.isupper())
+
+ def test_split(self):
+ s = 'hello world'
+ self.assertEqual(s.split(), ['hello', 'world'])
+ # check that s.split fails when the separator is not a string
+ with self.assertRaises(TypeError):
+ s.split(2)
+
+# ======================================================================
+class TestSuperScannerOptions(unittest.TestCase):
+
+ def test_options_minimal(self):
+ options = superscanner.get_input_options(["-ctoto"])
+ self.assertEqual(options.config_file, 'toto')
+
+# ======================================================================
+class TestSuperScannerDecode(unittest.TestCase):
+
+ def test_decode_bytes(self):
+ msg_bytes = b'\x40\xd9\xab\x08\x00\x00\x00\x00' + \
+ b'\xff\x00\x00\x00\x00\x00\x00\x00' + \
+ b'\x69\x63\xbb\x55\x72\x01\x00\x00' + \
+ b'\x00\x04\x00\x00' + \
+ b'\x00\xb8\x0b\x00' + \
+ b'\x04\x00\x00\x00'
+ for i in range(1024):
+ msg_bytes += b'\x00\x00\x00\x00'
+ msg_struct = superscanner.decode_message(msg_bytes)
+ self.assertEqual(msg_struct['fft_size'], 1024)
+
+# ======================================================================
+class TestSuperScannerProcessHotspots(unittest.TestCase):
+
+ @mock.patch('superscanner.get_deviceset_info', side_effect=get_deviceset_info)
+ def setUp(self, urandom_function):
+ self.options = type('options', (object,), {})()
+ self.options.address = '127.0.0.1'
+ self.options.passes = 10
+ self.options.api_port = 8091
+ self.options.ws_port = 8887
+ self.options.config_file = 'toto'
+ self.options.psd_input_file = None
+ self.options.psd_output_file = None
+ self.options.passes = 10
+ self.options.margin = 3
+ self.options.psd_fixed = None
+ self.options.psd_exclude_higher = None
+ self.options.psd_exclude_lower = None
+ self.options.psd_graph = None
+ self.options.group_tolerance = 1
+ self.options.freq_round = 1
+ self.options.freq_offset = 0
+ superscanner.OPTIONS = self.options
+ superscanner.CONFIG = {
+ "deviceset_index": 0,
+ "freqrange_exclusions": [
+ [145290000, 145335000],
+ [145692500, 145707500]
+ ],
+ "freqrange_inclusions": [
+ [145170000, 145800000]
+ ],
+ "channel_info": [
+ {
+ "index": 0,
+ "fc_pos": "center"
+ },
+ {
+ "index": 2
+ },
+ {
+ "index": 3
+ }
+ ]
+ }
+ superscanner.make_config()
+
+ def test_make_config(self):
+ self.assertEqual(superscanner.CONFIG['device_frequency'], 145480000)
+
+ @mock.patch('superscanner.set_channel_frequency', side_effect=set_channel_frequency)
+ @mock.patch('superscanner.set_channel_mute', side_effect=set_channel_mute)
+ def test_process_hotspot(self, set_channel_frequency, set_channel_mute):
+ hotspots1 = [
+ {
+ 'begin': 145550000,
+ 'end': 145550000,
+ 'power': -50
+ }
+ ]
+ superscanner.process_hotspots(hotspots1)
+ channel_info = superscanner.CONFIG['channel_info']
+ self.assertEqual(channel_info[0]['usage'], 1)
+ self.assertEqual(channel_info[1]['usage'], 0)
+ self.assertEqual(channel_info[2]['usage'], 0)
+ self.assertEqual(channel_info[0]['frequency'], 145550000)
+ hotspots2 = [
+ {
+ 'begin': 145200000,
+ 'end': 145200000,
+ 'power': -35
+ },
+ {
+ 'begin': 145550000,
+ 'end': 145550000,
+ 'power': -50
+ }
+ ]
+ superscanner.process_hotspots(hotspots2)
+ channel_info = superscanner.CONFIG['channel_info']
+ self.assertEqual(channel_info[0]['usage'], 1)
+ self.assertEqual(channel_info[1]['usage'], 1)
+ self.assertEqual(channel_info[2]['usage'], 0)
+ self.assertEqual(channel_info[0]['frequency'], 145550000)
+ self.assertEqual(channel_info[1]['frequency'], 145200000)
+ hotspots3 = [
+ {
+ 'begin': 145200000,
+ 'end': 145200000,
+ 'power': -35
+ }
+ ]
+ superscanner.process_hotspots(hotspots3)
+ channel_info = superscanner.CONFIG['channel_info']
+ self.assertEqual(channel_info[0]['usage'], 0)
+ self.assertEqual(channel_info[1]['usage'], 1)
+ self.assertEqual(channel_info[2]['usage'], 0)
+ self.assertEqual(channel_info[1]['frequency'], 145200000)
+
+
+# ======================================================================
+if __name__ == '__main__':
+ unittest.main()
+