├── .coveragerc ├── .github └── workflows │ ├── ci-tests.yml │ └── packaging-tests.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── Rtl WWB Scanner.e4p ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── data │ ├── 572-636.csv │ └── 572-636.sdb2 ├── test_dbstore.py ├── test_io.py ├── test_sample_gen.py └── test_scan_objects.py └── wwb_scanner ├── __init__.py ├── core.py ├── file_handlers ├── __init__.py ├── exporters.py └── importers.py ├── log_config.py ├── scan_objects ├── __init__.py ├── sample.py ├── samplearray.py └── spectrum.py ├── scanner ├── __init__.py ├── config.py ├── main.py ├── rtlpower_scan.py ├── sample_processing.py └── sdrwrapper.py ├── ui ├── __init__.py ├── plots.py └── pyside │ ├── __init__.py │ ├── device_config.py │ ├── graph.py │ ├── main.py │ ├── qml │ ├── ChartSelect.qml │ ├── ChartSelectDelegate.qml │ ├── Crosshair.qml │ ├── DeviceConfigPopup.qml │ ├── ExportDialog.qml │ ├── Graph.qml │ ├── GraphHViewControls.qml │ ├── GraphViewController.qml │ ├── ImportDialog.qml │ ├── LiveSpectrumGraph.qml │ ├── NumberInput.qml │ ├── ScanConfig.qml │ ├── ScanControls.qml │ ├── ScanControlsDialog.qml │ ├── SpectrumGraph.qml │ ├── ThemeSelectPopup.qml │ ├── UHFChannel.qml │ ├── UHFChannels.qml │ ├── ViewScale.qml │ ├── ViewXYScale.qml │ ├── XScrollBar.qml │ └── main.qml │ ├── scanner.py │ └── utils.py └── utils ├── __init__.py ├── color.py ├── config.py ├── dbmath.py ├── dbstore.py └── numpyjson.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = wwb_scanner/* 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | def __repr__ 8 | raise NotImplementedError 9 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: CI Tests 2 | 3 | on: 4 | push: 5 | branches: [ master, develop ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | schedule: 11 | - cron: '0 0 * * SUN' 12 | 13 | jobs: 14 | test: 15 | 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ['3.8', '3.9', '3.10', '3.11'] 20 | fail-fast: false 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip setuptools wheel 31 | pip install -U pytest pytest-cov coveralls 32 | pip install -e . 33 | - name: Test with pytest 34 | run: | 35 | py.test --cov-config .coveragerc --cov=wwb_scanner 36 | - name: Upload to Coveralls 37 | run: coveralls --service=github 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | COVERALLS_FLAG_NAME: run-${{ matrix.python-version }} 41 | COVERALLS_PARALLEL: true 42 | 43 | coveralls: 44 | name: Indicate completion to coveralls.io 45 | needs: test 46 | runs-on: ubuntu-latest 47 | container: python:3-slim 48 | steps: 49 | - name: Finished 50 | run: | 51 | pip3 install --upgrade coveralls 52 | coveralls --finish 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/packaging-tests.yml: -------------------------------------------------------------------------------- 1 | name: Packaging Tests 2 | 3 | on: 4 | push: 5 | branches: [ master, develop ] 6 | pull_request: 7 | branches: [ master ] 8 | release: 9 | types: [created] 10 | workflow_dispatch: 11 | 12 | schedule: 13 | - cron: '0 0 * * SUN' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: 3.8 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip setuptools wheel 27 | - name: Build dists 28 | run: python setup.py sdist bdist_wheel 29 | - name: Upload artifacts 30 | uses: actions/upload-artifact@v2 31 | with: 32 | name: 'dists' 33 | path: 'dist/*' 34 | 35 | test: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | python-version: ['3.8', '3.9', '3.10', '3.11'] 41 | fail-fast: false 42 | 43 | steps: 44 | - uses: actions/checkout@v2 45 | - name: Delete package root 46 | run: rm -Rf wwb_scanner 47 | - name: Set up Python ${{ matrix.python-version }} 48 | uses: actions/setup-python@v2 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | - name: Install dependencies 52 | run: | 53 | python -m pip install --upgrade pip setuptools wheel 54 | pip install -U pytest pytest-cov coveralls 55 | - name: Download artifacts 56 | uses: actions/download-artifact@v2 57 | with: 58 | name: 'dists' 59 | path: dist 60 | - name: Install wheel 61 | run: pip install dist/*.whl 62 | - name: Test built wheel 63 | run: py.test -o testpaths=tests 64 | - name: Install sdist 65 | run: | 66 | pip uninstall -y rtlsdr-wwb-scanner 67 | pip install dist/*.tar.gz 68 | - name: Test built sdist 69 | run: py.test -o testpaths=tests 70 | 71 | deploy: 72 | needs: test 73 | if: ${{ success() && github.event_name == 'release' }} 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v2 77 | - name: Set up Python ${{ matrix.python-version }} 78 | uses: actions/setup-python@v2 79 | with: 80 | python-version: 3.8 81 | - name: Install dependencies 82 | run: | 83 | python -m pip install --upgrade pip setuptools wheel twine 84 | - name: Download artifacts 85 | uses: actions/download-artifact@v2 86 | with: 87 | name: 'dists' 88 | path: dist 89 | - name: Publish to PyPI 90 | env: 91 | TWINE_REPOSITORY_URL: ${{ secrets.TWINE_REPOSITORY_URL }} 92 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 93 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 94 | run: twine upload dist/* 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | *.csv 60 | *.sdb 61 | *.sdb2 62 | 63 | venv* 64 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE* 2 | include README* 3 | include wwb_scanner/ui/pyside/qml/*.qml 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rtlsdr-wwb-scanner 2 | 3 | RF Scanner and Exporter for use with Shure Wireless Workbench 4 | 5 | Allows wide-band RF scans to be performed by an inexpensive [RTL-SDR][osmosdr-wiki] device. 6 | The scan data can then be exported as a CSV file formatted for use in WWB. 7 | 8 | ## Installation 9 | 10 | ### librtlsdr 11 | 12 | The `librtlsdr` library is required and must be installed separately. 13 | Installation documentation for various platforms can be found on the [osmocom wiki][osmosdr-wiki] 14 | and in the [pyrtlsdr project][pyrtlsdr]. 15 | 16 | ### Install via pip 17 | 18 | ```bash 19 | pip install rtlsdr-wwb-scanner 20 | ``` 21 | 22 | It is recommended however to install into a virtual environment such as 23 | [virtualenv](https://pypi.org/project/virtualenv/) or Python's built-in 24 | [venv](https://docs.python.org/3.8/library/venv.html) module. 25 | 26 | 27 | ```bash 28 | # Create the environment using the built-in venv module 29 | python3 -m venv /path/to/new/virtual/environment 30 | 31 | # Activate it using /bin/activate 32 | source /path/to/new/virtual/environment/bin/activate 33 | 34 | # Install rtlsdr-wwb-scanner and its dependencies in the virtual environment 35 | python -m pip install rtlsdr-wwb-scanner 36 | ``` 37 | 38 | *Note* for Windows users: The `bin` directory should be replaced with `Scripts` 39 | making the "activate" command `/Scripts/activate` 40 | 41 | 42 | ## Dependencies 43 | 44 | These packages are required, but should be collected and installed automatically: 45 | 46 | * Numpy: https://numpy.org 47 | * Scipy: https://scipy.org/scipylib/index.html 48 | * pyrtlsdr: https://github.com/roger-/pyrtlsdr 49 | * PySide2: https://pypi.org/project/PySide2/ 50 | 51 | ## Usage 52 | 53 | After installation, the user interface can be launched by: 54 | 55 | ```bash 56 | wwb_scanner-ui 57 | ``` 58 | 59 | If a virtual environment was used, it must either be activated (see above) or 60 | the `wwb_scanner-ui` script must be executed by its absolute file name: 61 | 62 | ```bash 63 | /path/to/new/virtual/environment/bin/wwb_scanner-ui 64 | ``` 65 | 66 | Or for Windows: 67 | 68 | ```bash 69 | /path/to/new/virtual/environment/Scripts/wwb_scanner-ui 70 | ``` 71 | 72 | For convenience, a shortcut may be created to launch the above script directly. 73 | 74 | 75 | [osmosdr-wiki]: http://sdr.osmocom.org/trac/wiki/rtl-sdr 76 | [pyrtlsdr]: https://github.com/roger-/pyrtlsdr 77 | [scipy-install]: http://www.scipy.org/install.html 78 | -------------------------------------------------------------------------------- /Rtl WWB Scanner.e4p: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | e17531231962011160a39fb60c24b5676fe49992 8 | Python3 9 | Other 10 | 11 | 0.1 12 | 13 | 14 | 15 | 16 | wwb_scanner/__init__.py 17 | wwb_scanner/file_handlers/__init__.py 18 | wwb_scanner/scan_objects/__init__.py 19 | wwb_scanner/scan_objects/spectrum.py 20 | wwb_scanner/scan_objects/sample.py 21 | wwb_scanner/file_handlers/exporters.py 22 | wwb_scanner/scanner/__init__.py 23 | wwb_scanner/scanner/main.py 24 | wwb_scanner/scanner/sample_processing.py 25 | wwb_scanner/ui/__init__.py 26 | wwb_scanner/ui/plots.py 27 | wwb_scanner/file_handlers/importers.py 28 | wwb_scanner/scanner/rtlpower_scan.py 29 | wwb_scanner/core.py 30 | wwb_scanner/utils/__init__.py 31 | wwb_scanner/utils/numpyjson.py 32 | wwb_scanner/scanner/sdrwrapper.py 33 | wwb_scanner/ui/kivyui/__init__.py 34 | wwb_scanner/ui/kivyui/main.py 35 | kivyapp.py 36 | wwb_scanner/ui/kivyui/plots.py 37 | wwb_scanner/ui/kivyui/scan.py 38 | wwb_scanner/ui/kivyui/actions.py 39 | wwb_scanner/utils/color.py 40 | wwb_scanner/scanner/config.py 41 | wwb_scanner/utils/dbstore.py 42 | wwb_scanner/utils/config.py 43 | 44 | 45 | 46 | 47 | 48 | 49 | requirements.txt 50 | wwb_scanner/ui/kivyui/plots.kv 51 | wwb_scanner/ui/kivyui/scan.kv 52 | wwb_scanner/ui/kivyui/rtlsdrscanner.kv 53 | 54 | 55 | Git 56 | 57 | 58 | 59 | add 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | checkout 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | commit 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | diff 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | export 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | global 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | history 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | log 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | remove 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | status 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | tag 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | update 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = *.py 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.13 2 | scipy 3 | pyrtlsdr 4 | tinydb==3.13.0 5 | json-object-factory 6 | PySide2>=5.13.1 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | name = rtlsdr-wwb-scanner 6 | version = 0.0.1 7 | author = Matthew Reid 8 | author_email = matt@nomadic-recording.com 9 | url = https://github.com/nocarryr/rtlsdr-wwb-scanner 10 | description = RF Scanner and Exporter for use with Shure Wireless Workbench 11 | long_description = file: README.md 12 | long_description_content_type = text/markdown 13 | license = GNU General Public License v2 (GPLv2) 14 | license_file = LICENSE 15 | platforms = any 16 | classifiers = 17 | Development Status :: 3 - Alpha 18 | License :: OSI Approved :: GNU General Public License v2 (GPLv2) 19 | Topic :: Multimedia :: Sound/Audio 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3.5 22 | Programming Language :: Python :: 3.6 23 | Programming Language :: Python :: 3.7 24 | 25 | [options] 26 | zip_safe = False 27 | packages: find: 28 | include_package_data = True 29 | install_requires = 30 | numpy>=1.13 31 | scipy 32 | pyrtlsdr>=0.2.6 33 | tinydb==3.13.0 34 | json-object-factory 35 | PySide2>=5.13.1 36 | 37 | [options.packages.find] 38 | exclude = 39 | tests 40 | 41 | [options.package_data] 42 | wwb_scanner = ui/pyside/qml/*.qml 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | USE_CX_FREEZE = False 5 | if 'cx_freeze' in sys.argv: 6 | USE_CX_FREEZE = True 7 | sys.argv.remove('cx_freeze') 8 | 9 | if USE_CX_FREEZE: 10 | from cx_Freeze import setup, Executable 11 | else: 12 | from setuptools import setup 13 | 14 | setup_data = dict( 15 | entry_points={ 16 | 'console_scripts':[ 17 | 'wwb_scanner-ui = wwb_scanner.ui.pyside.main:run', 18 | ], 19 | }, 20 | ) 21 | 22 | if USE_CX_FREEZE: 23 | exe_base = None 24 | if sys.platform == 'win32': 25 | exe_base = "Win32GUI" 26 | ui_script_path = Path('.') / 'wwb_scanner' / 'ui' / 'pyside' / 'main.py' 27 | setup_data['executables'] = [ 28 | Executable(str(ui_script_path), base=exe_base, targetName='rtlsdr-wwb-scanner'), 29 | ] 30 | deps = [ 31 | 'numpy', 'scipy', 'rtlsdr', 'PySide2', 32 | ] 33 | 34 | # https://github.com/anthony-tuininga/cx_Freeze/issues/233#issuecomment-348078191 35 | excludes = ['scipy.spatial.cKDTree'] 36 | 37 | setup_data['options'] = { 38 | 'build_exe':{'packages':deps, 'excludes':excludes}, 39 | } 40 | 41 | setup(**setup_data) 42 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | import numpy as np 5 | from scipy import signal 6 | 7 | @pytest.fixture 8 | def tmp_db_store(tmpdir, monkeypatch): 9 | db_root = tmpdir.mkdir('db_root') 10 | db_path = db_root.join('db.json') 11 | scan_db_path = db_root.join('scan_db.json') 12 | monkeypatch.setattr('wwb_scanner.utils.dbstore.DBStore.DB_PATH', str(db_path)) 13 | monkeypatch.setattr('wwb_scanner.utils.dbstore.DBStore.SCAN_DB_PATH', str(scan_db_path)) 14 | return {'db_path':db_path, 'scan_db_path':scan_db_path} 15 | 16 | @pytest.fixture 17 | def random_samples(): 18 | def gen(n=1024, rs=2.048e6, fc=800e6): 19 | a = np.random.randint(low=-128, high=128, size=n) 20 | sig = signal.hilbert(a / 256.) 21 | freqs, Pxx = signal.welch(sig, fs=rs, return_onesided=False) 22 | if np.count_nonzero(Pxx) != Pxx.size: 23 | ix = np.argwhere(Pxx==0) 24 | Pxx[ix] += 1. / 1e8 25 | s_ix = np.argsort(freqs) 26 | freqs = freqs[s_ix] 27 | Pxx = Pxx[s_ix] 28 | 29 | freqs += fc 30 | Pxx *= fc 31 | freqs /= 1e6 32 | 33 | return freqs, sig, Pxx 34 | return gen 35 | 36 | @pytest.fixture 37 | def data_files(): 38 | base_path = os.path.dirname(os.path.abspath(__file__)) 39 | data_dir = os.path.join(base_path, 'data') 40 | 41 | files = {} 42 | for fn in os.listdir(data_dir): 43 | if os.path.splitext(fn)[1] not in ['.csv', '.sdb2']: 44 | continue 45 | s = os.path.splitext(os.path.basename(fn))[0] 46 | start_freq, end_freq = [float(v) for v in s.split('-')] 47 | fkey = (start_freq, end_freq) 48 | skey = os.path.splitext(fn)[1].strip('.') 49 | if fkey not in files: 50 | files[fkey] = {} 51 | files[fkey][skey] = os.path.join(data_dir, fn) 52 | 53 | return files 54 | -------------------------------------------------------------------------------- /tests/test_dbstore.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def test_dbstore_monkeypatch(tmp_db_store): 4 | from wwb_scanner.scan_objects import Spectrum 5 | from wwb_scanner.scan_objects.spectrum import db_store as _spec_db_store 6 | from wwb_scanner.utils import dbstore 7 | 8 | 9 | assert dbstore.DBStore.DB_PATH == dbstore.db_store.DB_PATH == tmp_db_store['db_path'] 10 | assert dbstore.DBStore.SCAN_DB_PATH == dbstore.db_store.SCAN_DB_PATH == tmp_db_store['scan_db_path'] 11 | assert _spec_db_store.DB_PATH == tmp_db_store['db_path'] 12 | assert _spec_db_store.SCAN_DB_PATH == tmp_db_store['scan_db_path'] 13 | 14 | def test_dbstore(tmp_db_store, data_files, random_samples): 15 | from wwb_scanner.file_handlers import BaseImporter 16 | from wwb_scanner.scan_objects import Spectrum 17 | from wwb_scanner.utils.dbstore import db_store 18 | 19 | spec = {} 20 | for fkey, d in data_files.items(): 21 | for skey, fn in d.items(): 22 | spectrum = BaseImporter.import_file(fn) 23 | spectrum.save_to_dbstore() 24 | assert spectrum.eid is not None 25 | spec[spectrum.eid] = spectrum 26 | 27 | rs = 1.024e6 28 | nsamp = 256 29 | step_size = 0.5e6 30 | freq_range = [572e6, 636e6] 31 | 32 | spectrum = Spectrum() 33 | fc = freq_range[0] 34 | while True: 35 | freqs, sig, Pxx = random_samples(n=nsamp, rs=rs, fc=fc) 36 | spectrum.add_sample_set(frequency=freqs, iq=Pxx) 37 | if spectrum.sample_data['frequency'].max() >= freq_range[1] / 1e6: 38 | break 39 | fc += step_size 40 | 41 | spectrum.save_to_dbstore() 42 | assert spectrum.eid is not None 43 | spec[spectrum.eid] = spectrum 44 | 45 | for eid, spectrum in spec.items(): 46 | db_spectrum = Spectrum.from_dbstore(eid=spectrum.eid) 47 | for attr in Spectrum._serialize_attrs: 48 | assert getattr(spectrum, attr) == getattr(db_spectrum, attr) 49 | assert np.array_equal(spectrum.sample_data, db_spectrum.sample_data) 50 | 51 | db_data = db_store.get_all_scans() 52 | 53 | assert set(db_data.keys()) == set(spec.keys()) 54 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | 5 | def test_importers(data_files): 6 | from wwb_scanner.file_handlers import BaseImporter 7 | 8 | spec = {} 9 | for fkey, d in data_files.items(): 10 | if fkey not in spec: 11 | spec[fkey] = {} 12 | for skey, fn in d.items(): 13 | spectrum = BaseImporter.import_file(fn) 14 | spec[fkey][skey] = spectrum 15 | 16 | for fkey, d in spec.items(): 17 | start_freq, end_freq = fkey 18 | for spectrum in d.values(): 19 | freqs = spectrum.sample_data['frequency'] 20 | assert freqs.min() <= start_freq 21 | assert freqs.max() >= end_freq 22 | assert not np.any(np.isnan(spectrum.sample_data['magnitude'])) 23 | assert not np.any(np.isnan(spectrum.sample_data['dbFS'])) 24 | 25 | def test_exporters(data_files, tmpdir): 26 | from wwb_scanner.scan_objects import Spectrum 27 | 28 | for fkey, d in data_files.items(): 29 | p = tmpdir.mkdir('-'.join((str(f) for f in fkey))) 30 | for skey, fn in d.items(): 31 | src_spectrum = Spectrum.import_from_file(fn) 32 | assert not np.any(np.isnan(src_spectrum.sample_data['magnitude'])) 33 | assert not np.any(np.isnan(src_spectrum.sample_data['dbFS'])) 34 | for ext in ['csv', 'sdb2']: 35 | exp_fn = p.join('{}_src.{}'.format(skey, ext)) 36 | src_spectrum.export_to_file(filename=str(exp_fn)) 37 | imp_spectrum = Spectrum.import_from_file(str(exp_fn)) 38 | 39 | # Account for frequency units in KHz for sdb2 40 | assert np.allclose(src_spectrum.sample_data['frequency'], imp_spectrum.sample_data['frequency']) 41 | # sdb2 stores dB values rounded to the first decimal 42 | assert np.array_equal( 43 | np.around(src_spectrum.sample_data['dbFS'], 1), 44 | np.around(imp_spectrum.sample_data['dbFS'], 1), 45 | ) 46 | 47 | def test_io(tmpdir, random_samples): 48 | from wwb_scanner.scan_objects import Spectrum 49 | 50 | rs = 1.024e6 51 | nsamp = 256 52 | step_size = 0.5e6 53 | freq_range = [572e6, 636e6] 54 | 55 | def build_data(fc): 56 | freqs, sig, Pxx = random_samples(n=nsamp, rs=rs, fc=fc) 57 | 58 | return freqs, Pxx 59 | 60 | spectrum = Spectrum(step_size=step_size) 61 | spectrum.color['r'] = 1. 62 | 63 | fc = freq_range[0] 64 | 65 | while True: 66 | freqs, ff = build_data(fc) 67 | spectrum.add_sample_set(frequency=freqs, iq=ff) 68 | if spectrum.sample_data['frequency'].max() >= freq_range[1] / 1e6: 69 | break 70 | fc += step_size 71 | 72 | dB = np.around(spectrum.sample_data['dbFS'], decimals=1) 73 | 74 | p = tmpdir.mkdir('test_io') 75 | for ext in ['csv', 'sdb2']: 76 | fn = p.join('foo.{}'.format(ext)) 77 | spectrum.export_to_file(filename=str(fn)) 78 | imp_spectrum = Spectrum.import_from_file(str(fn)) 79 | 80 | assert np.allclose(spectrum.sample_data['frequency'], imp_spectrum.sample_data['frequency']) 81 | imp_dB = np.around(imp_spectrum.sample_data['dbFS'], decimals=1) 82 | assert np.allclose(dB, imp_dB) 83 | -------------------------------------------------------------------------------- /tests/test_sample_gen.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def test_sample_gen(random_samples): 4 | for i in range(256): 5 | freqs, sig, Pxx = random_samples(256) 6 | 7 | print(i) 8 | 9 | z = np.count_nonzero(Pxx) == Pxx.size 10 | if not z: 11 | print('-------sig--------') 12 | print(sig) 13 | print('-------Pxx--------') 14 | print(Pxx) 15 | print('------') 16 | ix = np.argwhere(Pxx==0) 17 | print(ix, sig[ix], Pxx[ix]) 18 | assert z is True 19 | -------------------------------------------------------------------------------- /tests/test_scan_objects.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | def test_sample_array(random_samples): 4 | from wwb_scanner.scan_objects import SampleArray 5 | 6 | rs = 2.048e6 7 | fc = 600e6 8 | 9 | a = SampleArray() 10 | 11 | freqs, sig, ff = random_samples(rs=rs, fc=fc) 12 | 13 | b = SampleArray.create(frequency=freqs, iq=ff) 14 | a.insert_sorted(b) 15 | 16 | assert np.array_equal(a.data, b.data) 17 | 18 | assert np.array_equal(a.frequency, freqs) 19 | assert np.array_equal(a.iq, ff) 20 | assert np.array_equal(a.magnitude, np.abs(ff)) 21 | assert np.array_equal(a.dbFS, 10 * np.log10(np.abs(ff))) 22 | 23 | assert np.array_equal(a.frequency, a['frequency']) 24 | assert np.array_equal(a.iq, a['iq']) 25 | assert np.array_equal(a.magnitude, a['magnitude']) 26 | assert np.array_equal(a.dbFS, a['dbFS']) 27 | 28 | fc = 900e6 29 | 30 | freqs, sig, ff = random_samples(rs=rs, fc=fc) 31 | 32 | a.set_fields(frequency=freqs, iq=ff) 33 | c = SampleArray.create(frequency=freqs, iq=ff) 34 | 35 | ix = np.flatnonzero(np.in1d(a.frequency, c.frequency)) 36 | assert np.array_equal(a.data[ix], c.data) 37 | 38 | def test_spectrum(random_samples): 39 | from wwb_scanner.scan_objects import Spectrum 40 | 41 | rs = 2.048e6 42 | fc = 600e6 43 | 44 | spectrum = Spectrum() 45 | freqs, sig, ff = random_samples(rs=rs, fc=fc) 46 | 47 | for freq, val in zip(freqs, ff): 48 | spectrum.add_sample(frequency=freq, iq=val) 49 | 50 | assert np.array_equal(spectrum.sample_data['frequency'], freqs) 51 | assert np.array_equal(spectrum.sample_data['iq'], ff) 52 | assert np.array_equal(spectrum.sample_data['magnitude'], np.abs(ff)) 53 | assert np.array_equal(spectrum.sample_data['dbFS'], 10 * np.log10(np.abs(ff))) 54 | 55 | def test_add_sample_set(random_samples): 56 | from wwb_scanner.scan_objects import Spectrum 57 | from wwb_scanner.scan_objects import SampleArray 58 | 59 | rs = 2.000e6 60 | 61 | def build_data(fc): 62 | freqs, sig, Pxx = random_samples(n=256, rs=rs, fc=fc) 63 | 64 | return freqs, Pxx 65 | 66 | def build_struct_data(freqs, ff): 67 | data = np.zeros(freqs.size, dtype=SampleArray.dtype) 68 | data['frequency'] = freqs 69 | data['iq'] = ff 70 | data['magnitude'] = np.abs(ff) 71 | data['dbFS'] = 10 * np.log10(np.abs(ff)) 72 | return data 73 | 74 | def get_overlap_arrays(data1, data2): 75 | freqs1 = data1['frequency'] 76 | freqs2 = data2['frequency'] 77 | overlap_data1 = data1[np.flatnonzero(np.in1d(freqs1, freqs2))] 78 | overlap_data2 = data2[np.flatnonzero(np.in1d(freqs2, freqs1))] 79 | avg_data = np.zeros(overlap_data1.size, dtype=overlap_data1.dtype) 80 | avg_data['frequency'] = overlap_data1['frequency'] 81 | for key in ['iq', 'magnitude', 'dbFS']: 82 | avg_data[key] = np.mean([overlap_data1[key], overlap_data2[key]], axis=0) 83 | non_overlap_data2 = data2[np.flatnonzero(np.in1d(freqs2, freqs1, invert=True))] 84 | assert np.array_equal(overlap_data1['frequency'], overlap_data2['frequency']) 85 | 86 | return avg_data, non_overlap_data2 87 | 88 | fc = 600e6 89 | 90 | spectrum = Spectrum() 91 | 92 | freqs, ff = build_data(fc) 93 | data1 = build_struct_data(freqs, ff) 94 | 95 | spectrum.add_sample_set(frequency=freqs, iq=ff, center_frequency=fc, force_lower_freq=True) 96 | 97 | assert np.array_equal(spectrum.sample_data['frequency'], freqs) 98 | assert np.array_equal(spectrum.sample_data['iq'], ff) 99 | assert np.array_equal(spectrum.sample_data['magnitude'], np.abs(ff)) 100 | assert np.array_equal(spectrum.sample_data['dbFS'], 10 * np.log10(np.abs(ff))) 101 | 102 | fc += 1e6 103 | 104 | freqs2, ff2 = build_data(fc) 105 | assert np.any(np.in1d(freqs, freqs2)) 106 | 107 | print('in1d: ', np.nonzero(np.in1d(spectrum.sample_data['frequency'], freqs2))) 108 | print('spectrum size: ', spectrum.sample_data['frequency'].size) 109 | 110 | spectrum.add_sample_set(frequency=freqs2, iq=ff2, center_frequency=fc, force_lower_freq=True) 111 | print('spectrum size: ', spectrum.sample_data['frequency'].size) 112 | 113 | assert np.unique(spectrum.sample_data['frequency']).size == spectrum.sample_data['frequency'].size 114 | 115 | data2 = build_struct_data(freqs2, ff2) 116 | avg_data, non_overlap_data2 = get_overlap_arrays(data1, data2) 117 | assert data2.size == avg_data.size + non_overlap_data2.size 118 | assert spectrum.sample_data.size == data1.size + non_overlap_data2.size 119 | 120 | for freq in freqs2: 121 | mask = np.isin([freq], avg_data['frequency']) 122 | if np.any(mask): 123 | val = avg_data[np.searchsorted(avg_data['frequency'], freq)] 124 | else: 125 | val = non_overlap_data2[np.searchsorted(non_overlap_data2['frequency'], freq)] 126 | sample = spectrum.samples[freq] 127 | ix = sample.spectrum_index 128 | iq = spectrum.sample_data['iq'][ix] 129 | m = spectrum.sample_data['magnitude'][ix] 130 | dB = spectrum.sample_data['dbFS'][ix] 131 | assert iq == val['iq'] == sample.iq 132 | assert m == val['magnitude'] == sample.magnitude 133 | assert dB == val['dbFS'] == sample.dbFS 134 | 135 | 136 | fc = 800e6 137 | freqs3, ff3 = build_data(fc) 138 | assert not np.any(np.in1d(spectrum.sample_data['frequency'], freqs3)) 139 | 140 | spectrum.add_sample_set(frequency=freqs3, iq=ff3, center_frequency=fc, force_lower_freq=True) 141 | print('spectrum size: ', spectrum.sample_data['frequency'].size) 142 | 143 | assert np.unique(spectrum.sample_data['frequency']).size == spectrum.sample_data['frequency'].size 144 | assert spectrum.sample_data['frequency'].size == spectrum.sample_data['iq'].size 145 | assert spectrum.sample_data['frequency'].size == spectrum.sample_data['magnitude'].size 146 | 147 | data3 = build_struct_data(freqs3, ff3) 148 | avg_data2, non_overlap_data3 = get_overlap_arrays(data2, data3) 149 | assert data3.size == avg_data2.size + non_overlap_data3.size 150 | assert spectrum.sample_data.size == data1.size + non_overlap_data2.size + non_overlap_data3.size 151 | 152 | for freq in freqs3: 153 | mask = np.isin([freq], avg_data2['frequency']) 154 | if np.any(mask): 155 | val = avg_data2[np.searchsorted(avg_data2['frequency'], freq)] 156 | else: 157 | val = non_overlap_data3[np.searchsorted(non_overlap_data3['frequency'], freq)] 158 | sample = spectrum.samples[freq] 159 | ix = sample.spectrum_index 160 | iq = spectrum.sample_data['iq'][ix] 161 | m = spectrum.sample_data['magnitude'][ix] 162 | dB = spectrum.sample_data['dbFS'][ix] 163 | assert iq == val['iq'] == sample.iq 164 | assert m == val['magnitude'] == sample.magnitude 165 | assert dB == val['dbFS'] == sample.dbFS 166 | -------------------------------------------------------------------------------- /wwb_scanner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocarryr/rtlsdr-wwb-scanner/b88e83a91b47415ccdc5c11cac07bcf024dc9737/wwb_scanner/__init__.py -------------------------------------------------------------------------------- /wwb_scanner/core.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import jsonfactory 4 | 5 | from wwb_scanner.utils import numpyjson as json 6 | 7 | try: 8 | basestring = basestring 9 | except NameError: 10 | basestring = str 11 | 12 | class JSONMixin(object): 13 | @classmethod 14 | def from_json(cls, data, **kwargs): 15 | if isinstance(data, basestring): 16 | data = json.loads(data) 17 | kwargs.update(data) 18 | kwargs['__from_json__'] = True 19 | obj = cls(**kwargs) 20 | obj._deserialize(**kwargs) 21 | return obj 22 | def instance_from_json(self, data, **kwargs): 23 | if isinstance(data, basestring): 24 | data = json.loads(data) 25 | kwargs.update(data) 26 | self._deserialize(**kwargs) 27 | def to_json(self, **kwargs): 28 | d = self._serialize() 29 | return json.dumps(d, **kwargs) 30 | def _serialize(self): 31 | raise NotImplementedError('method must be implemented by subclasses') 32 | def _deserialize(self, **kwargs): 33 | pass 34 | 35 | @jsonfactory.register 36 | class JSONEncoder(object): 37 | _dt_fmt = '%Y-%m-%dT%H:%M:%S.%f %z' 38 | def encode(self, o): 39 | if isinstance(o, datetime.datetime): 40 | return {'__datetime.datetime__':o.strftime(self._dt_fmt)} 41 | return None 42 | def decode(self, d): 43 | if '__datetime.datetime__' in d: 44 | s = d['__datetime.datetime__'] 45 | fmt = self._dt_fmt 46 | if s.endswith(' '): 47 | s = s.strip(' ') 48 | fmt = fmt.split(' ')[0] 49 | return datetime.datetime.strptime(s, fmt) 50 | return d 51 | -------------------------------------------------------------------------------- /wwb_scanner/file_handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .exporters import BaseExporter, CSVExporter, WWBLegacyExporter, WWBExporter 2 | from .importers import BaseImporter, CSVImporter, WWBImporter 3 | -------------------------------------------------------------------------------- /wwb_scanner/file_handlers/exporters.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import datetime 4 | import uuid 5 | import xml.etree.ElementTree as ET 6 | import xml.dom.minidom as minidom 7 | 8 | import numpy as np 9 | 10 | EPOCH = datetime.datetime(1970, 1, 1) 11 | 12 | class BaseExporter(object): 13 | def __init__(self, **kwargs): 14 | self.spectrum = kwargs.get('spectrum') 15 | self.filename = kwargs.get('filename') 16 | def __call__(self): 17 | self.build_data() 18 | self.write_file() 19 | @classmethod 20 | def export_to_file(cls, **kwargs): 21 | filename = kwargs.get('filename') 22 | ext = os.path.splitext(filename)[1].strip('.').lower() 23 | def find_exporter(_cls): 24 | if getattr(_cls, '_extension', None) == ext: 25 | return _cls 26 | for _subcls in _cls.__subclasses__(): 27 | r = find_exporter(_subcls) 28 | if r is not None: 29 | return r 30 | return None 31 | cls = find_exporter(cls) 32 | fh = cls(**kwargs) 33 | fh() 34 | return fh 35 | @property 36 | def filename(self): 37 | return getattr(self, '_filename', None) 38 | @filename.setter 39 | def filename(self, value): 40 | if value is None: 41 | return 42 | self.set_filename(value) 43 | def set_filename(self, value): 44 | if value == self.filename: 45 | return 46 | self._filename = value 47 | def build_data(self): 48 | raise NotImplementedError('Method must be implemented by subclasses') 49 | def write_file(self): 50 | s = self.build_data() 51 | with open(self.filename, 'w') as f: 52 | f.write(s) 53 | 54 | class NumpyExporter(BaseExporter): 55 | _extension = 'npz' 56 | def build_data(self): 57 | pass 58 | def write_file(self): 59 | np.savez(self.filename, sample_data=self.spectrum.sample_data) 60 | 61 | class CSVExporter(BaseExporter): 62 | _extension = 'csv' 63 | newline_chars = '\r\n' 64 | delimiter_char = ',' 65 | def __init__(self, **kwargs): 66 | super(CSVExporter, self).__init__(**kwargs) 67 | self.frequency_format = kwargs.get('frequency_format') 68 | def set_filename(self, value): 69 | if os.path.splitext(value)[1] == '.CSV': 70 | value = '.'.join([os.path.splitext(value)[0], 'csv']) 71 | super(CSVExporter, self).set_filename(value) 72 | def build_data(self): 73 | newline_chars = self.newline_chars 74 | delim = self.delimiter_char 75 | frequency_format = self.frequency_format 76 | lines = [] 77 | freqs = np.around(self.spectrum.sample_data['frequency'], decimals=3) 78 | dB = np.around(self.spectrum.sample_data['dbFS'], decimals=1) 79 | for f, v in zip(freqs, dB): 80 | lines.append(delim.join([str(f), str(v)])) 81 | return newline_chars.join(lines) 82 | 83 | class BaseWWBExporter(BaseExporter): 84 | def __init__(self, **kwargs): 85 | super(BaseWWBExporter, self).__init__(**kwargs) 86 | self.dt = kwargs.get('dt', datetime.datetime.utcnow()) 87 | def set_filename(self, value): 88 | ext = self._extension 89 | if os.path.splitext(value)[1].lower() != '.%s' % (ext): 90 | value = '.'.join([os.path.splitext(value)[0], ext]) 91 | super(BaseWWBExporter, self).set_filename(value) 92 | def build_attribs(self): 93 | dt = self.dt 94 | spectrum = self.spectrum 95 | d = dict( 96 | scan_data_source=dict( 97 | ver='0.0.0.1', 98 | id='{%s}' % (uuid.uuid4()), 99 | model='TODO', 100 | name=os.path.abspath(self.filename), 101 | date=dt.strftime('%a %b %d %Y'), 102 | time=dt.strftime('%H:%M:%S'), 103 | color=spectrum.color.to_hex(), 104 | ), 105 | data_sets=dict( 106 | count='1', 107 | no_data_value='-140', 108 | ), 109 | ) 110 | if spectrum.step_size is None: 111 | spectrum.smooth(11) 112 | spectrum.interpolate() 113 | d['data_set'] = dict( 114 | index='0', 115 | freq_units='KHz', 116 | ampl_units='dBm', 117 | start_freq=str(min(spectrum.samples.keys()) * 1000), 118 | stop_freq=str(max(spectrum.samples.keys()) * 1000), 119 | step_freq=str(spectrum.step_size * 1000), 120 | res_bandwidth='TODO', 121 | scale_factor='1', 122 | date=d['scan_data_source']['date'], 123 | time=d['scan_data_source']['time'], 124 | date_time=str(int((dt - EPOCH).total_seconds() * 1000)), 125 | ) 126 | return d 127 | def build_data(self): 128 | attribs = self.attribs = self.build_attribs() 129 | root = self.root = ET.Element('scan_data_source', attribs['scan_data_source']) 130 | ET.SubElement(root, 'data_sets', attribs['data_sets']) 131 | tree = self.tree = ET.ElementTree(root) 132 | return tree 133 | def write_file(self): 134 | tree = self.build_data() 135 | fd = io.BytesIO() 136 | tree.write(fd, encoding='UTF-8', xml_declaration=True) 137 | doc = minidom.parseString(fd.getvalue()) 138 | fd.close() 139 | s = doc.toprettyxml(encoding='UTF-8') 140 | if isinstance(s, bytes): 141 | s = s.decode('UTF-8') 142 | with open(self.filename, 'w') as f: 143 | f.write(s) 144 | 145 | 146 | class WWBLegacyExporter(BaseWWBExporter): 147 | _extension = 'sbd' 148 | def build_data(self): 149 | tree = super(WWBLegacyExporter, self).build_data() 150 | root = tree.getroot() 151 | spectrum = self.spectrum 152 | attribs = self.attribs 153 | data_sets = root.find('data_sets') 154 | data_set = ET.SubElement(data_sets, 'data_set', attribs['data_set']) 155 | freqs = np.around(self.spectrum.sample_data['frequency'], decimals=3) 156 | dB = np.around(self.spectrum.sample_data['dbFS'], decimals=1) 157 | for val in dB: 158 | v = ET.SubElement(data_set, 'v') 159 | v.text = str(val) 160 | return tree 161 | 162 | class WWBExporter(BaseWWBExporter): 163 | _extension = 'sdb2' 164 | def build_data(self): 165 | tree = super(WWBExporter, self).build_data() 166 | root = tree.getroot() 167 | spectrum = self.spectrum 168 | data_sets = root.find('data_sets') 169 | freq_set = ET.SubElement(data_sets, 'freq_set') 170 | data_set = ET.SubElement(data_sets, 'data_set', self.attribs['data_set']) 171 | freqs = self.spectrum.sample_data['frequency'] * 1000 172 | dB = np.around(self.spectrum.sample_data['dbFS'], decimals=1) 173 | nanix = np.flatnonzero(np.isnan(dB) | np.isinf(dB)) 174 | dB[nanix] = -140. 175 | for freq, val in zip(freqs, dB): 176 | f = ET.SubElement(freq_set, 'f') 177 | f.text = str(int(freq)) 178 | v = ET.SubElement(data_set, 'v') 179 | v.text = str(val) 180 | return tree 181 | -------------------------------------------------------------------------------- /wwb_scanner/file_handlers/importers.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import datetime 3 | import xml.etree.ElementTree as ET 4 | import itertools 5 | 6 | import numpy as np 7 | 8 | from wwb_scanner.scan_objects import Spectrum 9 | 10 | class BaseImporter(object): 11 | def __init__(self, **kwargs): 12 | self.spectrum = Spectrum() 13 | self.filename = kwargs.get('filename') 14 | @classmethod 15 | def import_file(cls, filename): 16 | ext = os.path.splitext(filename)[1].strip('.').lower() 17 | def find_importer(_cls): 18 | if getattr(_cls, '_extension', None) == ext: 19 | return _cls 20 | for _subcls in _cls.__subclasses__(): 21 | r = find_importer(_subcls) 22 | if r is not None: 23 | return r 24 | return None 25 | cls = find_importer(BaseImporter) 26 | fh = cls(filename=filename) 27 | return fh() 28 | def __call__(self): 29 | self.file_data = self.load_file() 30 | self.parse_file_data() 31 | return self.spectrum 32 | def load_file(self): 33 | with open(self.filename, 'r') as f: 34 | s = f.read() 35 | return s 36 | def parse_file_data(self): 37 | raise NotImplementedError('Method must be implemented by subclasses') 38 | 39 | class NumpyImporter(BaseImporter): 40 | _extension = 'npz' 41 | def __call__(self): 42 | data = np.load(self.filename) 43 | spectrum = self.spectrum 44 | spectrum.name = os.path.basename(self.filename) 45 | spectrum.add_sample_set(data=data['sample_data']) 46 | return spectrum 47 | 48 | 49 | class CSVImporter(BaseImporter): 50 | delimiter_char = ',' 51 | _extension = 'csv' 52 | def parse_file_data(self): 53 | spectrum = self.spectrum 54 | spectrum.name = os.path.basename(self.filename) 55 | def iter_lines(): 56 | for line in self.file_data.splitlines(): 57 | line = line.rstrip('\n').rstrip('\r') 58 | if ',' not in line: 59 | continue 60 | f, v = line.split(',') 61 | yield float(f) 62 | yield float(v) 63 | a = np.fromiter(iter_lines(), dtype=np.float64) 64 | freqs = a[::2] 65 | dB = a[1::2] 66 | spectrum.add_sample_set(frequency=freqs, dbFS=dB) 67 | 68 | class BaseWWBImporter(BaseImporter): 69 | def load_file(self): 70 | return ET.parse(self.filename) 71 | 72 | class WWBImporter(BaseWWBImporter): 73 | _extension = 'sdb2' 74 | def parse_file_data(self): 75 | spectrum = self.spectrum 76 | root = self.file_data.getroot() 77 | color = root.get('color') 78 | if color is not None: 79 | spectrum.color = spectrum.color.from_hex(color) 80 | name = root.get('name') 81 | if name is not None: 82 | spectrum.name = name 83 | freq_set = root.find('*/freq_set') 84 | data_set = root.find('*/data_set') 85 | ts = data_set.get('date_time') 86 | if ts is not None: 87 | spectrum.timestamp_utc = float(ts) / 1000. 88 | else: 89 | dt_str = ' '.join([root.get('date'), root.get('time')]) 90 | dt_fmt = '%a %b %d %Y %H:%M:%S' 91 | dt = datetime.datetime.strptime(dt_str, dt_fmt) 92 | spectrum.datetime_utc = dt 93 | freqs = np.fromiter((float(t.text) / 1000. for t in freq_set), dtype=np.float64) 94 | dB = np.fromiter((float(t.text) for t in data_set), dtype=np.float64) 95 | spectrum.add_sample_set(frequency=freqs, dbFS=dB) 96 | -------------------------------------------------------------------------------- /wwb_scanner/log_config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import logging 3 | import logging.handlers 4 | 5 | USER_HOME = Path.home() 6 | LOG_DIR = USER_HOME / '.config' / 'rtlsdr-wwb-scanner' / 'logs' 7 | if not LOG_DIR.exists(): 8 | LOG_DIR.mkdir(parents=True) 9 | LOG_BASE_FILENAME = LOG_DIR / 'wwb_scanner.log' 10 | 11 | def setup(use_console=True, use_file=False): 12 | handlers = [] 13 | 14 | if use_console: 15 | term_handler = logging.StreamHandler() 16 | term_handler.setLevel(logging.DEBUG) 17 | term_handler.setFormatter(logging.Formatter( 18 | '{name:40} [{levelname:^10}] : {message}', 19 | style='{', 20 | )) 21 | handlers.append(term_handler) 22 | 23 | if use_file: 24 | file_handler = logging.handlers.TimedRotatingFileHandler( 25 | LOG_BASE_FILENAME, when='d', interval=1, backupCount=7, 26 | ) 27 | file_handler.setLevel(logging.DEBUG) 28 | file_handler.setFormatter(logging.Formatter( 29 | '{asctime} {name:40} [{levelname:^10}] : {message}', 30 | style='{', 31 | )) 32 | handlers.append(file_handler) 33 | 34 | logging.basicConfig( 35 | level=logging.DEBUG, 36 | handlers=handlers, 37 | ) 38 | logging.captureWarnings(True) 39 | -------------------------------------------------------------------------------- /wwb_scanner/scan_objects/__init__.py: -------------------------------------------------------------------------------- 1 | from .samplearray import SampleArray 2 | from .sample import Sample, TimeBasedSample 3 | from .spectrum import Spectrum, TimeBasedSpectrum 4 | -------------------------------------------------------------------------------- /wwb_scanner/scan_objects/sample.py: -------------------------------------------------------------------------------- 1 | import time 2 | import numbers 3 | import numpy as np 4 | 5 | from wwb_scanner.core import JSONMixin 6 | from wwb_scanner.utils import dbmath 7 | 8 | class Sample(JSONMixin): 9 | def __init__(self, **kwargs): 10 | self.init_complete = kwargs.get('init_complete', False) 11 | self.spectrum = kwargs.get('spectrum') 12 | self.frequency = kwargs.get('frequency') 13 | self.iq = iq = kwargs.get('iq') 14 | self.magnitude = m = kwargs.get('magnitude') 15 | self.power = kwargs.get('power') 16 | self.dbFS = kwargs.get('dbFS') 17 | self.init_complete = True 18 | @property 19 | def spectrum_index(self): 20 | f = self.spectrum.sample_data['frequency'] 21 | if self.frequency not in f: 22 | return None 23 | return np.argwhere(f == self.frequency)[0][0] 24 | @property 25 | def frequency(self): 26 | return getattr(self, '_frequency', None) 27 | @frequency.setter 28 | def frequency(self, value): 29 | if not isinstance(value, numbers.Number): 30 | return 31 | if self.frequency == value: 32 | return 33 | if not isinstance(value, float): 34 | value = float(value) 35 | self._frequency = value 36 | @property 37 | def iq(self): 38 | ix = self.spectrum_index 39 | if ix is None: 40 | return None 41 | return self.spectrum.sample_data['iq'][ix] 42 | @iq.setter 43 | def iq(self, value): 44 | if value is None: 45 | return 46 | if self.init_complete: 47 | old = self.iq 48 | if old == value: 49 | return 50 | if isinstance(value, (list, tuple)): 51 | i, q = value 52 | value = np.complex128(float(i) + 1j*float(q)) 53 | ix = self.spectrum_index 54 | self.spectrum.sample_data['iq'][ix] = value 55 | if not self.init_complete: 56 | return 57 | self.spectrum.on_sample_change(sample=self, iq=value, old=old) 58 | @property 59 | def magnitude(self): 60 | ix = self.spectrum_index 61 | if ix is None: 62 | return None 63 | return self.spectrum.sample_data['magnitude'][ix] 64 | @magnitude.setter 65 | def magnitude(self, value): 66 | if value is None: 67 | return 68 | if not isinstance(value, numbers.Number): 69 | return 70 | if self.init_complete: 71 | old = self.magnitude 72 | if old == value: 73 | return 74 | if not isinstance(value, float): 75 | value = float(value) 76 | ix = self.spectrum_index 77 | self.spectrum.sample_data['magnitude'][ix] = value 78 | if not self.init_complete: 79 | return 80 | self.spectrum.on_sample_change(sample=self, magnitude=value, old=old) 81 | @property 82 | def dbFS(self): 83 | ix = self.spectrum_index 84 | if ix is None: 85 | return None 86 | return self.spectrum.sample_data['dbFS'][ix] 87 | @dbFS.setter 88 | def dbFS(self, value): 89 | if value is None: 90 | return 91 | if self.init_complete: 92 | old = self.dbFS 93 | if old == value: 94 | return 95 | if not isinstance(value, numbers.Number): 96 | return 97 | m = dbmath.from_dB(value) 98 | ix = self.spectrum_index 99 | self.spectrum.sample_data['dbFS'][ix] = value 100 | self.spectrum.sample_data['magnitude'][ix] = m 101 | if not self.init_complete: 102 | return 103 | self.spectrum.on_sample_change(sample=self, dbFS=value, old=old) 104 | @property 105 | def formatted_frequency(self): 106 | return '%07.4f' % (self.frequency) 107 | @property 108 | def formatted_magnitude(self): 109 | return '%03.1f' % (self.magnitude) 110 | @property 111 | def formatted_dbFS(self): 112 | return '%03.1f' % (self.dbFS) 113 | def _serialize(self): 114 | d = {'frequency':self.frequency} 115 | if self.iq is not None: 116 | d['iq'] = (str(self.iq.real), str(self.iq.imag)) 117 | elif self.magnitude is not None: 118 | d['magnitude'] = self.magnitude 119 | else: 120 | d['dbFS'] = self.dbFS 121 | return d 122 | def __repr__(self): 123 | return str(self) 124 | def __str__(self): 125 | return '%s (%s dB)' % (self.formatted_frequency, self.dbFS) 126 | 127 | class TimeBasedSample(Sample): 128 | def __init__(self, **kwargs): 129 | ts = kwargs.get('timestamp') 130 | if ts is None: 131 | ts = time.time() 132 | self.timestamp = ts 133 | super(TimeBasedSample, self).__init__(**kwargs) 134 | -------------------------------------------------------------------------------- /wwb_scanner/scan_objects/samplearray.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.interpolate import CubicSpline 3 | import jsonfactory 4 | 5 | from wwb_scanner.core import JSONMixin 6 | from wwb_scanner.utils import dbmath 7 | 8 | class SampleArray(JSONMixin): 9 | dtype = np.dtype([ 10 | ('frequency', np.float64), 11 | ('iq', np.complex128), 12 | ('magnitude', np.float64), 13 | ('dbFS', np.float64) 14 | ]) 15 | def __init__(self, data=None, keep_sorted=True): 16 | self.keep_sorted = keep_sorted 17 | if data is None: 18 | data = np.empty([0], dtype=self.dtype) 19 | self.data = data 20 | if keep_sorted: 21 | self.data = np.sort(self.data, order='frequency') 22 | @classmethod 23 | def create(cls, keep_sorted=True, **kwargs): 24 | data = kwargs.get('data') 25 | obj = cls(data, keep_sorted=keep_sorted) 26 | if not obj.data.size: 27 | obj.set_fields(**kwargs) 28 | return obj 29 | def set_fields(self, **kwargs): 30 | f = kwargs.get('frequency') 31 | if f is None: 32 | raise Exception('frequency array must be provided') 33 | if not isinstance(f, np.ndarray): 34 | f = np.array([f]) 35 | data = np.zeros(f.size, dtype=self.dtype) 36 | data['frequency'] = f 37 | for key, val in kwargs.items(): 38 | if key not in self.dtype.fields: 39 | continue 40 | if key == 'frequency': 41 | continue 42 | if not isinstance(val, np.ndarray): 43 | val = np.array([val]) 44 | data[key] = val 45 | 46 | if data is None: 47 | return 48 | 49 | iq = kwargs.get('iq') 50 | mag = kwargs.get('magnitude') 51 | dbFS = kwargs.get('dbFS') 52 | 53 | if iq is not None and mag is None: 54 | mag = data['magnitude'] = np.abs(iq) 55 | if dbFS is not None and mag is None: 56 | mag = data['magnitude'] = dbmath.from_dB(dbFS) 57 | if mag is not None and dbFS is None: 58 | data['dbFS'] = dbmath.to_dB(mag) 59 | 60 | self.append(data) 61 | def __getattr__(self, attr): 62 | if attr in self.dtype.fields.keys(): 63 | return self.data[attr] 64 | raise AttributeError 65 | def __setattr__(self, attr, val): 66 | if attr in self.dtype.fields.keys(): 67 | self.data[attr] = val 68 | super(SampleArray, self).__setattr__(attr, val) 69 | def __getitem__(self, key): 70 | return self.data[key] 71 | def __setitem__(self, key, value): 72 | self.data[key] = value 73 | def __len__(self): 74 | return len(self.data) 75 | def __iter__(self): 76 | return iter(self.data) 77 | @property 78 | def size(self): 79 | return self.data.size 80 | @property 81 | def shape(self): 82 | return self.data.shape 83 | def _check_obj_type(self, other): 84 | if isinstance(other, SampleArray): 85 | data = other.data 86 | else: 87 | if isinstance(other, np.ndarray) and other.dtype == self.dtype: 88 | data = other 89 | else: 90 | raise Exception('Cannot extend this object type: {}'.format(other)) 91 | return data 92 | def append(self, other): 93 | if self.keep_sorted: 94 | self.insert_sorted(other) 95 | else: 96 | data = self._check_obj_type(other) 97 | self.data = np.append(self.data, data) 98 | def insert_sorted(self, other): 99 | data = self._check_obj_type(other) 100 | in_ix_self = np.flatnonzero(np.in1d(self.frequency, data['frequency'])) 101 | in_ix_data = np.flatnonzero(np.in1d(data['frequency'], self.frequency)) 102 | if in_ix_self.size: 103 | self.iq[in_ix_self] = np.mean([ 104 | self.iq[in_ix_self], data['iq'][in_ix_data] 105 | ], axis=0) 106 | self.magnitude[in_ix_self] = np.mean([ 107 | self.magnitude[in_ix_self], data['magnitude'][in_ix_data] 108 | ], axis=0) 109 | self.dbFS[in_ix_self] = np.mean([ 110 | self.dbFS[in_ix_self], data['dbFS'][in_ix_data] 111 | ], axis=0) 112 | 113 | nin_ix = np.flatnonzero(np.in1d(data['frequency'], self.frequency, invert=True)) 114 | 115 | if nin_ix.size: 116 | d = np.append(self.data, data[nin_ix]) 117 | d = np.sort(d, order='frequency') 118 | self.data = d 119 | def smooth(self, window_size): 120 | x = self.magnitude 121 | w = np.hanning(window_size) 122 | 123 | s = np.r_[x[window_size-1:0:-1], x, x[-2:-window_size-1:-1]] 124 | 125 | y = np.convolve(w/w.sum(), s, mode='valid') 126 | m = y[(window_size//2-1):-(window_size//2)] 127 | 128 | if m.size != x.size: 129 | raise Exception('Smooth result size {} != data size {}'.format(m.size, x.size)) 130 | 131 | self.data['magnitude'] = m 132 | self.data['dbFS'] = dbmath.to_dB(m) 133 | def interpolate(self, spacing=0.025): 134 | fmin = np.ceil(self.frequency.min()) 135 | fmax = np.floor(self.frequency.max()) 136 | 137 | x = self.frequency 138 | y = self.magnitude 139 | cs = CubicSpline(x, y) 140 | xs = np.arange(fmin, fmax+spacing, spacing) 141 | n_dec = len(str(spacing).split('.')[1]) 142 | xs = np.around(xs, n_dec) 143 | 144 | ys = cs(xs) 145 | data = np.zeros(xs.size, dtype=self.dtype) 146 | data['frequency'] = xs 147 | data['magnitude'] = ys 148 | data['dbFS'] = dbmath.to_dB(ys) 149 | self.data = data 150 | 151 | def _serialize(self): 152 | return {'data':self.data, 'keep_sorted':self.keep_sorted} 153 | def __repr__(self): 154 | return '<{self.__class__.__name__}: {self}>'.format(self=self) 155 | def __str__(self): 156 | return str(self.data) 157 | 158 | @jsonfactory.register 159 | class JSONEncoder(object): 160 | def encode(self, o): 161 | if isinstance(o, SampleArray): 162 | d = o._serialize() 163 | d['__class__'] = o.__class__.__name__ 164 | return d 165 | return None 166 | def decode(self, d): 167 | if d.get('__class__') == 'SampleArray': 168 | return SampleArray(d['data'], d['keep_sorted']) 169 | return d 170 | -------------------------------------------------------------------------------- /wwb_scanner/scan_objects/spectrum.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import datetime 3 | import time 4 | 5 | import numpy as np 6 | from scipy import signal 7 | 8 | from wwb_scanner.core import JSONMixin 9 | from wwb_scanner.utils import dbmath 10 | from wwb_scanner.utils.dbstore import db_store 11 | from wwb_scanner.utils.color import Color 12 | from wwb_scanner.scan_objects import SampleArray, Sample, TimeBasedSample 13 | try: 14 | from wwb_scanner import file_handlers 15 | except ImportError: 16 | file_handlers = None 17 | try: 18 | from wwb_scanner.ui.plots import SpectrumPlot 19 | except ImportError: 20 | SpectrumPlot = None 21 | 22 | EPOCH = datetime.datetime(1970, 1, 1) 23 | 24 | def get_file_handlers(): 25 | global file_handlers 26 | if file_handlers is None: 27 | from wwb_scanner import file_handlers as _file_handlers 28 | file_handlers = _file_handlers 29 | return file_handlers 30 | def get_importer(): 31 | return get_file_handlers().BaseImporter 32 | def get_exporter(): 33 | return get_file_handlers().BaseExporter 34 | 35 | def get_spectrum_plot(): 36 | global SpectrumPlot 37 | if SpectrumPlot is None: 38 | from wwb_scanner.ui.plots import SpectrumPlot as _SpectrumPlot 39 | SpectrumPlot = _SpectrumPlot 40 | return SpectrumPlot 41 | 42 | class Spectrum(JSONMixin): 43 | _serialize_attrs = [ 44 | 'name', 'color', 'timestamp_utc', 'step_size', 45 | 'center_frequencies', 'scan_config_eid', 46 | ] 47 | DEFAULT_COLOR = Color({'r':0, 'g':1, 'b':0, 'a':1}) 48 | def __init__(self, **kwargs): 49 | self.name = kwargs.get('name') 50 | self.eid = kwargs.get('eid') 51 | eid = kwargs.get('scan_config_eid') 52 | config = kwargs.get('scan_config') 53 | if config is not None: 54 | self.scan_config = config 55 | elif eid is not None: 56 | self.scan_config_eid = eid 57 | 58 | color = kwargs.get('color') 59 | if color is None: 60 | color = self.DEFAULT_COLOR 61 | if isinstance(color, Color): 62 | self.color = color.copy() 63 | else: 64 | self.color = Color(color) 65 | 66 | datetime_utc = kwargs.get('datetime_utc') 67 | timestamp_utc = kwargs.get('timestamp_utc') 68 | if datetime_utc is not None: 69 | self.datetime_utc = datetime_utc 70 | else: 71 | if timestamp_utc is None: 72 | timestamp_utc = time.time() 73 | self.timestamp_utc = timestamp_utc 74 | self.step_size = kwargs.get('step_size') 75 | self.data_updated = threading.Event() 76 | self.data_update_lock = threading.RLock() 77 | self.samples = {} 78 | self.sample_data = SampleArray() 79 | self.center_frequencies = kwargs.get('center_frequencies', []) 80 | @property 81 | def datetime_utc(self): 82 | return getattr(self, '_datetime_utc', None) 83 | @datetime_utc.setter 84 | def datetime_utc(self, value): 85 | if value == self.datetime_utc: 86 | return 87 | if value is None: 88 | return 89 | self._datetime_utc = value 90 | td = value - EPOCH 91 | timestamp = td.total_seconds() 92 | if timestamp != self.timestamp_utc: 93 | self.timestamp_utc = timestamp 94 | @property 95 | def timestamp_utc(self): 96 | return getattr(self, '_timestamp_utc', None) 97 | @timestamp_utc.setter 98 | def timestamp_utc(self, value): 99 | if value == self.timestamp_utc: 100 | return 101 | if value is None: 102 | return 103 | self._timestamp_utc = value 104 | dt = datetime.datetime.utcfromtimestamp(value) 105 | if dt != self.datetime_utc: 106 | self.datetime_utc = dt 107 | @property 108 | def scan_config(self): 109 | return getattr(self, '_scan_config', None) 110 | @scan_config.setter 111 | def scan_config(self, value): 112 | if value == self.scan_config: 113 | return 114 | self._scan_config = value 115 | if value.get('eid') is None: 116 | eid = self.scan_config_eid 117 | if eid is not None: 118 | value.eid = eid 119 | @property 120 | def scan_config_eid(self): 121 | eid = getattr(self, '_scan_config_eid', None) 122 | if eid is None: 123 | config = self.scan_config 124 | if config is not None: 125 | eid = self._scan_config_eid = config.get('eid') 126 | return eid 127 | @scan_config_eid.setter 128 | def scan_config_eid(self, value): 129 | if value == self.scan_config_eid: 130 | return 131 | self._scan_config_eid = value 132 | if value is None: 133 | return 134 | config = self.scan_config 135 | if config is None: 136 | config = self._scan_config = db_store.get_scan_config(eid=value) 137 | else: 138 | if config.get('eid') is None: 139 | config.eid = value 140 | def _deserialize(self, **kwargs): 141 | sample_data = kwargs.get('sample_data') 142 | samples = kwargs.get('samples') 143 | if sample_data is not None: 144 | self.sample_data = sample_data 145 | self.samples.clear() 146 | skwargs = dict(spectrum=self, init_complete=True) 147 | for f in sample_data.frequency: 148 | skwargs['frequency'] = f 149 | sample = self._build_sample(**skwargs) 150 | self.samples[f] = sample 151 | elif samples is not None: 152 | if isinstance(samples, dict): 153 | for key, data in samples.items(): 154 | if isinstance(data, dict): 155 | self.add_sample(**data) 156 | else: 157 | self.add_sample(frequency=key, dbFS=data) 158 | else: 159 | for sample_kwargs in samples: 160 | self.add_sample(**sample_kwargs) 161 | @classmethod 162 | def import_from_file(cls, filename): 163 | importer = get_importer() 164 | return importer.import_file(filename) 165 | def export_to_file(self, **kwargs): 166 | exporter = get_exporter() 167 | kwargs['spectrum'] = self 168 | exporter.export_to_file(**kwargs) 169 | def show_plot(self): 170 | plot_cls = get_spectrum_plot() 171 | plot = plot_cls(spectrum=self) 172 | plot.build_plot() 173 | return plot 174 | def smooth(self, N): 175 | with self.data_update_lock: 176 | if N % 2 != 0: 177 | N += 1 178 | self.sample_data.smooth(N) 179 | self.set_data_updated() 180 | def interpolate(self, spacing=0.025): 181 | with self.data_update_lock: 182 | self.sample_data.interpolate(spacing) 183 | self.samples.clear() 184 | kwargs = {'spectrum':self, 'init_complete':True} 185 | for freq in self.sample_data.frequency: 186 | kwargs['frequency'] = freq 187 | sample = self._build_sample(**kwargs) 188 | self.samples[freq] = sample 189 | self.step_size = spacing 190 | self.set_data_updated() 191 | def scale(self, min_dB, max_dB): 192 | with self.data_update_lock: 193 | y = self.sample_data['dbFS'] 194 | ymin = y.min() 195 | ymax = y.max() 196 | y -= ymin 197 | out_scale = max_dB - min_dB 198 | 199 | y /= y.max() 200 | y *= out_scale 201 | y += min_dB 202 | self.sample_data['magnitude'] = dbmath.from_dB(y) 203 | self.sample_data['dbFS'] = y 204 | self.set_data_updated() 205 | def add_sample(self, **kwargs): 206 | f = kwargs.get('frequency') 207 | iq = kwargs.get('iq') 208 | if iq is not None and isinstance(iq, list): 209 | iq = complex(*(float(v) for v in iq)) 210 | kwargs['iq'] = iq 211 | if kwargs.get('is_center_frequency') and f not in self.center_frequencies: 212 | self.center_frequencies.append(f) 213 | if f in self.samples: 214 | sample = self.samples[f] 215 | if kwargs.get('force_magnitude'): 216 | self.sample_data.set_fields(**kwargs) 217 | return sample 218 | if len(self.samples) and f < max(self.samples.keys()): 219 | if not kwargs.get('force_lower_freq', True): 220 | return 221 | with self.data_update_lock: 222 | if f not in self.sample_data['frequency']: 223 | self.sample_data.set_fields(**kwargs) 224 | skwargs = {'spectrum':self, 'init_complete':True, 'frequency':f} 225 | sample = self._build_sample(**skwargs) 226 | self.samples[f] = sample 227 | self.set_data_updated() 228 | return sample 229 | def add_sample_set(self, **kwargs): 230 | with self.data_update_lock: 231 | self._add_sample_set(**kwargs) 232 | self.set_data_updated() 233 | def _add_sample_set(self, **kwargs): 234 | force_lower_freq = kwargs.get('force_lower_freq') 235 | a = SampleArray.create(**kwargs) 236 | 237 | sdata = self.sample_data 238 | 239 | if not force_lower_freq and sdata['frequency'].size: 240 | r_ix = np.flatnonzero(np.greater_equal(a['frequency'], [sdata['frequency'].max()])) 241 | a = a[r_ix] 242 | self.sample_data.insert_sorted(a) 243 | 244 | skwargs = {'spectrum':self, 'init_complete':True} 245 | for f in a['frequency']: 246 | if f in self.samples: 247 | continue 248 | skwargs['frequency'] = f 249 | sample = self._build_sample(**skwargs) 250 | self.samples[f] = sample 251 | def _build_sample(self, **kwargs): 252 | sample = Sample(**kwargs) 253 | self.samples[sample.frequency] = sample 254 | return sample 255 | def iter_frequencies(self): 256 | for key in sorted(self.samples.keys()): 257 | yield key 258 | def iter_samples(self): 259 | for key in self.iter_frequencies(): 260 | yield self.samples[key] 261 | def on_sample_change(self, **kwargs): 262 | sample = kwargs.get('sample') 263 | if sample.frequency not in self.samples: 264 | return 265 | self.set_data_updated() 266 | def set_data_updated(self): 267 | with self.data_update_lock: 268 | self.data_updated.set() 269 | def save_to_dbstore(self): 270 | db_store.add_scan(self) 271 | def update_dbstore(self, *attrs): 272 | if self.eid is None: 273 | return 274 | if not len(attrs): 275 | attrs = self._serialize_attrs 276 | d = {attr:getattr(self, attr) for attr in attrs} 277 | db_store.update_scan(self.eid, **d) 278 | @classmethod 279 | def from_dbstore(cls, dbdata=None, eid=None): 280 | if dbdata is None: 281 | assert eid is not None 282 | dbdata = db_store.get_scan(eid) 283 | else: 284 | eid = dbdata.eid 285 | return cls.from_json(dbdata, eid=eid) 286 | def _serialize(self): 287 | d = {attr: getattr(self, attr) for attr in self._serialize_attrs} 288 | d['sample_data'] = self.sample_data 289 | return d 290 | 291 | class TimeBasedSpectrum(Spectrum): 292 | def _build_sample(self, **kwargs): 293 | sample = TimeBasedSample(**kwargs) 294 | if sample.frequency not in self.samples: 295 | self.samples[sample.frequency] = {} 296 | self.samples[sample.frequency][sample.timestamp] = sample 297 | return sample 298 | def iter_samples(self): 299 | samples = self.samples 300 | last_ts = None 301 | for key in self.iter_frequencies(): 302 | if last_ts is None: 303 | last_ts = min(samples[key]) 304 | sample = samples[key][last_ts] 305 | else: 306 | if last_ts in samples[key]: 307 | sample = samples[key][last_ts] 308 | else: 309 | l = [ts for ts in samples[key] if last_ts < ts] 310 | if not len(l): 311 | sample = None 312 | else: 313 | last_ts = min(l) 314 | sample = samples[key][last_ts] 315 | if sample is None: 316 | break 317 | yield sample 318 | 319 | def compare_spectra(spec1, spec2): 320 | diff_spec = Spectrum() 321 | for sample in spec1.iter_samples(): 322 | other_sample = spec2.samples.get(sample.frequency) 323 | if other_sample is None: 324 | continue 325 | magnitude = sample.magnitude - other_sample.magnitude 326 | diff_spec.add_sample(frequency=sample.frequency, magnitude=magnitude) 327 | return diff_spec 328 | -------------------------------------------------------------------------------- /wwb_scanner/scanner/__init__.py: -------------------------------------------------------------------------------- 1 | from . import sample_processing 2 | from .main import Scanner 3 | from .rtlpower_scan import RtlPowerScanner 4 | -------------------------------------------------------------------------------- /wwb_scanner/scanner/config.py: -------------------------------------------------------------------------------- 1 | from wwb_scanner.utils.config import Config 2 | 3 | class ScanConfig(Config): 4 | DEFAULTS = dict( 5 | scan_range=[400., 900.], 6 | save_raw_values=False, 7 | ) 8 | def __init__(self, initdict=None, **kwargs): 9 | kwargs.setdefault('_child_conf_keys', ['device', 'sampling', 'processing']) 10 | super(ScanConfig, self).__init__(initdict, **kwargs) 11 | if 'device' not in self._data: 12 | self['device'] = DeviceConfig() 13 | if 'sampling' not in self._data: 14 | self['sampling'] = SamplingConfig() 15 | if 'processing' not in self._data: 16 | self['processing'] = ProcessingConfig() 17 | def _deserialize_child(self, key, val, cls=None): 18 | if key == 'device': 19 | cls = DeviceConfig 20 | elif key == 'sampling': 21 | cls = SamplingConfig 22 | elif key == 'processing': 23 | cls = ProcessingConfig 24 | return super(ScanConfig, self)._deserialize_child(key, val, cls) 25 | 26 | class DeviceConfig(Config): 27 | DEFAULTS = dict( 28 | serial_number=None, 29 | gain=30., 30 | freq_correction=0, 31 | is_remote=False, 32 | remote_hostname='127.0.0.1', 33 | remote_port=1235, 34 | ) 35 | 36 | class SamplingConfig(Config): 37 | DEFAULTS = dict( 38 | sample_rate=2.048e6, 39 | sweep_overlap_ratio=.5, 40 | sweeps_per_scan=20, 41 | samples_per_sweep=8192, 42 | window_size=None, 43 | fft_size=1024, 44 | window_type='boxcar', 45 | rtl_bin_size=0.025, 46 | rtl_crop=50, 47 | rtl_fir_size=4, 48 | ) 49 | 50 | class ProcessingConfig(Config): 51 | DEFAULTS = dict( 52 | smoothing_enabled=False, 53 | smoothing_factor=1., 54 | scaling_enabled=True, 55 | scaling_min_db=-140., 56 | scaling_max_db=-50., 57 | ) 58 | -------------------------------------------------------------------------------- /wwb_scanner/scanner/main.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import numpy as np 4 | 5 | import logging 6 | logger = logging.getLogger(__name__) 7 | 8 | from wwb_scanner.core import JSONMixin 9 | from wwb_scanner.utils.dbstore import db_store 10 | from wwb_scanner.scanner.sdrwrapper import SdrWrapper 11 | from wwb_scanner.scanner.config import ScanConfig 12 | from wwb_scanner.scanner.sample_processing import ( 13 | SampleCollection, 14 | calc_num_samples, 15 | WINDOW_TYPES, 16 | ) 17 | from wwb_scanner.scan_objects import Spectrum 18 | 19 | def mhz_to_hz(mhz): 20 | return mhz * 1000000.0 21 | def hz_to_mhz(hz): 22 | return hz / 1000000.0 23 | 24 | def get_freq_resolution(nfft, fs): 25 | freqs = np.fft.fftfreq(nfft, 1/fs) 26 | freqs = np.fft.fftshift(freqs) 27 | r = np.unique(np.diff(np.around(freqs))) 28 | if r.size != 1: 29 | logger.error(f'!!! Not unique: {r}') 30 | return r.mean() 31 | return r[0] 32 | 33 | def is_equal_spacing(nfft, fs, step_size): 34 | freqs = np.fft.fftfreq(nfft, 1/fs) 35 | freqs = np.fft.fftshift(freqs) 36 | 37 | freqs2 = freqs + step_size 38 | all_freqs = np.unique(np.around(np.append(freqs, freqs2))) 39 | diff = np.unique(np.diff(np.around(all_freqs))) 40 | logger.debug(f'freq spacing diff={diff}') 41 | return diff.size == 1 42 | 43 | class StopScanner(Exception): 44 | pass 45 | 46 | class ScannerBase(JSONMixin): 47 | WINDOW_TYPES = WINDOW_TYPES 48 | def __init__(self, **kwargs): 49 | self._running = threading.Event() 50 | self._stopped = threading.Event() 51 | self._current_freq = None 52 | self._progress = 0. 53 | ckwargs = kwargs.get('config') 54 | if not ckwargs: 55 | ckwargs = db_store.get_scan_config() 56 | if not ckwargs: 57 | ckwargs = {} 58 | self.config = ScanConfig(ckwargs) 59 | self.device_config = self.config.device 60 | self.sampling_config = self.config.sampling 61 | if 'spectrum' in kwargs: 62 | self.spectrum = Spectrum.from_json(kwargs['spectrum']) 63 | else: 64 | self.spectrum = Spectrum() 65 | self.spectrum.scan_config = self.config 66 | if not kwargs.get('__from_json__'): 67 | self.sample_collection = SampleCollection(scanner=self) 68 | @property 69 | def current_freq(self): 70 | return self._current_freq 71 | @current_freq.setter 72 | def current_freq(self, value): 73 | self._current_freq = value 74 | if value is not None: 75 | f_min, f_max = self.config.scan_range 76 | self.progress = (value - f_min) / (f_max - f_min) 77 | self.on_current_freq(value) 78 | def on_current_freq(self, value): 79 | pass 80 | @property 81 | def progress(self): 82 | return self._progress 83 | @progress.setter 84 | def progress(self, value): 85 | if value == self._progress: 86 | return 87 | self._progress = value 88 | self.on_progress(value) 89 | def on_progress(self, value): 90 | pass 91 | def build_sample_sets(self): 92 | freq, end_freq = self.config.scan_range 93 | sample_collection = self.sample_collection 94 | while freq <= end_freq: 95 | sample_set = sample_collection.build_sample_set(mhz_to_hz(freq)) 96 | freq += self.step_size 97 | def run_scan(self): 98 | self.build_sample_sets() 99 | running = self._running 100 | running.set() 101 | self.sample_collection.scan_all_freqs() 102 | self.sample_collection.stopped.wait() 103 | if running.is_set(): 104 | self.save_to_dbstore() 105 | running.clear() 106 | self._stopped.set() 107 | def stop_scan(self): 108 | self._running.clear() 109 | self.sample_collection.cancel() 110 | self._stopped.wait() 111 | def save_to_dbstore(self): 112 | self.spectrum.save_to_dbstore() 113 | def _serialize(self): 114 | d = dict( 115 | config=self.config._serialize(), 116 | spectrum=self.spectrum._serialize(), 117 | sample_collection=self.sample_collection._serialize(), 118 | ) 119 | return d 120 | def _deserialize(self, **kwargs): 121 | data = kwargs.get('sample_collection') 122 | self.sample_collection = SampleCollection.from_json(data, scanner=self) 123 | 124 | class Scanner(ScannerBase): 125 | ''' 126 | params: 127 | scan_range: (list) frequency range to scan (in MHz) 128 | step_size: increment (in MHz) to return scan values 129 | ''' 130 | def __init__(self, **kwargs): 131 | super(Scanner, self).__init__(**kwargs) 132 | self.sdr_wrapper = SdrWrapper(scanner=self) 133 | self.gain = self.gain 134 | @property 135 | def sdr(self): 136 | return self.sdr_wrapper.sdr 137 | @property 138 | def sample_rate(self): 139 | return self.sampling_config.get('sample_rate') 140 | @sample_rate.setter 141 | def sample_rate(self, value): 142 | self.sampling_config.sample_rate = value 143 | @property 144 | def freq_correction(self): 145 | return self.device_config.get('freq_correction') 146 | @freq_correction.setter 147 | def freq_correction(self, value): 148 | self.device_config.freq_correction = value 149 | @property 150 | def sweeps_per_scan(self): 151 | return self.sampling_config.sweeps_per_scan 152 | @sweeps_per_scan.setter 153 | def sweeps_per_scan(self, value): 154 | self.sampling_config.sweeps_per_scan = value 155 | @property 156 | def samples_per_sweep(self): 157 | return self.sampling_config.samples_per_sweep 158 | @samples_per_sweep.setter 159 | def samples_per_sweep(self, value): 160 | self.sampling_config.samples_per_sweep = value 161 | @property 162 | def step_size(self): 163 | step_size = getattr(self, '_step_size', None) 164 | if step_size is not None: 165 | return step_size 166 | c = self.sampling_config 167 | overlap = c.sweep_overlap_ratio 168 | self.sdr.sample_rate = c.sample_rate 169 | rs = int(round(self.sdr.sample_rate)) 170 | 171 | self.sample_rate = rs 172 | nfft = self.window_size 173 | resolution = get_freq_resolution(nfft, rs) 174 | 175 | step_size = self._step_size = rs / 2. * overlap 176 | self._equal_spacing = is_equal_spacing(nfft, rs, step_size) 177 | if not self.equal_spacing: 178 | step_size -= step_size % resolution 179 | step_size = round(step_size) 180 | if step_size <= 0: 181 | step_size += resolution 182 | self._equal_spacing = is_equal_spacing(nfft, rs, step_size) 183 | 184 | step_size = hz_to_mhz(step_size) 185 | self._step_size = step_size 186 | logger.info(f'step_size: {step_size!r}, equal_spacing: {self._equal_spacing}') 187 | return step_size 188 | @property 189 | def equal_spacing(self): 190 | r = getattr(self, '_equal_spacing', None) 191 | if r is not None: 192 | return r 193 | _ = self.step_size 194 | return self._equal_spacing 195 | @property 196 | def window_size(self): 197 | c = self.config 198 | return c.sampling.get('window_size') 199 | @window_size.setter 200 | def window_size(self, value): 201 | if value == self.sampling_config.get('window_size'): 202 | return 203 | self.sampling_config.window_size = value 204 | @property 205 | def gain(self): 206 | return self.device_config.get('gain') 207 | @gain.setter 208 | def gain(self, value): 209 | if value is not None and hasattr(self, 'sdr_wrapper'): 210 | value = self.get_nearest_gain(value) 211 | self.device_config.gain = value 212 | @property 213 | def gains(self): 214 | gains = getattr(self, '_gains', None) 215 | if gains is None: 216 | gains = self._gains = self.get_gains() 217 | return gains 218 | def get_gains(self): 219 | self.sdr_wrapper.enable_scanner_updates = False 220 | with self.sdr_wrapper: 221 | sdr = self.sdr 222 | if sdr is None: 223 | gains = None 224 | else: 225 | gains = self.sdr.get_gains() 226 | self.sdr_wrapper.enable_scanner_updates = True 227 | if gains is not None: 228 | gains = [gain / 10. for gain in gains] 229 | return gains 230 | def get_nearest_gain(self, gain): 231 | gains = self.gains 232 | if gains is None: 233 | return gain 234 | npgains = np.array(gains) 235 | return gains[np.abs(npgains - gain).argmin()] 236 | def run_scan(self): 237 | with self.sdr_wrapper: 238 | super(Scanner, self).run_scan() 239 | def on_sample_set_processed(self, sample_set): 240 | powers = sample_set.powers 241 | freqs = sample_set.frequencies 242 | spectrum = self.spectrum 243 | center_freq = sample_set.center_frequency 244 | if self.equal_spacing: 245 | force_lower_freq = True 246 | else: 247 | force_lower_freq = False 248 | spectrum.add_sample_set( 249 | frequency=freqs, 250 | magnitude=powers, 251 | center_frequency=center_freq, 252 | force_lower_freq=force_lower_freq, 253 | ) 254 | self.progress = self.sample_collection.calc_progress() 255 | 256 | class ThreadedScanner(threading.Thread, Scanner): 257 | def __init__(self, **kwargs): 258 | threading.Thread.__init__(self) 259 | Scanner.__init__(self, **kwargs) 260 | self.plot = kwargs.get('plot') 261 | self.run_once = kwargs.get('run_once', True) 262 | self.scan_wait_timeout = kwargs.get('scan_wait_timeout', 5.) 263 | self.scanning = threading.Event() 264 | self.waiting = threading.Event() 265 | self.stopping = threading.Event() 266 | self.stopped = threading.Event() 267 | self.need_update = threading.Event() 268 | self.need_update_lock = threading.Lock() 269 | def on_current_freq(self, value): 270 | if self.plot is not None: 271 | self.plot.update_plot() 272 | with self.need_update_lock: 273 | self.need_update.set() 274 | def run(self): 275 | scanning = self.scanning 276 | waiting = self.waiting 277 | stopping = self.stopping 278 | stopped = self.stopped 279 | scan_wait_timeout = self.scan_wait_timeout 280 | run_once = self.run_once 281 | run_scan = self.run_scan 282 | while True: 283 | if stopping.is_set(): 284 | break 285 | scanning.set() 286 | run_scan() 287 | scanning.clear() 288 | if run_once: 289 | break 290 | waiting.wait(scan_wait_timeout) 291 | stopped.set() 292 | def stop(self): 293 | self.stopping.set() 294 | self.waiting.set() 295 | self.stopped.wait() 296 | 297 | def scan_and_plot(**kwargs): 298 | scanner = Scanner(**kwargs) 299 | scanner.run_scan() 300 | scanner.spectrum.show_plot() 301 | return scanner 302 | 303 | def scan_and_save(filename=None, frequency_format=None, **kwargs): 304 | scanner = Scanner(**kwargs) 305 | scanner.run_scan() 306 | scanner.spectrum.export_to_file(filename=filename, frequency_format=frequency_format) 307 | return scanner 308 | -------------------------------------------------------------------------------- /wwb_scanner/scanner/rtlpower_scan.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shlex 3 | 4 | import numpy as np 5 | 6 | from rtlsdr import RtlSdr 7 | 8 | from wwb_scanner.scanner.main import ScannerBase, hz_to_mhz, mhz_to_hz 9 | 10 | class RtlPowerScanner(ScannerBase): 11 | @property 12 | def device_index(self): 13 | i = getattr(self, '_device_index', None) 14 | if i is None: 15 | serial_number = self.device_config.serial_number 16 | if serial_number is None: 17 | i = 0 18 | else: 19 | i = RtlSdr.get_device_index_by_serial(serial_number) 20 | self._device_index = i 21 | return i 22 | def run_scan(self): 23 | self.rtl_bin_size_hz = mhz_to_hz(self.sampling_config.rtl_bin_size) 24 | self.rtl_gain = self.device_config.gain# / 10. 25 | 26 | cmd_str = [ 27 | 'rtl_power -i 1 -d {self.device_index}', 28 | '-f {self.config.scan_range[0]}M:{self.config.scan_range[1]}M:{self.rtl_bin_size_hz}', 29 | '-g {self.rtl_gain} -w {self.sampling_config.window_type}',# -i {self.integration_interval}', 30 | '-c {self.sampling_config.rtl_crop}% -F {self.sampling_config.rtl_fir_size}', 31 | '-1 -', 32 | ] 33 | cmd_str = ' '.join(cmd_str).format(self=self) 34 | print(cmd_str) 35 | proc = self.proc = subprocess.Popen( 36 | shlex.split(cmd_str), 37 | stdout=subprocess.PIPE, 38 | stderr=subprocess.STDOUT, 39 | universal_newlines=True, 40 | ) 41 | spectrum = self.spectrum 42 | while True: 43 | line = proc.stdout.readline().strip() 44 | if not line and proc.poll() is not None: 45 | break 46 | values = line.split(',') 47 | if len(values) < 6: 48 | print(line) 49 | continue 50 | f_lo = hz_to_mhz(float(values[2])) 51 | f_hi = hz_to_mhz(float(values[3])) 52 | step = hz_to_mhz(float(values[4])) 53 | 54 | dbvals = np.array([float(v) for v in values[6:]]) 55 | freqs = np.linspace(f_lo, f_hi, dbvals.size) 56 | spectrum.add_sample_set(frequency=freqs, dbFS=dbvals) 57 | self._running.clear() 58 | self._stopped.set() 59 | self.progress = 1. 60 | -------------------------------------------------------------------------------- /wwb_scanner/scanner/sample_processing.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import numpy as np 4 | from scipy.signal.windows import __all__ as WINDOW_TYPES 5 | from scipy.signal import welch, get_window, hilbert 6 | 7 | import logging 8 | logger = logging.getLogger(__name__) 9 | 10 | from wwb_scanner.core import JSONMixin 11 | 12 | WINDOW_TYPES = [s for s in WINDOW_TYPES if s != 'get_window'] 13 | 14 | NPERSEG = 128 15 | 16 | def next_2_to_pow(val): 17 | val -= 1 18 | val |= val >> 1 19 | val |= val >> 2 20 | val |= val >> 4 21 | val |= val >> 8 22 | val |= val >> 16 23 | return val + 1 24 | 25 | def calc_num_samples(num_samples): 26 | return next_2_to_pow(int(num_samples)) 27 | 28 | def sort_psd(f, Pxx, onesided=False): 29 | return np.fft.fftshift(f), np.fft.fftshift(Pxx) 30 | 31 | class SampleSet(JSONMixin): 32 | __slots__ = ('scanner', 'center_frequency', 'raw', 'current_sweep', 'complete', 33 | '_frequencies', 'powers', 'collection', 'process_thread', 'samples_discarded') 34 | _serialize_attrs = ('center_frequency', '_frequencies', 'powers') 35 | def __init__(self, **kwargs): 36 | for key in self.__slots__: 37 | if key == '_frequencies': 38 | key = 'frequencies' 39 | setattr(self, key, kwargs.get(key)) 40 | self.complete = threading.Event() 41 | self.samples_discarded = False 42 | if self.scanner is None and self.collection is not None: 43 | self.scanner = self.collection.scanner 44 | @property 45 | def frequencies(self): 46 | f = getattr(self, '_frequencies', None) 47 | if f is None: 48 | f = self._frequencies= self.calc_expected_freqs() 49 | return f 50 | @frequencies.setter 51 | def frequencies(self, value): 52 | self._frequencies = value 53 | @property 54 | def sweeps_per_scan(self): 55 | return self.scanner.sweeps_per_scan 56 | @property 57 | def samples_per_sweep(self): 58 | return self.scanner.samples_per_sweep 59 | @property 60 | def window_size(self): 61 | return getattr(self.scanner, 'window_size', NPERSEG) 62 | def read_samples(self): 63 | scanner = self.scanner 64 | freq = self.center_frequency 65 | sweeps_per_scan = scanner.sweeps_per_scan 66 | samples_per_sweep = scanner.samples_per_sweep 67 | sdr = scanner.sdr 68 | sdr.set_center_freq(freq) 69 | self.raw = np.zeros((sweeps_per_scan, samples_per_sweep), 'complex') 70 | self.powers = np.zeros((sweeps_per_scan, samples_per_sweep), 'float64') 71 | sdr.read_samples_async(self.samples_callback, num_samples=samples_per_sweep) 72 | def samples_callback(self, iq, context): 73 | samples_per_sweep = self.scanner.samples_per_sweep 74 | if not self.samples_discarded: 75 | self.samples_discarded = True 76 | return 77 | current_sweep = getattr(self, 'current_sweep', None) 78 | if current_sweep is None: 79 | current_sweep = self.current_sweep = 0 80 | if current_sweep >= self.raw.shape[0]: 81 | self.on_sample_read_complete() 82 | return 83 | self.raw[current_sweep] = iq 84 | self.current_sweep += 1 85 | if current_sweep > self.raw.shape[0]: 86 | self.on_sample_read_complete() 87 | def on_sample_read_complete(self): 88 | sdr = self.scanner.sdr 89 | if not sdr.read_async_canceling: 90 | sdr.cancel_read_async() 91 | self.process_samples() 92 | def translate_freq(self, samples, freq, rs): 93 | # Adapted from https://github.com/vsergeev/luaradio/blob/master/radio/blocks/signal/frequencytranslator.lua 94 | if not np.iscomplexobj(samples): 95 | samples = hilbert(samples) 96 | omega = 2 * np.pi * (freq / rs) 97 | def iter_phase(): 98 | p = 0 99 | i = 0 100 | while i < samples.shape[-1]: 101 | yield p 102 | p += omega 103 | p -= 2 * np.pi 104 | i += 1 105 | phase_rot = np.fromiter(iter_phase(), dtype=np.float64) 106 | phase_rot = np.unwrap(phase_rot) 107 | xlator = np.zeros(phase_rot.size, dtype=samples.dtype) 108 | xlator.real = np.cos(phase_rot) 109 | xlator.imag = np.sin(phase_rot) 110 | samples *= xlator 111 | return samples 112 | def process_samples(self): 113 | rs = self.scanner.sample_rate 114 | fc = self.center_frequency 115 | 116 | samples = self.raw.flatten() 117 | 118 | win_size = self.window_size 119 | win = get_window(self.scanner.sampling_config.window_type, win_size) 120 | freqs, Pxx = welch(samples, fs=rs, window=win, detrend=False, 121 | nperseg=win_size, scaling='density', return_onesided=False) 122 | 123 | iPxx = np.fft.irfft(Pxx) 124 | iPxx = self.translate_freq(iPxx, fc, rs) 125 | Pxx = np.abs(np.fft.rfft(iPxx.real)) 126 | 127 | freqs, Pxx = sort_psd(freqs, Pxx) 128 | freqs = np.around(freqs) 129 | 130 | freqs += fc 131 | freqs /= 1e6 132 | 133 | self.powers = Pxx 134 | if not np.array_equal(freqs, self.frequencies): 135 | logger.warning('freq not equal: %s, %s' % (self.frequencies.size, freqs.size)) 136 | self.frequencies = freqs 137 | self.raw = None 138 | self.collection.on_sample_set_processed(self) 139 | self.complete.set() 140 | def calc_expected_freqs(self): 141 | freq = self.center_frequency 142 | scanner = self.scanner 143 | rs = scanner.sample_rate 144 | win_size = self.window_size 145 | num_samples = scanner.samples_per_sweep * scanner.sweeps_per_scan 146 | overlap_ratio = scanner.sampling_config.sweep_overlap_ratio 147 | fake_samples = np.zeros(num_samples, 'complex') 148 | f_expected, Pxx = welch(fake_samples.real, fs=rs, nperseg=win_size, return_onesided=False) 149 | 150 | f_expected, Pxx = sort_psd(f_expected, Pxx) 151 | 152 | f_expected = np.around(f_expected) 153 | 154 | f_expected += freq 155 | f_expected /= 1e6 156 | return f_expected 157 | def _serialize(self): 158 | return {k:getattr(self, k) for k in self._serialize_attrs} 159 | 160 | 161 | class SampleCollection(JSONMixin): 162 | def __init__(self, **kwargs): 163 | self.scanner = kwargs.get('scanner') 164 | self.scanning = threading.Event() 165 | self.stopped = threading.Event() 166 | self.sample_sets = {} 167 | def calc_progress(self): 168 | num_sets = len(self.sample_sets) 169 | if not num_sets: 170 | return 0 171 | num_complete = 0. 172 | for sample_set in self.sample_sets.values(): 173 | if sample_set.complete.is_set(): 174 | num_complete += 1 175 | return num_complete / num_sets 176 | def add_sample_set(self, sample_set): 177 | self.sample_sets[sample_set.center_frequency] = sample_set 178 | def build_sample_set(self, freq): 179 | sample_set = SampleSet(collection=self, center_frequency=freq) 180 | self.add_sample_set(sample_set) 181 | return sample_set 182 | def scan_all_freqs(self): 183 | self.scanning.set() 184 | complete_events = set() 185 | for key in sorted(self.sample_sets.keys()): 186 | if not self.scanning.is_set(): 187 | break 188 | sample_set = self.sample_sets[key] 189 | sample_set.read_samples() 190 | if not sample_set.complete.is_set(): 191 | complete_events.add(sample_set.complete) 192 | if self.scanning.is_set(): 193 | for e in complete_events.copy(): 194 | if e.is_set(): 195 | complete_events.discard(e) 196 | else: 197 | e.wait() 198 | self.scanning.clear() 199 | self.stopped.set() 200 | def stop(self): 201 | if self.scanning.is_set(): 202 | self.scanning.clear() 203 | self.stopped.wait() 204 | def cancel(self): 205 | if self.scanning.is_set(): 206 | self.scanning.clear() 207 | self.stopped.wait() 208 | def on_sample_set_processed(self, sample_set): 209 | self.scanner.on_sample_set_processed(sample_set) 210 | def _serialize(self): 211 | return {'sample_sets': 212 | {k: v._serialize() for k, v in self.sample_sets.items()}, 213 | } 214 | def _deserialize(self, **kwargs): 215 | for key, val in kwargs.get('sample_sets', {}).items(): 216 | sample_set = SampleSet.from_json(val, collection=self) 217 | self.sample_sets[key] = sample_set 218 | -------------------------------------------------------------------------------- /wwb_scanner/scanner/sdrwrapper.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import traceback 3 | 4 | from rtlsdr import RtlSdr 5 | try: 6 | from rtlsdr import RtlSdrTcpClient 7 | except ImportError: 8 | RtlSdrTcpClient = None 9 | 10 | class SdrWrapper(object): 11 | def __init__(self, **kwargs): 12 | self.sdr = None 13 | self.scanner = kwargs.get('scanner') 14 | self.enable_scanner_updates = kwargs.get('enable_scanner_updates', True) 15 | self.device_open = threading.Event() 16 | self.device_wait = threading.Event() 17 | self.device_lock = threading.RLock() 18 | def set_sdr_values(self): 19 | if not self.enable_scanner_updates: 20 | return 21 | scanner = self.scanner 22 | if scanner is None: 23 | return 24 | sdr = self.sdr 25 | if sdr is None: 26 | return 27 | keys = ['sample_rate', 'gain', 'freq_correction'] 28 | scanner_vals = {key: getattr(scanner, key, None) for key in keys} 29 | for key, scanner_val in scanner_vals.items(): 30 | if key == 'gain': 31 | sdr_val = None 32 | elif key == 'freq_correction' and scanner_val in [0, None]: 33 | continue 34 | else: 35 | sdr_val = getattr(sdr, key) 36 | if sdr_val == scanner_val: 37 | continue 38 | setattr(sdr, key, scanner_val) 39 | if key == 'gain' and scanner_val == 0: 40 | sdr_val = 0. 41 | else: 42 | sdr_val = getattr(sdr, key) 43 | if sdr_val != scanner_val: 44 | setattr(scanner, key, sdr_val) 45 | def open_sdr(self): 46 | with self.device_lock: 47 | if self.sdr is not None: 48 | if not self.sdr.device_opened: 49 | self.sdr.close() 50 | self.sdr = None 51 | self.device_open.clear() 52 | else: 53 | self.device_open.wait() 54 | if self.sdr is None: 55 | if self.scanner.device_config.is_remote: 56 | self.sdr = self._open_sdr_remote() 57 | else: 58 | self.sdr = self._open_sdr_local() 59 | if self.sdr is not None: 60 | self.set_sdr_values() 61 | self.device_open.set() 62 | return self.sdr 63 | def _open_sdr_local(self): 64 | serial_number = self.scanner.device_config.serial_number 65 | try: 66 | sdr = RtlSdr(serial_number=serial_number) 67 | except IOError: 68 | sdr = None 69 | return sdr 70 | def _open_sdr_remote(self): 71 | try: 72 | if RtlSdrTcpClient is None: 73 | raise Exception('Tcp client not available') 74 | sdr = RtlSdrTcpClient(hostname=self.scanner.device_config.remote_hostname, 75 | port=self.scanner.device_config.remote_port) 76 | sdr.get_sample_rate() 77 | except: 78 | traceback.print_exc() 79 | sdr = None 80 | return sdr 81 | def close_sdr(self): 82 | with self.device_lock: 83 | if self.sdr is not None: 84 | self.sdr.close() 85 | self.sdr = None 86 | self.device_open.clear() 87 | def __enter__(self): 88 | self.open_sdr() 89 | def __exit__(self, *args): 90 | self.close_sdr() 91 | -------------------------------------------------------------------------------- /wwb_scanner/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocarryr/rtlsdr-wwb-scanner/b88e83a91b47415ccdc5c11cac07bcf024dc9737/wwb_scanner/ui/__init__.py -------------------------------------------------------------------------------- /wwb_scanner/ui/plots.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | 4 | from wwb_scanner.scan_objects.spectrum import compare_spectra 5 | from wwb_scanner.file_handlers import BaseImporter 6 | 7 | class BasePlot(object): 8 | def __init__(self, **kwargs): 9 | self.filename = kwargs.get('filename') 10 | if self.filename is not None: 11 | self.spectrum = BaseImporter.import_file(self.filename) 12 | else: 13 | self.spectrum = kwargs.get('spectrum') 14 | 15 | #self.figure.canvas.mpl_connect('idle_event', self.on_idle) 16 | 17 | @property 18 | def x(self): 19 | return getattr(self, '_x', None) 20 | @x.setter 21 | def x(self, value): 22 | self._x = value 23 | @property 24 | def y(self): 25 | return getattr(self, '_y', None) 26 | @y.setter 27 | def y(self, value): 28 | self._y = value 29 | @property 30 | def figure(self): 31 | return getattr(self, '_figure', None) 32 | @figure.setter 33 | def figure(self, figure): 34 | self._figure = figure 35 | #self.timer = figure.canvas.new_timer(interval=100) 36 | #self.timer.add_callback(self.on_timer) 37 | def on_timer(self): 38 | print('timer') 39 | spectrum = self.spectrum 40 | with spectrum.data_update_lock: 41 | if spectrum.data_updated.is_set(): 42 | print('update plot') 43 | self.update_plot() 44 | spectrum.data_updated.clear() 45 | def build_data(self): 46 | dtype = np.dtype(float) 47 | if not len(self.spectrum.samples): 48 | x = self.x = np.array(0.) 49 | y = self.y = np.array(0.) 50 | else: 51 | x = self.x = np.fromiter(self.spectrum.iter_frequencies(), dtype) 52 | y = self.y = np.fromiter((s.magnitude for s in self.spectrum.iter_samples()), dtype) 53 | if not hasattr(self, 'plot'): 54 | self.spectrum.data_updated.clear() 55 | return x, y 56 | def update_plot(self): 57 | if not hasattr(self, 'plot'): 58 | return 59 | x, y = self.build_data() 60 | self.plot.set_xdata(x) 61 | self.plot.set_ydata(y) 62 | #self.figure.canvas.draw_event(self.figure.canvas) 63 | self.figure.canvas.draw_idle() 64 | def build_plot(self): 65 | pass 66 | 67 | class SpectrumPlot(BasePlot): 68 | def build_plot(self): 69 | self.figure = plt.figure() 70 | self.plot = plt.plot(*self.build_data())[0] 71 | plt.xlabel('frequency (MHz)') 72 | plt.ylabel('dBm') 73 | center_frequencies = self.spectrum.center_frequencies 74 | if len(center_frequencies): 75 | samples = [self.spectrum.samples.get(f) for f in center_frequencies] 76 | ymin = self.y.min() 77 | plt.vlines(center_frequencies, 78 | [ymin] * len(center_frequencies), 79 | [s.magnitude-5 if s.magnitude-5 > ymin else s.magnitude for s in samples]) 80 | plt.show() 81 | 82 | class DiffSpectrum(object): 83 | def __init__(self, **kwargs): 84 | self.spectra = [] 85 | self.figure, self.axes = plt.subplots(3, 1, sharex='col') 86 | def add_spectrum(self, spectrum=None, **kwargs): 87 | name = kwargs.get('name') 88 | if name is None: 89 | name = str(len(self.spectra)) 90 | if spectrum is None: 91 | spectrum = BaseImporter.import_file(kwargs.get('filename')) 92 | self.spectra.append({'name':name, 'spectrum':spectrum}) 93 | def build_plots(self): 94 | dtype = np.dtype(float) 95 | if len(self.spectra) == 2: 96 | diff_spec = compare_spectra(self.spectra[0]['spectrum'], 97 | self.spectra[1]['spectrum']) 98 | self.spectra.append({'name':'diff', 'spectrum':diff_spec}) 99 | for i, spec_data in enumerate(self.spectra): 100 | spectrum = spec_data['spectrum'] 101 | x = np.fromiter(spectrum.iter_frequencies(), dtype) 102 | y = np.fromiter((s.magnitude for s in spectrum.iter_samples()), dtype) 103 | axes = self.axes[i] 104 | axes.plot(x, y) 105 | axes.set_title(spec_data['name']) 106 | plt.show() 107 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pkg_resources import resource_filename 3 | 4 | def get_resource_filename(name): 5 | return Path(resource_filename(__name__, name)) 6 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/device_config.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from rtlsdr import RtlSdr 3 | 4 | from PySide2 import QtCore, QtQml 5 | from PySide2.QtCore import Signal, Property 6 | import logging 7 | logger = logging.getLogger(__name__) 8 | 9 | from wwb_scanner.ui.pyside.utils import GenericQObject 10 | 11 | # def get_devices(): 12 | # serials = RtlSdr.get_device_serial_addresses() 13 | 14 | 15 | class TUNER_TYPE(enum.Enum): 16 | UNKNOWN = 0 17 | E4000 = 1 18 | FC0012 = 2 19 | FC0013 = 3 20 | FC2580 = 4 21 | R820T = 5 22 | R828D = 6 23 | 24 | 25 | class DeviceInfo(GenericQObject): 26 | _n_text = Signal() 27 | _n_device_index = Signal() 28 | _n_device_serial = Signal() 29 | _n_tuner_type = Signal() 30 | _n_gains = Signal() 31 | def __init__(self, *args): 32 | self._device_index = None 33 | self._device_serial = None 34 | self._tuner_type = None 35 | self._gains = [] 36 | super().__init__(*args) 37 | 38 | def _g_text(self): return str(self) 39 | text = Property(str, _g_text, notify=_n_text) 40 | 41 | def _g_device_index(self): return self._device_index 42 | def _s_device_index(self, value): self._generic_setter('_device_index', value) 43 | device_index = Property(int, _g_device_index, _s_device_index, notify=_n_device_index) 44 | 45 | def _g_device_serial(self): return self._device_serial 46 | def _s_device_serial(self, value): self._generic_setter('_device_serial', value) 47 | device_serial = Property(str, _g_device_serial, _s_device_serial, notify=_n_device_serial) 48 | 49 | def _g_tuner_type(self): return self._tuner_type 50 | def _s_tuner_type(self, value): self._generic_setter('_tuner_type', value) 51 | tuner_type = Property(str, _g_tuner_type, _s_tuner_type, notify=_n_tuner_type) 52 | 53 | def _g_gains(self): return self._gains 54 | def _s_gains(self, value): self._generic_setter('_gains', value) 55 | gains = Property('QVariantList', _g_gains, _s_gains, notify=_n_gains) 56 | 57 | def _get_info_from_device(self, sdr): 58 | tuner_type = sdr.get_tuner_type() 59 | self.tuner_type = TUNER_TYPE(tuner_type).name 60 | self.gains = [g / 10 for g in sdr.gain_values] 61 | self._n_text.emit() 62 | def get_info_from_device_index(self, device_index): 63 | self.device_index = device_index 64 | if self.device_serial is None: 65 | self.device_serial = RtlSdr.get_device_serial_addresses()[device_index] 66 | sdr = RtlSdr(device_index) 67 | self._get_info_from_device(sdr) 68 | sdr.close() 69 | def get_info_from_device_serial(self, device_serial): 70 | self.device_serial = device_serial 71 | if self.device_index is None: 72 | self.device_index = RtlSdr.get_device_index_by_serial(device_serial) 73 | sdr = RtlSdr(device_serial=device_serial) 74 | self._get_info_from_device(sdr) 75 | sdr.close() 76 | def __repr__(self): 77 | return f'<{self.__class__}: {self}>' 78 | def __str__(self): 79 | return f'{self.tuner_type} - {self.device_index} ({self.device_serial})' 80 | 81 | class DeviceInfoList(GenericQObject): 82 | _n_devices = Signal() 83 | update_devices = Signal() 84 | def __init__(self, *args): 85 | self._devices = {} 86 | self._devices_by_index = {} 87 | self._devices_by_serial = {} 88 | super().__init__(*args) 89 | self.update_devices.connect(self._on_update_devices) 90 | 91 | def _g_devices(self): 92 | d = self._devices 93 | return [d[key] for key in sorted(d.keys())] 94 | def _s_devices(self, value): 95 | changed = False 96 | for i, val in enumerate(value): 97 | if self._devices.get(i) != val: 98 | changed = True 99 | self._devices[i] = val 100 | if changed: 101 | self._n_devices.emit() 102 | devices = Property('QVariantList', _g_devices, _s_devices, notify=_n_devices) 103 | 104 | 105 | def _on_update_devices(self): 106 | device_serials = RtlSdr.get_device_serial_addresses() 107 | logger.debug(f'found sdr serial numbers: {device_serials}') 108 | for i, device_serial in enumerate(device_serials): 109 | if i in self._devices: 110 | continue 111 | device = self.add_device(i, device_serial) 112 | 113 | def add_device(self, device_index, device_serial): 114 | assert device_index not in self._devices 115 | device = DeviceInfo() 116 | device.device_index = device_index 117 | device.device_serial = device_serial 118 | self._devices_by_index[device_index] = device 119 | device.get_info_from_device_index(device_index) 120 | self._devices[device_index] = device 121 | self._n_devices.emit() 122 | return device 123 | 124 | def register_qml_types(): 125 | QtQml.qmlRegisterType(DeviceInfo, 'DeviceConfig', 1, 0, 'DeviceInfo') 126 | QtQml.qmlRegisterType(DeviceInfoList, 'DeviceConfig', 1, 0, 'DeviceInfoList') 127 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | from wwb_scanner import log_config 6 | log_config.setup(use_console=True, use_file=True) 7 | 8 | import logging 9 | logger = logging.getLogger() 10 | 11 | from PySide2 import QtCore, QtQml 12 | from PySide2.QtWidgets import QApplication 13 | from PySide2.QtQuick import QQuickView 14 | 15 | from wwb_scanner.ui.pyside import get_resource_filename 16 | from wwb_scanner.ui.pyside import device_config, graph, scanner 17 | 18 | def register_qml_types(): 19 | device_config.register_qml_types() 20 | graph.register_qml_types() 21 | scanner.register_qml_types() 22 | 23 | QML_PATH = get_resource_filename('qml') 24 | 25 | def on_app_quit(): 26 | logger.info('exiting application') 27 | 28 | def get_qmsg_levelname(mode): 29 | if mode == QtCore.QtSystemMsg: 30 | return None 31 | for name, value in QtCore.QtMsgType.values.items(): 32 | if mode == value: 33 | name = name.split('Qt')[1].split('Msg')[0] 34 | return name.upper() 35 | 36 | def qt_message_handler(mode, context, message): 37 | levelname = get_qmsg_levelname(mode) 38 | if levelname is None: 39 | return 40 | lvl = getattr(logging, levelname) 41 | # show_trace = lvl not in [logging.INFO, logging.DEBUG] 42 | fn = QtCore.QUrl(context.file) 43 | p = Path(fn.toLocalFile()).relative_to(QML_PATH) 44 | name = '.'.join(p.parts) 45 | _logger = logging.getLogger(name) 46 | _logger.log(lvl, message) 47 | 48 | def run(argv=None): 49 | if argv is None: 50 | argv = sys.argv 51 | logger.info('starting application') 52 | app = QApplication(argv) 53 | app.setOrganizationName('rtlsdr-wwb-scanner') 54 | app.setApplicationName('wwb_scanner') 55 | app.aboutToQuit.connect(on_app_quit) 56 | QtCore.qInstallMessageHandler(qt_message_handler) 57 | engine = QtQml.QQmlApplicationEngine() 58 | engine.setBaseUrl(str(QML_PATH)) 59 | engine.addImportPath(str(QML_PATH)) 60 | register_qml_types() 61 | qml_main = QML_PATH / 'main.qml' 62 | engine.load(str(qml_main)) 63 | win = engine.rootObjects()[0] 64 | win.show() 65 | sys.exit(app.exec_()) 66 | 67 | if __name__ == '__main__': 68 | run() 69 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ChartSelect.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | import QtQuick.Dialogs 1.0 5 | import GraphUtils 1.0 6 | 7 | Item { 8 | id: root 9 | property var activeSpectrum 10 | property int activeIndex: -1 11 | property var spectrumGraphs: ({}) 12 | 13 | signal addItem(var spectrum) 14 | signal selected(var spectrum, bool state) 15 | 16 | onActiveSpectrumChanged: { 17 | if (root.activeSpectrum){ 18 | root.activeIndex = root.activeSpectrum.index; 19 | } 20 | } 21 | 22 | onAddItem: { 23 | var index = spectrum.index; 24 | root.spectrumGraphs[index] = spectrum; 25 | var data = { 26 | 'index':index, 27 | 'name':spectrum.name, 28 | 'color':spectrum.color, 29 | }; 30 | listModel.append(data); 31 | spectrum.onNameChanged.connect(function(){ 32 | listModel.get(index).name = spectrum.name; 33 | }); 34 | spectrum.onColorChanged.connect(function(){ 35 | listModel.get(index).color = spectrum.color; 36 | }); 37 | } 38 | 39 | ListModel { 40 | id: listModel 41 | dynamicRoles: true 42 | } 43 | 44 | ListView { 45 | id: listView 46 | anchors.fill: parent 47 | model: listModel 48 | 49 | spacing: 2 50 | contentWidth: listView.width 51 | 52 | ButtonGroup { 53 | id: btnGroup 54 | 55 | onClicked: { 56 | var spectrum = root.spectrumGraphs[button.itemIndex]; 57 | if (spectrum.index == root.activeIndex){ 58 | return; 59 | } 60 | root.selected(spectrum, true); 61 | } 62 | } 63 | 64 | delegate: ChartSelectDelegate { 65 | ButtonGroup.group: btnGroup 66 | text: name 67 | itemIndex: index 68 | itemName: name 69 | itemColor: color 70 | checked: root.activeIndex == index 71 | graphVisible: root.spectrumGraphs[itemIndex].graphVisible 72 | width: listView.contentWidth 73 | onColorButtonPressed: { 74 | var spectrum = root.spectrumGraphs[itemIndex]; 75 | colorDialog.activate(spectrum); 76 | } 77 | onVisibleCheckBoxPressed: { 78 | root.spectrumGraphs[itemIndex].graphVisible = state; 79 | } 80 | } 81 | } 82 | 83 | Loader { 84 | id: colorDialog 85 | sourceComponent: Component { ColorDialog {} } 86 | property var spectrum 87 | property color color 88 | active: false 89 | 90 | function activate(spectrum){ 91 | colorDialog.spectrum = spectrum; 92 | colorDialog.color = spectrum.color; 93 | colorDialog.active = true; 94 | } 95 | 96 | function close(){ 97 | var dlg = colorDialog.item; 98 | if (dlg){ 99 | dlg.close(); 100 | } 101 | colorDialog.spectrum = null; 102 | colorDialog.active = false; 103 | } 104 | 105 | onItemChanged: { 106 | var dlg = item; 107 | if (dlg){ 108 | dlg.color = colorDialog.color; 109 | dlg.open(); 110 | } 111 | } 112 | 113 | Connections { 114 | target: colorDialog.item 115 | function onAccepted() { 116 | var dlg = colorDialog.item; 117 | colorDialog.spectrum.color = dlg.color; 118 | dlg.close(); 119 | colorDialog.close(); 120 | } 121 | function onRejected() { 122 | var dlg = colorDialog.item; 123 | dlg.close(); 124 | colorDialog.close(); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ChartSelectDelegate.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.12 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | import GraphUtils 1.0 5 | 6 | RadioDelegate { 7 | id: control 8 | property string itemName 9 | property color itemColor: "#8080FF" 10 | property int itemIndex 11 | property alias graphVisible: visibleCheckBox.checked 12 | leftPadding: 4 13 | rightPadding: 4 14 | font.pointSize: 9 15 | 16 | signal colorButtonPressed(int itemIndex) 17 | signal visibleCheckBoxPressed(int itemIndex, bool state) 18 | 19 | contentItem: Label { 20 | rightPadding: colorBtn.width + control.spacing 21 | leftPadding: control.indicator.width + visibleCheckBox.width + control.spacing 22 | text: control.itemName 23 | font: control.font 24 | color: control.itemColor 25 | elide: Text.ElideRight 26 | maximumLineCount: 2 27 | wrapMode: Text.Wrap 28 | verticalAlignment: Text.AlignVCenter 29 | horizontalAlignment: Qt.AlignRight 30 | } 31 | 32 | CheckBox { 33 | id: visibleCheckBox 34 | x: control.indicator.width + control.spacing 35 | y: control.topPadding + (control.availableHeight - height) / 2 36 | onToggled: { 37 | control.visibleCheckBoxPressed(control.itemIndex, visibleCheckBox.checked); 38 | } 39 | } 40 | RoundButton { 41 | id: colorBtn 42 | property color highlightColor: Qt.lighter(control.itemColor, 1.25) 43 | 44 | x: control.width - width - control.rightPadding 45 | y: control.topPadding + (control.availableHeight - height) / 2 46 | radius: 2 47 | 48 | onClicked: { 49 | control.colorButtonPressed(control.itemIndex); 50 | } 51 | 52 | background: Rectangle { 53 | implicitWidth: 20 54 | implicitHeight: 20 55 | radius: colorBtn.radius 56 | color: colorBtn.hovered ? colorBtn.highlightColor : control.itemColor 57 | border.color: Qt.darker(control.itemColor, 1.5) 58 | border.width: 1 59 | } 60 | } 61 | 62 | indicator: Rectangle { 63 | implicitWidth: 26 64 | implicitHeight: 26 65 | x: control.leftPadding 66 | y: control.topPadding + (control.availableHeight - height) / 2 67 | radius: width / 2 68 | color: control.down ? control.palette.light : control.palette.base 69 | border.width: 1 70 | border.color: control.visualFocus ? control.palette.highlight : control.palette.mid 71 | 72 | Rectangle { 73 | x: (parent.width - width) / 2 74 | y: (parent.height - height) / 2 75 | width: 20 76 | height: 20 77 | radius: width / 2 78 | color: control.palette.text 79 | visible: control.checked 80 | } 81 | } 82 | 83 | background: Rectangle { 84 | implicitWidth: 100 85 | implicitHeight: 40 86 | opacity: control.down ? 1 : .3 87 | color: control.hovered ? control.palette.midlight : control.palette.light 88 | border.width: 1 89 | border.color: control.visualFocus ? control.palette.highlight : control.palette.mid 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/Crosshair.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | 3 | Canvas { 4 | id: root 5 | enabled: false 6 | property color bgColor: Qt.rgba(0, 0, 0, 0) 7 | property color fgColor: Qt.rgba(0, 0, 1, 1) 8 | property point dataPos: Qt.point(-1, -1) 9 | property point dataValue: Qt.point(-1, -1) 10 | 11 | signal setData(point pos, point value) 12 | 13 | onSetData: { 14 | root.dataPos = pos; 15 | root.dataValue = value; 16 | root.requestPaint(); 17 | } 18 | 19 | onPaint: { 20 | var ctx = getContext('2d'), 21 | width = root.width, 22 | height = root.height, 23 | pos = root.dataPos; 24 | 25 | ctx.clearRect(0, 0, width, height); 26 | ctx.reset(); 27 | ctx.fillStyle = root.bgColor; 28 | if (pos.x > 0 && pos.y > 0){ 29 | ctx.lineWidth = 1; 30 | ctx.strokeStyle = root.fgColor; 31 | ctx.moveTo(0, pos.y); 32 | ctx.lineTo(width, pos.y); 33 | ctx.moveTo(pos.x, 0); 34 | ctx.lineTo(pos.x, height); 35 | ctx.stroke(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/DeviceConfigPopup.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | import Qt.labs.settings 1.0 5 | import DeviceConfig 1.0 6 | 7 | Dialog { 8 | id: root 9 | property var device 10 | property real deviceIndex 11 | property string deviceSerial 12 | property real sampleRate: 2048 13 | property real gain 14 | 15 | onDeviceChanged:{ 16 | var device = device_list.device; 17 | gainSelect.updateChoices(); 18 | if (device) { 19 | root.deviceIndex = device.device_index; 20 | root.deviceSerial = device.device_serial; 21 | } 22 | } 23 | 24 | Settings { 25 | category: 'Device Config' 26 | property alias deviceIndex: root.deviceIndex 27 | property alias deviceSerial: root.deviceSerial 28 | property alias sampleRate: root.sampleRate 29 | property alias gain: root.gain 30 | } 31 | 32 | DeviceInfoList { 33 | id: device_list 34 | onDevicesChanged: { 35 | updateDeviceList(); 36 | } 37 | } 38 | 39 | function updateDeviceList(){ 40 | var devices = device_list.devices, 41 | device, 42 | data; 43 | device_model.clear(); 44 | for (var i=0;i maxValue.x){ 59 | maxValue.x = spectrum.maxValue.x; 60 | } 61 | if (spectrum.maxValue.y > maxValue.y){ 62 | maxValue.y = spectrum.maxValue.y; 63 | } 64 | } 65 | } 66 | if (minValue == null || maxValue == null){ 67 | return; 68 | } 69 | viewController.setDataExtents(minValue, maxValue); 70 | } 71 | 72 | onLoadFromFile: { 73 | var spectrum = addModel({}, function(obj){ 74 | obj.load_from_file(fileName); 75 | }); 76 | } 77 | 78 | function callable(obj){ 79 | if (typeof(obj) == 'function'){ 80 | return true; 81 | } 82 | return false; 83 | } 84 | 85 | function buildComponent(prnt, uri, props, callback){ 86 | var component = Qt.createComponent(uri), 87 | obj; 88 | function onComponentReady(){ 89 | if (component.status == Component.Ready){ 90 | obj = component.createObject(prnt, props); 91 | if (callable(callback)){ 92 | callback(obj); 93 | } 94 | } else { 95 | console.error('Error creating object: ', component.errorString()); 96 | } 97 | } 98 | if (component.status == Component.Ready){ 99 | obj = component.createObject(prnt, props); 100 | if (callable(callback)){ 101 | callback(obj); 102 | } 103 | } else if (component.status == Component.Error){ 104 | console.error('Error creating object: ', component.errorString()); 105 | } else { 106 | component.statusChanged.connect(onComponentReady); 107 | } 108 | } 109 | 110 | function addModel(props, callback){ 111 | var series = chart.addMappedSeries(), 112 | qmlFile = 'SpectrumGraph.qml'; 113 | 114 | if (props.isLive){ 115 | qmlFile = 'LiveSpectrumGraph.qml'; 116 | } 117 | props['series'] = series; 118 | props['graphParent'] = root; 119 | buildComponent(root, qmlFile, props, function(obj){ 120 | obj.index = root.spectrumGraphs.length; 121 | root.spectrumGraphs.push(obj); 122 | root.activeSpectrum = obj; 123 | updateAxisExtents(); 124 | obj.axisExtentsUpdate.connect(updateAxisExtents); 125 | obj.seriesClicked.connect(root.seriesClicked); 126 | chartSelect.addItem(obj); 127 | if (callable(callback)){ 128 | callback(obj); 129 | } 130 | }); 131 | } 132 | 133 | GraphViewController { 134 | id: viewController 135 | axisX: axisX 136 | axisY: axisY 137 | } 138 | 139 | GraphHViewControls { 140 | id: hViewControls 141 | anchors.left: parent.left 142 | anchors.right: parent.right 143 | anchors.top: parent.top 144 | height: parent.height * .1 145 | controller: viewController 146 | } 147 | 148 | RowLayout { 149 | anchors.left: parent.left 150 | anchors.right: parent.right 151 | anchors.top: hViewControls.bottom 152 | anchors.bottom: parent.bottom 153 | spacing: 1 154 | Item { 155 | Layout.fillWidth: true 156 | Layout.fillHeight: true 157 | ChartView { 158 | id: chart 159 | anchors.fill: parent 160 | antialiasing: true 161 | legend.visible: false 162 | animationOptions: ChartView.SeriesAnimations 163 | property var activeSeries 164 | 165 | ValueAxis { 166 | id: axisX 167 | min: 100 168 | max: 900 169 | labelFormat: "%07.3f MHz" 170 | } 171 | ValueAxis { 172 | id: axisY 173 | min: -160 174 | max: 0 175 | labelFormat: "%07.2f dB" 176 | } 177 | 178 | function addMappedSeries(){ 179 | var series = chart.createSeries(ChartView.SeriesTypeLine, 'foo', axisX, axisY); 180 | chart.activeSeries = series; 181 | return series; 182 | } 183 | 184 | states: [ 185 | State { 186 | name: 'NORMAL' 187 | when: !hViewControls.scrolling 188 | PropertyChanges { target:chart; animationOptions:ChartView.SeriesAnimations } 189 | }, 190 | State { 191 | name: 'SCROLLING' 192 | when: hViewControls.scrolling 193 | PropertyChanges { target:chart; animationOptions:ChartView.NoAnimation } 194 | } 195 | ] 196 | } 197 | 198 | UHFChannels { 199 | id: channelLabels 200 | axisX: axisX 201 | chart: chart 202 | x: chart.plotArea.x 203 | y: chart.plotArea.y 204 | width: chart.plotArea.width 205 | height: 30 206 | } 207 | 208 | Crosshair { 209 | id: crosshair 210 | x: chart.plotArea.x 211 | y: chart.plotArea.y 212 | width: chart.plotArea.width 213 | height: chart.plotArea.height 214 | enabled: false 215 | } 216 | 217 | 218 | MouseArea { 219 | id: mouseArea 220 | anchors.fill: parent 221 | hoverEnabled: true 222 | propagateComposedEvents: true 223 | 224 | property point dataPoint: Qt.point(0, 0) 225 | property point dataPos: Qt.point(0, 0) 226 | 227 | onPositionChanged: { 228 | mouse.accepted = false; 229 | if (!mouseArea.containsMouse){ 230 | return; 231 | } 232 | var pt = mouseArea.mapToGlobal(mouse.x, mouse.y); 233 | timedMouse.setPoint(pt); 234 | } 235 | 236 | onClicked: { mouse.accepted = false } 237 | onPressed: { mouse.accepted = false } 238 | onReleased: { mouse.accepted = false } 239 | 240 | Timer { 241 | id: timedMouse 242 | interval: 1 243 | repeat: false 244 | property var point 245 | 246 | function setPoint(pt) { 247 | timedMouse.stop(); 248 | timedMouse.point = pt; 249 | timedMouse.start(); 250 | } 251 | 252 | onTriggered: { 253 | var series = chart.activeSeries, 254 | spectrum = root.activeSpectrum; 255 | if (!series || !spectrum){ 256 | return; 257 | } 258 | var pos = chart.mapFromGlobal(timedMouse.point.x, timedMouse.point.y), 259 | mouseDataPoint = chart.mapToValue(pos, series), 260 | dataPoint = spectrum.get_nearest_by_x(mouseDataPoint.x), 261 | dataPos; 262 | if (dataPoint.x < 0) { 263 | dataPos = dataPoint; 264 | } else { 265 | dataPos = chart.mapToPosition(dataPoint, series); 266 | dataPos = chart.mapToItem(crosshair, dataPos.x, dataPos.y); 267 | } 268 | crosshair.setData(dataPos, dataPoint); 269 | } 270 | } 271 | } 272 | } 273 | 274 | ChartSelect { 275 | id: chartSelect 276 | Layout.minimumWidth: 200 277 | Layout.fillHeight: true 278 | Layout.leftMargin: 0 279 | Layout.rightMargin: 5 280 | Layout.topMargin: 5 281 | activeSpectrum: root.activeSpectrum 282 | 283 | onSelected: { 284 | setActiveSpectrum(spectrum); 285 | } 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/GraphHViewControls.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Controls 2.12 3 | import QtQuick.Layouts 1.11 4 | 5 | RowLayout { 6 | id: root 7 | property GraphViewController controller 8 | property alias scrolling: scrollBar.scrolling 9 | 10 | RoundButton { 11 | Layout.leftMargin: 6 12 | text: "\u2190" 13 | onClicked: { controller.scrollLeft() } 14 | } 15 | XScrollBar { 16 | id: scrollBar 17 | Layout.fillWidth: true 18 | Layout.fillHeight: true 19 | dataExtents: controller.dataExtents 20 | viewScale: controller.viewScale 21 | } 22 | RoundButton { 23 | text: "\u2192" 24 | onClicked: { controller.scrollRight() } 25 | } 26 | Item { 27 | Layout.preferredWidth: 20 28 | } 29 | RoundButton { 30 | text: '+' 31 | onClicked: { controller.viewScale.zoomX(1-controller.defaultZoomIncr) } 32 | } 33 | RoundButton { 34 | text: '-' 35 | onClicked: { controller.viewScale.zoomX(controller.defaultZoomIncr+1) } 36 | } 37 | Button { 38 | Layout.rightMargin: 6 39 | text: 'Reset' 40 | onClicked: { controller.reset() } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/GraphViewController.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Controls 2.12 3 | import QtQuick.Layouts 1.11 4 | 5 | Item { 6 | id: root 7 | 8 | property var axisX 9 | property var axisY 10 | 11 | property ViewXYScale dataExtents: dataExtents 12 | property ViewXYScale viewScale: viewScale 13 | property alias dataMin: dataExtents.valueMin 14 | property alias dataMax: dataExtents.valueMax 15 | property point origDataMin: Qt.point(0, 1) 16 | property point origDataMax: Qt.point(0, 1) 17 | property alias dataCenter: dataExtents.valueCenter 18 | property alias dataZoomFactor: dataExtents.scaleFactor 19 | property alias viewMin: viewScale.valueMin 20 | property alias viewMax: viewScale.valueMax 21 | property alias viewCenter: viewScale.valueCenter 22 | property alias viewZoomFactor: viewScale.scaleFactor 23 | property real defaultZoomIncr: 0.125 24 | property real scrollIncrFactor: 0.125 25 | property point scrollIncr: Qt.point(1, 1) 26 | property alias isZoomed: viewScale.isZoomed 27 | property bool isScrolled: false 28 | property bool isDefault: root.isZoomed || root.isScrolled ? false : true 29 | 30 | function setDataExtents(vmin, vmax){ 31 | root.origDataMin = vmin; 32 | root.origDataMax = vmax; 33 | dataExtents.setValueExtents(vmin, vmax); 34 | } 35 | 36 | onAxisXChanged: { 37 | if (!root.axisX){ 38 | return; 39 | } 40 | if (root.dataMin.x == 0 && root.dataMax.x == 1){ 41 | dataExtents.setXExtents(root.axisX.min, root.axisX.max); 42 | } 43 | } 44 | 45 | onAxisYChanged: { 46 | if (!root.axisY){ 47 | return; 48 | } 49 | if (root.dataMin.y == 0 && root.dataMax.y == 1){ 50 | dataExtents.setYExtents(root.axisY.min, root.axisY.max); 51 | } 52 | } 53 | 54 | onDataMinChanged: { 55 | if (root.isDefault){ 56 | viewScale.setValueMin(root.dataMin); 57 | updateAxes(); 58 | } 59 | } 60 | 61 | onDataMaxChanged: { 62 | if (root.isDefault){ 63 | viewScale.setValueMax(root.dataMax); 64 | updateAxes(); 65 | } 66 | } 67 | 68 | function updateScrollIncr(){ 69 | var size = viewScale.valueSize; 70 | root.scrollIncr.x = size.x * root.scrollIncrFactor; 71 | root.scrollIncr.y = size.y * root.scrollIncrFactor; 72 | } 73 | 74 | function updateAxes(){ 75 | if (root.axisX && root.axisY){ 76 | root.axisX.min = root.viewMin.x; 77 | root.axisX.max = root.viewMax.x; 78 | root.axisY.min = root.viewMin.y; 79 | root.axisY.max = root.viewMax.y; 80 | } 81 | } 82 | 83 | function scrollLeft(){ 84 | var curPos = root.viewCenter.x, 85 | newPos = curPos - root.scrollIncr.x; 86 | viewScale.translateToX(newPos); 87 | if (root.viewMin.x < root.dataMin.x){ 88 | viewScale.translateToX(curPos); 89 | } 90 | root.isScrolled = true; 91 | } 92 | 93 | function scrollRight(){ 94 | var curPos = root.viewCenter.x, 95 | newPos = curPos + root.scrollIncr.x; 96 | viewScale.translateToX(newPos); 97 | if (root.viewMax.x > root.dataMax.x){ 98 | viewScale.translateToX(curPos); 99 | } 100 | root.isScrolled = true; 101 | } 102 | 103 | function reset(){ 104 | dataExtents.setValueExtents(root.origDataMin, root.origDataMax); 105 | viewScale.translateTo(root.dataCenter.x, root.dataCenter.y); 106 | viewScale.setScale(1, 1); 107 | root.isScrolled = false; 108 | } 109 | 110 | ViewXYScale { 111 | id: dataExtents 112 | onValuesChanged: { 113 | if (root.isDefault){ 114 | viewScale.setValueExtents(min, max); 115 | } 116 | } 117 | } 118 | 119 | ViewXYScale { 120 | id: viewScale 121 | onValuesChanged: { 122 | root.updateAxes(); 123 | } 124 | onValueSizeChanged: { 125 | root.updateScrollIncr(); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ImportDialog.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Dialogs 1.0 3 | import Qt.labs.settings 1.0 4 | 5 | FileDialog { 6 | id: root 7 | title: "Please choose a file" 8 | folder: shortcuts.home 9 | selectMultiple: false 10 | nameFilters: [ 11 | 'Scan Files (*.csv *.sdb2)', 12 | 'Numpy Files (*.npz)', 13 | ] 14 | signal importFile(var fileName) 15 | 16 | Item { 17 | Settings { 18 | category: 'Folder Preferences' 19 | property alias importFolder: root.folder 20 | } 21 | } 22 | 23 | onAccepted: { 24 | root.importFile(root.fileUrl); 25 | root.close(); 26 | } 27 | 28 | onRejected: { 29 | root.close(); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/LiveSpectrumGraph.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtCharts 2.13 3 | import GraphUtils 1.0 4 | 5 | Item { 6 | id: root 7 | 8 | property var graphParent 9 | property var mapper: modelMapper 10 | property alias series: modelMapper.series 11 | property alias name: graphData.name 12 | property var model: tblModel 13 | property alias minValue: graphData.minValue 14 | property alias maxValue: graphData.maxValue 15 | property alias spectrum: graphData.spectrum 16 | property alias scanner: graphData.scanner 17 | property alias graphVisible: graphData.graphVisible 18 | property int index 19 | property alias color: graphData.color 20 | property bool seriesInitialized: false 21 | property bool selected: true 22 | property real seriesWidth: selected ? 2 : 1 23 | 24 | signal axisExtentsUpdate() 25 | signal seriesClicked(int index) 26 | 27 | 28 | onSeriesChanged: { 29 | if (series){ 30 | series.name = root.name; 31 | series.width = root.seriesWidth; 32 | if (root.seriesInitialized){ 33 | series.color = root.color; 34 | } else { 35 | root.color = series.color; 36 | } 37 | root.seriesInitialized = true; 38 | } 39 | } 40 | 41 | Connections { 42 | target: root.graphParent 43 | function onActiveSpectrumChanged() { 44 | root.selected = root.graphParent.activeSpectrum.index == root.index; 45 | } 46 | } 47 | 48 | Connections { 49 | target: root.series 50 | function onClicked() { 51 | root.seriesClicked(root.index); 52 | } 53 | } 54 | 55 | onNameChanged: { 56 | if (series){ 57 | root.series.name = root.name; 58 | } 59 | } 60 | 61 | onSeriesWidthChanged: { 62 | if (root.series){ 63 | root.series.width = root.seriesWidth; 64 | } 65 | } 66 | 67 | onGraphVisibleChanged: { 68 | if (!root.series){ 69 | return; 70 | } 71 | root.series.visible = root.graphVisible; 72 | } 73 | 74 | onMinValueChanged: { axisExtentsUpdate() } 75 | onMaxValueChanged: { axisExtentsUpdate() } 76 | 77 | function save_to_file(fileName){ 78 | graphData.save_to_file(fileName); 79 | } 80 | 81 | function get_nearest_by_x(value){ 82 | return graphData.get_nearest_by_x(value); 83 | } 84 | 85 | GraphTableModel { 86 | id: tblModel 87 | } 88 | 89 | LiveSpectrumGraphData { 90 | id: graphData 91 | model: tblModel 92 | 93 | onColorChanged: { 94 | if (root.series){ 95 | if (root.series.color != graphData.color){ 96 | root.series.color = graphData.color; 97 | } 98 | } 99 | } 100 | } 101 | 102 | HXYModelMapper { 103 | id: modelMapper 104 | series: root.series 105 | model: tblModel 106 | xRow: 0 107 | yRow: 1 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/NumberInput.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.13 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | 5 | Frame { 6 | id: root 7 | property bool isFloat: true 8 | property bool showFrame: false 9 | property real floatPrecision: 3 10 | property alias name: lbl.text 11 | property alias labelFontSize: lbl.font.pointSize 12 | property alias inputFontSize: txtField.font.pointSize 13 | property real value: 0 14 | property string textValue: isFloat ? value.toFixed(root.floatPrecision): parseInt(value).toString() 15 | signal submit(real value) 16 | 17 | leftPadding: 12 18 | rightPadding: 12 19 | topPadding: 6 20 | bottomPadding: 6 21 | 22 | ColumnLayout { 23 | anchors.fill: parent 24 | spacing: 1 25 | Label { 26 | id: lbl 27 | Layout.alignment: Qt.AlignLeft || Qt.AlignBottom 28 | horizontalAlignment: Text.AlignLeft 29 | verticalAlignment: Text.AlignBottom 30 | } 31 | 32 | TextField { 33 | id: txtField 34 | Layout.alignment: Qt.AlignLeft || Qt.AlignTop 35 | Layout.fillWidth: true 36 | inputMethodHints: Qt.ImhDigitsOnly 37 | maximumLength: 7 38 | implicitWidth: txtMetrics.maxWidth + txtField.leftPadding + txtField.rightPadding 39 | implicitHeight: txtMetrics.height + txtField.topPadding + txtField.bottomPadding 40 | text: root.textValue 41 | horizontalAlignment: Text.AlignLeft 42 | onEditingFinished: { 43 | if (root.isFloat){ 44 | root.value = parseFloat(txtField.text); 45 | } else { 46 | root.value = parseInt(txtField.text); 47 | } 48 | root.submit(root.value); 49 | } 50 | } 51 | } 52 | 53 | background: Rectangle { 54 | color: 'transparent' 55 | border.color: root.palette.mid 56 | border.width: root.showFrame ? 1 : 0 57 | radius: 3 58 | } 59 | 60 | FontMetrics { 61 | id: txtMetrics 62 | font: txtField.font 63 | property real maxWidth: txtMetrics.averageCharacterWidth * txtField.maximumLength 64 | } 65 | FontMetrics { 66 | id: lblMetrics 67 | font: lbl.font 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ScanConfig.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import Qt.labs.settings 1.0 3 | import ScanTools 1.0 4 | 5 | Item { 6 | id: root 7 | 8 | property ScanConfigData model: model 9 | property alias startFreq: model.startFreq 10 | property alias endFreq: model.endFreq 11 | property alias samplesPerSweep: model.samplesPerSweep 12 | property alias sweepsPerScan: model.sweepsPerScan 13 | property alias sweepOverlapRatio: model.sweepOverlapRatio 14 | property alias windowType: model.windowType 15 | property alias windowSize: model.windowSize 16 | property alias smoothingEnabled: model.smoothingEnabled 17 | property alias smoothingFactor: model.smoothingFactor 18 | property alias scalingEnabled: model.scalingEnabled 19 | property alias scalingMinDB: model.scalingMinDB 20 | property alias scalingMaxDB: model.scalingMaxDB 21 | 22 | signal configUpdate() 23 | 24 | ScanConfigData { 25 | id: model 26 | startFreq: 470. 27 | endFreq: 536. 28 | samplesPerSweep: 8192 29 | sweepsPerScan: 20 30 | sweepOverlapRatio: 0.5 31 | windowType: 'hann' 32 | windowSize: 128 33 | smoothingEnabled: false 34 | smoothingFactor: 1.0 35 | scalingEnabled: false 36 | scalingMinDB: -140 37 | scalingMaxDB: -55 38 | 39 | onConfigUpdate: { 40 | root.configUpdate(); 41 | } 42 | } 43 | 44 | Settings { 45 | id: settings 46 | category: 'Scan Config' 47 | property alias startFreq: model.startFreq 48 | property alias endFreq: model.endFreq 49 | property alias samplesPerSweep: model.samplesPerSweep 50 | property alias sweepsPerScan: model.sweepsPerScan 51 | property alias sweepOverlapRatio: model.sweepOverlapRatio 52 | property alias windowType: model.windowType 53 | property alias windowSize: model.windowSize 54 | property alias smoothingEnabled: model.smoothingEnabled 55 | property alias smoothingFactor: model.smoothingFactor 56 | property alias scalingEnabled: model.scalingEnabled 57 | property alias scalingMinDB: model.scalingMinDB 58 | property alias scalingMaxDB: model.scalingMaxDB 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ScanControls.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | 5 | RowLayout { 6 | id: root 7 | property ScanConfig config 8 | property alias startFreq: startFreqInput.value 9 | property alias endFreq: endFreqInput.value 10 | property bool configReady: false 11 | property bool scanRunning: false 12 | property bool scanReady: !root.scanRunning 13 | property alias progress: progressBar.value 14 | 15 | signal scannerState(bool state) 16 | 17 | onConfigChanged: { 18 | if (!config){ 19 | return; 20 | } 21 | configUpdateCallback(); 22 | root.configReady = true; 23 | config.configUpdate.connect(configUpdateCallback); 24 | } 25 | 26 | function configUpdateCallback(){ 27 | if (root.startFreq != config.startFreq){ 28 | root.startFreq = config.startFreq; 29 | } 30 | if (root.endFreq != config.endFreq){ 31 | root.endFreq = config.endFreq; 32 | } 33 | } 34 | 35 | NumberInput { 36 | id: startFreqInput 37 | name: 'Start Freq' 38 | Layout.leftMargin: 6 39 | labelFontSize: 10 40 | onSubmit: { 41 | root.config.startFreq = startFreqInput.value; 42 | } 43 | } 44 | NumberInput { 45 | id: endFreqInput 46 | name: 'End Freq' 47 | labelFontSize: 10 48 | onSubmit: { 49 | root.config.endFreq = endFreqInput.value; 50 | } 51 | } 52 | 53 | ToolSeparator { } 54 | 55 | Item { 56 | Layout.fillWidth: true 57 | Layout.fillHeight: true 58 | } 59 | 60 | ProgressBar { 61 | id: progressBar 62 | visible: root.scanRunning 63 | Component.onCompleted: { 64 | contentItem.color = '#17a81a'; 65 | background.color = '#aeaec8'; 66 | } 67 | } 68 | 69 | ToolSeparator { } 70 | 71 | ToolButton { 72 | id: scanStartBtn 73 | text: "Start" 74 | icon.name: 'media-playback-start' 75 | enabled: root.scanReady 76 | onClicked: root.scannerState(true) 77 | } 78 | ToolButton { 79 | id: scanStopBtn 80 | text: "Stop" 81 | icon.name: 'media-playback-stop' 82 | Layout.rightMargin: 6 83 | // enabled: !root.scanReady 84 | onClicked: root.scannerState(false) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ScanControlsDialog.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | import ScanTools 1.0 5 | 6 | Dialog { 7 | id: root 8 | property ScanConfig config 9 | property ScannerInterface scanner 10 | property real sampleRate 11 | property alias samplesPerSweep: samplesPerSweepInput.value 12 | property alias sweepsPerScan: sweepsPerScanInput.value 13 | property alias sweepOverlapRatio: sweepOverlapRatioInput.value 14 | property alias windowType: windowCombo.textValue 15 | property alias windowSize: windowSizeInput.value 16 | property alias smoothingEnabled: smoothingSwitch.checked 17 | property alias smoothingFactor: smoothingFactorInput.value 18 | property alias scalingEnabled: scalingSwitch.checked 19 | property alias scalingMinDB: scalingMinDBInput.value 20 | property alias scalingMaxDB: scalingMaxDBInput.value 21 | 22 | onWindowTypeChanged: { 23 | if (!root.windowType){ 24 | return; 25 | } 26 | windowCombo.setFromString(root.windowType); 27 | } 28 | 29 | function getFreqResolution(){ 30 | if (!root.scanner){ 31 | return -1; 32 | } 33 | var fs = root.sampleRate, 34 | nfft = root.windowSize; 35 | if (!fs || !nfft){ 36 | return -1; 37 | } 38 | var r = root.scanner.getFreqResolution(nfft, fs); 39 | freqResolutionLbl.value = r; 40 | } 41 | 42 | onScannerChanged: { 43 | root.sampleRate = Qt.binding(function(){ return root.scanner.sampleRate }); 44 | getFreqResolution(); 45 | } 46 | 47 | onSampleRateChanged: { getFreqResolution() } 48 | onWindowSizeChanged: { getFreqResolution() } 49 | 50 | ColumnLayout { 51 | anchors.fill: parent 52 | 53 | GroupBox { 54 | title: 'Sweep Parameters' 55 | 56 | RowLayout { 57 | anchors.fill: parent 58 | NumberInput { 59 | id: samplesPerSweepInput 60 | name: 'Samples Per Sweep' 61 | isFloat: false 62 | showFrame: true 63 | } 64 | NumberInput { 65 | id: sweepsPerScanInput 66 | name: 'Sweeps Per Scan' 67 | isFloat: false 68 | showFrame: true 69 | } 70 | NumberInput { 71 | id: sweepOverlapRatioInput 72 | name: 'Sweep Overlap Ratio' 73 | showFrame: true 74 | } 75 | } 76 | } 77 | 78 | GroupBox { 79 | title: 'Window Parameters' 80 | 81 | RowLayout { 82 | anchors.fill: parent 83 | 84 | ComboBox { 85 | id: windowCombo 86 | property string textValue 87 | model: [ 88 | 'barthann', 'bartlett', 'blackman', 'blackmanharris', 89 | 'bohman', 'boxcar', 'cosine', 'flattop', 'general_cosine', 90 | 'general_hamming', 'hamming', 'hann', 'hanning', 91 | 'nuttall', 'parzen', 'triang', 92 | ] 93 | onTextValueChanged: { windowCombo.setFromString(windowCombo.textValue) } 94 | function setFromString(wname){ 95 | var idx = windowCombo.find(wname); 96 | windowCombo.currentIndex = idx; 97 | return idx; 98 | } 99 | } 100 | 101 | NumberInput { 102 | id: windowSizeInput 103 | name: 'Window Size' 104 | isFloat: false 105 | showFrame: true 106 | } 107 | Label { 108 | id: freqResolutionLbl 109 | property real value 110 | property bool isEvenFreq: freqResolutionLbl.value*1e6 == parseInt(freqResolutionLbl.value*1e6) 111 | text: "Resolution: \n" + freqResolutionLbl.value.toString() + " MHz" 112 | font.pointSize: 9 113 | states: [ 114 | State { 115 | name: 'normal' 116 | when: freqResolutionLbl.isEvenFreq 117 | PropertyChanges { target: freqResolutionLbl; color: '#008000' } 118 | }, 119 | State { 120 | name: 'critical' 121 | when: !freqResolutionLbl.isEvenFreq 122 | PropertyChanges { target: freqResolutionLbl; color: '#800000'} 123 | } 124 | ] 125 | } 126 | } 127 | } 128 | 129 | GroupBox { 130 | title: 'Smoothing' 131 | 132 | RowLayout { 133 | anchors.fill: parent 134 | Switch { 135 | id: smoothingSwitch 136 | text: 'Enabled' 137 | } 138 | NumberInput { 139 | id: smoothingFactorInput 140 | name: 'Factor' 141 | showFrame: true 142 | } 143 | } 144 | } 145 | 146 | GroupBox { 147 | title: 'Scaling' 148 | 149 | RowLayout { 150 | anchors.fill: parent 151 | Switch { 152 | id: scalingSwitch 153 | text: 'Enabled' 154 | } 155 | NumberInput { 156 | id: scalingMinDBInput 157 | name: 'Minimum (dB)' 158 | showFrame: true 159 | } 160 | NumberInput { 161 | id: scalingMaxDBInput 162 | name: 'Maximum (dB)' 163 | showFrame: true 164 | } 165 | } 166 | } 167 | } 168 | 169 | standardButtons: Dialog.Ok | Dialog.Cancel 170 | 171 | function commitSettings(){ 172 | config.samplesPerSweep = root.samplesPerSweep; 173 | config.sweepsPerScan = root.sweepsPerScan; 174 | config.sweepOverlapRatio = root.sweepOverlapRatio; 175 | config.windowType = root.windowType; 176 | config.windowSize = root.windowSize; 177 | config.smoothingEnabled = root.smoothingEnabled; 178 | config.smoothingFactor = root.smoothingFactor; 179 | config.scalingEnabled = root.scalingEnabled; 180 | config.scalingMinDB = root.scalingMinDB; 181 | config.scalingMaxDB = root.scalingMaxDB; 182 | } 183 | 184 | function reloadSettings(){ 185 | if (!config){ 186 | return; 187 | } 188 | root.samplesPerSweep = config.samplesPerSweep; 189 | root.sweepsPerScan = config.sweepsPerScan; 190 | root.sweepOverlapRatio = config.sweepOverlapRatio; 191 | root.windowType = config.windowType; 192 | root.windowSize = config.windowSize; 193 | root.smoothingEnabled = config.smoothingEnabled; 194 | root.smoothingFactor = config.smoothingFactor; 195 | root.scalingEnabled = config.scalingEnabled; 196 | root.scalingMinDB = config.scalingMinDB; 197 | root.scalingMaxDB = config.scalingMaxDB; 198 | } 199 | 200 | onAccepted: { 201 | commitSettings(); 202 | root.close(); 203 | } 204 | 205 | onRejected: { 206 | reloadSettings(); 207 | root.close(); 208 | } 209 | onAboutToShow: { 210 | reloadSettings(); 211 | } 212 | Component.onCompleted: { 213 | reloadSettings(); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/SpectrumGraph.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtCharts 2.13 3 | import GraphUtils 1.0 4 | 5 | Item { 6 | id: root 7 | 8 | property var graphParent 9 | property var mapper: modelMapper 10 | property alias series: modelMapper.series 11 | property alias name: graphData.name 12 | property var model: tblModel 13 | property alias minValue: graphData.minValue 14 | property alias maxValue: graphData.maxValue 15 | property alias spectrum: graphData.spectrum 16 | property alias graphVisible: graphData.graphVisible 17 | property int index 18 | property alias color: graphData.color 19 | property bool seriesInitialized: false 20 | property bool selected: true 21 | property real seriesWidth: selected ? 2 : 1 22 | 23 | signal axisExtentsUpdate() 24 | signal seriesClicked(int index) 25 | 26 | onSeriesChanged: { 27 | if (series){ 28 | series.name = root.name; 29 | series.width = root.seriesWidth; 30 | if (root.seriesInitialized){ 31 | series.color = root.color; 32 | } else { 33 | root.color = series.color; 34 | } 35 | root.seriesInitialized = true; 36 | } 37 | } 38 | 39 | Connections { 40 | target: root.graphParent 41 | function onActiveSpectrumChanged() { 42 | root.selected = root.graphParent.activeSpectrum.index == root.index; 43 | } 44 | } 45 | 46 | Connections { 47 | target: root.series 48 | function onClicked() { 49 | root.seriesClicked(root.index); 50 | } 51 | } 52 | 53 | onNameChanged: { 54 | if (series){ 55 | root.series.name = root.name; 56 | } 57 | } 58 | 59 | onSeriesWidthChanged: { 60 | if (root.series){ 61 | root.series.width = root.seriesWidth; 62 | } 63 | } 64 | 65 | onGraphVisibleChanged: { 66 | if (!root.series){ 67 | return; 68 | } 69 | root.series.visible = root.graphVisible; 70 | } 71 | 72 | onMinValueChanged: { axisExtentsUpdate() } 73 | onMaxValueChanged: { axisExtentsUpdate() } 74 | 75 | function load_from_file(fileName){ 76 | graphData.load_from_file(fileName); 77 | } 78 | 79 | function save_to_file(fileName){ 80 | graphData.save_to_file(fileName); 81 | } 82 | 83 | function get_nearest_by_x(value){ 84 | return graphData.get_nearest_by_x(value); 85 | } 86 | 87 | GraphTableModel { 88 | id: tblModel 89 | } 90 | 91 | SpectrumGraphData { 92 | id: graphData 93 | model: tblModel 94 | 95 | onColorChanged: { 96 | if (root.series){ 97 | if (root.series.color != graphData.color){ 98 | root.series.color = graphData.color; 99 | } 100 | } 101 | } 102 | } 103 | 104 | HXYModelMapper { 105 | id: modelMapper 106 | series: root.series 107 | model: tblModel 108 | xRow: 0 109 | yRow: 1 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/ThemeSelectPopup.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | import Qt.labs.settings 1.0 5 | import QtCharts 2.13 6 | 7 | 8 | Dialog { 9 | id: root 10 | property alias theme: settings.chartTheme 11 | 12 | Settings { 13 | id: settings 14 | category: 'User Interface' 15 | property int chartTheme: ChartView.ChartThemeDark 16 | } 17 | 18 | ComboBox { 19 | id: combo 20 | textRole: 'text' 21 | model: ListModel { 22 | ListElement { text:'Light'; value:ChartView.ChartThemeLight } 23 | ListElement { text:'Cerulean Blue'; value:ChartView.ChartThemeBlueCerulean } 24 | ListElement { text:'Dark'; value:ChartView.ChartThemeDark } 25 | ListElement { text:'Brown Sand'; value:ChartView.ChartThemeBrownSand } 26 | ListElement { text:'Natural (NCS) Blue'; value:ChartView.ChartThemeBlueNcs } 27 | ListElement { text:'High Contrast'; value:ChartView.ChartThemeHighContrast } 28 | ListElement { text:'Icy Blue'; value:ChartView.ChartThemeBlueIcy } 29 | ListElement { text:'Qt'; value:ChartView.ChartThemeQt } 30 | } 31 | 32 | function getSelectedValue(){ 33 | var idx = combo.currentIndex, 34 | item; 35 | if (idx == -1){ 36 | return root.theme; 37 | } 38 | item = model.get(idx); 39 | return item.value; 40 | } 41 | function setSelectedValue(value){ 42 | var item; 43 | for (var i=0;i= axisWidth){ 53 | root.visible = false; 54 | return; 55 | } 56 | var leftClip = leftEdge < 0, 57 | rightClip = rightEdge > axisWidth, 58 | maskedX, maskedWidth; 59 | 60 | if (leftClip && rightClip){ 61 | maskedX = -leftEdge; 62 | maskedWidth = axisWidth; 63 | } else if (leftClip){ 64 | maskedX = -leftEdge; 65 | maskedWidth = root.width - maskedX; 66 | } else if (rightClip){ 67 | maskedX = 0; 68 | maskedWidth = root.width - (rightEdge-axisWidth); 69 | } else { 70 | maskedX = 0; 71 | maskedWidth = root.width; 72 | } 73 | root.maskedX = maskedX; 74 | root.maskedWidth = maskedWidth; 75 | root.visible = true; 76 | } 77 | 78 | Rectangle { 79 | id: bgRect 80 | anchors.top: parent.top 81 | anchors.bottom: parent.bottom 82 | x: root.maskedX 83 | width: root.maskedWidth 84 | color: root.bgColor 85 | border.color: root.borderColor 86 | border.width: 1 87 | } 88 | 89 | Text { 90 | id: lbl 91 | anchors.fill: parent 92 | horizontalAlignment: Text.AlignHCenter 93 | verticalAlignment: Text.AlignVCenter 94 | property real contentLeft: root.centerX - lbl.contentWidth/2 95 | property real contentRight: root.centerX + lbl.contentWidth/2 96 | visible: lbl.contentLeft >= 0 && lbl.contentRight <= root.parentWidth 97 | text: root.channel.toString() 98 | color: root.textColor 99 | fontSizeMode: Text.Fit 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/UHFChannels.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtCharts 2.13 3 | 4 | Item { 5 | id: root 6 | 7 | property ChartView chart 8 | property ValueAxis axisX 9 | property real min: axisX.min 10 | property real max: axisX.max 11 | property var channels: [] 12 | property var channelsByCenterFreq: ({}) 13 | property bool channelsBuilt: false 14 | 15 | property real freqScalar: 1 / freqRangeSize 16 | property real freqRangeSize: max - min 17 | signal geometryUpdate() 18 | signal rangeUpdate() 19 | 20 | 21 | onAxisXChanged: { 22 | var obj; 23 | if (axisX){ 24 | for (var i=0;i parent.gutterWidth ? parent.gutterWidth : parent.position 108 | height: 6 109 | radius: 3 110 | color: control.gutterColor 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/qml/main.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.0 2 | import QtQuick.Layouts 1.11 3 | import QtQuick.Controls 2.12 4 | import Qt.labs.settings 1.0 5 | import QtCharts 2.13 6 | import GraphUtils 1.0 7 | import ScanTools 1.0 8 | 9 | ApplicationWindow { 10 | id: window 11 | visible: true 12 | 13 | width: 800 14 | height: 600 15 | 16 | Settings { 17 | property alias x: window.x 18 | property alias y: window.y 19 | property alias width: window.width 20 | property alias height: window.height 21 | } 22 | 23 | menuBar: MenuBar { 24 | Menu { 25 | title: qsTr("&File") 26 | Action { 27 | text: qsTr("&Import") 28 | onTriggered: importDialog.open() 29 | } 30 | Action { 31 | text: qsTr("&Export") 32 | onTriggered: { 33 | exportDialog.graphData = chartWrapper.activeSpectrum; 34 | exportDialog.open(); 35 | } 36 | } 37 | Action { 38 | text: qsTr("&Device Settings") 39 | onTriggered: device_config.open() 40 | } 41 | Action { 42 | text: qsTr("S&can Settings") 43 | onTriggered: scanControlsDialog.open() 44 | } 45 | MenuSeparator { } 46 | Action { 47 | text: qsTr("&Quit") 48 | onTriggered: Qt.quit() 49 | } 50 | } 51 | Menu { 52 | title: qsTr("&View") 53 | Action { 54 | text: qsTr("&Theme") 55 | onTriggered: themeSelect.open() 56 | } 57 | } 58 | } 59 | 60 | header: ToolBar { 61 | ScanControls { 62 | id: scanControls 63 | anchors.fill: parent 64 | config: scanConfig 65 | progress: scanner.progress 66 | onScannerState: { 67 | if (state) { 68 | scanner.start(); 69 | chartWrapper.newLiveScan(scanner); 70 | } else { 71 | scanner.stop(); 72 | } 73 | } 74 | } 75 | } 76 | 77 | footer: ToolBar { 78 | RowLayout { 79 | anchors.fill: parent 80 | 81 | Label { 82 | text: chartWrapper.mouseDataPoint.x.toFixed(3); 83 | Layout.preferredWidth: contentWidth 84 | Layout.leftMargin: 12 85 | font.pointSize: 9 86 | verticalAlignment: Text.AlignVCenter 87 | horizontalAlignment: Qt.AlignLeft 88 | } 89 | 90 | Label { 91 | text: chartWrapper.mouseDataPoint.y.toFixed(3); 92 | Layout.preferredWidth: contentWidth 93 | font.pointSize: 9 94 | verticalAlignment: Text.AlignVCenter 95 | horizontalAlignment: Qt.AlignLeft 96 | } 97 | 98 | Item { Layout.fillWidth: true } 99 | 100 | } 101 | } 102 | 103 | StackView { 104 | anchors.fill: parent 105 | Graph { 106 | id: chartWrapper 107 | anchors.fill: parent 108 | theme: themeSelect.theme 109 | } 110 | } 111 | 112 | ImportDialog { 113 | id: importDialog 114 | onImportFile: { 115 | chartWrapper.loadFromFile(fileName); 116 | } 117 | } 118 | 119 | ExportDialog { 120 | id: exportDialog 121 | } 122 | 123 | DeviceConfigPopup { 124 | id: device_config 125 | } 126 | 127 | ScanControlsDialog { 128 | id: scanControlsDialog 129 | scanner: scanner 130 | config: scanConfig 131 | } 132 | 133 | ThemeSelectPopup { 134 | id: themeSelect 135 | } 136 | 137 | ScanConfig { 138 | id: scanConfig 139 | } 140 | 141 | ScannerInterface { 142 | id: scanner 143 | scanConfig: scanConfig.model 144 | deviceInfo: device_config.device ? device_config.device: null 145 | gain: device_config.gain 146 | sampleRate: device_config.sampleRate 147 | onScannerRunState: { 148 | scanControls.scanRunning = scanner.running; 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/scanner.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import numpy as np 4 | 5 | from PySide2 import QtCore, QtQml, QtQuick 6 | from PySide2.QtCore import Signal, Property, Slot 7 | import logging 8 | logger = logging.getLogger(__name__) 9 | 10 | from wwb_scanner.scanner import config 11 | from wwb_scanner.scanner import Scanner 12 | from wwb_scanner.scanner.main import get_freq_resolution 13 | from wwb_scanner.scan_objects import Spectrum 14 | 15 | from wwb_scanner.ui.pyside.utils import GenericQObject, QObjectThread 16 | 17 | class ScanConfigData(GenericQObject): 18 | _n_startFreq = Signal() 19 | _n_endFreq = Signal() 20 | _n_samplesPerSweep = Signal() 21 | _n_sweepsPerScan = Signal() 22 | _n_sweepOverlapRatio = Signal() 23 | _n_windowType = Signal() 24 | _n_windowSize = Signal() 25 | _n_smoothingEnabled = Signal() 26 | _n_smoothingFactor = Signal() 27 | _n_scalingEnabled = Signal() 28 | _n_scalingMinDB = Signal() 29 | _n_scalingMaxDB = Signal() 30 | configUpdate = Signal() 31 | def __init__(self, *args): 32 | self._startFreq = 470. 33 | self._endFreq = 536. 34 | self._samplesPerSweep = 8192 35 | self._sweepsPerScan = 20 36 | self._sweepOverlapRatio = .5 37 | self._windowType = 'hann' 38 | self._windowSize = 128 39 | self._smoothingEnabled = False 40 | self._smoothingFactor = 1. 41 | self._scalingEnabled = False 42 | self._scalingMinDB = -140. 43 | self._scalingMaxDB = -55. 44 | super().__init__(*args) 45 | 46 | def _generic_property_changed(self, attr, old_value, new_value): 47 | self.configUpdate.emit() 48 | 49 | def _g_startFreq(self): return self._startFreq 50 | def _s_startFreq(self, value): self._generic_setter('_startFreq', value) 51 | startFreq = Property(float, _g_startFreq, _s_startFreq, notify=_n_startFreq) 52 | 53 | def _g_endFreq(self): return self._endFreq 54 | def _s_endFreq(self, value): self._generic_setter('_endFreq', value) 55 | endFreq = Property(float, _g_endFreq, _s_endFreq, notify=_n_endFreq) 56 | 57 | def _g_samplesPerSweep(self): return self._samplesPerSweep 58 | def _s_samplesPerSweep(self, value): self._generic_setter('_samplesPerSweep', value) 59 | samplesPerSweep = Property(int, _g_samplesPerSweep, _s_samplesPerSweep, notify=_n_samplesPerSweep) 60 | 61 | def _g_sweepsPerScan(self): return self._sweepsPerScan 62 | def _s_sweepsPerScan(self, value): self._generic_setter('_sweepsPerScan', value) 63 | sweepsPerScan = Property(int, _g_sweepsPerScan, _s_sweepsPerScan, notify=_n_sweepsPerScan) 64 | 65 | def _g_sweepOverlapRatio(self): return self._sweepOverlapRatio 66 | def _s_sweepOverlapRatio(self, value): self._generic_setter('_sweepOverlapRatio', value) 67 | sweepOverlapRatio = Property(float, _g_sweepOverlapRatio, _s_sweepOverlapRatio, notify=_n_sweepOverlapRatio) 68 | 69 | def _g_windowType(self): return self._windowType 70 | def _s_windowType(self, value): self._generic_setter('_windowType', value) 71 | windowType = Property(str, _g_windowType, _s_windowType, notify=_n_windowType) 72 | 73 | def _g_windowSize(self): return self._windowSize 74 | def _s_windowSize(self, value): self._generic_setter('_windowSize', value) 75 | windowSize = Property(int, _g_windowSize, _s_windowSize, notify=_n_windowSize) 76 | 77 | def _g_smoothingEnabled(self): return self._smoothingEnabled 78 | def _s_smoothingEnabled(self, value): self._generic_setter('_smoothingEnabled', value) 79 | smoothingEnabled = Property(bool, _g_smoothingEnabled, _s_smoothingEnabled, notify=_n_smoothingEnabled) 80 | 81 | def _g_smoothingFactor(self): return self._smoothingFactor 82 | def _s_smoothingFactor(self, value): self._generic_setter('_smoothingFactor', value) 83 | smoothingFactor = Property(float, _g_smoothingFactor, _s_smoothingFactor, notify=_n_smoothingFactor) 84 | 85 | def _g_scalingEnabled(self): return self._scalingEnabled 86 | def _s_scalingEnabled(self, value): self._generic_setter('_scalingEnabled', value) 87 | scalingEnabled = Property(bool, _g_scalingEnabled, _s_scalingEnabled, notify=_n_scalingEnabled) 88 | 89 | def _g_scalingMinDB(self): return self._scalingMinDB 90 | def _s_scalingMinDB(self, value): self._generic_setter('_scalingMinDB', value) 91 | scalingMinDB = Property(float, _g_scalingMinDB, _s_scalingMinDB, notify=_n_scalingMinDB) 92 | 93 | def _g_scalingMaxDB(self): return self._scalingMaxDB 94 | def _s_scalingMaxDB(self, value): self._generic_setter('_scalingMaxDB', value) 95 | scalingMaxDB = Property(float, _g_scalingMaxDB, _s_scalingMaxDB, notify=_n_scalingMaxDB) 96 | 97 | class ScannerInterface(GenericQObject): 98 | _n_running = Signal() 99 | _n_scanConfig = Signal() 100 | _n_deviceInfo = Signal() 101 | _n_gain = Signal() 102 | _n_sampleRate = Signal() 103 | _n_spectrum = Signal() 104 | _n_progress = Signal() 105 | _n_scannerInitialized = Signal() 106 | scannerRunState = Signal(bool) 107 | def __init__(self, *args): 108 | self._running = False 109 | self._scanConfig = None 110 | self._deviceInfo = None 111 | self._gain = None 112 | self._sampleRate = None 113 | self._spectrum = None 114 | self._scannerInitialized = False 115 | self._progress = -1 116 | super().__init__(*args) 117 | self.scanner = None 118 | self.scan_thread = None 119 | 120 | @property 121 | def startFreq(self): return self.scanConfig.startFreq 122 | 123 | @property 124 | def endFreq(self): return self.scanConfig.endFreq 125 | 126 | def _g_running(self): return self._running 127 | def _s_running(self, value): 128 | if value == self._running: 129 | return 130 | self._running = value 131 | self._n_running.emit() 132 | self.scannerRunState.emit(value) 133 | running = Property(bool, _g_running, _s_running, notify='_n_running') 134 | 135 | def _g_scannerInitialized(self): return self._scannerInitialized 136 | def _s_scannerInitialized(self, value): self._generic_setter('_scannerInitialized', value) 137 | scannerInitialized = Property(bool, _g_scannerInitialized, _s_scannerInitialized, notify=_n_scannerInitialized) 138 | 139 | def _g_scanConfig(self): return self._scanConfig 140 | def _s_scanConfig(self, value): self._generic_setter('_scanConfig', value) 141 | scanConfig = Property(ScanConfigData, _g_scanConfig, _s_scanConfig, notify=_n_scanConfig) 142 | 143 | def _g_deviceInfo(self): return self._deviceInfo 144 | def _s_deviceInfo(self, value): self._generic_setter('_deviceInfo', value) 145 | deviceInfo = Property(QtCore.QObject, _g_deviceInfo, _s_deviceInfo, notify=_n_deviceInfo) 146 | 147 | def _g_gain(self): return self._gain 148 | def _s_gain(self, value): self._generic_setter('_gain', value) 149 | gain = Property(float, _g_gain, _s_gain, notify=_n_gain) 150 | 151 | def _g_sampleRate(self): return self._sampleRate 152 | def _s_sampleRate(self, value): self._generic_setter('_sampleRate', value) 153 | sampleRate = Property(float, _g_sampleRate, _s_sampleRate, notify=_n_sampleRate) 154 | 155 | def _g_spectrum(self): return self._spectrum 156 | def _s_spectrum(self, value): self._generic_setter('_spectrum', value) 157 | spectrum = Property(object, _g_spectrum, _s_spectrum, notify=_n_spectrum) 158 | 159 | def _g_progress(self): return self._progress 160 | def _s_progress(self, value): self._generic_setter('_progress', value) 161 | progress = Property(float, _g_progress, _s_progress, notify=_n_progress) 162 | 163 | def build_scan_config(self): 164 | conf = config.ScanConfig() 165 | conf.scan_range = [self.startFreq, self.endFreq] 166 | conf.device.serial_number = self.deviceInfo.device_serial 167 | conf.device.gain = self.gain 168 | conf.sampling.sample_rate = self.sampleRate * 1e3 169 | conf.sampling.samples_per_sweep = self.scanConfig.samplesPerSweep 170 | conf.sampling.sweeps_per_scan = self.scanConfig.sweepsPerScan 171 | conf.sampling.sweep_overlap_ratio = self.scanConfig.sweepOverlapRatio 172 | conf.sampling.window_size = self.scanConfig.windowSize 173 | return conf 174 | 175 | @Slot(int, float, result=float) 176 | def getFreqResolution(self, nfft, fs): 177 | return get_freq_resolution(nfft, fs*1e3) / 1e6 178 | 179 | @Slot() 180 | def start(self): 181 | if self.running: 182 | return 183 | self._start() 184 | 185 | @Slot() 186 | def stop(self): 187 | if not self.running: 188 | return 189 | self._stop() 190 | 191 | def _start(self): 192 | self.scannerInitialized = False 193 | conf = self.build_scan_config() 194 | self.scanner = Scanner(config=conf) 195 | self.spectrum = self.scanner.spectrum 196 | self.spectrum.name = f'{self.startFreq} - {self.endFreq} (live)' 197 | self.progress = 0. 198 | self.running = True 199 | self.scan_thread = ScanThread(target=self.scanner.run_scan) 200 | self.scanner.on_progress = self.scan_thread._on_scanner_progress 201 | self.scan_thread.complete.connect(self.on_scanner_finished) 202 | self.scan_thread.scannerProgress.connect(self.on_scanner_progress) 203 | self.scan_init_thread = QObjectThread(target=self.scanner._running.wait) 204 | self.scan_init_thread.complete.connect(self.on_scanner_ready) 205 | self.scan_thread.start() 206 | self.scan_init_thread.start() 207 | 208 | def _stop(self): 209 | if self.scanner is not None: 210 | if self.scanner._running.is_set(): 211 | self.scanner.stop_scan() 212 | 213 | @Slot() 214 | def on_scanner_progress(self, value): 215 | self.progress = value 216 | 217 | @Slot() 218 | def on_scanner_finished(self): 219 | self.scan_thread.stop() 220 | logger.info('scan_thread stopped') 221 | if self.scanConfig.smoothingEnabled: 222 | self.smooth_scan() 223 | if self.scanConfig.scalingEnabled: 224 | self.scale_scan() 225 | self.scan_thread = None 226 | self.scanner = None 227 | self.running = False 228 | 229 | @Slot() 230 | def smooth_scan(self): 231 | logger.info('Smoothing scan') 232 | N = int(self.spectrum.sample_data.size * self.scanConfig.smoothingFactor / 100.) 233 | self.spectrum.smooth(N) 234 | # TODO: figure out why interpolate stopped working 235 | # self.spectrum.interpolate() 236 | 237 | @Slot() 238 | def scale_scan(self): 239 | logger.info('Scaling scan') 240 | conf = self.scanConfig 241 | self.spectrum.scale(conf.scalingMinDB, conf.scalingMaxDB) 242 | 243 | @Slot() 244 | def on_scanner_ready(self): 245 | self.scannerInitialized = True 246 | self.scan_init_thread.stop() 247 | self.scan_init_thread = None 248 | 249 | 250 | class ScanThread(QObjectThread): 251 | scannerProgress = Signal(float) 252 | def _on_scanner_progress(self, value): 253 | self.scannerProgress.emit(value) 254 | 255 | def register_qml_types(): 256 | QtQml.qmlRegisterType(ScanConfigData, 'ScanTools', 1, 0, 'ScanConfigData') 257 | QtQml.qmlRegisterType(ScannerInterface, 'ScanTools', 1, 0, 'ScannerInterface') 258 | -------------------------------------------------------------------------------- /wwb_scanner/ui/pyside/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import pathlib 4 | 5 | from PySide2 import QtCore, QtQml 6 | from PySide2.QtCore import QObject, Property, Signal 7 | import logging 8 | logger = logging.getLogger(__name__) 9 | 10 | def is_pathlike(s): 11 | if '/' in s or '\\' in s: 12 | try: 13 | p = pathlib.PurePosixPath(s) 14 | uri = p.as_uri() 15 | return True, p 16 | except ValueError: 17 | pass 18 | try: 19 | p = pathlib.PureWindowsPath(s) 20 | uri = p.as_uri() 21 | return True, p 22 | except ValueError: 23 | pass 24 | return False, None 25 | 26 | 27 | class GenericQObject(QtCore.QObject): 28 | def _generic_property_changed(self, attr, old_value, new_value): 29 | pass 30 | def _generic_setter(self, attr, value): 31 | cur_value = getattr(self, attr) 32 | if cur_value == value: 33 | return 34 | setattr(self, attr, value) 35 | sig_name = f'_n{attr}' 36 | sig = getattr(self, sig_name) 37 | sig.emit() 38 | self._generic_property_changed(attr, cur_value, value) 39 | 40 | class IntervalTimer(QObject): 41 | trigger = Signal() 42 | start = Signal() 43 | stop = Signal() 44 | def __init__(self, parent=None, **kwargs): 45 | interval_ms = kwargs.pop('interval_ms', 100) 46 | super().__init__(parent=parent) 47 | self._interval_ms = interval_ms 48 | self._active = False 49 | self._working = False 50 | self._timer_id = None 51 | self.start.connect(self._start) 52 | self.stop.connect(self._stop) 53 | 54 | def _get_interval_ms(self): 55 | return self._interval_ms 56 | def _set_interval_ms(self, value): 57 | if value == self._interval_ms: 58 | return 59 | self._interval_ms = value 60 | if self._active: 61 | self._stop() 62 | self._start() 63 | self._interval_ms_changed.emit() 64 | @Signal 65 | def _interval_ms_changed(self): 66 | pass 67 | interval_ms = Property(float, _get_interval_ms, _set_interval_ms, notify=_interval_ms_changed) 68 | 69 | def _get_active(self): 70 | return self._active 71 | @Signal 72 | def _on_active_changed(self): 73 | pass 74 | active = Property(bool, _get_active) 75 | 76 | def _get_working(self): 77 | return self._working 78 | def _set_working(self, value): 79 | if value == self._working: 80 | return 81 | self._working = value 82 | working = Property(bool, _get_working, _set_working) 83 | 84 | def timerEvent(self, e): 85 | if self.working: 86 | return 87 | self.working = True 88 | self.trigger.emit() 89 | self.working = False 90 | 91 | def _start(self): 92 | self._stop() 93 | self._active = True 94 | self._timer_id = self.startTimer(self.interval_ms) 95 | 96 | def _stop(self): 97 | self._active = False 98 | timer_id = self._timer_id 99 | self._timer_id = None 100 | if timer_id is not None: 101 | self.killTimer(timer_id) 102 | 103 | class QObjectThread(QtCore.QObject): 104 | started = Signal() 105 | result = Signal(object) 106 | error = Signal(object) 107 | complete = Signal() 108 | shutdown = Signal() 109 | def __init__(self, parent=None, **kwargs): 110 | super().__init__(parent) 111 | self.target = kwargs.get('target') 112 | self._thread = QtCore.QThread() 113 | self._thread.started.connect(self._run) 114 | self._debug_enabled = False 115 | self.shutdown.connect(self._thread.quit) 116 | def start(self): 117 | self.print_debug('start()') 118 | self.moveToThread(self._thread) 119 | self._thread.start() 120 | def stop(self): 121 | self.shutdown.emit() 122 | self.join() 123 | def join(self): 124 | # self.shutdown.emit() 125 | self._thread.wait() 126 | def _run(self): 127 | self.print_debug('starting') 128 | self.started.emit() 129 | try: 130 | self.print_debug('run()') 131 | self.run() 132 | except Exception as exc: 133 | self.print_debug('Exception...') 134 | import traceback 135 | traceback.print_exc() 136 | self.error.emit(exc) 137 | self.shutdown.emit() 138 | return 139 | self.print_debug('run() finished') 140 | self.complete.emit() 141 | self.print_debug('complete') 142 | self.shutdown.emit() 143 | self.print_debug('shutdown') 144 | def run(self): 145 | result = self.target() 146 | self.result.emit(result) 147 | def print_debug(self, msg): 148 | if not self._debug_enabled: 149 | return 150 | t = threading.current_thread() 151 | logger.debug(f'{self!r} - {msg} - {t}') 152 | def __repr__(self): 153 | return f'' 154 | def __str__(self): 155 | return f'{self.target} - {self._thread}' 156 | -------------------------------------------------------------------------------- /wwb_scanner/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nocarryr/rtlsdr-wwb-scanner/b88e83a91b47415ccdc5c11cac07bcf024dc9737/wwb_scanner/utils/__init__.py -------------------------------------------------------------------------------- /wwb_scanner/utils/color.py: -------------------------------------------------------------------------------- 1 | 2 | class Color(dict): 3 | _color_keys = ['r', 'g', 'b', 'a'] 4 | def __init__(self, initdict=None, **kwargs): 5 | if initdict is None: 6 | initdict = {} 7 | initdict.setdefault('r', 0.) 8 | initdict.setdefault('g', 1.) 9 | initdict.setdefault('b', 0.) 10 | initdict.setdefault('a', 1.) 11 | super(Color, self).__init__(initdict, **kwargs) 12 | def copy(self): 13 | return Color({key:self[key] for key in self._color_keys}) 14 | def from_list(self, l): 15 | for i, val in enumerate(l): 16 | key = self._color_keys[i] 17 | self[key] = val 18 | def to_list(self): 19 | return [self[key] for key in self._color_keys] 20 | def to_hex(self, include_alpha=False): 21 | keys = self._color_keys 22 | if not include_alpha: 23 | keys = keys[:3] 24 | vals = [int(self[key] * 255) for key in keys] 25 | hexstr = ['#'] 26 | for v in vals: 27 | s = hex(v).split('0x')[1] 28 | if len(s) == 1: 29 | s = '0%s' % (s) 30 | hexstr.append(s) 31 | return ''.join(hexstr) 32 | @classmethod 33 | def from_hex(cls, hexstr): 34 | hexstr = hexstr.split('#')[1] 35 | d = {} 36 | i = 0 37 | while len(hexstr): 38 | s = '0x%s' % (hexstr[:2]) 39 | key = cls._color_keys[i] 40 | d[key] = float.fromhex(s) / 255. 41 | if len(hexstr) > 2: 42 | hexstr = hexstr[2:] 43 | else: 44 | break 45 | i += 1 46 | return cls(d) 47 | def __eq__(self, other): 48 | if isinstance(other, Color): 49 | other = other.to_list() 50 | elif isinstance(other, dict): 51 | other = Color(other).to_list() 52 | elif isinstance(other, (list, tuple)): 53 | other = list(other) 54 | else: 55 | return NotImplemented 56 | self_list = self.to_list() 57 | if len(other) < 3: 58 | return False 59 | elif len(other) == 3: 60 | if self['a'] != 1: 61 | return False 62 | return self_list[:3] == other 63 | return self_list == other 64 | def __ne__(self, other): 65 | eq = self.__eq__(other) 66 | if eq is NotImplemented: 67 | return eq 68 | return not eq 69 | def __repr__(self): 70 | return '<{self.__class__}: {self}>'.format(self=self) 71 | def __str__(self): 72 | return str(self.to_list()) 73 | -------------------------------------------------------------------------------- /wwb_scanner/utils/config.py: -------------------------------------------------------------------------------- 1 | from wwb_scanner.core import JSONMixin 2 | 3 | class Config(JSONMixin): 4 | def __init__(self, initdict=None, **kwargs): 5 | data = {} 6 | if initdict is not None: 7 | data.update(initdict) 8 | data.update(kwargs) 9 | if '__from_json__' in data: 10 | del data['__from_json__'] 11 | if hasattr(self, 'DEFAULTS'): 12 | for key, val in self.DEFAULTS.items(): 13 | data.setdefault(key, val) 14 | self._config_keys = set(data.keys()) 15 | self._child_conf_keys = set(data.get('_child_conf_keys', [])) 16 | self._data = {} 17 | for key, val in data.items(): 18 | if key == '_child_conf_keys': 19 | continue 20 | self[key] = val 21 | def __setitem__(self, key, item): 22 | if key in self._child_conf_keys: 23 | if not isinstance(item, Config): 24 | item = self._deserialize_child(key, item) 25 | elif isinstance(item, Config): 26 | self._child_conf_keys.add(key) 27 | self._data[key] = item 28 | self._config_keys.add(key) 29 | def __getitem__(self, key): 30 | return self._data[key] 31 | def __delitem__(self, key): 32 | del self._data[key] 33 | self._config_keys.discard(key) 34 | self._child_conf_keys.discard(key) 35 | def get(self, key, default=None): 36 | return self._data.get(key, default) 37 | def setdefault(self, key, default): 38 | self._config_keys.add(key) 39 | return self._data.setdefault(key, default) 40 | def update(self, other): 41 | for key, val in other.items(): 42 | self[key] = val 43 | def keys(self): 44 | return self._data.keys() 45 | def values(self): 46 | return self._data.values() 47 | def items(self): 48 | return self._data.items() 49 | def __getattr__(self, attr): 50 | if hasattr(self, '_data') and attr in self._config_keys: 51 | return self[attr] 52 | raise AttributeError 53 | def __setattr__(self, attr, value): 54 | if attr not in ['_config_keys', '_child_conf_keys', '_data']: 55 | self[attr] = value 56 | else: 57 | super(Config, self).__setattr__(attr, value) 58 | def _serialize(self): 59 | keys = self._config_keys - self._child_conf_keys 60 | d = {k:self._data.get(k) for k in keys} 61 | d['_child_conf_keys'] = list(self._child_conf_keys) 62 | for key in self._child_conf_keys: 63 | d[key] = self[key]._serialize() 64 | return d 65 | def _deserialize_child(self, key, data, cls=None): 66 | if cls is None: 67 | cls = Config 68 | return cls(data) 69 | -------------------------------------------------------------------------------- /wwb_scanner/utils/dbmath.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | REF_DB = 1.#1e-4 4 | 5 | def amplitude_to_dB(a, ref=REF_DB): 6 | return 20 * np.log10(a / ref) 7 | 8 | def dB_to_amplitude(dB, ref=REF_DB): 9 | return 10 ** (dB/20.) * ref 10 | 11 | def power_to_dB(p, ref=REF_DB): 12 | return 10 * np.log10(p / ref) 13 | 14 | def dB_to_power(dB, ref=REF_DB): 15 | return 10 ** (dB/10.) * ref 16 | 17 | def to_dB(p, ref=REF_DB): 18 | return power_to_dB(p, ref) 19 | 20 | def from_dB(dB, ref=REF_DB): 21 | return dB_to_power(dB, ref) 22 | -------------------------------------------------------------------------------- /wwb_scanner/utils/dbstore.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | 4 | import tinydb 5 | from tinydb import TinyDB, where 6 | 7 | from wwb_scanner.utils import numpyjson 8 | 9 | APP_PATH = os.path.expanduser('~/wwb_scanner_data') 10 | 11 | class JSONStorage(tinydb.JSONStorage): 12 | def read(self): 13 | # Get the file size 14 | self._handle.seek(0, os.SEEK_END) 15 | size = self._handle.tell() 16 | 17 | if not size: 18 | # File is empty 19 | return None 20 | else: 21 | self._handle.seek(0) 22 | return numpyjson.load(self._handle) 23 | def write(self, data): 24 | self._handle.seek(0) 25 | serialized = numpyjson.dumps(data) 26 | self._handle.write(serialized) 27 | self._handle.flush() 28 | self._handle.truncate() 29 | 30 | class DBStore(object): 31 | DB_PATH = os.path.join(APP_PATH, 'db.json') 32 | SCAN_DB_PATH = os.path.join(APP_PATH, 'scan_db.json') 33 | TABLES = ['scan_configs', 'scans_performed', 'scans_imported'] 34 | def __init__(self): 35 | 36 | self._db = None 37 | self._scan_db = None 38 | #self.migrate_db() 39 | @property 40 | def db(self): 41 | db = self._db 42 | if db is None: 43 | self._check_dirs() 44 | db = self._db = TinyDB(self.DB_PATH, storage=JSONStorage) 45 | return db 46 | @property 47 | def scan_db(self): 48 | db = self._scan_db 49 | if db is None: 50 | self._check_dirs() 51 | db = self._scan_db = TinyDB(self.SCAN_DB_PATH, storage=JSONStorage) 52 | return db 53 | def _check_dirs(self): 54 | if not os.path.exists(os.path.dirname(self.DB_PATH)): 55 | os.makedirs(os.path.dirname(self.DB_PATH)) 56 | if not os.path.exists(os.path.dirname(self.SCAN_DB_PATH)): 57 | os.makedirs(os.path.dirname(self.SCAN_DB_PATH)) 58 | def migrate_db(self): 59 | for table_name in ['scans_performed', 'scans_imported']: 60 | if table_name not in self.db.tables(): 61 | continue 62 | print('migrating table "{}"'.format(table_name)) 63 | old_table = self.db.table(table_name) 64 | new_table = self.scan_db.table(table_name) 65 | eids = [] 66 | for item in old_table.all(): 67 | eids.append(item.eid) 68 | new_table.insert(item) 69 | old_table.remove(eids=eids) 70 | self.db.purge_table(table_name) 71 | def add_scan_config(self, config, force_insert=False): 72 | if config.get('datetime') is None: 73 | config['datetime'] = datetime.datetime.utcnow() 74 | table = self.db.table('scan_configs') 75 | if config.get('eid') is not None: 76 | dbconfig = table.get(eid=config.eid) 77 | else: 78 | dbconfig = table.get(where('datetime') == config['datetime']) 79 | if dbconfig is not None: 80 | if force_insert: 81 | eids = [dbconfig.eid] 82 | table.update(config._serialize(), eids=eids) 83 | eid = dbconfig.eid 84 | else: 85 | eid = table.insert(config._serialize()) 86 | config.eid = eid 87 | def get_scan_config(self, **kwargs): 88 | table = self.db.table('scan_configs') 89 | if kwargs.get('datetime'): 90 | dbconfig = table.get(where('datetime')==kwargs.get('datetime')) 91 | elif kwargs.get('eid'): 92 | dbconfig = table.get(eid=kwargs.get('eid')) 93 | elif kwargs.get('name'): 94 | dbconfig = table.get(where('name')==kwargs.get('name')) 95 | elif table._last_id > 0: 96 | dbconfig = table.get(eid=table._last_id) 97 | else: 98 | dbconfig = None 99 | return dbconfig 100 | def add_scan(self, spectrum, scan_config=None): 101 | if scan_config is None: 102 | scan_config = spectrum.scan_config 103 | if scan_config is not None: 104 | if scan_config.get('eid') is None: 105 | self.add_scan_config(scan_config) 106 | spectrum.scan_config_eid = scan_config.eid 107 | data = spectrum._serialize() 108 | table = self.scan_db.table('scans_performed') 109 | if spectrum.eid is not None: 110 | eid = spectrum.eid 111 | table.update(data, eids=[eid]) 112 | else: 113 | eid = table.insert(data) 114 | spectrum.eid = eid 115 | return eid 116 | def get_all_scans(self): 117 | table = self.scan_db.table('scans_performed') 118 | scans = table.all() 119 | scan_data = {} 120 | for scan in scans: 121 | excluded = ['samples', 'sample_data', 'center_frequencies'] 122 | scan_data[scan.eid] = {key:scan[key] for key in scan.keys() 123 | if key not in excluded} 124 | return scan_data 125 | def get_scan(self, eid): 126 | table = self.scan_db.table('scans_performed') 127 | return table.get(eid=eid) 128 | def update_scan(self, eid, **kwargs): 129 | table = self.scan_db.table('scans_performed') 130 | table.update(kwargs, eids=[eid]) 131 | 132 | db_store = DBStore() 133 | -------------------------------------------------------------------------------- /wwb_scanner/utils/numpyjson.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import base64 3 | import json 4 | import pickle 5 | 6 | import numpy as np 7 | 8 | PY3 = sys.version_info.major >= 3 9 | 10 | import jsonfactory 11 | 12 | ## From http://stackoverflow.com/questions/27909658/ 13 | 14 | @jsonfactory.register 15 | class NumpyEncoder(object): 16 | def encode(self, obj): 17 | if isinstance(obj, np.ndarray): 18 | data_b64 = base64.b64encode(obj.dumps()) 19 | if isinstance(data_b64, bytes): 20 | data_b64 = data_b64.decode('UTF-8') 21 | return dict(__ndarray__=data_b64) 22 | return None 23 | def decode(self, d): 24 | if '__ndarray__' in d: 25 | data = base64.b64decode(d['__ndarray__']) 26 | if PY3: 27 | if not isinstance(data, bytes): 28 | data = bytes(data, 'UTF-8') 29 | return pickle.loads(data, encoding='bytes') 30 | return pickle.loads(data) 31 | return d 32 | 33 | def dumps(obj, **kwargs): 34 | return jsonfactory.dumps(obj, **kwargs) 35 | 36 | def loads(s, **kwargs): 37 | return jsonfactory.loads(s, **kwargs) 38 | 39 | def dump(*args, **kwargs): 40 | kwargs.setdefault('cls', jsonfactory.Encoder) 41 | return json.dump(*args, **kwargs) 42 | 43 | def load(*args, **kwargs): 44 | kwargs.setdefault('object_hook', jsonfactory.obj_hook) 45 | return json.load(*args, **kwargs) 46 | --------------------------------------------------------------------------------