From 0c0cc5a490fa13ada6c6d7a400fc476cfadcaf8e Mon Sep 17 00:00:00 2001 From: Carsten Schmiemann Date: Thu, 10 Feb 2022 23:48:55 +0100 Subject: [PATCH] Migrate to git --- openwebrx/.dockerignore | 7 + openwebrx/CHANGELOG.md | 204 ++ openwebrx/LICENSE.txt | 661 +++++ openwebrx/README.md | 60 + openwebrx/bands.json | 367 +++ openwebrx/config_webrx.py | 386 +++ openwebrx/csdr/__init__.py | 837 ++++++ .../csdr/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 24263 bytes .../csdr/__pycache__/output.cpython-37.pyc | Bin 0 -> 1485 bytes .../csdr/__pycache__/pipe.cpython-37.pyc | Bin 0 -> 5565 bytes openwebrx/csdr/output.py | 36 + openwebrx/csdr/pipe.py | 156 ++ openwebrx/debian/changelog | 217 ++ openwebrx/debian/compat | 1 + openwebrx/debian/control | 16 + openwebrx/debian/openwebrx.config | 8 + openwebrx/debian/openwebrx.dirs | 1 + openwebrx/debian/openwebrx.install | 3 + openwebrx/debian/openwebrx.postinst | 59 + openwebrx/debian/openwebrx.postrm | 8 + openwebrx/debian/openwebrx.templates | 23 + openwebrx/debian/rules | 8 + openwebrx/debian/source/format | 1 + openwebrx/docker.sh | 97 + .../docker/Dockerfiles/Dockerfile-airspy | 8 + openwebrx/docker/Dockerfiles/Dockerfile-base | 28 + openwebrx/docker/Dockerfiles/Dockerfile-fcdpp | 8 + openwebrx/docker/Dockerfiles/Dockerfile-full | 30 + .../docker/Dockerfiles/Dockerfile-hackrf | 8 + openwebrx/docker/Dockerfiles/Dockerfile-hpsdr | 9 + .../docker/Dockerfiles/Dockerfile-limesdr | 8 + .../docker/Dockerfiles/Dockerfile-perseus | 8 + .../docker/Dockerfiles/Dockerfile-plutosdr | 8 + .../docker/Dockerfiles/Dockerfile-radioberry | 8 + .../docker/Dockerfiles/Dockerfile-rtlsdr | 12 + .../Dockerfiles/Dockerfile-rtlsdr-soapy | 8 + .../docker/Dockerfiles/Dockerfile-rtltcp | 9 + openwebrx/docker/Dockerfiles/Dockerfile-runds | 12 + .../docker/Dockerfiles/Dockerfile-sdrplay | 12 + .../docker/Dockerfiles/Dockerfile-soapyremote | 8 + .../docker/Dockerfiles/Dockerfile-soapysdr | 9 + openwebrx/docker/Dockerfiles/Dockerfile-uhd | 8 + .../files/direwolf/direwolf-hamlib.patch | 20 + openwebrx/docker/files/dream/dream.patch | 96 + .../docker/files/js8call/js8call-hamlib.patch | 151 ++ .../files/sdrplay/install-lib.aarch64.patch | 23 + .../files/sdrplay/install-lib.armv7l.patch | 40 + .../files/sdrplay/install-lib.x86_64.patch | 39 + .../docker/files/services/codecserver/run | 2 + openwebrx/docker/files/services/sdrplay/run | 2 + .../docker/files/wsjtx/wsjtx-hamlib.patch | 50 + openwebrx/docker/files/wsjtx/wsjtx.patch | 316 +++ .../docker/scripts/install-connectors.sh | 31 + .../scripts/install-dependencies-airspy.sh | 44 + .../scripts/install-dependencies-fcdpp.sh | 32 + .../scripts/install-dependencies-hackrf.sh | 41 + .../scripts/install-dependencies-hpsdr.sh | 46 + .../scripts/install-dependencies-limesdr.sh | 32 + .../scripts/install-dependencies-perseus.sh | 27 + .../scripts/install-dependencies-plutosdr.sh | 39 + .../install-dependencies-radioberry.sh | 37 + .../install-dependencies-rtlsdr-soapy.sh | 36 + .../scripts/install-dependencies-rtlsdr.sh | 33 + .../scripts/install-dependencies-runds.sh | 32 + .../scripts/install-dependencies-sdrplay.sh | 57 + .../install-dependencies-soapyremote.sh | 32 + .../scripts/install-dependencies-soapysdr.sh | 33 + .../scripts/install-dependencies-uhd.sh | 60 + .../docker/scripts/install-dependencies.sh | 116 + .../docker/scripts/install-owrx-tools.sh | 53 + openwebrx/docker/scripts/run.sh | 37 + openwebrx/htdocs/__init__.py | 0 .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 120 bytes openwebrx/htdocs/apple-touch-icon.png | Bin 0 -> 17733 bytes openwebrx/htdocs/css/admin.css | 162 ++ openwebrx/htdocs/css/bootstrap.min.css | 12 + openwebrx/htdocs/css/login.css | 34 + openwebrx/htdocs/css/map.css | 65 + openwebrx/htdocs/css/openwebrx-globals.css | 7 + openwebrx/htdocs/css/openwebrx-header.css | 227 ++ openwebrx/htdocs/css/openwebrx.css | 1364 ++++++++++ openwebrx/htdocs/favicon.ico | Bin 0 -> 5430 bytes openwebrx/htdocs/features.html | 25 + openwebrx/htdocs/features.js | 23 + openwebrx/htdocs/fonts/RobotoMono-Regular.ttf | Bin 0 -> 86908 bytes .../htdocs/fonts/RobotoMono-Regular.woff | Bin 0 -> 53300 bytes .../htdocs/fonts/RobotoMono-Regular.woff2 | Bin 0 -> 40752 bytes openwebrx/htdocs/gfx/favicon128.png | Bin 0 -> 11679 bytes openwebrx/htdocs/gfx/favicon32.png | Bin 0 -> 2225 bytes openwebrx/htdocs/gfx/favicon44.png | Bin 0 -> 3555 bytes openwebrx/htdocs/gfx/favicon64.png | Bin 0 -> 5496 bytes openwebrx/htdocs/gfx/favicon96.png | Bin 0 -> 8665 bytes openwebrx/htdocs/gfx/openwebrx-avatar.png | Bin 0 -> 2763 bytes .../gfx/openwebrx-background-cool-blue.png | Bin 0 -> 66934 bytes .../gfx/openwebrx-background-cool-blue.webp | Bin 0 -> 22352 bytes openwebrx/htdocs/gfx/openwebrx-directcall.svg | 1 + openwebrx/htdocs/gfx/openwebrx-groupcall.svg | 1 + .../htdocs/gfx/openwebrx-scale-background.png | Bin 0 -> 21578 bytes openwebrx/htdocs/gfx/openwebrx-top-photo.jpg | Bin 0 -> 70244 bytes openwebrx/htdocs/gfx/svg-defs.svg | 28 + openwebrx/htdocs/include/header.include.html | 25 + openwebrx/htdocs/index.html | 250 ++ openwebrx/htdocs/lib/AprsMarker.js | 91 + openwebrx/htdocs/lib/AudioEngine.js | 447 +++ openwebrx/htdocs/lib/AudioProcessor.js | 61 + openwebrx/htdocs/lib/BookmarkBar.js | 147 + openwebrx/htdocs/lib/BookmarkDialog.js | 36 + openwebrx/htdocs/lib/BookmarkLocalStorage.js | 17 + openwebrx/htdocs/lib/Demodulator.js | 362 +++ openwebrx/htdocs/lib/DemodulatorPanel.js | 370 +++ openwebrx/htdocs/lib/FrequencyDisplay.js | 183 ++ openwebrx/htdocs/lib/Header.js | 67 + openwebrx/htdocs/lib/Js8Threads.js | 178 ++ openwebrx/htdocs/lib/Measurement.js | 70 + openwebrx/htdocs/lib/MessagePanel.js | 248 ++ openwebrx/htdocs/lib/MetaPanel.js | 316 +++ openwebrx/htdocs/lib/Modes.js | 55 + openwebrx/htdocs/lib/ProgressBar.js | 212 ++ openwebrx/htdocs/lib/bootstrap.bundle.min.js | 6 + openwebrx/htdocs/lib/chroma.min.js | 58 + openwebrx/htdocs/lib/jquery-3.2.1.min.js | 4 + .../htdocs/lib/jquery.nanoscroller.min.js | 3 + openwebrx/htdocs/lib/location-picker.min.js | 2 + openwebrx/htdocs/lib/nanoscroller.css | 55 + openwebrx/htdocs/lib/nite-overlay.js | 147 + .../htdocs/lib/settings/BookmarkTable.js | 402 +++ .../htdocs/lib/settings/ExponentialInput.js | 45 + openwebrx/htdocs/lib/settings/GainInput.js | 17 + openwebrx/htdocs/lib/settings/ImageUpload.js | 87 + openwebrx/htdocs/lib/settings/MapInput.js | 23 + .../htdocs/lib/settings/OptionalSection.js | 29 + .../htdocs/lib/settings/SchedulerInput.js | 33 + .../htdocs/lib/settings/WaterfallDropdown.js | 11 + .../lib/settings/WsjtDecodingDepthsInput.js | 68 + openwebrx/htdocs/login.html | 29 + openwebrx/htdocs/map.html | 26 + openwebrx/htdocs/map.js | 476 ++++ openwebrx/htdocs/mstile-144x144.png | Bin 0 -> 13823 bytes openwebrx/htdocs/openwebrx.js | 1574 +++++++++++ openwebrx/htdocs/pwchange.html | 32 + openwebrx/htdocs/settings.html | 41 + openwebrx/htdocs/settings.js | 11 + openwebrx/htdocs/settings/bookmarks.html | 69 + openwebrx/htdocs/settings/general.html | 23 + openwebrx/inkscape files/favicon.svg | 2388 +++++++++++++++++ openwebrx/inkscape files/google_maps_pin.svg | 76 + .../inkscape files/openwebrx-bookmark.svg | 56 + .../inkscape files/openwebrx-directcall.svg | 120 + openwebrx/inkscape files/openwebrx-edit.svg | 52 + .../inkscape files/openwebrx-groupcall.svg | 277 ++ openwebrx/inkscape files/openwebrx-logo.svg | 161 ++ openwebrx/inkscape files/openwebrx-mute.svg | 142 + .../inkscape files/openwebrx-panel-log.svg | 138 + .../inkscape files/openwebrx-panel-map.svg | 173 ++ .../openwebrx-panel-receiver.svg | 181 ++ .../openwebrx-panel-settings.svg | 115 + .../inkscape files/openwebrx-panel-status.svg | 146 + .../inkscape files/openwebrx-play-button.svg | 67 + .../openwebrx-rx-details-arrow-down.svg | 84 + .../openwebrx-rx-details-arrow-up.svg | 82 + .../openwebrx-speake-mutedr.svg | 129 + .../inkscape files/openwebrx-speaker.svg | 168 ++ .../inkscape files/openwebrx-squelch.svg | 145 + .../inkscape files/openwebrx-trashcan.svg | 63 + .../openwebrx-waterfall-auto.svg | 84 + .../openwebrx-waterfall-continuous.svg | 120 + .../openwebrx-waterfall-default.svg | 123 + .../openwebrx-zoom-in-total.svg | 149 + .../inkscape files/openwebrx-zoom-in.svg | 157 ++ .../openwebrx-zoom-out-total.svg | 169 ++ .../inkscape files/openwebrx-zoom-out.svg | 158 ++ openwebrx/openwebrx.conf | 10 + openwebrx/openwebrx.py | 6 + openwebrx/owrx/__main__.py | 115 + .../owrx/__pycache__/__main__.cpython-37.pyc | Bin 0 -> 3786 bytes .../owrx/__pycache__/aprs.cpython-37.pyc | Bin 0 -> 18973 bytes .../owrx/__pycache__/bands.cpython-37.pyc | Bin 0 -> 4496 bytes .../owrx/__pycache__/bookmarks.cpython-37.pyc | Bin 0 -> 5968 bytes .../__pycache__/breadcrumb.cpython-37.pyc | Bin 0 -> 2469 bytes .../owrx/__pycache__/client.cpython-37.pyc | Bin 0 -> 2106 bytes .../owrx/__pycache__/command.cpython-37.pyc | Bin 0 -> 3417 bytes .../__pycache__/connection.cpython-37.pyc | Bin 0 -> 21144 bytes openwebrx/owrx/__pycache__/cpu.cpython-37.pyc | Bin 0 -> 2410 bytes .../owrx/__pycache__/details.cpython-37.pyc | Bin 0 -> 1004 bytes openwebrx/owrx/__pycache__/dsp.cpython-37.pyc | Bin 0 -> 7115 bytes .../owrx/__pycache__/feature.cpython-37.pyc | Bin 0 -> 22854 bytes openwebrx/owrx/__pycache__/fft.cpython-37.pyc | Bin 0 -> 3291 bytes .../owrx/__pycache__/http.cpython-37.pyc | Bin 0 -> 7375 bytes openwebrx/owrx/__pycache__/js8.cpython-37.pyc | Bin 0 -> 5522 bytes .../owrx/__pycache__/jsons.cpython-37.pyc | Bin 0 -> 563 bytes .../owrx/__pycache__/kiss.cpython-37.pyc | Bin 0 -> 5312 bytes .../owrx/__pycache__/locator.cpython-37.pyc | Bin 0 -> 791 bytes openwebrx/owrx/__pycache__/map.cpython-37.pyc | Bin 0 -> 5365 bytes .../owrx/__pycache__/meta.cpython-37.pyc | Bin 0 -> 6312 bytes .../owrx/__pycache__/metrics.cpython-37.pyc | Bin 0 -> 2879 bytes .../owrx/__pycache__/modes.cpython-37.pyc | Bin 0 -> 6792 bytes .../owrx/__pycache__/parser.cpython-37.pyc | Bin 0 -> 964 bytes .../owrx/__pycache__/pocsag.cpython-37.pyc | Bin 0 -> 1101 bytes .../__pycache__/receiverid.cpython-37.pyc | Bin 0 -> 4514 bytes openwebrx/owrx/__pycache__/sdr.cpython-37.pyc | Bin 0 -> 10221 bytes .../owrx/__pycache__/soapy.cpython-37.pyc | Bin 0 -> 1509 bytes .../owrx/__pycache__/socket.cpython-37.pyc | Bin 0 -> 385 bytes .../owrx/__pycache__/users.cpython-37.pyc | Bin 0 -> 8989 bytes .../owrx/__pycache__/version.cpython-37.pyc | Bin 0 -> 269 bytes .../owrx/__pycache__/waterfall.cpython-37.pyc | Bin 0 -> 5452 bytes .../owrx/__pycache__/websocket.cpython-37.pyc | Bin 0 -> 8779 bytes .../owrx/__pycache__/wsjt.cpython-37.pyc | Bin 0 -> 15404 bytes openwebrx/owrx/admin/__init__.py | 60 + .../admin/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 2085 bytes .../admin/__pycache__/commands.cpython-37.pyc | Bin 0 -> 4762 bytes openwebrx/owrx/admin/commands.py | 115 + openwebrx/owrx/aprs.py | 590 ++++ openwebrx/owrx/audio/__init__.py | 86 + .../audio/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 4057 bytes .../audio/__pycache__/chopper.cpython-37.pyc | Bin 0 -> 3650 bytes .../audio/__pycache__/queue.cpython-37.pyc | Bin 0 -> 5583 bytes .../owrx/audio/__pycache__/wav.cpython-37.pyc | Bin 0 -> 4835 bytes openwebrx/owrx/audio/chopper.py | 90 + openwebrx/owrx/audio/queue.py | 172 ++ openwebrx/owrx/audio/wav.py | 139 + openwebrx/owrx/bands.py | 111 + openwebrx/owrx/bookmarks.py | 145 + openwebrx/owrx/breadcrumb.py | 44 + openwebrx/owrx/client.py | 54 + openwebrx/owrx/command.py | 79 + openwebrx/owrx/config/__init__.py | 43 + .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 1573 bytes .../config/__pycache__/classic.cpython-37.pyc | Bin 0 -> 1742 bytes .../__pycache__/commands.cpython-37.pyc | Bin 0 -> 921 bytes .../config/__pycache__/core.cpython-37.pyc | Bin 0 -> 2129 bytes .../__pycache__/defaults.cpython-37.pyc | Bin 0 -> 2776 bytes .../config/__pycache__/dynamic.cpython-37.pyc | Bin 0 -> 2950 bytes .../config/__pycache__/error.cpython-37.pyc | Bin 0 -> 521 bytes .../__pycache__/migration.cpython-37.pyc | Bin 0 -> 5164 bytes openwebrx/owrx/config/classic.py | 36 + openwebrx/owrx/config/commands.py | 30 + openwebrx/owrx/config/core.py | 59 + openwebrx/owrx/config/defaults.py | 176 ++ openwebrx/owrx/config/dynamic.py | 62 + openwebrx/owrx/config/error.py | 3 + openwebrx/owrx/config/migration.py | 134 + openwebrx/owrx/connection.py | 536 ++++ openwebrx/owrx/controllers/__init__.py | 60 + .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 2426 bytes .../__pycache__/admin.cpython-37.pyc | Bin 0 -> 2157 bytes .../__pycache__/api.cpython-37.pyc | Bin 0 -> 633 bytes .../__pycache__/assets.cpython-37.pyc | Bin 0 -> 7371 bytes .../__pycache__/feature.cpython-37.pyc | Bin 0 -> 941 bytes .../__pycache__/imageupload.cpython-37.pyc | Bin 0 -> 3142 bytes .../__pycache__/metrics.cpython-37.pyc | Bin 0 -> 1671 bytes .../__pycache__/profile.cpython-37.pyc | Bin 0 -> 1618 bytes .../__pycache__/receiverid.cpython-37.pyc | Bin 0 -> 1280 bytes .../__pycache__/robots.cpython-37.pyc | Bin 0 -> 640 bytes .../__pycache__/session.cpython-37.pyc | Bin 0 -> 3448 bytes .../__pycache__/status.cpython-37.pyc | Bin 0 -> 1940 bytes .../__pycache__/template.cpython-37.pyc | Bin 0 -> 2722 bytes .../__pycache__/websocket.cpython-37.pyc | Bin 0 -> 641 bytes openwebrx/owrx/controllers/admin.py | 56 + openwebrx/owrx/controllers/api.py | 9 + openwebrx/owrx/controllers/assets.py | 191 ++ openwebrx/owrx/controllers/feature.py | 11 + openwebrx/owrx/controllers/imageupload.py | 79 + openwebrx/owrx/controllers/metrics.py | 30 + openwebrx/owrx/controllers/profile.py | 24 + openwebrx/owrx/controllers/receiverid.py | 26 + openwebrx/owrx/controllers/robots.py | 16 + openwebrx/owrx/controllers/session.py | 79 + .../owrx/controllers/settings/__init__.py | 147 + .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 6001 bytes .../backgrounddecoding.cpython-37.pyc | Bin 0 -> 1344 bytes .../__pycache__/bookmarks.cpython-37.pyc | Bin 0 -> 6195 bytes .../__pycache__/decoding.cpython-37.pyc | Bin 0 -> 3759 bytes .../__pycache__/general.cpython-37.pyc | Bin 0 -> 6396 bytes .../__pycache__/reporting.cpython-37.pyc | Bin 0 -> 2997 bytes .../settings/__pycache__/sdr.cpython-37.pyc | Bin 0 -> 19717 bytes .../settings/backgrounddecoding.py | 25 + .../owrx/controllers/settings/bookmarks.py | 148 + .../owrx/controllers/settings/decoding.py | 91 + .../owrx/controllers/settings/general.py | 218 ++ .../owrx/controllers/settings/reporting.py | 93 + openwebrx/owrx/controllers/settings/sdr.py | 433 +++ openwebrx/owrx/controllers/status.py | 44 + openwebrx/owrx/controllers/template.py | 45 + openwebrx/owrx/controllers/websocket.py | 10 + openwebrx/owrx/cpu.py | 77 + openwebrx/owrx/details.py | 24 + openwebrx/owrx/dsp.py | 221 ++ openwebrx/owrx/feature.py | 568 ++++ openwebrx/owrx/fft.py | 90 + openwebrx/owrx/form/__init__.py | 0 .../form/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 123 bytes .../form/__pycache__/error.cpython-37.pyc | Bin 0 -> 922 bytes .../form/__pycache__/section.cpython-37.pyc | Bin 0 -> 5985 bytes openwebrx/owrx/form/error.py | 15 + openwebrx/owrx/form/input/__init__.py | 411 +++ .../input/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 18172 bytes .../input/__pycache__/aprs.cpython-37.pyc | Bin 0 -> 1635 bytes .../__pycache__/converter.cpython-37.pyc | Bin 0 -> 5188 bytes .../input/__pycache__/device.cpython-37.pyc | Bin 0 -> 17708 bytes .../form/input/__pycache__/gfx.cpython-37.pyc | Bin 0 -> 3062 bytes .../__pycache__/receiverid.cpython-37.pyc | Bin 0 -> 877 bytes .../__pycache__/validator.cpython-37.pyc | Bin 0 -> 1412 bytes .../form/input/__pycache__/wfm.cpython-37.pyc | Bin 0 -> 833 bytes .../input/__pycache__/wsjt.cpython-37.pyc | Bin 0 -> 4584 bytes openwebrx/owrx/form/input/aprs.py | 36 + openwebrx/owrx/form/input/converter.py | 96 + openwebrx/owrx/form/input/device.py | 434 +++ openwebrx/owrx/form/input/gfx.py | 67 + openwebrx/owrx/form/input/receiverid.py | 10 + openwebrx/owrx/form/input/validator.py | 26 + openwebrx/owrx/form/input/wfm.py | 16 + openwebrx/owrx/form/input/wsjt.py | 93 + openwebrx/owrx/form/section.py | 124 + openwebrx/owrx/http.py | 196 ++ openwebrx/owrx/js8.py | 135 + openwebrx/owrx/jsons.py | 9 + openwebrx/owrx/kiss.py | 191 ++ openwebrx/owrx/locator.py | 25 + openwebrx/owrx/map.py | 141 + openwebrx/owrx/meta.py | 166 ++ openwebrx/owrx/metrics.py | 70 + openwebrx/owrx/modes.py | 156 ++ openwebrx/owrx/parser.py | 20 + openwebrx/owrx/pocsag.py | 17 + openwebrx/owrx/property/__init__.py | 421 +++ .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 18732 bytes .../__pycache__/filter.cpython-37.pyc | Bin 0 -> 1285 bytes .../__pycache__/validators.cpython-37.pyc | Bin 0 -> 4461 bytes openwebrx/owrx/property/filter.py | 23 + openwebrx/owrx/property/validators.py | 97 + openwebrx/owrx/receiverid.py | 98 + openwebrx/owrx/reporting/__init__.py | 57 + .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 2455 bytes .../__pycache__/pskreporter.cpython-37.pyc | Bin 0 -> 7997 bytes .../__pycache__/reporter.cpython-37.pyc | Bin 0 -> 705 bytes .../__pycache__/wsprnet.cpython-37.pyc | Bin 0 -> 3366 bytes openwebrx/owrx/reporting/pskreporter.py | 222 ++ openwebrx/owrx/reporting/reporter.py | 15 + openwebrx/owrx/reporting/wsprnet.py | 97 + openwebrx/owrx/sdr.py | 261 ++ openwebrx/owrx/service/__init__.py | 374 +++ .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 14474 bytes .../__pycache__/schedule.cpython-37.pyc | Bin 0 -> 11702 bytes openwebrx/owrx/service/schedule.py | 315 +++ openwebrx/owrx/soapy.py | 21 + openwebrx/owrx/socket.py | 10 + openwebrx/owrx/source/__init__.py | 566 ++++ .../__pycache__/__init__.cpython-37.pyc | Bin 0 -> 19625 bytes .../source/__pycache__/airspy.cpython-37.pyc | Bin 0 -> 2149 bytes .../__pycache__/airspyhf.cpython-37.pyc | Bin 0 -> 822 bytes .../__pycache__/connector.cpython-37.pyc | Bin 0 -> 4018 bytes .../source/__pycache__/direct.cpython-37.pyc | Bin 0 -> 1976 bytes .../source/__pycache__/fcdpp.cpython-37.pyc | Bin 0 -> 800 bytes .../__pycache__/fifi_sdr.cpython-37.pyc | Bin 0 -> 2201 bytes .../source/__pycache__/hackrf.cpython-37.pyc | Bin 0 -> 1928 bytes .../source/__pycache__/hpsdr.cpython-37.pyc | Bin 0 -> 2745 bytes .../__pycache__/lime_sdr.cpython-37.pyc | Bin 0 -> 805 bytes .../__pycache__/perseussdr.cpython-37.pyc | Bin 0 -> 2895 bytes .../__pycache__/pluto_sdr.cpython-37.pyc | Bin 0 -> 808 bytes .../__pycache__/radioberry.cpython-37.pyc | Bin 0 -> 821 bytes .../__pycache__/resampler.cpython-37.pyc | Bin 0 -> 1772 bytes .../source/__pycache__/rtl_sdr.cpython-37.pyc | Bin 0 -> 1917 bytes .../__pycache__/rtl_sdr_soapy.cpython-37.pyc | Bin 0 -> 1924 bytes .../source/__pycache__/rtl_tcp.cpython-37.pyc | Bin 0 -> 1609 bytes .../source/__pycache__/runds.cpython-37.pyc | Bin 0 -> 2529 bytes .../source/__pycache__/sddc.cpython-37.pyc | Bin 0 -> 1020 bytes .../source/__pycache__/sdrplay.cpython-37.pyc | Bin 0 -> 2711 bytes .../source/__pycache__/soapy.cpython-37.pyc | Bin 0 -> 4884 bytes .../__pycache__/soapy_remote.cpython-37.pyc | Bin 0 -> 2512 bytes .../source/__pycache__/uhd.cpython-37.pyc | Bin 0 -> 795 bytes openwebrx/owrx/source/airspy.py | 44 + openwebrx/owrx/source/airspyhf.py | 11 + openwebrx/owrx/source/connector.py | 100 + openwebrx/owrx/source/direct.py | 53 + openwebrx/owrx/source/fcdpp.py | 11 + openwebrx/owrx/source/fifi_sdr.py | 44 + openwebrx/owrx/source/hackrf.py | 31 + openwebrx/owrx/source/hpsdr.py | 62 + openwebrx/owrx/source/lime_sdr.py | 11 + openwebrx/owrx/source/perseussdr.py | 79 + openwebrx/owrx/source/pluto_sdr.py | 11 + openwebrx/owrx/source/radioberry.py | 11 + openwebrx/owrx/source/resampler.py | 38 + openwebrx/owrx/source/rtl_sdr.py | 37 + openwebrx/owrx/source/rtl_sdr_soapy.py | 28 + openwebrx/owrx/source/rtl_tcp.py | 32 + openwebrx/owrx/source/runds.py | 54 + openwebrx/owrx/source/sddc.py | 14 + openwebrx/owrx/source/sdrplay.py | 64 + openwebrx/owrx/source/soapy.py | 108 + openwebrx/owrx/source/soapy_remote.py | 43 + openwebrx/owrx/source/uhd.py | 11 + openwebrx/owrx/users.py | 237 ++ openwebrx/owrx/version.py | 5 + openwebrx/owrx/waterfall.py | 326 +++ openwebrx/owrx/websocket.py | 290 ++ openwebrx/owrx/wsjt.py | 399 +++ openwebrx/setup.py | 42 + openwebrx/systemd/openwebrx.service | 12 + openwebrx/test/__init__.py | 0 openwebrx/test/property/__init__.py | 0 openwebrx/test/property/filter/__init__.py | 0 .../test/property/filter/test_by_lambda.py | 17 + .../property/filter/test_by_property_name.py | 12 + .../test/property/test_property_carousel.py | 125 + .../test/property/test_property_deletion.py | 8 + .../test/property/test_property_filter.py | 82 + .../test/property/test_property_layer.py | 93 + .../test/property/test_property_readonly.py | 23 + .../test/property/test_property_stack.py | 240 ++ .../test/property/test_property_validator.py | 37 + .../test/property/validators/__init__.py | 0 .../validators/test_bool_validator.py | 17 + .../validators/test_float_validator.py | 15 + .../validators/test_integer_validator.py | 15 + .../validators/test_lambda_validator.py | 21 + .../validators/test_number_validator.py | 18 + .../property/validators/test_or_validator.py | 17 + .../validators/test_regex_validator.py | 17 + .../validators/test_string_validator.py | 14 + .../property/validators/test_validator.py | 20 + 421 files changed, 32732 insertions(+) create mode 100644 openwebrx/.dockerignore create mode 100644 openwebrx/CHANGELOG.md create mode 100644 openwebrx/LICENSE.txt create mode 100644 openwebrx/README.md create mode 100644 openwebrx/bands.json create mode 100644 openwebrx/config_webrx.py create mode 100644 openwebrx/csdr/__init__.py create mode 100644 openwebrx/csdr/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/csdr/__pycache__/output.cpython-37.pyc create mode 100644 openwebrx/csdr/__pycache__/pipe.cpython-37.pyc create mode 100644 openwebrx/csdr/output.py create mode 100644 openwebrx/csdr/pipe.py create mode 100644 openwebrx/debian/changelog create mode 100644 openwebrx/debian/compat create mode 100644 openwebrx/debian/control create mode 100644 openwebrx/debian/openwebrx.config create mode 100644 openwebrx/debian/openwebrx.dirs create mode 100644 openwebrx/debian/openwebrx.install create mode 100644 openwebrx/debian/openwebrx.postinst create mode 100644 openwebrx/debian/openwebrx.postrm create mode 100644 openwebrx/debian/openwebrx.templates create mode 100644 openwebrx/debian/rules create mode 100644 openwebrx/debian/source/format create mode 100644 openwebrx/docker.sh create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-airspy create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-base create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-fcdpp create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-full create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-hackrf create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-hpsdr create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-limesdr create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-perseus create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-plutosdr create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-radioberry create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-rtltcp create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-runds create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-sdrplay create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-soapyremote create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-soapysdr create mode 100644 openwebrx/docker/Dockerfiles/Dockerfile-uhd create mode 100644 openwebrx/docker/files/direwolf/direwolf-hamlib.patch create mode 100644 openwebrx/docker/files/dream/dream.patch create mode 100644 openwebrx/docker/files/js8call/js8call-hamlib.patch create mode 100644 openwebrx/docker/files/sdrplay/install-lib.aarch64.patch create mode 100644 openwebrx/docker/files/sdrplay/install-lib.armv7l.patch create mode 100644 openwebrx/docker/files/sdrplay/install-lib.x86_64.patch create mode 100644 openwebrx/docker/files/services/codecserver/run create mode 100644 openwebrx/docker/files/services/sdrplay/run create mode 100644 openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch create mode 100644 openwebrx/docker/files/wsjtx/wsjtx.patch create mode 100644 openwebrx/docker/scripts/install-connectors.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-airspy.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-fcdpp.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-hackrf.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-hpsdr.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-limesdr.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-perseus.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-plutosdr.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-radioberry.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-rtlsdr.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-runds.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-sdrplay.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-soapyremote.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-soapysdr.sh create mode 100644 openwebrx/docker/scripts/install-dependencies-uhd.sh create mode 100644 openwebrx/docker/scripts/install-dependencies.sh create mode 100644 openwebrx/docker/scripts/install-owrx-tools.sh create mode 100644 openwebrx/docker/scripts/run.sh create mode 100644 openwebrx/htdocs/__init__.py create mode 100644 openwebrx/htdocs/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/htdocs/apple-touch-icon.png create mode 100644 openwebrx/htdocs/css/admin.css create mode 100644 openwebrx/htdocs/css/bootstrap.min.css create mode 100644 openwebrx/htdocs/css/login.css create mode 100644 openwebrx/htdocs/css/map.css create mode 100644 openwebrx/htdocs/css/openwebrx-globals.css create mode 100644 openwebrx/htdocs/css/openwebrx-header.css create mode 100644 openwebrx/htdocs/css/openwebrx.css create mode 100644 openwebrx/htdocs/favicon.ico create mode 100644 openwebrx/htdocs/features.html create mode 100644 openwebrx/htdocs/features.js create mode 100644 openwebrx/htdocs/fonts/RobotoMono-Regular.ttf create mode 100644 openwebrx/htdocs/fonts/RobotoMono-Regular.woff create mode 100644 openwebrx/htdocs/fonts/RobotoMono-Regular.woff2 create mode 100644 openwebrx/htdocs/gfx/favicon128.png create mode 100644 openwebrx/htdocs/gfx/favicon32.png create mode 100644 openwebrx/htdocs/gfx/favicon44.png create mode 100644 openwebrx/htdocs/gfx/favicon64.png create mode 100644 openwebrx/htdocs/gfx/favicon96.png create mode 100644 openwebrx/htdocs/gfx/openwebrx-avatar.png create mode 100644 openwebrx/htdocs/gfx/openwebrx-background-cool-blue.png create mode 100644 openwebrx/htdocs/gfx/openwebrx-background-cool-blue.webp create mode 100644 openwebrx/htdocs/gfx/openwebrx-directcall.svg create mode 100644 openwebrx/htdocs/gfx/openwebrx-groupcall.svg create mode 100644 openwebrx/htdocs/gfx/openwebrx-scale-background.png create mode 100644 openwebrx/htdocs/gfx/openwebrx-top-photo.jpg create mode 100644 openwebrx/htdocs/gfx/svg-defs.svg create mode 100644 openwebrx/htdocs/include/header.include.html create mode 100644 openwebrx/htdocs/index.html create mode 100644 openwebrx/htdocs/lib/AprsMarker.js create mode 100644 openwebrx/htdocs/lib/AudioEngine.js create mode 100644 openwebrx/htdocs/lib/AudioProcessor.js create mode 100644 openwebrx/htdocs/lib/BookmarkBar.js create mode 100644 openwebrx/htdocs/lib/BookmarkDialog.js create mode 100644 openwebrx/htdocs/lib/BookmarkLocalStorage.js create mode 100644 openwebrx/htdocs/lib/Demodulator.js create mode 100644 openwebrx/htdocs/lib/DemodulatorPanel.js create mode 100644 openwebrx/htdocs/lib/FrequencyDisplay.js create mode 100644 openwebrx/htdocs/lib/Header.js create mode 100644 openwebrx/htdocs/lib/Js8Threads.js create mode 100644 openwebrx/htdocs/lib/Measurement.js create mode 100644 openwebrx/htdocs/lib/MessagePanel.js create mode 100644 openwebrx/htdocs/lib/MetaPanel.js create mode 100644 openwebrx/htdocs/lib/Modes.js create mode 100644 openwebrx/htdocs/lib/ProgressBar.js create mode 100644 openwebrx/htdocs/lib/bootstrap.bundle.min.js create mode 100644 openwebrx/htdocs/lib/chroma.min.js create mode 100644 openwebrx/htdocs/lib/jquery-3.2.1.min.js create mode 100644 openwebrx/htdocs/lib/jquery.nanoscroller.min.js create mode 100644 openwebrx/htdocs/lib/location-picker.min.js create mode 100644 openwebrx/htdocs/lib/nanoscroller.css create mode 100644 openwebrx/htdocs/lib/nite-overlay.js create mode 100644 openwebrx/htdocs/lib/settings/BookmarkTable.js create mode 100644 openwebrx/htdocs/lib/settings/ExponentialInput.js create mode 100644 openwebrx/htdocs/lib/settings/GainInput.js create mode 100644 openwebrx/htdocs/lib/settings/ImageUpload.js create mode 100644 openwebrx/htdocs/lib/settings/MapInput.js create mode 100644 openwebrx/htdocs/lib/settings/OptionalSection.js create mode 100644 openwebrx/htdocs/lib/settings/SchedulerInput.js create mode 100644 openwebrx/htdocs/lib/settings/WaterfallDropdown.js create mode 100644 openwebrx/htdocs/lib/settings/WsjtDecodingDepthsInput.js create mode 100644 openwebrx/htdocs/login.html create mode 100644 openwebrx/htdocs/map.html create mode 100644 openwebrx/htdocs/map.js create mode 100644 openwebrx/htdocs/mstile-144x144.png create mode 100644 openwebrx/htdocs/openwebrx.js create mode 100644 openwebrx/htdocs/pwchange.html create mode 100644 openwebrx/htdocs/settings.html create mode 100644 openwebrx/htdocs/settings.js create mode 100644 openwebrx/htdocs/settings/bookmarks.html create mode 100644 openwebrx/htdocs/settings/general.html create mode 100644 openwebrx/inkscape files/favicon.svg create mode 100644 openwebrx/inkscape files/google_maps_pin.svg create mode 100644 openwebrx/inkscape files/openwebrx-bookmark.svg create mode 100644 openwebrx/inkscape files/openwebrx-directcall.svg create mode 100644 openwebrx/inkscape files/openwebrx-edit.svg create mode 100644 openwebrx/inkscape files/openwebrx-groupcall.svg create mode 100644 openwebrx/inkscape files/openwebrx-logo.svg create mode 100644 openwebrx/inkscape files/openwebrx-mute.svg create mode 100644 openwebrx/inkscape files/openwebrx-panel-log.svg create mode 100644 openwebrx/inkscape files/openwebrx-panel-map.svg create mode 100644 openwebrx/inkscape files/openwebrx-panel-receiver.svg create mode 100644 openwebrx/inkscape files/openwebrx-panel-settings.svg create mode 100644 openwebrx/inkscape files/openwebrx-panel-status.svg create mode 100644 openwebrx/inkscape files/openwebrx-play-button.svg create mode 100644 openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg create mode 100644 openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg create mode 100644 openwebrx/inkscape files/openwebrx-speake-mutedr.svg create mode 100644 openwebrx/inkscape files/openwebrx-speaker.svg create mode 100644 openwebrx/inkscape files/openwebrx-squelch.svg create mode 100644 openwebrx/inkscape files/openwebrx-trashcan.svg create mode 100644 openwebrx/inkscape files/openwebrx-waterfall-auto.svg create mode 100644 openwebrx/inkscape files/openwebrx-waterfall-continuous.svg create mode 100644 openwebrx/inkscape files/openwebrx-waterfall-default.svg create mode 100644 openwebrx/inkscape files/openwebrx-zoom-in-total.svg create mode 100644 openwebrx/inkscape files/openwebrx-zoom-in.svg create mode 100644 openwebrx/inkscape files/openwebrx-zoom-out-total.svg create mode 100644 openwebrx/inkscape files/openwebrx-zoom-out.svg create mode 100644 openwebrx/openwebrx.conf create mode 100644 openwebrx/openwebrx.py create mode 100644 openwebrx/owrx/__main__.py create mode 100644 openwebrx/owrx/__pycache__/__main__.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/aprs.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/bands.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/bookmarks.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/breadcrumb.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/client.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/command.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/connection.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/cpu.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/details.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/dsp.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/feature.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/fft.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/http.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/js8.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/jsons.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/kiss.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/locator.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/map.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/meta.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/metrics.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/modes.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/parser.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/pocsag.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/receiverid.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/sdr.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/soapy.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/socket.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/users.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/version.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/waterfall.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/websocket.cpython-37.pyc create mode 100644 openwebrx/owrx/__pycache__/wsjt.cpython-37.pyc create mode 100644 openwebrx/owrx/admin/__init__.py create mode 100644 openwebrx/owrx/admin/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/admin/__pycache__/commands.cpython-37.pyc create mode 100644 openwebrx/owrx/admin/commands.py create mode 100644 openwebrx/owrx/aprs.py create mode 100644 openwebrx/owrx/audio/__init__.py create mode 100644 openwebrx/owrx/audio/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/audio/__pycache__/chopper.cpython-37.pyc create mode 100644 openwebrx/owrx/audio/__pycache__/queue.cpython-37.pyc create mode 100644 openwebrx/owrx/audio/__pycache__/wav.cpython-37.pyc create mode 100644 openwebrx/owrx/audio/chopper.py create mode 100644 openwebrx/owrx/audio/queue.py create mode 100644 openwebrx/owrx/audio/wav.py create mode 100644 openwebrx/owrx/bands.py create mode 100644 openwebrx/owrx/bookmarks.py create mode 100644 openwebrx/owrx/breadcrumb.py create mode 100644 openwebrx/owrx/client.py create mode 100644 openwebrx/owrx/command.py create mode 100644 openwebrx/owrx/config/__init__.py create mode 100644 openwebrx/owrx/config/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/classic.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/commands.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/core.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/defaults.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/dynamic.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/error.cpython-37.pyc create mode 100644 openwebrx/owrx/config/__pycache__/migration.cpython-37.pyc create mode 100644 openwebrx/owrx/config/classic.py create mode 100644 openwebrx/owrx/config/commands.py create mode 100644 openwebrx/owrx/config/core.py create mode 100644 openwebrx/owrx/config/defaults.py create mode 100644 openwebrx/owrx/config/dynamic.py create mode 100644 openwebrx/owrx/config/error.py create mode 100644 openwebrx/owrx/config/migration.py create mode 100644 openwebrx/owrx/connection.py create mode 100644 openwebrx/owrx/controllers/__init__.py create mode 100644 openwebrx/owrx/controllers/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/admin.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/api.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/assets.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/feature.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/imageupload.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/metrics.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/profile.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/receiverid.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/robots.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/session.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/status.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/template.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/__pycache__/websocket.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/admin.py create mode 100644 openwebrx/owrx/controllers/api.py create mode 100644 openwebrx/owrx/controllers/assets.py create mode 100644 openwebrx/owrx/controllers/feature.py create mode 100644 openwebrx/owrx/controllers/imageupload.py create mode 100644 openwebrx/owrx/controllers/metrics.py create mode 100644 openwebrx/owrx/controllers/profile.py create mode 100644 openwebrx/owrx/controllers/receiverid.py create mode 100644 openwebrx/owrx/controllers/robots.py create mode 100644 openwebrx/owrx/controllers/session.py create mode 100644 openwebrx/owrx/controllers/settings/__init__.py create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/backgrounddecoding.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/bookmarks.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/decoding.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/general.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/reporting.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/__pycache__/sdr.cpython-37.pyc create mode 100644 openwebrx/owrx/controllers/settings/backgrounddecoding.py create mode 100644 openwebrx/owrx/controllers/settings/bookmarks.py create mode 100644 openwebrx/owrx/controllers/settings/decoding.py create mode 100644 openwebrx/owrx/controllers/settings/general.py create mode 100644 openwebrx/owrx/controllers/settings/reporting.py create mode 100644 openwebrx/owrx/controllers/settings/sdr.py create mode 100644 openwebrx/owrx/controllers/status.py create mode 100644 openwebrx/owrx/controllers/template.py create mode 100644 openwebrx/owrx/controllers/websocket.py create mode 100644 openwebrx/owrx/cpu.py create mode 100644 openwebrx/owrx/details.py create mode 100644 openwebrx/owrx/dsp.py create mode 100644 openwebrx/owrx/feature.py create mode 100644 openwebrx/owrx/fft.py create mode 100644 openwebrx/owrx/form/__init__.py create mode 100644 openwebrx/owrx/form/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/form/__pycache__/error.cpython-37.pyc create mode 100644 openwebrx/owrx/form/__pycache__/section.cpython-37.pyc create mode 100644 openwebrx/owrx/form/error.py create mode 100644 openwebrx/owrx/form/input/__init__.py create mode 100644 openwebrx/owrx/form/input/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/aprs.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/converter.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/device.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/gfx.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/receiverid.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/validator.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/wfm.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/__pycache__/wsjt.cpython-37.pyc create mode 100644 openwebrx/owrx/form/input/aprs.py create mode 100644 openwebrx/owrx/form/input/converter.py create mode 100644 openwebrx/owrx/form/input/device.py create mode 100644 openwebrx/owrx/form/input/gfx.py create mode 100644 openwebrx/owrx/form/input/receiverid.py create mode 100644 openwebrx/owrx/form/input/validator.py create mode 100644 openwebrx/owrx/form/input/wfm.py create mode 100644 openwebrx/owrx/form/input/wsjt.py create mode 100644 openwebrx/owrx/form/section.py create mode 100644 openwebrx/owrx/http.py create mode 100644 openwebrx/owrx/js8.py create mode 100644 openwebrx/owrx/jsons.py create mode 100644 openwebrx/owrx/kiss.py create mode 100644 openwebrx/owrx/locator.py create mode 100644 openwebrx/owrx/map.py create mode 100644 openwebrx/owrx/meta.py create mode 100644 openwebrx/owrx/metrics.py create mode 100644 openwebrx/owrx/modes.py create mode 100644 openwebrx/owrx/parser.py create mode 100644 openwebrx/owrx/pocsag.py create mode 100644 openwebrx/owrx/property/__init__.py create mode 100644 openwebrx/owrx/property/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/property/__pycache__/filter.cpython-37.pyc create mode 100644 openwebrx/owrx/property/__pycache__/validators.cpython-37.pyc create mode 100644 openwebrx/owrx/property/filter.py create mode 100644 openwebrx/owrx/property/validators.py create mode 100644 openwebrx/owrx/receiverid.py create mode 100644 openwebrx/owrx/reporting/__init__.py create mode 100644 openwebrx/owrx/reporting/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/reporting/__pycache__/pskreporter.cpython-37.pyc create mode 100644 openwebrx/owrx/reporting/__pycache__/reporter.cpython-37.pyc create mode 100644 openwebrx/owrx/reporting/__pycache__/wsprnet.cpython-37.pyc create mode 100644 openwebrx/owrx/reporting/pskreporter.py create mode 100644 openwebrx/owrx/reporting/reporter.py create mode 100644 openwebrx/owrx/reporting/wsprnet.py create mode 100644 openwebrx/owrx/sdr.py create mode 100644 openwebrx/owrx/service/__init__.py create mode 100644 openwebrx/owrx/service/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/service/__pycache__/schedule.cpython-37.pyc create mode 100644 openwebrx/owrx/service/schedule.py create mode 100644 openwebrx/owrx/soapy.py create mode 100644 openwebrx/owrx/socket.py create mode 100644 openwebrx/owrx/source/__init__.py create mode 100644 openwebrx/owrx/source/__pycache__/__init__.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/airspy.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/airspyhf.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/connector.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/direct.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/fcdpp.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/fifi_sdr.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/hackrf.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/hpsdr.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/lime_sdr.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/perseussdr.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/pluto_sdr.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/radioberry.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/resampler.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/rtl_sdr.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/rtl_sdr_soapy.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/rtl_tcp.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/runds.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/sddc.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/sdrplay.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/soapy.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/soapy_remote.cpython-37.pyc create mode 100644 openwebrx/owrx/source/__pycache__/uhd.cpython-37.pyc create mode 100644 openwebrx/owrx/source/airspy.py create mode 100644 openwebrx/owrx/source/airspyhf.py create mode 100644 openwebrx/owrx/source/connector.py create mode 100644 openwebrx/owrx/source/direct.py create mode 100644 openwebrx/owrx/source/fcdpp.py create mode 100644 openwebrx/owrx/source/fifi_sdr.py create mode 100644 openwebrx/owrx/source/hackrf.py create mode 100644 openwebrx/owrx/source/hpsdr.py create mode 100644 openwebrx/owrx/source/lime_sdr.py create mode 100644 openwebrx/owrx/source/perseussdr.py create mode 100644 openwebrx/owrx/source/pluto_sdr.py create mode 100644 openwebrx/owrx/source/radioberry.py create mode 100644 openwebrx/owrx/source/resampler.py create mode 100644 openwebrx/owrx/source/rtl_sdr.py create mode 100644 openwebrx/owrx/source/rtl_sdr_soapy.py create mode 100644 openwebrx/owrx/source/rtl_tcp.py create mode 100644 openwebrx/owrx/source/runds.py create mode 100644 openwebrx/owrx/source/sddc.py create mode 100644 openwebrx/owrx/source/sdrplay.py create mode 100644 openwebrx/owrx/source/soapy.py create mode 100644 openwebrx/owrx/source/soapy_remote.py create mode 100644 openwebrx/owrx/source/uhd.py create mode 100644 openwebrx/owrx/users.py create mode 100644 openwebrx/owrx/version.py create mode 100644 openwebrx/owrx/waterfall.py create mode 100644 openwebrx/owrx/websocket.py create mode 100644 openwebrx/owrx/wsjt.py create mode 100644 openwebrx/setup.py create mode 100644 openwebrx/systemd/openwebrx.service create mode 100644 openwebrx/test/__init__.py create mode 100644 openwebrx/test/property/__init__.py create mode 100644 openwebrx/test/property/filter/__init__.py create mode 100644 openwebrx/test/property/filter/test_by_lambda.py create mode 100644 openwebrx/test/property/filter/test_by_property_name.py create mode 100644 openwebrx/test/property/test_property_carousel.py create mode 100644 openwebrx/test/property/test_property_deletion.py create mode 100644 openwebrx/test/property/test_property_filter.py create mode 100644 openwebrx/test/property/test_property_layer.py create mode 100644 openwebrx/test/property/test_property_readonly.py create mode 100644 openwebrx/test/property/test_property_stack.py create mode 100644 openwebrx/test/property/test_property_validator.py create mode 100644 openwebrx/test/property/validators/__init__.py create mode 100644 openwebrx/test/property/validators/test_bool_validator.py create mode 100644 openwebrx/test/property/validators/test_float_validator.py create mode 100644 openwebrx/test/property/validators/test_integer_validator.py create mode 100644 openwebrx/test/property/validators/test_lambda_validator.py create mode 100644 openwebrx/test/property/validators/test_number_validator.py create mode 100644 openwebrx/test/property/validators/test_or_validator.py create mode 100644 openwebrx/test/property/validators/test_regex_validator.py create mode 100644 openwebrx/test/property/validators/test_string_validator.py create mode 100644 openwebrx/test/property/validators/test_validator.py diff --git a/openwebrx/.dockerignore b/openwebrx/.dockerignore new file mode 100644 index 0000000..8c815de --- /dev/null +++ b/openwebrx/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.idea +**/*.pyc +**/*.swp +black-env +debian \ No newline at end of file diff --git a/openwebrx/CHANGELOG.md b/openwebrx/CHANGELOG.md new file mode 100644 index 0000000..4635f83 --- /dev/null +++ b/openwebrx/CHANGELOG.md @@ -0,0 +1,204 @@ +**1.1.0** +- Reworked most graphical elements as SVGs for faster loadtimes and crispier display on hi-dpi displays +- Updated pipelines to match changes in digiham +- Changed D-Star and NXDN integrations to use new decoders from digiham +- Added D-Star and NXDN metadata display + +**1.0.0** +- Introduced `squelch_auto_margin` config option that allows configuring the auto squelch level +- Removed `port` configuration option; `rtltcp_compat` takes the port number with the new connectors +- Added support for new WSJT-X modes FST4, FST4W (only available with WSJT-X 2.3) and Q65 (only avilable with + WSJT-X 2.4) +- Added support for demodulating M17 digital voice signals using m17-cxx-demod +- New reporting infrastructure, allowing WSPR and FST4W spots to be sent to wsprnet.org +- Add some basic filtering capabilities to the map +- New arguments to the `openwebrx` command-line to facilitate the administration of users (try `openwebrx admin`) +- Default bandwidth changes: + - "WFM" changed to 150kHz + - "Packet" (APRS) changed to 12.5kHz +- Configuration rework: + - New: fully web-based configuration interface + - System configuration parameters have been moved to a new, separate `openwebrx.conf` file + - Remaining parameters are now editable in the web configuration + - Existing `config_webrx.py` files will still be read, but changes made in the web configuration will be written to + a new storage system + - Added upload of avatar and panorama image via web configuration +- New devices supported: + - HPSDR devices (Hermes Lite 2) thanks to @jancona + - BBRF103 / RX666 / RX888 devices supported by libsddc + - R&S devices using the EB200 or Ammos protocols + +**0.20.3** +- Fix a compatibility issue with python versions <= 3.6 + +**0.20.2** +- Fix a security problem that allowed arbitrary commands to be executed on the receiver + ([See github issue #215](https://github.com/jketterl/openwebrx/issues/215)) + +**0.20.1** +- Remove broken OSM map fallback + +**0.20.0** +- Added the ability to sign multiple keys in a single request, thus enabling multiple users to claim a single receiver + on receiverbook.de +- Fixed file descriptor leaks to prevent "too many open files" errors +- Add new demodulator chain for FreeDV +- Added new HD audio streaming mode along with a new WFM demodulator +- Reworked AGC code for better results in AM, SSB and digital modes +- Added support for demodulation of "Digital Radio Mondiale" (DRM) broadcast using the "dream" decoder. +- New default waterfall color scheme +- Prototype of a continuous automatic waterfall calibration mode +- New devices supported: + - FunCube Dongle Pro+ (`"type": "fcdpp"`) + - Support for connections to rtl_tcp (`"type": "rtl_tcp"`) + +**0.19.1** +- Added ability to authenticate receivers with listing sites using "receiver id" tokens + +**0.19.0** +- Fix direwolf connection setup by implementing a retry loop +- Pass direct sampling mode changes for rtl_sdr_soapy to owrx_connector +- OSM maps instead of Google when google_maps_api_key is not set (thanks @jquagga) +- Improved logic to pass parameters to soapy devices. + - `rtl_sdr_soapy`: added support for `bias_tee` + - `sdrplay`: added support for `bias_tee`, `rf_notch` and `dab_notch` + - `airspy`: added support for `bitpack` +- Added support for Perseus-SDR devices, (thanks @amontefusco) +- Property System has been rewritten so that defaults on sdr behave as expected +- Waterfall range auto-adjustment now only takes the center 80% of the spectrum into account, which should work better + with SDRs that oversample or have rather flat filter curves towards the spectrum edges +- Bugfix for negative network usage +- FiFi SDR: prevent arecord from shutting down after 2GB of data has been sent +- Added support for bias tee control on rtl_sdr devices +- All connector driven SDRs now support `"rf_gain": "auto"` to enable AGC +- `rtl_sdr` type now also supports the `direct_sampling` option +- Added decoding implementation for for digimode "JS8Call" + (requires an installation of [js8call](http://js8call.com/) and + [the js8py library](https://github.com/jketterl/js8py)) +- Reorganization of the frontend demodulator code +- Improve receiver load time by concatenating javascript assets +- Docker images migrated to Debian slim images; This was necessary to allow the use of function multiversioning in + csdr and owrx_connector to allow the images to run on a wider range of CPUs +- Docker containers have been updated to include the SDRplay driver version 3 +- HackRF support is now based on SoapyHackRF +- Removed sdr.hu server listing support since the site has been shut down +- Added support for Radioberry 2 Rasbperry Pi SDR Cape + +**0.18.0** +- Support for SoapyRemote + +**2020-02-08** +- Compression, resampling and filtering in the frontend have been rewritten in javascript, sdr.js has been removed +- Decoding of Pocsag modulation is now possible +- Removed the 3D waterfall since it had no real application and required ~1MB of javascript code to be downloaded +- Improved the frontend handling of the "too many users" scenario +- PSK63 digimode is now available (same decoding pipeline as PSK31, but with adopted parameters) +- The frequency can now be manipulated with the mousewheel, which should allow the user to tune more precise. The tuning + step size is determined by the digit the mouse cursor is hovering over. +- Clicking on the frequency now opens an input for direct frequency selection +- URL hashes have been fixed and improved: They are now updated automatically, so a shared URL will include frequency + and demodulator, which allows for improved sharing and linking. +- New daylight scheduler for background decoding, allows profiles to be selected by local sunrise / sunset times +- New devices supported: + - LimeSDR (`"type": "lime_sdr"`) + - PlutoSDR (`"type": "pluto_sdr"`) + - RTL_SDR via Soapy (`"type": "rtl_sdr_soapy"`) on special request to allow use of the direct sampling mode + +**2020-01-04** +- The [owrx_connector](https://github.com/jketterl/owrx_connector) is now the default way of communicating with sdr + devices. The old sdr types have been replaced, all `_connector` suffixes on the type must be removed! +- The sources have been refactored, making it a lot easier to add support for other devices +- SDR device failure handling has been improved, including user feedback +- New devices supported: + - FiFiSDR (`"type": "fifi_sdr"`) + +**2019-12-15** +- wsjt-x updated to 2.1.2 +- The rtl_tcp compatibility mode of the owrx_connector is now configurable using the `rtltcp_compat` flag + +**2019-12-10** +- added support for airspyhf devices (Airspy HF+ / Discovery) + +**2019-12-05** +- explicit device filter for soapy devices for multi-device setups + +**2019-12-03** +- compatibility fixes for safari browsers (ios and mac) + +**2019-11-24** +- There is now a new way to interface with SDR hardware, . + They talk directly to the hardware (no rtl_sdr / rx_sdr necessary) and offer I/Q data on a socket, just like nmux + did before. They additionally offer a control socket that allows openwebrx to control the SDR parameters directly, + without the need for repeated restarts. This allows for quicker profile changes, and also reduces the risk of your + SDR hardware from failing during the switchover. See `config_webrx.py` for further information and instructions. +- Offset tuning using the `lfo_offset` has been reworked in a way that `center_freq` has to be set to the frequency you + actually want to listen to. If you're using an `lfo_offset` already, you will probably need to change its sign. +- `initial_squelch_level` can now be set on each profile. +- As usual, plenty of fixes and improvements. + +**2019-10-27** +- Part of the frontend code has been reworked + - Audio buffer minimums have been completely stripped. As a result, you should get better latency. Unfortunately, + this also means there will be some skipping when audio starts. + - Now also supports AudioWorklets (for those browser that have it). The Raspberry Pi image has been updated to include + https due to the SecureContext requirement. + - Mousewheel controls for the receiver sliders +- Error handling for failed SDR devices + +**2019-09-29** +- One of the most-requested features is finally coming to OpenWebRX: Bookmarks (sometimes also referred to as labels). + There's two kinds of bookmarks available: + - Serverside bookmarks that are set up by the receiver administrator. Check the file `bookmarks.json` for examples! + - Clientside bookmarks which every user can store for themselves. They are stored in the browser's localStorage. +- Some more bugs in the websocket handling have been fixed. + +**2019-09-25** +- Automatic reporting of spots to [pskreporter](https://pskreporter.info/) is now possible. Please have a look at the + configuration on how to set it up. +- Websocket communication has been overhauled in large parts. It should now be more reliable, and failing connections + should now have no impact on other users. +- Profile scheduling allows to set up band-hopping if you are running background services. +- APRS now has the ability to show symbols on the map, if a corresponding symbol set has been installed. Check the + config! +- Debug logging has been disabled in a handful of modules, expect vastly reduced output on the shell. + +**2019-09-13** +- New set of APRS-related features + - Decode Packet transmissions using [direwolf](https://github.com/wb2osz/direwolf) (1k2 only for now) + - APRS packets are mostly decoded and shown both in a new panel and on the map + - APRS is also available as a background service + - direwolfs I-gate functionality can be enabled, which allows your receiver to work as a receive-only I-gate for the + APRS network in the background +- Demodulation for background services has been optimized to use less total bandwidth, saving CPU +- More metrics have been added; they can be used together with collectd and its curl_json plugin for now, with some + limitations. + +**2019-07-21** +- Latest Features: + - More WSJT-X modes have been added, including the new FT4 mode + - I started adding a bandplan feature, the first thing visible is the "dial" indicator that brings you right to the + dial frequency for digital modes + - fixed some bugs in the websocket communication which broke the map + +**2019-07-13** +- Latest Features: + - FT8 Integration (using wsjt-x demodulators) + - New Map Feature that shows both decoded grid squares from FT8 and Locations decoded from YSF digital voice + - New Feature report that will show what functionality is available +- There's a new Raspbian SD Card image available (see below) + +**2019-06-30** +- I have done some major rework on the openwebrx core, and I am planning to continue adding more features in the near + future. Please check this place for updates. +- My work has not been accepted into the upstream repository, so you will need to chose between my fork and the official + version. +- I have enabled the issue tracker on this project, so feel free to file bugs or suggest enhancements there! +- This version sports the following new and amazing features: + - Support of multiple SDR devices simultaneously + - Support for multiple profiles per SDR that allow the user to listen to different frequencies + - Support for digital voice decoding + - Feature detection that will disable functionality when dependencies are not available (if you're missing the digital + buttons, this is probably why) +- Raspbian SD Card Images and Docker builds available (see below) +- I am currently working on the feature set for a stable release, but you are more than welcome to test development + versions! diff --git a/openwebrx/LICENSE.txt b/openwebrx/LICENSE.txt new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/openwebrx/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/openwebrx/README.md b/openwebrx/README.md new file mode 100644 index 0000000..9517148 --- /dev/null +++ b/openwebrx/README.md @@ -0,0 +1,60 @@ +OpenWebRX +========= + +OpenWebRX is a multi-user SDR receiver software with a web interface. + +![OpenWebRX](https://www.openwebrx.de/gfx/openwebrx-screenshot.png) + +It has the following features: + +- [csdr](https://github.com/jketterl/csdr) based demodulators (AM/FM/SSB/CW/BPSK31/BPSK63) +- filter passband can be set from GUI +- it extensively uses HTML5 features like WebSocket, Web Audio API, and Canvas +- it works in Google Chrome, Chromium and Mozilla Firefox +- supports a wide range of [SDR hardware](https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices) +- Multiple SDR devices can be used simultaneously +- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF, Pocsag, D-Star, NXDN) +- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9, FST4, + FST4W) +- [direwolf](https://github.com/wb2osz/direwolf) based demodulation of APRS packets +- [JS8Call](http://js8call.com/) support +- [DRM](https://github.com/jketterl/openwebrx/wiki/DRM-demodulator-notes) support +- [FreeDV](https://github.com/jketterl/openwebrx/wiki/FreeDV-demodulator-notes) support +- M17 support based on [m17-cxx-demod](https://github.com/mobilinkd/m17-cxx-demod) + +## Setup + +The following methods of setting up a receiver are currently available: + +- Raspberry Pi SD card images +- Debian repository +- Docker images +- Manual installation + +Please checkout the [setup guide on the wiki](https://github.com/jketterl/openwebrx/wiki/Setup-Guide) for more details +on the respective methods. + +## Community + +If you have trouble setting up or configuring your receiver, you have some great idea you want to see implemented, or +you just generally want to have some OpenWebRX-related chat, come visit us over on +[our groups.io group](https://groups.io/g/openwebrx). + +If you want to hang out, chat, or get in touch directly with the developers, receiver operators or users, feel free to +drop by in [our Discord server](https://discord.gg/gnE9hPz). + +## Usage tips + +You can zoom the waterfall display by the mouse wheel. You can also drag the waterfall to pan across it. + +The filter envelope can be dragged at its ends and moved around to set the passband. + +However, if you hold down the shift key, you can drag the center line (BFO) or the whole passband (PBS). + +## Licensing + +OpenWebRX is available under Affero GPL v3 license +([summary](https://tldrlegal.com/license/gnu-affero-general-public-license-v3-(agpl-3.0))). + +OpenWebRX is also available under a commercial license on request. Please contact me at the address +*<randras@sdr.hu>* for licensing options. diff --git a/openwebrx/bands.json b/openwebrx/bands.json new file mode 100644 index 0000000..02bb703 --- /dev/null +++ b/openwebrx/bands.json @@ -0,0 +1,367 @@ +[ + { + "name": "2190m", + "lower_bound": 135700, + "upper_bound": 137800, + "frequencies": { + "fst4": 136000, + "fst4w": 136000 + }, + "tags": ["hamradio"] + }, + { + "name": "630m", + "lower_bound": 472000, + "upper_bound": 479000, + "frequencies": { + "fst4": 474200, + "fst4w": 474200 + }, + "tags": ["hamradio"] + }, + { + "name": "160m", + "lower_bound": 1810000, + "upper_bound": 2000000, + "frequencies": { + "bpsk31": 1838000, + "ft8": 1840000, + "wspr": 1836600, + "jt65": 1838000, + "jt9": 1839000, + "js8": 1842000, + "fst4": 1839000, + "fst4w": 1836800 + }, + "tags": ["hamradio"] + }, + { + "name": "80m", + "lower_bound": 3500000, + "upper_bound": 3800000, + "frequencies": { + "bpsk31": 3580000, + "ft8": 3573000, + "wspr": 3592600, + "jt65": 3570000, + "jt9": 3572000, + "ft4": [3568000, 3575000], + "js8": 3578000 + }, + "tags": ["hamradio"] + }, + { + "name": "60m", + "lower_bound": 5351500, + "upper_bound": 5366500, + "frequencies": { + "ft8": 5357000, + "wspr": 5364700 + }, + "tags": ["hamradio"] + }, + { + "name": "40m", + "lower_bound": 7000000, + "upper_bound": 7200000, + "frequencies": { + "bpsk31": 7040000, + "ft8": 7074000, + "wspr": 7038600, + "jt65": 7076000, + "jt9": 7078000, + "ft4": 7047500, + "js8": 7078000 + }, + "tags": ["hamradio"] + }, + { + "name": "30m", + "lower_bound": 10100000, + "upper_bound": 10150000, + "frequencies": { + "bpsk31": 10141000, + "ft8": 10136000, + "wspr": 10138700, + "jt65": 10138000, + "jt9": 10140000, + "ft4": 10140000, + "js8": 10130000 + }, + "tags": ["hamradio"] + }, + { + "name": "20m", + "lower_bound": 14000000, + "upper_bound": 14350000, + "frequencies": { + "bpsk31": 14070000, + "ft8": 14074000, + "wspr": 14095600, + "jt65": 14076000, + "jt9": 14078000, + "ft4": 14080000, + "js8": 14078000 + }, + "tags": ["hamradio"] + }, + { + "name": "17m", + "lower_bound": 18068000, + "upper_bound": 18168000, + "frequencies": { + "bpsk31": 18098000, + "ft8": 18100000, + "wspr": 18104600, + "jt65": 18102000, + "jt9": 18104000, + "ft4": 18104000, + "js8": 18104000 + }, + "tags": ["hamradio"] + }, + { + "name": "15m", + "lower_bound": 21000000, + "upper_bound": 21450000, + "frequencies": { + "bpsk31": 21070000, + "ft8": 21074000, + "wspr": 21094600, + "jt65": 21076000, + "jt9": 21078000, + "ft4": 21140000, + "js8": 21078000 + }, + "tags": ["hamradio"] + }, + { + "name": "12m", + "lower_bound": 24890000, + "upper_bound": 24990000, + "frequencies": { + "bpsk31": 24920000, + "ft8": 24915000, + "wspr": 24924600, + "jt65": 24917000, + "jt9": 24919000, + "ft4": 24919000, + "js8": 24922000 + }, + "tags": ["hamradio"] + }, + { + "name": "10m", + "lower_bound": 28000000, + "upper_bound": 29700000, + "frequencies": { + "bpsk31": [28070000, 28120000], + "ft8": 28074000, + "wspr": 28124600, + "jt65": 28076000, + "jt9": 28078000, + "ft4": 28180000, + "js8": 28078000 + }, + "tags": ["hamradio"] + }, + { + "name": "6m", + "lower_bound": 50030000, + "upper_bound": 51000000, + "frequencies": { + "bpsk31": 50305000, + "ft8": 50313000, + "wspr": 50293000, + "jt65": 50310000, + "jt9": 50312000, + "ft4": 50318000, + "js8": 50318000, + "q65": [50211000, 50275000] + }, + "tags": ["hamradio"] + }, + { + "name": "4m", + "lower_bound": 70150000, + "upper_bound": 70200000, + "frequencies": { + "wspr": 70091000 + }, + "tags": ["hamradio"] + }, + { + "name": "2m", + "lower_bound": 144000000, + "upper_bound": 146000000, + "frequencies": { + "wspr": 144489000, + "ft8": 144174000, + "ft4": 144170000, + "jt65": 144120000, + "packet": 144800000, + "q65": 144116000 + }, + "tags": ["hamradio"] + }, + { + "name": "70cm", + "lower_bound": 430000000, + "upper_bound": 440000000, + "frequencies": { + "pocsag": 439987500, + "q65": 432065000 + }, + "tags": ["hamradio"] + }, + { + "name": "23cm", + "lower_bound": 1240000000, + "upper_bound": 1300000000, + "frequencies": { + "q65": 1296065000 + }, + "tags": ["hamradio"] + }, + { + "name": "13cm", + "lower_bound": 2320000000, + "upper_bound": 2450000000, + "frequencies": { + "q65": [2301065000, 2304065000, 2320065000] + }, + "tags": ["hamradio"] + }, + { + "name": "9cm", + "lower_bound": 3400000000, + "upper_bound": 3475000000, + "frequencies": { + "q65": 3400065000 + }, + "tags": ["hamradio"] + }, + { + "name": "6cm", + "lower_bound": 5650000000, + "upper_bound": 5850000000, + "frequencies": { + "q65": 5760200000 + }, + "tags": ["hamradio"] + }, + { + "name": "3cm", + "lower_bound": 10000000000, + "upper_bound": 10500000000, + "frequencies": { + "q65": 10368200000 + }, + "tags": ["hamradio"] + }, + { + "name": "120m Broadcast", + "lower_bound": 2300000, + "upper_bound": 2495000, + "tags": ["broadcast"] + }, + { + "name": "90m Broadcast", + "lower_bound": 3200000, + "upper_bound": 3400000, + "tags": ["broadcast"] + }, + { + "name": "75m Broadcast", + "lower_bound": 3900000, + "upper_bound": 4000000, + "tags": ["broadcast"] + }, + { + "name": "60m Broadcast", + "lower_bound": 4750000, + "upper_bound": 4995000, + "tags": ["broadcast"] + }, + { + "name": "49m Broadcast", + "lower_bound": 5900000, + "upper_bound": 6200000, + "tags": ["broadcast"] + }, + { + "name": "41m Broadcast", + "lower_bound": 7200000, + "upper_bound": 7450000, + "tags": ["broadcast"] + }, + { + "name": "31m Broadcast", + "lower_bound": 9400000, + "upper_bound": 9900000, + "tags": ["broadcast"] + }, + { + "name": "25m Broadcast", + "lower_bound": 11600000, + "upper_bound": 12100000, + "tags": ["broadcast"] + }, + { + "name": "22m Broadcast", + "lower_bound": 13570000, + "upper_bound": 13870000, + "tags": ["broadcast"] + }, + { + "name": "19m Broadcast", + "lower_bound": 15100000, + "upper_bound": 15830000, + "tags": ["broadcast"] + }, + { + "name": "16m Broadcast", + "lower_bound": 17480000, + "upper_bound": 17900000, + "tags": ["broadcast"] + }, + { + "name": "15m Broadcast", + "lower_bound": 18900000, + "upper_bound": 19020000, + "tags": ["broadcast"] + }, + { + "name": "13m Broadcast", + "lower_bound": 21450000, + "upper_bound": 21850000, + "tags": ["broadcast"] + }, + { + "name": "11m Broadcast", + "lower_bound": 25670000, + "upper_bound": 26100000, + "tags": ["broadcast"] + }, + { + "name": "FM Broadcast", + "lower_bound": 87500000, + "upper_bound": 108000000, + "tags": ["broadcast"] + }, + { + "name": "11m CB", + "lower_bound": 26965000, + "upper_bound": 27405000, + "frequencies": { + "js8": 27245000 + }, + "tags": ["public"] + }, + { + "name": "PMR446", + "lower_bound": 446000000, + "upper_bound": 446200000, + "tags": ["public"] + } +] \ No newline at end of file diff --git a/openwebrx/config_webrx.py b/openwebrx/config_webrx.py new file mode 100644 index 0000000..f06f50e --- /dev/null +++ b/openwebrx/config_webrx.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- + +""" +config_webrx: configuration options for OpenWebRX + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + + In addition, as a special exception, the copyright holders + state that config_rtl.py and config_webrx.py are not part of the + Corresponding Source defined in GNU AGPL version 3 section 1. + + (It means that you do not have to redistribute config_rtl.py and + config_webrx.py if you make any changes to these two configuration files, + and use them for running your web service with OpenWebRX.) +""" + +""" +DEPRECATION notice + +As of OpenWebRX 0.21, the configuration system has been completely overhauled. +The configuration of OpenWebRX should now be done in the new web-based +configuration interface exclusively. + +Existing configurations can still be used, but their values will be migrated +to the new storage infrastructure as soon as the web configuration is used to +edit them. + +The new configuration storage is not intended to be edited manually. +""" + +# configuration version. please only modify if you're able to perform the associated migration steps. +version = 7 + +# NOTE: you can find additional information about configuring OpenWebRX in the Wiki: +# https://github.com/jketterl/openwebrx/wiki/Configuration-guide + +# ==== Server settings ==== +#max_clients = 20 + +# ==== Web GUI configuration ==== +#receiver_name = "[Callsign]" +#receiver_location = "Budapest, Hungary" +#receiver_asl = 200 +#receiver_admin = "example@example.com" +#receiver_gps = {"lat": 47.000000, "lon": 19.000000} +#photo_title = "Panorama of Budapest from Schönherz Zoltán Dormitory" +# photo_desc allows you to put pretty much any HTML you like into the receiver description. +# The lines below should give you some examples of what's possible. +#photo_desc = """ +#You can add your own background photo and receiver information.
+#Receiver is operated by: Receiver Operator
+#Device: Receiver Device
+#Antenna: Receiver Antenna
+#Website: http://localhost +#""" + +# ==== Public receiver listings ==== +# You can publish your receiver on online receiver directories, like https://www.receiverbook.de +# You will receive a receiver key from the directory that will authenticate you as the operator of this receiver. +# Please note that you not share your receiver keys publicly since anyone that obtains your receiver key can take over +# your public listing. +# Your receiver keys should be placed into this array: +#receiver_keys = [] +# If you list your receiver on multiple sites, you can place all your keys into the array above, or you can append +# keys to the arraylike this: +# receiver_keys += ["my-receiver-key"] + +# If you're not sure, simply copy & paste the code you received from your listing site below this line: + +# ==== DSP/RX settings ==== +#fft_fps = 9 +#fft_size = 4096 # Should be power of 2 +#fft_voverlap_factor = ( +# 0.3 # If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the diagram. +#) + +#audio_compression = "adpcm" # valid values: "adpcm", "none" +#fft_compression = "adpcm" # valid values: "adpcm", "none" + +# Tau setting for WFM (broadcast FM) deemphasis\ +# Quote from wikipedia https://en.wikipedia.org/wiki/FM_broadcasting#Pre-emphasis_and_de-emphasis +# "In most of the world a 50 µs time constant is used. In the Americas and South Korea, 75 µs is used" +# Enable one of the following lines, depending on your location: +# wfm_deemphasis_tau = 75e-6 # for US and South Korea +#wfm_deemphasis_tau = 50e-6 # for the rest of the world + +#digimodes_fft_size = 2048 + +# enables lookup of DMR ids using the radioid api +#digital_voice_dmr_id_lookup = True + +""" +Note: if you experience audio underruns while CPU usage is 100%, you can: +- decrease `samp_rate`, +- set `fft_voverlap_factor` to 0, +- decrease `fft_fps` and `fft_size`, +- limit the number of users by decreasing `max_clients`. +""" + +# ==== I/Q sources ==== +# (Uncomment the appropriate by removing # characters at the beginning of the corresponding lines.) + +############################################################################### +# Is my SDR hardware supported? # +# Check here: https://github.com/jketterl/openwebrx/wiki/Supported-Hardware # +############################################################################### + +# Currently supported types of sdr receivers: +# "rtl_sdr", "rtl_sdr_soapy", "sdrplay", "hackrf", "airspy", "airspyhf", "fifi_sdr", +# "perseussdr", "lime_sdr", "pluto_sdr", "soapy_remote", "hpsdr", "uhd", +# "radioberry", "fcdpp", "rtl_tcp", "sddc", "runds" + +# For more details on specific types, please checkout the wiki: +# https://github.com/jketterl/openwebrx/wiki/Supported-Hardware#sdr-devices + +#sdrs = { +# "rtlsdr": { +# "name": "RTL-SDR USB Stick", +# "type": "rtl_sdr", +# "ppm": 0, +# # you can change this if you use an upconverter. formula is: +# # center_freq + lfo_offset = actual frequency on the sdr +# # "lfo_offset": 0, +# "profiles": { +# "70cm": { +# "name": "70cm Relais", +# "center_freq": 438800000, +# "rf_gain": 29, +# "samp_rate": 2400000, +# "start_freq": 439275000, +# "start_mod": "nfm", +# }, +# "2m": { +# "name": "2m komplett", +# "center_freq": 145000000, +# "rf_gain": 29, +# "samp_rate": 2048000, +# "start_freq": 145725000, +# "start_mod": "nfm", +# }, +# }, +# }, +# "airspy": { +# "name": "Airspy HF+", +# "type": "airspyhf", +# "ppm": 0, +# "rf_gain": "auto", +# "profiles": { +# "20m": { +# "name": "20m", +# "center_freq": 14150000, +# "samp_rate": 384000, +# "start_freq": 14070000, +# "start_mod": "usb", +# }, +# "30m": { +# "name": "30m", +# "center_freq": 10125000, +# "samp_rate": 192000, +# "start_freq": 10142000, +# "start_mod": "usb", +# }, +# "40m": { +# "name": "40m", +# "center_freq": 7100000, +# "samp_rate": 256000, +# "start_freq": 7070000, +# "start_mod": "lsb", +# }, +# "80m": { +# "name": "80m", +# "center_freq": 3650000, +# "samp_rate": 384000, +# "start_freq": 3570000, +# "start_mod": "lsb", +# }, +# "49m": { +# "name": "49m Broadcast", +# "center_freq": 6050000, +# "samp_rate": 384000, +# "start_freq": 6070000, +# "start_mod": "am", +# }, +# }, +# }, +# "sdrplay": { +# "name": "SDRPlay RSP2", +# "type": "sdrplay", +# "ppm": 0, +# "antenna": "Antenna A", +# "profiles": { +# "20m": { +# "name": "20m", +# "center_freq": 14150000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 14070000, +# "start_mod": "usb", +# }, +# "30m": { +# "name": "30m", +# "center_freq": 10125000, +# "rf_gain": 0, +# "samp_rate": 250000, +# "start_freq": 10142000, +# "start_mod": "usb", +# }, +# "40m": { +# "name": "40m", +# "center_freq": 7100000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 7070000, +# "start_mod": "lsb", +# }, +# "80m": { +# "name": "80m", +# "center_freq": 3650000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 3570000, +# "start_mod": "lsb", +# }, +# "49m": { +# "name": "49m Broadcast", +# "center_freq": 6000000, +# "rf_gain": 0, +# "samp_rate": 500000, +# "start_freq": 6070000, +# "start_mod": "am", +# }, +# }, +# }, +#} + +# ==== Color themes ==== + +### google turbo colormap (see: https://ai.googleblog.com/2019/08/turbo-improved-rainbow-colormap-for.html) +#waterfall_scheme = "GoogleTurboWaterfall" + +### original theme by teejez: +#waterfall_scheme = "TeejeezWaterfall" + +### old theme by HA7ILM: +#waterfall_scheme = "Ha7ilmWaterfall" +##For the old colors, you might also want to set [fft_voverlap_factor] to 0. + +### custom waterfall schemes can be configured like this: +#waterfall_scheme = "CustomWaterfall" +#waterfall_colors = [0x0000FF, 0x00FF00, 0xFF0000] + +### Waterfall calibration +#waterfall_levels = {"min": -88, "max": -20} # in dB + +#waterfall_auto_levels = {"min": 3, "max": 10} +#waterfall_auto_min_range = 50 + +# Note: When the auto waterfall level button is clicked, the following happens: +# [waterfall_levels.min] = [current_min_power_level] - [waterfall_auto_levels["min"]] +# [waterfall_levels.max] = [current_max_power_level] + [waterfall_auto_levels["max"]] +# +# ___|__________________________________|____________________________________|__________________________________|___> signal power +# \_waterfall_auto_levels["min"]_/ |__ current_min_power_level | \_waterfall_auto_levels["max"]_/ +# current_max_power_level __| + +# This setting allows you to modify the precision of the frequency displays in OpenWebRX. +# Set this to exponent of 10 to select the most precise digit in Hz you'd like to see +# examples: +# a value of 2 selects 10^2 = 100Hz tuning precision (default): +#tuning_precision = 2 +# a value of 1 selects 10^1 = 10Hz tuning precision: +#tuning_precision = 1 + +# This setting tells the auto-squelch the offset to add to the current signal level to use as the new squelch level. +# Lowering this setting will give you a more sensitive squelch, but it may also cause unwanted squelch openings when +# using the auto squelch. +#squelch_auto_margin = 10 # in dB + +#google_maps_api_key = "" + +# how long should positions be visible on the map? +# they will start fading out after half of that +# in seconds; default: 2 hours +#map_position_retention_time = 2 * 60 * 60 + +# decoder queue configuration +# due to the nature of some operating modes (ft8, ft8, jt9, jt65, wspr and js8), the data is recorded for a given amount +# of time (6 seconds up to 2 minutes) and decoded at the end. this can lead to very high peak loads. +# to mitigate this, the recordings will be queued and processed in sequence. +# the number of workers will limit the total amount of work (one worker will losely occupy one cpu / thread) +#decoding_queue_workers = 2 +# the maximum queue length will cause decodes to be dumped if the workers cannot keep up +# if you are running background services, make sure this number is high enough to accept the task influx during peaks +# i.e. this should be higher than the number of decoding services running at the same time +#decoding_queue_length = 10 + +# wsjt decoding depth will allow more results, but will also consume more cpu +#wsjt_decoding_depth = 3 +# can also be set for each mode separately +# jt65 seems to be somewhat prone to erroneous decodes, this setting handles that to some extent +#wsjt_decoding_depths = {"jt65": 1} + +# FST4 can be transmitted in different intervals. This setting determines which intervals will be decoded. +# available values (in seconds): 15, 30, 60, 120, 300, 900, 1800 +#fst4_enabled_intervals = [15, 30] + +# FST4W can be transmitted in different intervals. This setting determines which intervals will be decoded. +# available values (in seconds): 120, 300, 900, 1800 +#fst4w_enabled_intervals = [120, 300] + +# Q65 allows many combinations of intervals and submodes. This setting determines which combinations will be decoded. +# Please use the mode letter followed by the decode interval in seconds to specify the combinations. For example: +#q65_enabled_combinations = ["A30", "E120", "C60"] + +# JS8 comes in different speeds: normal, slow, fast, turbo. This setting controls which ones are enabled. +#js8_enabled_profiles = ["normal", "slow"] +# JS8 decoding depth; higher value will get more results, but will also consume more cpu +#js8_decoding_depth = 3 + +# Enable background service for decoding digital data. You can find more information at: +# https://github.com/jketterl/openwebrx/wiki/Background-decoding +#services_enabled = False +#services_decoders = ["ft8", "ft4", "wspr", "packet"] + +# === aprs igate settings === +# If you want to share your APRS decodes with the aprs network, configure these settings accordingly. +# Make sure that you have set services_enabled to true and customize services_decoders to your needs. +#aprs_callsign = "N0CALL" +#aprs_igate_enabled = False +#aprs_igate_server = "euro.aprs2.net" +#aprs_igate_password = "" +# beacon uses the receiver_gps setting, so if you enable this, make sure the location is correct there +#aprs_igate_beacon = False + +# Uncomment the following to customize gateway beacon details reported to the aprs network +# Plese see Dire Wolf's documentation on PBEACON configuration for complete details: +# https://github.com/wb2osz/direwolf/raw/master/doc/User-Guide.pdf + +# Symbol in its two-character form as specified by the APRS spec at http://www.aprs.org/symbols/symbols-new.txt +# Default: Receive only IGate (do not send msgs back to RF) +# aprs_igate_symbol = "R&" + +# Custom comment about igate +# Default: OpenWebRX APRS gateway +# aprs_igate_comment = "OpenWebRX APRS gateway" + +# Antenna Height and Gain details +# Unspecified by default +# Antenna height above average terrain (HAAT) in meters +# aprs_igate_height = "5" +# Antenna gain in dBi +# aprs_igate_gain = "0" +# Antenna direction (N, NE, E, SE, S, SW, W, NW). Omnidirectional by default +# aprs_igate_dir = "NE" + +# === PSK Reporter settings === +# enable this if you want to upload all ft8, ft4 etc spots to pskreporter.info +# this also uses the receiver_gps setting from above, so make sure it contains a correct locator +#pskreporter_enabled = False +#pskreporter_callsign = "N0CALL" +# optional antenna information, uncomment to enable +#pskreporter_antenna_information = "Dipole" + +# === WSPRNet reporting settings +# enable this if you want to upload WSPR spots to wsprnet.ort +# in addition to these settings also make sure that receiver_gps contains your correct location +#wsprnet_enabled = False +#wsprnet_callsign = "N0CALL" diff --git a/openwebrx/csdr/__init__.py b/openwebrx/csdr/__init__.py new file mode 100644 index 0000000..32e2b09 --- /dev/null +++ b/openwebrx/csdr/__init__.py @@ -0,0 +1,837 @@ +""" +OpenWebRX csdr plugin: do the signal processing with csdr + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +""" + +import subprocess +import os +import signal +import threading +import math +from functools import partial + +from csdr.output import Output + +from owrx.kiss import KissClient, DirewolfConfig, DirewolfConfigSubscriber +from owrx.audio.chopper import AudioChopper + +from csdr.pipe import Pipe + +import logging + +logger = logging.getLogger(__name__) + + +class Dsp(DirewolfConfigSubscriber): + def __init__(self, output: Output): + self.samp_rate = 250000 + self.output_rate = 11025 + self.hd_output_rate = 44100 + self.fft_size = 1024 + self.fft_fps = 5 + self.center_freq = 0 + self.offset_freq = 0 + self.low_cut = -4000 + self.high_cut = 4000 + self.bpf_transition_bw = 320 # Hz, and this is a constant + self.ddc_transition_bw_rate = 0.15 # of the IF sample rate + self.running = False + self.secondary_processes_running = False + self.audio_compression = "none" + self.fft_compression = "none" + self.demodulator = "nfm" + self.name = "csdr" + self.base_bufsize = 512 + self.decimation = None + self.last_decimation = None + self.nc_port = None + self.squelch_level = -150 + self.fft_averages = 50 + self.wfm_deemphasis_tau = 50e-6 + self.iqtee = False + self.iqtee2 = False + self.secondary_demodulator = None + self.secondary_fft_size = 1024 + self.secondary_process_fft = None + self.secondary_process_demod = None + self.pipe_names = { + "bpf_pipe": Pipe.WRITE, + "shift_pipe": Pipe.WRITE, + "squelch_pipe": Pipe.WRITE, + "smeter_pipe": Pipe.READ, + "meta_pipe": Pipe.READ, + "iqtee_pipe": Pipe.NONE, + "iqtee2_pipe": Pipe.NONE, + "dmr_control_pipe": Pipe.WRITE, + } + self.pipes = {} + self.secondary_pipe_names = {"secondary_shift_pipe": Pipe.WRITE} + self.secondary_offset_freq = 1000 + self.codecserver = None + self.modification_lock = threading.Lock() + self.output = output + + self.temporary_directory = None + self.pipe_base_path = None + self.set_temporary_directory("/tmp") + + self.is_service = False + self.direwolf_config = None + self.direwolf_config_path = None + self.process = None + + def set_service(self, flag=True): + self.is_service = flag + + def set_temporary_directory(self, what): + self.temporary_directory = what + self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_".format(tmp_dir=self.temporary_directory) + + def chain(self, which): + chain = ["nc -v 127.0.0.1 {nc_port}"] + if which == "fft": + chain += [ + "csdr fft_cc {fft_size} {fft_block_size}", + "csdr logpower_cf -70" + if self.fft_averages == 0 + else "csdr logaveragepower_cf -70 {fft_size} {fft_averages}", + "csdr fft_exchange_sides_ff {fft_size}", + ] + if self.fft_compression == "adpcm": + chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"] + return chain + chain += ["csdr shift_addfast_cc --fifo {shift_pipe}"] + if self.decimation > 1: + chain += ["csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING"] + chain += ["csdr bandpass_fir_fft_cc --fifo {bpf_pipe} {bpf_transition_bw} HAMMING"] + if self.output.supports_type("smeter"): + chain += [ + "csdr squelch_and_smeter_cc --fifo {squelch_pipe} --outfifo {smeter_pipe} 5 {smeter_report_every}" + ] + if self.secondary_demodulator: + if self.output.supports_type("secondary_fft"): + chain += ["csdr tee {iqtee_pipe}"] + chain += ["csdr tee {iqtee2_pipe}"] + # early exit if we don't want audio + if not self.output.supports_type("audio"): + return chain + # safe some cpu cycles... no need to decimate if decimation factor is 1 + last_decimation_block = [] + if self.last_decimation >= 2.0: + # activate prefilter if signal has been oversampled, e.g. WFM + last_decimation_block = ["csdr fractional_decimator_ff {last_decimation} 12 --prefilter"] + elif self.last_decimation != 1.0: + last_decimation_block = ["csdr fractional_decimator_ff {last_decimation}"] + if which == "nfm": + chain += ["csdr fmdemod_quadri_cf", "csdr limit_ff"] + chain += last_decimation_block + chain += [ + "csdr deemphasis_nfm_ff {audio_rate}", + "csdr agc_ff --profile slow --max 3", + ] + if self.get_audio_rate() != self.get_output_rate(): + chain += [ + "sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + ] + else: + chain += ["csdr convert_f_s16"] + elif which == "wfm": + chain += [ + "csdr fmdemod_quadri_cf", + "csdr limit_ff", + ] + chain += last_decimation_block + chain += ["csdr deemphasis_wfm_ff {audio_rate} {wfm_deemphasis_tau}", "csdr convert_f_s16"] + elif self.isDigitalVoice(which): + chain += ["csdr fmdemod_quadri_cf"] + chain += last_decimation_block + chain += ["dc_block"] + # m17 + if which == "m17": + chain += [ + "csdr limit_ff", + "csdr convert_f_s16", + "m17-demod", + ] + else: + # digiham modes + if which == "dstar": + chain += [ + "fsk_demodulator -s 10", + "dstar_decoder --fifo {meta_pipe}", + "mbe_synthesizer -d {codecserver_arg}", + ] + elif which == "nxdn": + chain += [ + "rrc_filter --narrow", + "gfsk_demodulator --samples 20", + "nxdn_decoder --fifo {meta_pipe}", + "mbe_synthesizer {codecserver_arg}", + ] + else: + chain += ["rrc_filter", "gfsk_demodulator"] + if which == "dmr": + chain += [ + "dmr_decoder --fifo {meta_pipe} --control-fifo {dmr_control_pipe}", + "mbe_synthesizer {codecserver_arg}", + ] + elif which == "ysf": + chain += ["ysf_decoder --fifo {meta_pipe}", "mbe_synthesizer -y {codecserver_arg}"] + chain += ["digitalvoice_filter"] + chain += [ + "CSDR_FIXED_BUFSIZE=32 csdr agc_s16 --max 30 --initial 3", + "sox -t raw -r 8000 -e signed-integer -b 16 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", + ] + elif which == "am": + chain += ["csdr amdemod_cf", "csdr fastdcblock_ff"] + chain += last_decimation_block + chain += [ + "csdr agc_ff --profile slow --initial 200", + "csdr convert_f_s16", + ] + elif self.isFreeDV(which): + chain += ["csdr realpart_cf"] + chain += last_decimation_block + chain += [ + "csdr agc_ff", + "csdr convert_f_s16", + "freedv_rx 1600 - -", + "csdr agc_s16 --max 30 --initial 3", + "sox -t raw -r 8000 -e signed-integer -b 16 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", + ] + elif self.isDrm(which): + if self.last_decimation != 1.0: + # we are still dealing with complex samples here, so the regular last_decimation_block doesn't fit + chain += ["csdr fractional_decimator_cc {last_decimation}"] + chain += [ + "csdr convert_f_s16", + "dream -c 6 --sigsrate 48000 --audsrate 48000 -I - -O -", + "sox -t raw -r 48000 -e signed-integer -b 16 -c 2 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - ", + ] + elif which == "ssb": + chain += ["csdr realpart_cf"] + chain += last_decimation_block + chain += ["csdr agc_ff"] + # fixed sample rate necessary for the wsjt-x tools. fix with sox... + if self.get_audio_rate() != self.get_output_rate(): + chain += [ + "sox -t raw -r {audio_rate} -e floating-point -b 32 -c 1 --buffer 32 - -t raw -r {output_rate} -e signed-integer -b 16 -c 1 - " + ] + else: + chain += ["csdr convert_f_s16"] + + if self.audio_compression == "adpcm": + chain += ["csdr encode_ima_adpcm_i16_u8"] + return chain + + def secondary_chain(self, which): + chain = ["cat {input_pipe}"] + if which == "fft": + chain += [ + "csdr fft_cc {secondary_fft_input_size} {secondary_fft_block_size}", + "csdr logpower_cf -70" + if self.fft_averages == 0 + else "csdr logaveragepower_cf -70 {secondary_fft_size} {fft_averages}", + "csdr fft_exchange_sides_ff {secondary_fft_input_size}", + ] + if self.fft_compression == "adpcm": + chain += ["csdr compress_fft_adpcm_f_u8 {secondary_fft_size}"] + return chain + elif which == "bpsk31" or which == "bpsk63": + return chain + [ + "csdr shift_addfast_cc --fifo {secondary_shift_pipe}", + "csdr bandpass_fir_fft_cc -{secondary_bpf_cutoff} {secondary_bpf_cutoff} {secondary_bpf_cutoff}", + "csdr simple_agc_cc 0.001 0.5", + "csdr timing_recovery_cc GARDNER {secondary_samples_per_bits} 0.5 2 --add_q", + "CSDR_FIXED_BUFSIZE=1 csdr dbpsk_decoder_c_u8", + "CSDR_FIXED_BUFSIZE=1 csdr psk31_varicode_decoder_u8_u8", + ] + elif self.isWsjtMode(which) or self.isJs8(which): + chain += ["csdr realpart_cf"] + if self.last_decimation != 1.0: + chain += ["csdr fractional_decimator_ff {last_decimation}"] + return chain + ["csdr agc_ff", "csdr convert_f_s16"] + elif which == "packet": + chain += ["csdr fmdemod_quadri_cf"] + if self.last_decimation != 1.0: + chain += ["csdr fractional_decimator_ff {last_decimation}"] + return chain + ["csdr convert_f_s16", "direwolf -c {direwolf_config} -r {audio_rate} -t 0 -q d -q h 1>&2"] + elif which == "pocsag": + chain += ["csdr fmdemod_quadri_cf"] + if self.last_decimation != 1.0: + chain += ["csdr fractional_decimator_ff {last_decimation}"] + return chain + ["fsk_demodulator -i", "pocsag_decoder"] + + def set_secondary_demodulator(self, what): + if self.get_secondary_demodulator() == what: + return + self.secondary_demodulator = what + self.calculate_decimation() + self.restart() + + def secondary_fft_block_size(self): + base = (self.samp_rate / self.decimation) / (self.fft_fps * 2) + if self.fft_averages == 0: + return base + return base / self.fft_averages + + def secondary_decimation(self): + return 1 # currently unused + + def secondary_bpf_cutoff(self): + if self.secondary_demodulator == "bpsk31": + return 31.25 / self.if_samp_rate() + elif self.secondary_demodulator == "bpsk63": + return 62.5 / self.if_samp_rate() + return 0 + + def secondary_bpf_transition_bw(self): + if self.secondary_demodulator == "bpsk31": + return 31.25 / self.if_samp_rate() + elif self.secondary_demodulator == "bpsk63": + return 62.5 / self.if_samp_rate() + return 0 + + def secondary_samples_per_bits(self): + if self.secondary_demodulator == "bpsk31": + return int(round(self.if_samp_rate() / 31.25)) & ~3 + elif self.secondary_demodulator == "bpsk63": + return int(round(self.if_samp_rate() / 62.5)) & ~3 + return 0 + + def secondary_bw(self): + if self.secondary_demodulator == "bpsk31": + return 31.25 + elif self.secondary_demodulator == "bpsk63": + return 62.5 + + def start_secondary_demodulator(self): + if not self.secondary_demodulator: + return + logger.debug("starting secondary demodulator from IF input sampled at %d" % self.if_samp_rate()) + secondary_command_demod = " | ".join(self.secondary_chain(self.secondary_demodulator)) + self.try_create_pipes(self.secondary_pipe_names, secondary_command_demod) + self.try_create_configs(secondary_command_demod) + + secondary_command_demod = secondary_command_demod.format( + input_pipe=self.pipes["iqtee2_pipe"], + secondary_shift_pipe=self.pipes["secondary_shift_pipe"], + secondary_decimation=self.secondary_decimation(), + secondary_samples_per_bits=self.secondary_samples_per_bits(), + secondary_bpf_cutoff=self.secondary_bpf_cutoff(), + secondary_bpf_transition_bw=self.secondary_bpf_transition_bw(), + if_samp_rate=self.if_samp_rate(), + last_decimation=self.last_decimation, + audio_rate=self.get_audio_rate(), + direwolf_config=self.direwolf_config_path, + ) + + logger.debug("secondary command (demod) = %s", secondary_command_demod) + if self.output.supports_type("secondary_fft"): + secondary_command_fft = " | ".join(self.secondary_chain("fft")) + secondary_command_fft = secondary_command_fft.format( + input_pipe=self.pipes["iqtee_pipe"], + secondary_fft_input_size=self.secondary_fft_size, + secondary_fft_size=self.secondary_fft_size, + secondary_fft_block_size=self.secondary_fft_block_size(), + fft_averages=self.fft_averages, + ) + logger.debug("secondary command (fft) = %s", secondary_command_fft) + + self.secondary_process_fft = subprocess.Popen( + secondary_command_fft, stdout=subprocess.PIPE, shell=True, start_new_session=True + ) + self.output.send_output( + "secondary_fft", + partial(self.secondary_process_fft.stdout.read, int(self.get_secondary_fft_bytes_to_read())), + ) + + # direwolf does not provide any meaningful data on stdout + # more specifically, it doesn't provide any data. if however, for any strange reason, it would start to do so, + # it would block if not read. by piping it to devnull, we avoid a potential pitfall here. + secondary_output = subprocess.DEVNULL if self.isPacket() else subprocess.PIPE + self.secondary_process_demod = subprocess.Popen( + secondary_command_demod, stdout=secondary_output, shell=True, start_new_session=True + ) + self.secondary_processes_running = True + + if self.isWsjtMode() or self.isJs8(): + chopper = AudioChopper(self, self.get_secondary_demodulator()) + chopper.send_output("audio", self.secondary_process_demod.stdout.read) + output_type = "js8_demod" if self.isJs8() else "wsjt_demod" + self.output.send_output(output_type, chopper.read) + elif self.isPacket(): + # we best get the ax25 packets from the kiss socket + kiss = KissClient(self.direwolf_config.getPort()) + self.output.send_output("packet_demod", kiss.read) + elif self.isPocsag(): + self.output.send_output("pocsag_demod", self.secondary_process_demod.stdout.readline) + else: + self.output.send_output("secondary_demod", partial(self.secondary_process_demod.stdout.read, 1)) + + # open control pipes for csdr and send initialization data + if self.has_pipe("secondary_shift_pipe"): # TODO digimodes + self.set_secondary_offset_freq(self.secondary_offset_freq) # TODO digimodes + + def set_secondary_offset_freq(self, value): + self.secondary_offset_freq = value + if self.secondary_processes_running and self.has_pipe("secondary_shift_pipe"): + self.pipes["secondary_shift_pipe"].write( + "%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate()) + ) + + def stop_secondary_demodulator(self): + if not self.secondary_processes_running: + return + self.try_delete_pipes(self.secondary_pipe_names) + self.try_delete_configs() + if self.secondary_process_fft: + try: + os.killpg(os.getpgid(self.secondary_process_fft.pid), signal.SIGTERM) + # drain any leftover data to free file descriptors + self.secondary_process_fft.communicate() + self.secondary_process_fft = None + except ProcessLookupError: + # been killed by something else, ignore + pass + if self.secondary_process_demod: + try: + os.killpg(os.getpgid(self.secondary_process_demod.pid), signal.SIGTERM) + # drain any leftover data to free file descriptors + self.secondary_process_demod.communicate() + self.secondary_process_demod = None + except ProcessLookupError: + # been killed by something else, ignore + pass + self.secondary_processes_running = False + + def get_secondary_demodulator(self): + return self.secondary_demodulator + + def set_secondary_fft_size(self, secondary_fft_size): + if self.secondary_fft_size == secondary_fft_size: + return + self.secondary_fft_size = secondary_fft_size + self.restart() + + def set_audio_compression(self, what): + if self.audio_compression == what: + return + self.audio_compression = what + self.restart() + + def get_audio_bytes_to_read(self): + # desired latency: 5ms + # uncompressed audio has 16 bits = 2 bytes per sample + base = self.output_rate * 0.005 * 2 + # adpcm compresses the bitstream by 4 + if self.audio_compression == "adpcm": + base = base / 4 + return int(base) + + def set_fft_compression(self, what): + if self.fft_compression == what: + return + self.fft_compression = what + self.restart() + + def get_fft_bytes_to_read(self): + if self.fft_compression == "none": + return self.fft_size * 4 + if self.fft_compression == "adpcm": + return int((self.fft_size / 2) + (10 / 2)) + + def get_secondary_fft_bytes_to_read(self): + if self.fft_compression == "none": + return self.secondary_fft_size * 4 + if self.fft_compression == "adpcm": + return (self.secondary_fft_size / 2) + (10 / 2) + + def set_samp_rate(self, samp_rate): + self.samp_rate = samp_rate + self.calculate_decimation() + if self.running: + self.restart() + + def calculate_decimation(self): + (self.decimation, self.last_decimation) = self.get_decimation(self.samp_rate, self.get_audio_rate()) + + def get_decimation(self, input_rate, output_rate): + if output_rate <= 0: + raise ValueError("invalid output rate: {rate}".format(rate=output_rate)) + decimation = 1 + target_rate = output_rate + # wideband fm has a much higher frequency deviation (75kHz). + # we cannot cover this if we immediately decimate to the sample rate the audio will have later on, so we need + # to compensate here. + if self.get_demodulator() == "wfm" and output_rate < 200000: + target_rate = 200000 + while input_rate / (decimation + 1) >= target_rate: + decimation += 1 + fraction = float(input_rate / decimation) / output_rate + return decimation, fraction + + def if_samp_rate(self): + return self.samp_rate / self.decimation + + def get_name(self): + return self.name + + def get_output_rate(self): + return self.output_rate + + def get_hd_output_rate(self): + return self.hd_output_rate + + def get_audio_rate(self): + if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isDrm(): + return 48000 + elif self.isWsjtMode() or self.isJs8(): + return 12000 + elif self.isFreeDV(): + return 8000 + elif self.isHdAudio(): + return self.get_hd_output_rate() + return self.get_output_rate() + + def isDigitalVoice(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator in ["dmr", "dstar", "nxdn", "ysf", "m17"] + + def isWsjtMode(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4", "fst4", "fst4w", "q65"] + + def isJs8(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator == "js8" + + def isPacket(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator == "packet" + + def isPocsag(self, demodulator=None): + if demodulator is None: + demodulator = self.get_secondary_demodulator() + return demodulator == "pocsag" + + def isFreeDV(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator == "freedv" + + def isHdAudio(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator == "wfm" + + def isDrm(self, demodulator=None): + if demodulator is None: + demodulator = self.get_demodulator() + return demodulator == "drm" + + def set_output_rate(self, output_rate): + if self.output_rate == output_rate: + return + self.output_rate = output_rate + self.calculate_decimation() + self.restart() + + def set_hd_output_rate(self, hd_output_rate): + if self.hd_output_rate == hd_output_rate: + return + self.hd_output_rate = hd_output_rate + self.calculate_decimation() + self.restart() + + def set_demodulator(self, demodulator): + if demodulator in ["usb", "lsb", "cw"]: + demodulator = "ssb" + if self.demodulator == demodulator: + return + self.demodulator = demodulator + self.calculate_decimation() + self.restart() + + def get_demodulator(self): + return self.demodulator + + def set_fft_size(self, fft_size): + if self.fft_size == fft_size: + return + self.fft_size = fft_size + self.restart() + + def set_fft_fps(self, fft_fps): + self.fft_fps = fft_fps + self.restart() + + def set_fft_averages(self, fft_averages): + self.fft_averages = fft_averages + self.restart() + + def fft_block_size(self): + if self.fft_averages == 0: + return self.samp_rate / self.fft_fps + else: + return self.samp_rate / self.fft_fps / self.fft_averages + + def set_offset_freq(self, offset_freq): + if offset_freq is None: + return + self.offset_freq = offset_freq + if self.running: + self.pipes["shift_pipe"].write("%g\n" % (-float(self.offset_freq) / self.samp_rate)) + + def set_center_freq(self, center_freq): + # dsp only needs to know this to be able to pass it to decoders in the form of get_operating_freq() + self.center_freq = center_freq + + def get_operating_freq(self): + return self.center_freq + self.offset_freq + + def set_bandpass(self, bandpass): + self.set_bpf(bandpass.low_cut, bandpass.high_cut) + + def set_bpf(self, low_cut, high_cut): + self.low_cut = low_cut + self.high_cut = high_cut + if self.running: + self.pipes["bpf_pipe"].write( + "%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate()) + ) + + def get_bpf(self): + return [self.low_cut, self.high_cut] + + def convertToLinear(self, db): + return float(math.pow(10, db / 10)) + + def set_squelch_level(self, squelch_level): + self.squelch_level = squelch_level + # no squelch required on digital voice modes + actual_squelch = ( + -150 + if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isFreeDV() or self.isDrm() + else self.squelch_level + ) + if self.running: + self.pipes["squelch_pipe"].write("%g\n" % (self.convertToLinear(actual_squelch))) + + def set_codecserver(self, s): + if self.codecserver == s: + return + self.codecserver = s + self.restart() + + def get_codecserver_arg(self): + return "-s {}".format(self.codecserver) if self.codecserver else "" + + def set_dmr_filter(self, filter): + if self.has_pipe("dmr_control_pipe"): + self.pipes["dmr_control_pipe"].write("{0}\n".format(filter)) + + def set_wfm_deemphasis_tau(self, tau): + if self.wfm_deemphasis_tau == tau: + return + self.wfm_deemphasis_tau = tau + self.restart() + + def ddc_transition_bw(self): + return self.ddc_transition_bw_rate * (self.if_samp_rate() / float(self.samp_rate)) + + def try_create_pipes(self, pipe_names, command_base): + for pipe_name, pipe_type in pipe_names.items(): + if self.has_pipe(pipe_name): + logger.warning("{pipe_name} is still in use", pipe_name=pipe_name) + self.pipes[pipe_name].close() + if "{" + pipe_name + "}" in command_base: + p = self.pipe_base_path + pipe_name + encoding = None + # TODO make digiham output unicode and then change this here + # the whole pipe enoding feature onlye exists because of this + if pipe_name == "meta_pipe": + encoding = "cp437" + self.pipes[pipe_name] = Pipe.create(p, pipe_type, encoding=encoding) + else: + self.pipes[pipe_name] = None + + def has_pipe(self, name): + return name in self.pipes and self.pipes[name] is not None + + def try_delete_pipes(self, pipe_names): + for pipe_name in pipe_names: + if self.has_pipe(pipe_name): + self.pipes[pipe_name].close() + self.pipes[pipe_name] = None + + def try_create_configs(self, command): + if "{direwolf_config}" in command: + self.direwolf_config_path = "{tmp_dir}/openwebrx_direwolf_{myid}.conf".format( + tmp_dir=self.temporary_directory, myid=id(self) + ) + self.direwolf_config = DirewolfConfig() + self.direwolf_config.wire(self) + file = open(self.direwolf_config_path, "w") + file.write(self.direwolf_config.getConfig(self.is_service)) + file.close() + else: + self.direwolf_config = None + self.direwolf_config_path = None + + def try_delete_configs(self): + if self.direwolf_config is not None: + self.direwolf_config.unwire(self) + self.direwolf_config = None + if self.direwolf_config_path is not None: + try: + os.unlink(self.direwolf_config_path) + except FileNotFoundError: + # result suits our expectations. fine :) + pass + except Exception: + logger.exception("try_delete_configs()") + self.direwolf_config_path = None + + def onConfigChanged(self): + self.restart() + + def start(self): + with self.modification_lock: + if self.running: + return + self.running = True + + command_base = " | ".join(self.chain(self.demodulator)) + + # create control pipes for csdr + self.try_create_pipes(self.pipe_names, command_base) + + # send initial config through the pipes + if self.has_pipe("bpf_pipe"): + self.set_bpf(self.low_cut, self.high_cut) + if self.has_pipe("shift_pipe"): + self.set_offset_freq(self.offset_freq) + if self.has_pipe("squelch_pipe"): + self.set_squelch_level(self.squelch_level) + if self.has_pipe("dmr_control_pipe"): + self.set_dmr_filter(3) + + # run the command + command = command_base.format( + bpf_pipe=self.pipes["bpf_pipe"], + shift_pipe=self.pipes["shift_pipe"], + squelch_pipe=self.pipes["squelch_pipe"], + smeter_pipe=self.pipes["smeter_pipe"], + meta_pipe=self.pipes["meta_pipe"], + iqtee_pipe=self.pipes["iqtee_pipe"], + iqtee2_pipe=self.pipes["iqtee2_pipe"], + dmr_control_pipe=self.pipes["dmr_control_pipe"], + decimation=self.decimation, + last_decimation=self.last_decimation, + fft_size=self.fft_size, + fft_block_size=self.fft_block_size(), + fft_averages=self.fft_averages, + bpf_transition_bw=float(self.bpf_transition_bw) / self.if_samp_rate(), + ddc_transition_bw=self.ddc_transition_bw(), + flowcontrol=int(self.samp_rate * 2), + start_bufsize=self.base_bufsize * self.decimation, + nc_port=self.nc_port, + output_rate=self.get_output_rate(), + smeter_report_every=int(self.if_samp_rate() / 6000), + codecserver_arg=self.get_codecserver_arg(), + audio_rate=self.get_audio_rate(), + wfm_deemphasis_tau=self.wfm_deemphasis_tau, + ) + + logger.debug("Command = %s", command) + + out = subprocess.PIPE if self.output.supports_type("audio") else subprocess.DEVNULL + self.process = subprocess.Popen(command, stdout=out, shell=True, start_new_session=True) + + def watch_thread(): + rc = self.process.wait() + logger.debug("dsp thread ended with rc=%d", rc) + if rc == 0 and self.running and not self.modification_lock.locked(): + logger.debug("restarting since rc = 0, self.running = true, and no modification") + self.restart() + + threading.Thread(target=watch_thread, name="csdr_watch_thread").start() + + audio_type = "hd_audio" if self.isHdAudio() else "audio" + if self.output.supports_type(audio_type): + self.output.send_output( + audio_type, + partial( + self.process.stdout.read, + self.get_fft_bytes_to_read() if self.demodulator == "fft" else self.get_audio_bytes_to_read(), + ), + ) + + self.start_secondary_demodulator() + + if self.has_pipe("smeter_pipe"): + + def read_smeter(): + raw = self.pipes["smeter_pipe"].readline() + if len(raw) == 0: + return None + else: + return float(raw.rstrip("\n")) + + self.output.send_output("smeter", read_smeter) + if self.has_pipe("meta_pipe"): + + def read_meta(): + raw = self.pipes["meta_pipe"].readline() + if len(raw) == 0: + return None + else: + return raw.rstrip("\n") + + self.output.send_output("meta", read_meta) + + def stop(self): + with self.modification_lock: + self.running = False + if self.process is not None: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + # drain any leftover data to free file descriptors + self.process.communicate() + self.process = None + except ProcessLookupError: + # been killed by something else, ignore + pass + self.stop_secondary_demodulator() + + self.try_delete_pipes(self.pipe_names) + self.try_delete_configs() + + def restart(self): + if not self.running: + return + self.stop() + self.start() diff --git a/openwebrx/csdr/__pycache__/__init__.cpython-37.pyc b/openwebrx/csdr/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1fed4e930effc229e1697524995411f406de189 GIT binary patch literal 24263 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 literal 0 HcmV?d00001 diff --git a/openwebrx/csdr/__pycache__/output.cpython-37.pyc b/openwebrx/csdr/__pycache__/output.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bc97027e1f810a55abbbb73352c3b3143b6053c GIT binary patch literal 1485 zcmZuxPjA~c6z3ypSykdJ>(WDkHPf)|AfRwAJM2&d#fEJ?6fUq7YiAS?WRc0-D3VIj zOKdnN7icg04#}~frgvU*RQEsmS}NcJu= z&4!@ZkSkF9B^wGQltd|2M+GQ5s;fejfx3a-A|E`OxO(Dpfu^)b21q|3*$2qx?1IhV z66|*lGgE+j4$O(?5;dJUZ*#ol3M2f-3Sjz#~b>}eW^fjH6U@-euO-|}@c@$So z(6~B`-L%&6H#WB8GS~6g#bXgRx2&>~56fMltMjawMJVF`FRGn9J6 z{=TzU)ecFQr+Q@0_HwJtUK_JtPknf=$WBHo+uuZs4@hkGb=!u%)ul?8U*0RKJS*&e zfA#rrn;fMz7LmH&jm}iUec&>4q@9;#cA}He_m-L4luGkKQ5_v=}F%XOuugUS1iD_MtlVH@Z{#5Z9V zB+PF9x43KbrlvOYzS7htMk35j)S1tS?GXFe<0i8C6z{f?EJ2XL?A9{U^%_0~Bmd-q z$dcz*>=higPz}$;Tp&!u8r$TQ>Y?yy+&A2tJ{luJaYBev-x*Dvwq6vvL;wggDZNyg%kUs+M80!s#!k1*Y9=J}s-98< zEir~e36MBMu!Xx_2>9&wvUP+7MBEa3WY>;DwxjqQ&CMNRyTquI2{-Fo3THk+)-;V8 z&S0xI+qmLAZ-#{mlGkTXe;AG(Sh`&gpDxz*7y{T=NUOQ;tPs||+7|nRr0c^prFW60 zK1$P*N=*vdZ>H&sNmjHPbBlZvlA635-)*RW1*LSm6qGO#LKuc3+%|NZbWIP1@6uyy q9vQyKht0z?U!cNlkT$4B&vot^i~kFN|AjWgeQJuOzaU28k@yFX%u1&K literal 0 HcmV?d00001 diff --git a/openwebrx/csdr/__pycache__/pipe.cpython-37.pyc b/openwebrx/csdr/__pycache__/pipe.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d993c0a9f9eb7cb9f75ae9a9dd5538924a55a4c1 GIT binary patch literal 5565 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 Jer^5r{{Xqt9*F<| literal 0 HcmV?d00001 diff --git a/openwebrx/csdr/output.py b/openwebrx/csdr/output.py new file mode 100644 index 0000000..5fef242 --- /dev/null +++ b/openwebrx/csdr/output.py @@ -0,0 +1,36 @@ +import threading +import logging + +logger = logging.getLogger(__name__) + + +class Output(object): + def send_output(self, t, read_fn): + if not self.supports_type(t): + # TODO rewrite the output mechanism in a way that avoids producing unnecessary data + logger.warning("dumping output of type %s since it is not supported.", t) + threading.Thread(target=self.pump(read_fn, lambda x: None), name="csdr_pump_thread").start() + return + self.receive_output(t, read_fn) + + def receive_output(self, t, read_fn): + pass + + def pump(self, read, write): + def copy(): + run = True + while run: + data = None + try: + data = read() + except ValueError: + pass + if data is None or (isinstance(data, bytes) and len(data) == 0): + run = False + else: + write(data) + + return copy + + def supports_type(self, t): + return True diff --git a/openwebrx/csdr/pipe.py b/openwebrx/csdr/pipe.py new file mode 100644 index 0000000..025e287 --- /dev/null +++ b/openwebrx/csdr/pipe.py @@ -0,0 +1,156 @@ +import os +import select +import time +import threading + +import logging + +logger = logging.getLogger(__name__) + + +class Pipe(object): + READ = "r" + WRITE = "w" + NONE = None + + @staticmethod + def create(path, t, encoding=None): + if t == Pipe.READ: + return ReadingPipe(path, encoding=encoding) + elif t == Pipe.WRITE: + return WritingPipe(path, encoding=encoding) + elif t == Pipe.NONE: + return Pipe(path, None, encoding=encoding) + + def __init__(self, path, direction, encoding=None): + self.doOpen = True + self.path = "{base}_{myid}".format(base=path, myid=id(self)) + self.direction = direction + self.encoding = encoding + self.file = None + os.mkfifo(self.path) + + def open(self): + """ + this method opens the file descriptor with an added O_NONBLOCK flag. This gives us a special behaviour for + FIFOS, when they are not opened by the opposing side: + + - opening a pipe for writing will throw an OSError with errno = 6 (ENXIO). This is handled specially in the + WritingPipe class. + - opening a pipe for reading will pass through this method instantly, even if the opposing end has not been + opened yet, but the resulting file descriptor will behave as if O_NONBLOCK is set (even if we remove it + immediately here), resulting in empty reads until data is available. This is handled specially in the + ReadingPipe class. + """ + + def opener(path, flags): + fd = os.open(path, flags | os.O_NONBLOCK) + os.set_blocking(fd, True) + return fd + + self.file = open(self.path, self.direction, encoding=self.encoding, opener=opener) + + def close(self): + self.doOpen = False + try: + if self.file is not None: + self.file.close() + os.unlink(self.path) + except FileNotFoundError: + # it seems like we keep calling this twice. no idea why, but we don't need the resulting error. + pass + except Exception: + logger.exception("Pipe.close()") + + def __str__(self): + return self.path + + +class WritingPipe(Pipe): + def __init__(self, path, encoding=None): + self.queue = [] + self.queueLock = threading.Lock() + super().__init__(path, "w", encoding=encoding) + self.open() + + def open_and_dequeue(self): + """ + This method implements a retry loop that can be interrupted in case the Pipe gets shutdown before actually + being connected. + + After the pipe is opened successfully, all data that has been queued is sent in the order it was passed into + write(). + """ + retries = 0 + + while self.file is None and self.doOpen and retries < 10: + try: + super().open() + except OSError as error: + # ENXIO = FIFO has not been opened for reading + if error.errno == 6: + time.sleep(0.1) + retries += 1 + else: + raise + + # if doOpen is false, opening has been canceled, so no warning in that case. + if self.file is None: + if self.doOpen: + logger.warning("could not open FIFO %s", self.path) + return + + with self.queueLock: + for i in self.queue: + self.file.write(i) + self.file.flush() + self.queue = None + + def open(self): + """ + This sends the opening operation off to a background thread. If we were to block the thread here, another pipe + may be waiting in the queue to be opened on the opposing side, resulting in a deadlock + """ + threading.Thread(target=self.open_and_dequeue, name="csdr_pipe_thread").start() + + def write(self, data): + """ + This method queues all data to be written until the file is actually opened. As soon as a file is available, + it becomes a passthrough. + """ + if self.file is None: + with self.queueLock: + self.queue.append(data) + return + r = self.file.write(data) + self.file.flush() + return r + + +class ReadingPipe(Pipe): + def __init__(self, path, encoding=None): + super().__init__(path, "r", encoding=encoding) + + def open(self): + """ + This method implements an interruptible loop that waits for the file descriptor to be opened and the first + batch of data coming in using repeated select() calls. + :return: + """ + if not self.doOpen: + return + super().open() + while self.doOpen: + (read, _, _) = select.select([self.file], [], [], 1) + if self.file in read: + break + + def read(self): + if self.file is None: + self.open() + return self.file.read() + + def readline(self): + if self.file is None: + self.open() + return self.file.readline() diff --git a/openwebrx/debian/changelog b/openwebrx/debian/changelog new file mode 100644 index 0000000..fda5286 --- /dev/null +++ b/openwebrx/debian/changelog @@ -0,0 +1,217 @@ +openwebrx (1.1.0) buster hirsute; urgency=low + + * Reworked most graphical elements as SVGs for faster loadtimes and crispier + display on hi-dpi displays + * Updated pipelines to match changes in digiham + * Changed D-Star and NXDN integrations to use new decoder from digiham + * Added D-Star and NXDN metadata display + + -- Jakob Ketterl Mon, 02 Aug 2021 16:24:00 +0000 + +openwebrx (1.0.0) buster hirsute; urgency=low + * Introduced `squelch_auto_margin` config option that allows configuring the + auto squelch level + * Removed `port` configuration option; `rtltcp_compat` takes the port number + with the new connectors + * Added support for new WSJT-X modes FST4, FST4W (only available with WSJT-X + 2.3) and Q65 (only available with WSJT-X 2.4) + * Added support for demodulating M17 digital voice signals using + m17-cxx-demod + * New reporting infrastructure, allowing WSPR and FST4W spots to be sent to + wsprnet.org + * Add some basic filtering capabilities to the map + * New arguments to the `openwebrx` command-line to facilitate the + administration of users (try `openwebrx admin`) + * New command-line tool `openwebrx-admin` that facilitates the + administration of users + * Default bandwidth changes: + - "WFM" changed to 150kHz + - "Packet" (APRS) changed to 12.5kHz + * Configuration rework: + - New: fully web-based configuration interface + - System configuration parameters have been moved to a new, separate + `openwebrx.conf` file + - Remaining parameters are now editable in the web configuration + - Existing `config_webrx.py` files will still be read, but changes made in + the web configuration will be written to a new storage system + - Added upload of avatar and panorama image via web configuration + * New devices supported: + - HPSDR devices (Hermes Lite 2) thanks to @jancona + - BBRF103 / RX666 / RX888 devices supported by libsddc + - R&S devices using the EB200 or Ammos protocols + + -- Jakob Ketterl Thu, 06 May 2021 17:22:00 +0000 + +openwebrx (0.20.3) buster focal; urgency=low + + * Fix a compatibility issue with python versions <= 3.6 + + -- Jakob Ketterl Tue, 26 Jan 2021 15:28:00 +0000 + +openwebrx (0.20.2) buster focal; urgency=high + + * Fix a security problem that allowed arbitrary commands to be executed on + the receiver (See github issue #215: + https://github.com/jketterl/openwebrx/issues/215) + + -- Jakob Ketterl Sun, 24 Jan 2021 22:50:00 +0000 + +openwebrx (0.20.1) buster focal; urgency=low + + * Remove broken OSM map fallback + + -- Jakob Ketterl Mon, 30 Nov 2020 17:29:00 +0000 + +openwebrx (0.20.0) buster focal; urgency=low + + * Added the ability to sign multiple keys in a single request, thus enabling + multiple users to claim a single receiver on receiverbook.de + * Fixed file descriptor leaks to prevent "too many open files" errors + * Add new demodulator chain for FreeDV + * Added new HD audio streaming mode along with a new WFM demodulator + * Reworked AGC code for better results in AM, SSB and digital modes + * Added support for demodulation of "Digital Radio Mondiale" (DRM) broadcast + using the "dream" decoder. + * New default waterfall color scheme + * Prototype of a continuous automatic waterfall calibration mode + * New devices supported: + - FunCube Dongle Pro+ (`"type": "fcdpp"`) + - Support for connections to rtl_tcp (`"type": "rtl_tcp"`) + + -- Jakob Ketterl Sun, 11 Oct 2020 13:02:00 +0000 + +openwebrx (0.19.1) buster focal; urgency=low + + * Added ability to authenticate receivers with listing sites using + "receiver id" tokens + + -- Jakob Ketterl Sat, 13 Jun 2020 16:46:00 +0000 + +openwebrx (0.19.0) buster focal; urgency=low + * Fix direwolf connection setup by implementing a retry loop + * Pass direct sampling mode changes for rtl_sdr_soapy to owrx_connector + * OSM maps instead of Google when google_maps_api_key is not set (thanks + @jquagga) + * Improved logic to pass parameters to soapy devices. + - `rtl_sdr_soapy`: added support for `bias_tee` + - `sdrplay`: added support for `bias_tee`, `rf_notch` and `dab_notch` + - `airspy`: added support for `bitpack` + * Added support for Perseus-SDR devices, (thanks @amontefusco) + * Property System has been rewritten so that defaults on sdr behave as + expected + * Waterfall range auto-adjustment now only takes the center 80% of the + spectrum into account, which should work better with SDRs that oversample + or have rather flat filter curves towards the spectrum edges + * Bugfix for negative network usage + * FiFi SDR: prevent arecord from shutting down after 2GB of data has been + sent + * Added support for bias tee control on rtl_sdr devices + * All connector driven SDRs now support `"rf_gain": "auto"` to enable AGC + * `rtl_sdr` type now also supports the `direct_sampling` option + * Added decoding implementation for for digimode "JS8Call" (requires an + installation of js8call and the js8py library) + * Reorganization of the frontend demodulator code + * Improve receiver load time by concatenating javascript assets + * HackRF support is now based on SoapyHackRF + * Removed sdr.hu server listing support since the site has been shut down + * Added support for Radioberry 2 Rasbperry Pi SDR Cape + + -- Jakob Ketterl Mon, 01 Jun 2020 17:02:00 +0000 + +openwebrx (0.18.0) buster; urgency=low + + * Compression, resampling and filtering in the frontend have been rewritten + in javascript, sdr.js has been removed + * Decoding of Pocsag modulation is now possible + * Removed the 3D waterfall since it had no real application and required ~1MB + of javascript code to be downloaded + * Improved the frontend handling of the "too many users" scenario + * PSK63 digimode is now available (same decoding pipeline as PSK31, but with + adopted parameters) + * The frequency can now be manipulated with the mousewheel, which should + allow the user to tune more precise. The tuning step size is determined by + the digit the mouse cursor is hovering over. + * Clicking on the frequency now opens an input for direct frequency selection + * URL hashes have been fixed and improved: They are now updated + automatically, so a shared URL will include frequency and demodulator, + which allows for improved sharing and linking. + * New daylight scheduler for background decoding, allows profiles to be + selected by local sunrise / sunset times + * The owrx_connector is now the default way of communicating with sdr + devices. The old sdr types have been replaced, all `_connector` suffixes on + the type must be removed! + * The sources have been refactored, making it a lot easier to add support for + other devices + * SDR device failure handling has been improved, including user feedback + * New devices supported: + * wsjt-x updated to 2.1.2 + * The rtl_tcp compatibility mode of the owrx_connector is now configurable + using the `rtltcp_compat` flag + * explicit device filter for soapy devices for multi-device setups + * compatibility fixes for safari browsers (ios and mac) + * Offset tuning using the `lfo_offset` has been reworked in a way that + `center_freq` has to be set to the frequency you actually want to listen + to. If you're using an `lfo_offset` already, you will probably need to + change its sign. + * `initial_squelch_level` can now be set on each profile. + * Part of the frontend code has been reworked + - Audio buffer minimums have been completely stripped. As a result, you + should get better latency. Unfortunately, this also means there will be + some skipping when audio starts. + - Now also supports AudioWorklets (for those browser that have it). + - Mousewheel controls for the receiver sliders + * Error handling for failed SDR devices + * One of the most-requested features is finally coming to OpenWebRX: + Bookmarks (sometimes also referred to as labels). + There's two kinds of bookmarks available: + - Serverside bookmarks that are set up by the receiver administrator. + Check the file `bookmarks.json` for examples! + - Clientside bookmarks which every user can store for themselves. They are + stored in the browser's localStorage. + * Automatic reporting of spots to [pskreporter](https://pskreporter.info/) is + now possible. Please have a look at the configuration on how to set it up. + * Websocket communication has been overhauled in large parts. It should now + be more reliable, and failing connections should now have no impact on + other users. + * Profile scheduling allows to set up band-hopping if you are running + background services. + * APRS now has the ability to show symbols on the map, if a corresponding + symbol set has been installed. Check the config! + * Debug logging has been disabled in a handful of modules, expect vastly + reduced output on the shell. + * New set of APRS-related features + - Decode Packet transmissions using direwolf (1k2 only for now) + - APRS packets are mostly decoded and shown both in a new panel and on the + map + - APRS is also available as a background service + - direwolfs I-gate functionality can be enabled, which allows your receiver + to work as a receive-only I-gate for the APRS network in the background + * Demodulation for background services has been optimized to use less total + bandwidth, saving CPU + * More metrics have been added; they can be used together with collectd and + its curl_json plugin for now, with some limitations. + * New bandplan feature, the first thing visible is the "dial" indicator that + brings you right to the dial frequency for digital modes + * fixed some bugs in the websocket communication which broke the map + * WSJT-X integration (FT8, FT4, WSPR, JT65, JT9 using wsjt-x demodulators) + * New Map Feature that shows both decoded grid squares from FT8 and Locations + decoded from YSF digital voice + * New Feature report that will show what functionality is available + * major rework on the openwebrx core + * Support of multiple SDR devices simultaneously + * Support for multiple profiles per SDR that allow the user to listen to + different frequencies + * Support for digital voice decoding + * Feature detection that will disable functionality when dependencies are not + available (if you're missing the digital + buttons, this is probably why) + * Support added for the following SDR sources: + - LimeSDR (`"type": "lime_sdr"`) + - PlutoSDR (`"type": "pluto_sdr"`) + - RTL_SDR via Soapy (`"type": "rtl_sdr_soapy"`) on special request to allow + use of the direct sampling mode + - SoapyRemote (`"type": "soapy_remote"`) + - FiFiSDR (`"type": "fifi_sdr"`) + - airspyhf devices (Airspy HF+ / Discovery) (`"type": "airspyhf"`) + + -- Jakob Ketterl Tue, 18 Feb 2020 20:09:00 +0000 diff --git a/openwebrx/debian/compat b/openwebrx/debian/compat new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/openwebrx/debian/compat @@ -0,0 +1 @@ +10 diff --git a/openwebrx/debian/control b/openwebrx/debian/control new file mode 100644 index 0000000..6b274ba --- /dev/null +++ b/openwebrx/debian/control @@ -0,0 +1,16 @@ +Source: openwebrx +Maintainer: Jakob Ketterl +Section: hamradio +Priority: optional +Standards-Version: 4.2.0 +Build-Depends: debhelper (>= 11), dh-python, python3-all (>= 3.5), python3-setuptools +Homepage: https://www.openwebrx.de/ +Vcs-Browser: https://github.com/jketterl/openwebrx +Vcs-Git: https://github.com/jketterl/openwebrx.git + +Package: openwebrx +Architecture: all +Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.17), netcat, owrx-connector (>= 0.5), soapysdr-tools, python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends} +Recommends: digiham (>= 0.5), sox, direwolf (>= 1.4), wsjtx, runds-connector (>= 0.2), hpsdrconnector, aprs-symbols, m17-demod, js8call +Description: multi-user web sdr + Open source, multi-user SDR receiver with a web interface diff --git a/openwebrx/debian/openwebrx.config b/openwebrx/debian/openwebrx.config new file mode 100644 index 0000000..9a19393 --- /dev/null +++ b/openwebrx/debian/openwebrx.config @@ -0,0 +1,8 @@ +#!/bin/sh -e +. /usr/share/debconf/confmodule + +db_get openwebrx/admin_user_configured +if [ "${1:-}" = "reconfigure" ] || [ "${RET}" != true ]; then + db_input high openwebrx/admin_user_password || true + db_go +fi diff --git a/openwebrx/debian/openwebrx.dirs b/openwebrx/debian/openwebrx.dirs new file mode 100644 index 0000000..c87b1b2 --- /dev/null +++ b/openwebrx/debian/openwebrx.dirs @@ -0,0 +1 @@ +/etc/openwebrx/openwebrx.conf.d \ No newline at end of file diff --git a/openwebrx/debian/openwebrx.install b/openwebrx/debian/openwebrx.install new file mode 100644 index 0000000..8db9344 --- /dev/null +++ b/openwebrx/debian/openwebrx.install @@ -0,0 +1,3 @@ +bands.json etc/openwebrx/ +openwebrx.conf etc/openwebrx/ +systemd/openwebrx.service lib/systemd/system/ \ No newline at end of file diff --git a/openwebrx/debian/openwebrx.postinst b/openwebrx/debian/openwebrx.postinst new file mode 100644 index 0000000..935a0fe --- /dev/null +++ b/openwebrx/debian/openwebrx.postinst @@ -0,0 +1,59 @@ +#!/bin/bash +. /usr/share/debconf/confmodule + +set -euo pipefail + +OWRX_USER="openwebrx" +OWRX_DATADIR="/var/lib/openwebrx" +OWRX_USERS_FILE="${OWRX_DATADIR}/users.json" +OWRX_SETTINGS_FILE="${OWRX_DATADIR}/settings.json" +OWRX_BOOKMARKS_FILE="${OWRX_DATADIR}/bookmarks.json" + +case "$1" in + configure|reconfigure) + adduser --system --group --no-create-home --home /nonexistent --quiet "${OWRX_USER}" + usermod -aG plugdev "${OWRX_USER}" + + # create OpenWebRX data directory and set the correct permissions + if [ ! -d "${OWRX_DATADIR}" ] && [ ! -L "${OWRX_DATADIR}" ]; then mkdir "${OWRX_DATADIR}"; fi + chown "${OWRX_USER}". ${OWRX_DATADIR} + + # create empty config files now to avoid permission problems later + if [ ! -e "${OWRX_USERS_FILE}" ]; then + echo "[]" > "${OWRX_USERS_FILE}" + chown "${OWRX_USER}". "${OWRX_USERS_FILE}" + chmod 0600 "${OWRX_USERS_FILE}" + fi + + if [ ! -e "${OWRX_SETTINGS_FILE}" ]; then + echo "{}" > "${OWRX_SETTINGS_FILE}" + chown "${OWRX_USER}". "${OWRX_SETTINGS_FILE}" + fi + + if [ ! -e "${OWRX_BOOKMARKS_FILE}" ]; then + touch "${OWRX_BOOKMARKS_FILE}" + chown "${OWRX_USER}". "${OWRX_BOOKMARKS_FILE}" + fi + + db_get openwebrx/admin_user_password + if [ ! -z "${RET}" ]; then + if ! openwebrx admin --silent hasuser admin; then + # create initial openwebrx user + OWRX_PASSWORD="${RET}" openwebrx admin --noninteractive adduser admin + else + # change existing user's password + OWRX_PASSWORD="${RET}" openwebrx admin --noninteractive resetpassword admin + fi + fi + # remove password from debconf database + db_unregister openwebrx/admin_user_password + # set a marker that admin is configured to avoid future questions + db_set openwebrx/admin_user_configured true + ;; + *) + echo "postinst called with unknown argument '$1'" 1>&2 + exit 1 + ;; +esac + +#DEBHELPER# diff --git a/openwebrx/debian/openwebrx.postrm b/openwebrx/debian/openwebrx.postrm new file mode 100644 index 0000000..9260b8e --- /dev/null +++ b/openwebrx/debian/openwebrx.postrm @@ -0,0 +1,8 @@ +#!/bin/sh -e + +if [ "$1" = purge ] && [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_purge +fi + +#DEBHELPER# diff --git a/openwebrx/debian/openwebrx.templates b/openwebrx/debian/openwebrx.templates new file mode 100644 index 0000000..8ef7e84 --- /dev/null +++ b/openwebrx/debian/openwebrx.templates @@ -0,0 +1,23 @@ +Template: openwebrx/admin_user_password +Type: password +Description: OpenWebRX "admin" user password: + The system can create a user for the OpenWebRX web configuration interface for + you. Using this user, you will be able to log into the "settings" area of + OpenWebRX to configure your receiver conveniently through your browser. + . + The name of the created user will be "admin". + . + If you do not wish to create a web admin user right now, you can leave this + empty for now. You can return to this prompt at a later time by running the + command "sudo dpkg-reconfigure openwebrx". + . + You can also use the "openwebrx admin" command to create, delete or manage + existing users. More information is available in by running the command + "openwebrx admin --help". + +Template: openwebrx/admin_user_configured +Type: boolean +Default: false +Description: OpenWebRX "admin" user previously configured? + Marker used internally by the config scripts to remember if an admin user has + been created. \ No newline at end of file diff --git a/openwebrx/debian/rules b/openwebrx/debian/rules new file mode 100644 index 0000000..3b7418e --- /dev/null +++ b/openwebrx/debian/rules @@ -0,0 +1,8 @@ +#!/usr/bin/make -f +export PYBUILD_NAME=openwebrx + +%: + dh $@ --with python3 --buildsystem=pybuild --with systemd + +override_dh_strip_nondeterminism: + dh_strip_nondeterminism -X.png diff --git a/openwebrx/debian/source/format b/openwebrx/debian/source/format new file mode 100644 index 0000000..9f67427 --- /dev/null +++ b/openwebrx/debian/source/format @@ -0,0 +1 @@ +3.0 (native) \ No newline at end of file diff --git a/openwebrx/docker.sh b/openwebrx/docker.sh new file mode 100644 index 0000000..c2bfe7f --- /dev/null +++ b/openwebrx/docker.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCH=$(uname -m) +IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-plutosdr openwebrx-limesdr openwebrx-soapyremote openwebrx-perseus openwebrx-fcdpp openwebrx-radioberry openwebrx-uhd openwebrx-rtltcp openwebrx-runds openwebrx-hpsdr openwebrx-full openwebrx" +ALL_ARCHS="x86_64 armv7l aarch64" +TAG=${TAG:-"latest"} +ARCHTAG="${TAG}-${ARCH}" + +usage () { + echo "Usage: ${0} [command]" + echo "Available commands:" + echo " help Show this usage information" + echo " build Build all docker images" + echo " push Push built docker images to the docker hub" + echo " manifest Compile the docker hub manifest (combines arm and x86 tags into one)" + echo " tag Tag a release" +} + +build () { + # build the base images + docker build --pull -t openwebrx-base:${ARCHTAG} -f docker/Dockerfiles/Dockerfile-base . + docker build --build-arg ARCHTAG=${ARCHTAG} -t openwebrx-soapysdr-base:${ARCHTAG} -f docker/Dockerfiles/Dockerfile-soapysdr . + + for image in ${IMAGES}; do + i=${image:10} + # "openwebrx" is a special image that gets tag-aliased later on + if [[ ! -z "${i}" ]] ; then + docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/${image}:${ARCHTAG} -f docker/Dockerfiles/Dockerfile-${i} . + fi + done + + # tag openwebrx alias image + docker tag jketterl/openwebrx-full:${ARCHTAG} jketterl/openwebrx:${ARCHTAG} +} + +push () { + for image in ${IMAGES}; do + docker push jketterl/${image}:${ARCHTAG} + done +} + +manifest () { + for image in ${IMAGES}; do + # there's no docker manifest rm command, and the create --amend does not work, so we have to clean up manually + rm -rf "${HOME}/.docker/manifests/docker.io_jketterl_${image}-${TAG}" + IMAGE_LIST="" + for a in ${ALL_ARCHS}; do + IMAGE_LIST="${IMAGE_LIST} jketterl/${image}:${TAG}-${a}" + done + docker manifest create jketterl/${image}:${TAG} ${IMAGE_LIST} + docker manifest push --purge jketterl/${image}:${TAG} + done +} + +tag () { + if [[ -x ${1:-} || -z ${2:-} ]] ; then + echo "Usage: ${0} tag [SRC_TAG] [TARGET_TAG]" + return + fi + + local SRC_TAG=${1} + local TARGET_TAG=${2} + + for image in ${IMAGES}; do + # there's no docker manifest rm command, and the create --amend does not work, so we have to clean up manually + rm -rf "${HOME}/.docker/manifests/docker.io_jketterl_${image}-${TARGET_TAG}" + IMAGE_LIST="" + for a in ${ALL_ARCHS}; do + docker pull jketterl/${image}:${SRC_TAG}-${a} + docker tag jketterl/${image}:${SRC_TAG}-${a} jketterl/${image}:${TARGET_TAG}-${a} + docker push jketterl/${image}:${TARGET_TAG}-${a} + IMAGE_LIST="${IMAGE_LIST} jketterl/${image}:${TARGET_TAG}-${a}" + done + docker manifest create jketterl/${image}:${TARGET_TAG} ${IMAGE_LIST} + docker manifest push --purge jketterl/${image}:${TARGET_TAG} + docker pull jketterl/${image}:${TARGET_TAG} + done +} + +case ${1:-} in + build) + build + ;; + push) + push + ;; + manifest) + manifest + ;; + tag) + tag ${@:2} + ;; + *) + usage + ;; +esac \ No newline at end of file diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-airspy b/openwebrx/docker/Dockerfiles/Dockerfile-airspy new file mode 100644 index 0000000..94b348b --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-airspy @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-airspy.sh / +RUN /install-dependencies-airspy.sh &&\ + rm /install-dependencies-airspy.sh + +ADD . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-base b/openwebrx/docker/Dockerfiles/Dockerfile-base new file mode 100644 index 0000000..6fd184f --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-base @@ -0,0 +1,28 @@ +FROM debian:buster-slim + +COPY docker/files/js8call/js8call-hamlib.patch \ + docker/files/wsjtx/wsjtx.patch \ + docker/files/wsjtx/wsjtx-hamlib.patch \ + docker/files/dream/dream.patch \ + docker/files/direwolf/direwolf-hamlib.patch \ + docker/scripts/install-dependencies.sh / +RUN /install-dependencies.sh && \ + rm /install-dependencies.sh && \ + rm /*.patch +COPY docker/scripts/install-owrx-tools.sh / +RUN /install-owrx-tools.sh && \ + rm /install-owrx-tools.sh + +COPY docker/files/services/codecserver /etc/services.d/codecserver + +ENTRYPOINT ["/init"] + +WORKDIR /opt/openwebrx + +VOLUME /etc/openwebrx +VOLUME /var/lib/openwebrx + +ENV S6_CMD_ARG0="/opt/openwebrx/docker/scripts/run.sh" +CMD [] + +EXPOSE 8073 diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-fcdpp b/openwebrx/docker/Dockerfiles/Dockerfile-fcdpp new file mode 100644 index 0000000..3e28ac7 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-fcdpp @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-fcdpp.sh / +RUN /install-dependencies-fcdpp.sh &&\ + rm /install-dependencies-fcdpp.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-full b/openwebrx/docker/Dockerfiles/Dockerfile-full new file mode 100644 index 0000000..cecf45a --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-full @@ -0,0 +1,30 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-*.sh \ + docker/files/sdrplay/install-lib.*.patch \ + docker/scripts/install-connectors.sh / + +RUN /install-dependencies-rtlsdr.sh &&\ + /install-dependencies-soapysdr.sh &&\ + /install-dependencies-hackrf.sh &&\ + /install-dependencies-sdrplay.sh &&\ + /install-dependencies-airspy.sh &&\ + /install-dependencies-rtlsdr-soapy.sh &&\ + /install-dependencies-plutosdr.sh &&\ + /install-dependencies-limesdr.sh &&\ + /install-dependencies-soapyremote.sh &&\ + /install-dependencies-perseus.sh &&\ + /install-dependencies-fcdpp.sh &&\ + /install-dependencies-radioberry.sh &&\ + /install-dependencies-uhd.sh &&\ + /install-dependencies-hpsdr.sh &&\ + /install-connectors.sh &&\ + /install-dependencies-runds.sh &&\ + rm /install-dependencies-*.sh &&\ + rm /install-lib.*.patch && \ + rm /install-connectors.sh + +COPY docker/files/services/sdrplay /etc/services.d/sdrplay + +ADD . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-hackrf b/openwebrx/docker/Dockerfiles/Dockerfile-hackrf new file mode 100644 index 0000000..6dab0f1 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-hackrf @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-hackrf.sh / +RUN /install-dependencies-hackrf.sh &&\ + rm /install-dependencies-hackrf.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-hpsdr b/openwebrx/docker/Dockerfiles/Dockerfile-hpsdr new file mode 100644 index 0000000..96d58b9 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-hpsdr @@ -0,0 +1,9 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-hpsdr.sh / + +RUN /install-dependencies-hpsdr.sh &&\ + rm /install-dependencies-hpsdr.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-limesdr b/openwebrx/docker/Dockerfiles/Dockerfile-limesdr new file mode 100644 index 0000000..9603c60 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-limesdr @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-limesdr.sh / +RUN /install-dependencies-limesdr.sh &&\ + rm /install-dependencies-limesdr.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-perseus b/openwebrx/docker/Dockerfiles/Dockerfile-perseus new file mode 100644 index 0000000..bc16583 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-perseus @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-perseus.sh / +RUN /install-dependencies-perseus.sh &&\ + rm /install-dependencies-perseus.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-plutosdr b/openwebrx/docker/Dockerfiles/Dockerfile-plutosdr new file mode 100644 index 0000000..4a263e8 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-plutosdr @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-plutosdr.sh / +RUN /install-dependencies-plutosdr.sh &&\ + rm /install-dependencies-plutosdr.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-radioberry b/openwebrx/docker/Dockerfiles/Dockerfile-radioberry new file mode 100644 index 0000000..3cbe978 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-radioberry @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-radioberry.sh / +RUN /install-dependencies-radioberry.sh &&\ + rm /install-dependencies-radioberry.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr new file mode 100644 index 0000000..6144641 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr @@ -0,0 +1,12 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-rtlsdr.sh \ + docker/scripts/install-connectors.sh / + +RUN /install-dependencies-rtlsdr.sh &&\ + rm /install-dependencies-rtlsdr.sh &&\ + /install-connectors.sh &&\ + rm /install-connectors.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy new file mode 100644 index 0000000..5dce90f --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-rtlsdr-soapy @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-rtlsdr-soapy.sh / +RUN /install-dependencies-rtlsdr-soapy.sh &&\ + rm /install-dependencies-rtlsdr-soapy.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-rtltcp b/openwebrx/docker/Dockerfiles/Dockerfile-rtltcp new file mode 100644 index 0000000..240799d --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-rtltcp @@ -0,0 +1,9 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-connectors.sh / + +RUN /install-connectors.sh &&\ + rm /install-connectors.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-runds b/openwebrx/docker/Dockerfiles/Dockerfile-runds new file mode 100644 index 0000000..2a087e1 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-runds @@ -0,0 +1,12 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-connectors.sh \ + docker/scripts/install-dependencies-runds.sh / + +RUN /install-connectors.sh &&\ + rm /install-connectors.sh && \ + /install-dependencies-runds.sh && \ + rm /install-dependencies-runds.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-sdrplay b/openwebrx/docker/Dockerfiles/Dockerfile-sdrplay new file mode 100644 index 0000000..bb53d7e --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-sdrplay @@ -0,0 +1,12 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-sdrplay.sh \ + docker/files/sdrplay/install-lib.*.patch / +RUN /install-dependencies-sdrplay.sh &&\ + rm /install-dependencies-sdrplay.sh &&\ + rm /install-lib.*.patch + +COPY docker/files/services/sdrplay /etc/services.d/sdrplay + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-soapyremote b/openwebrx/docker/Dockerfiles/Dockerfile-soapyremote new file mode 100644 index 0000000..e5c207c --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-soapyremote @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-soapyremote.sh / +RUN /install-dependencies-soapyremote.sh &&\ + rm /install-dependencies-soapyremote.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-soapysdr b/openwebrx/docker/Dockerfiles/Dockerfile-soapysdr new file mode 100644 index 0000000..45ac693 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-soapysdr @@ -0,0 +1,9 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-soapysdr.sh \ + docker/scripts/install-connectors.sh / +RUN /install-dependencies-soapysdr.sh &&\ + rm /install-dependencies-soapysdr.sh &&\ + /install-connectors.sh &&\ + rm /install-connectors.sh diff --git a/openwebrx/docker/Dockerfiles/Dockerfile-uhd b/openwebrx/docker/Dockerfiles/Dockerfile-uhd new file mode 100644 index 0000000..ae1e758 --- /dev/null +++ b/openwebrx/docker/Dockerfiles/Dockerfile-uhd @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +COPY docker/scripts/install-dependencies-uhd.sh / +RUN /install-dependencies-uhd.sh &&\ + rm /install-dependencies-uhd.sh + +COPY . /opt/openwebrx diff --git a/openwebrx/docker/files/direwolf/direwolf-hamlib.patch b/openwebrx/docker/files/direwolf/direwolf-hamlib.patch new file mode 100644 index 0000000..2347c24 --- /dev/null +++ b/openwebrx/docker/files/direwolf/direwolf-hamlib.patch @@ -0,0 +1,20 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 9e710f5..da90b43 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -257,13 +257,8 @@ else() + set(GPSD_LIBRARIES "") + endif() + +-find_package(hamlib) +-if(HAMLIB_FOUND) +- set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DUSE_HAMLIB") +-else() +- set(HAMLIB_INCLUDE_DIRS "") +- set(HAMLIB_LIBRARIES "") +-endif() ++set(HAMLIB_INCLUDE_DIRS "") ++set(HAMLIB_LIBRARIES "") + + if(LINUX) + find_package(ALSA REQUIRED) diff --git a/openwebrx/docker/files/dream/dream.patch b/openwebrx/docker/files/dream/dream.patch new file mode 100644 index 0000000..9a7a8e2 --- /dev/null +++ b/openwebrx/docker/files/dream/dream.patch @@ -0,0 +1,96 @@ +--- dream.pro.org 2020-09-04 22:51:51.579926191 +0200 ++++ dream.pro 2020-09-04 22:52:57.609434707 +0200 +@@ -70,9 +70,6 @@ + exists(/opt/local/include/speex/speex_preprocess.h) { + CONFIG += speexdsp + } +- exists(/opt/local/include/hamlib/rig.h) { +- CONFIG += hamlib +- } + contains(QT_VERSION, ^4\\.7.*) { + QT += phonon opengl svg + DEFINES -= QWT_NO_SVG +@@ -138,12 +135,6 @@ + packagesExist(sndfile) { + CONFIG += sndfile + } +- packagesExist(hamlib) { +- CONFIG += hamlib +- } +- packagesExist(gpsd) { +- CONFIG += gps +- } + packagesExist(pcap) { + CONFIG += pcap + } +@@ -159,14 +150,6 @@ + exists(/usr/local/include/sndfile.h) { + CONFIG += sndfile + } +- exists(/usr/include/hamlib/rig.h) | \ +- exists(/usr/local/include/hamlib/rig.h) { +- CONFIG += hamlib +- } +- exists(/usr/include/gps.h) | \ +- exists(/usr/local/include/gps.h) { +- CONFIG += gps +- } + exists(/usr/include/pcap.h) | \ + exists(/usr/local/include/pcap.h) { + CONFIG += pcap +@@ -194,9 +177,6 @@ + exists($$OUT_PWD/include/speex/speex_preprocess.h) { + CONFIG += speexdsp + } +- exists($$OUT_PWD/include/hamlib/rig.h) { +- CONFIG += hamlib +- } + exists($$OUT_PWD/include/pcap.h) { + CONFIG += pcap + } +@@ -225,7 +205,7 @@ + LIBS += -lz + } + } +-exists($$OUT_PWD/include/neaacdec.h) { ++exists(/usr/include/neaacdec.h) { + DEFINES += HAVE_LIBFAAD \ + USE_FAAD2_LIBRARY + LIBS += -lfaad_drm +@@ -257,11 +237,6 @@ + win32:LIBS += libspeexdsp.lib + message("with libspeexdsp") + } +-gps { +- DEFINES += HAVE_LIBGPS +- unix:LIBS += -lgps +- message("with gps") +-} + pcap { + DEFINES += HAVE_LIBPCAP + unix:LIBS += -lpcap +@@ -269,24 +244,6 @@ + win32-g++:LIBS += -lwpcap -lpacket + message("with pcap") + } +-hamlib { +- DEFINES += HAVE_LIBHAMLIB +- macx:LIBS += -framework IOKit +- unix:LIBS += -lhamlib +- win32:LIBS += libhamlib-2.lib +- HEADERS += src/util/Hamlib.h +- SOURCES += src/util/Hamlib.cpp +- qt { +- HEADERS += src/util-QT/Rig.h +- SOURCES += src/util-QT/Rig.cpp +- } +- gui { +- HEADERS += src/GUI-QT/RigDlg.h +- SOURCES += src/GUI-QT/RigDlg.cpp +- FORMS += RigDlg.ui +- } +- message("with hamlib") +-} + qwt { + DEFINES += QWT_NO_SVG + macx { diff --git a/openwebrx/docker/files/js8call/js8call-hamlib.patch b/openwebrx/docker/files/js8call/js8call-hamlib.patch new file mode 100644 index 0000000..899f83e --- /dev/null +++ b/openwebrx/docker/files/js8call/js8call-hamlib.patch @@ -0,0 +1,151 @@ +diff -ur js8call-orig/CMake/Modules/Findhamlib.cmake js8call/CMake/Modules/Findhamlib.cmake +--- js8call-orig/CMake/Modules/Findhamlib.cmake 2020-07-22 18:14:18.014499840 +0200 ++++ js8call/CMake/Modules/Findhamlib.cmake 2020-07-22 18:16:07.200375473 +0200 +@@ -78,4 +78,4 @@ + # Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to + # TRUE if all listed variables are TRUE + include (FindPackageHandleStandardArgs) +-find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS) ++find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES) +diff -ur js8call-orig/CMakeLists.txt js8call/CMakeLists.txt +--- js8call-orig/CMakeLists.txt 2020-07-22 18:14:18.014499840 +0200 ++++ js8call/CMakeLists.txt 2020-07-22 18:17:55.629633825 +0200 +@@ -558,7 +558,7 @@ + # + # libhamlib setup + # +-set (hamlib_STATIC 1) ++set (hamlib_STATIC 0) + find_package (hamlib 3 REQUIRED) + find_program (RIGCTL_EXE rigctl) + find_program (RIGCTLD_EXE rigctld) +@@ -911,56 +911,6 @@ + target_link_libraries (js8 wsjt_fort wsjt_cxx Qt5::Core) + endif (${OPENMP_FOUND} OR APPLE) + +-# build the main application +-add_executable (js8call MACOSX_BUNDLE +- ${sqlite3_CSRCS} +- ${wsjtx_CXXSRCS} +- ${wsjtx_GENUISRCS} +- wsjtx.rc +- ${WSJTX_ICON_FILE} +- ${wsjtx_RESOURCES_RCC} +- images.qrc +- ) +- +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (js8call PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- +-set_target_properties (js8call PROPERTIES +- MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Darwin/Info.plist.in" +- MACOSX_BUNDLE_INFO_STRING "${WSJTX_DESCRIPTION_SUMMARY}" +- MACOSX_BUNDLE_ICON_FILE "${WSJTX_ICON_FILE}" +- MACOSX_BUNDLE_BUNDLE_VERSION ${wsjtx_VERSION} +- MACOSX_BUNDLE_SHORT_VERSION_STRING "v${wsjtx_VERSION}" +- MACOSX_BUNDLE_LONG_VERSION_STRING "Version ${wsjtx_VERSION}" +- MACOSX_BUNDLE_BUNDLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_BUNDLE_EXECUTABLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_COPYRIGHT "${PROJECT_COPYRIGHT}" +- MACOSX_BUNDLE_GUI_IDENTIFIER "org.kn4crd.js8call" +- ) +- +-target_include_directories (js8call PRIVATE ${FFTW3_INCLUDE_DIRS}) +-if (APPLE) +- target_link_libraries (js8call wsjt_fort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +-else () +- target_link_libraries (js8call wsjt_fort_omp wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +- if (OpenMP_C_FLAGS) +- set_target_properties (js8call PROPERTIES +- COMPILE_FLAGS "${OpenMP_C_FLAGS}" +- LINK_FLAGS "${OpenMP_C_FLAGS}" +- ) +- endif () +- set_target_properties (js8call PROPERTIES +- Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/fortran_modules_omp +- ) +- if (WIN32) +- set_target_properties (js8call PROPERTIES +- LINK_FLAGS -Wl,--stack,16777216 +- ) +- endif () +-endif () +-qt5_use_modules (js8call SerialPort) # not sure why the interface link library syntax above doesn't work +- + # if (UNIX) + # if (NOT WSJT_SKIP_MANPAGES) + # add_subdirectory (manpages) +@@ -976,38 +926,10 @@ + # + # installation + # +-install (TARGETS js8call +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION . COMPONENT runtime +- ) +- + install (TARGETS js8 RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + ) + +-install (PROGRAMS +- ${RIGCTL_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctl-local${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (PROGRAMS +- ${RIGCTLD_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctld-local${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (FILES +- README +- COPYING +- INSTALL +- INSTALL-WSJTX +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- #COMPONENT runtime +- ) +- + install (FILES + contrib/Ephemeris/JPLEPH + DESTINATION ${CMAKE_INSTALL_DATADIR}/${CMAKE_PROJECT_NAME} +@@ -1061,32 +983,6 @@ + "${CMAKE_CURRENT_BINARY_DIR}/wsjtx_config.h" + ) + +- +-if (NOT WIN32 AND NOT APPLE) +- # install a desktop file so js8call appears in the application start +- # menu with an icon +- install ( +- FILES js8call.desktop +- DESTINATION /usr/share/applications +- #COMPONENT runtime +- ) +- install ( +- FILES icons/Unix/js8call_icon.png +- DESTINATION /usr/share/pixmaps +- #COMPONENT runtime +- ) +- +- IF("${CMAKE_INSTALL_PREFIX}" STREQUAL "/opt/js8call") +- execute_process(COMMAND ln -s /opt/js8call/bin/js8call ljs8call) +- +- install(FILES +- ${CMAKE_BINARY_DIR}/ljs8call DESTINATION /usr/bin/ RENAME js8call +- #COMPONENT runtime +- ) +- endif() +-endif (NOT WIN32 AND NOT APPLE) +- +- + # + # bundle fixup only done in Release or MinSizeRel configurations + # +Only in js8call/: .idea diff --git a/openwebrx/docker/files/sdrplay/install-lib.aarch64.patch b/openwebrx/docker/files/sdrplay/install-lib.aarch64.patch new file mode 100644 index 0000000..1f3dc57 --- /dev/null +++ b/openwebrx/docker/files/sdrplay/install-lib.aarch64.patch @@ -0,0 +1,23 @@ +diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh +--- sdrplay-orig/install_lib.sh 2020-05-24 14:30:06.022483867 +0000 ++++ sdrplay/install_lib.sh 2020-05-24 14:30:49.093435726 +0000 +@@ -4,19 +4,6 @@ + export MAJVERS="3" + + echo "Installing SDRplay RSP API library ${VERS}..." +-read -p "Press RETURN to view the license agreement" ret +- +-more sdrplay_license.txt +- +-while true; do +- echo "Press y and RETURN to accept the license agreement and continue with" +- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + + export ARCH=`uname -m` + diff --git a/openwebrx/docker/files/sdrplay/install-lib.armv7l.patch b/openwebrx/docker/files/sdrplay/install-lib.armv7l.patch new file mode 100644 index 0000000..22a78f6 --- /dev/null +++ b/openwebrx/docker/files/sdrplay/install-lib.armv7l.patch @@ -0,0 +1,40 @@ +diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh +--- sdrplay-orig/install_lib.sh 2020-05-24 14:13:04.561271707 +0000 ++++ sdrplay/install_lib.sh 2020-05-24 14:16:20.068329040 +0000 +@@ -4,19 +4,6 @@ + MAJVERS="3" + + echo "Installing SDRplay RSP API library ${VERS}..." +-read -p "Press RETURN to view the license agreement" ret +- +-more sdrplay_license.txt +- +-while true; do +- echo "Press y and RETURN to accept the license agreement and continue with" +- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + + ARCH=`uname -m` + +@@ -141,16 +128,6 @@ + echo "SDRplay API ${VERS} Installation Finished" + echo " " + +-while true; do +- echo "Would you like to add SDRplay USB IDs to the local database for easier +-" +- read -p "identification in applications such as lsusb? [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + sudo cp scripts/sdrplay_usbids.sh ${INSTALLBINDIR}/. + sudo chmod 755 ${INSTALLBINDIR}/sdrplay_usbids.sh + sudo cp scripts/sdrplay_ids.txt ${INSTALLBINDIR}/. diff --git a/openwebrx/docker/files/sdrplay/install-lib.x86_64.patch b/openwebrx/docker/files/sdrplay/install-lib.x86_64.patch new file mode 100644 index 0000000..d66023b --- /dev/null +++ b/openwebrx/docker/files/sdrplay/install-lib.x86_64.patch @@ -0,0 +1,39 @@ +diff -ur sdrplay-orig/install_lib.sh sdrplay/install_lib.sh +--- sdrplay-orig/install_lib.sh 2020-05-24 13:56:56.622000041 +0000 ++++ sdrplay/install_lib.sh 2020-05-24 13:58:51.837801559 +0000 +@@ -4,19 +4,6 @@ + MAJVERS="3" + + echo "Installing SDRplay RSP API library ${VERS}..." +-read -p "Press RETURN to view the license agreement" ret +- +-more sdrplay_license.txt +- +-while true; do +- echo "Press y and RETURN to accept the license agreement and continue with" +- read -p "the installation, or press n and RETURN to exit the installer [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + + ARCH=`uname -m` + OSDIST="Unknown" +@@ -157,15 +144,6 @@ + echo " " + echo "SDRplay API ${VERS} Installation Finished" + echo " " +-while true; do +- echo "Would you like to add SDRplay USB IDs to the local database for easier" +- read -p "identification in applications such as lsusb? [y/n] " yn +- case $yn in +- [Yy]* ) break;; +- [Nn]* ) exit;; +- * ) echo "Please answer y or n";; +- esac +-done + sudo cp scripts/sdrplay_usbids.sh ${INSTALLBINDIR}/. + sudo chmod 755 ${INSTALLBINDIR}/sdrplay_usbids.sh + sudo cp scripts/sdrplay_ids.txt ${INSTALLBINDIR}/. diff --git a/openwebrx/docker/files/services/codecserver/run b/openwebrx/docker/files/services/codecserver/run new file mode 100644 index 0000000..43c8212 --- /dev/null +++ b/openwebrx/docker/files/services/codecserver/run @@ -0,0 +1,2 @@ +#!/usr/bin/execlineb -P +/usr/local/bin/codecserver \ No newline at end of file diff --git a/openwebrx/docker/files/services/sdrplay/run b/openwebrx/docker/files/services/sdrplay/run new file mode 100644 index 0000000..0f31c4c --- /dev/null +++ b/openwebrx/docker/files/services/sdrplay/run @@ -0,0 +1,2 @@ +#!/usr/bin/execlineb -P +/usr/local/bin/sdrplay_apiService \ No newline at end of file diff --git a/openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch b/openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch new file mode 100644 index 0000000..47f37d9 --- /dev/null +++ b/openwebrx/docker/files/wsjtx/wsjtx-hamlib.patch @@ -0,0 +1,50 @@ +--- CMakeLists.txt.orig 2021-03-30 15:28:36.956587995 +0200 ++++ CMakeLists.txt 2021-03-30 15:29:45.719326832 +0200 +@@ -106,24 +106,6 @@ + + + # +-# build and install hamlib locally so it can be referenced by the +-# WSJT-X build +-# +-ExternalProject_Add (hamlib +- GIT_REPOSITORY ${hamlib_repo} +- GIT_TAG ${hamlib_TAG} +- GIT_SHALLOW False +- URL ${CMAKE_CURRENT_SOURCE_DIR}/src/${__hamlib_upstream}.tar.gz +- URL_HASH MD5=${hamlib_md5sum} +- #UPDATE_COMMAND ${CMAKE_COMMAND} -E env "[ -f ./bootstrap ] && ./bootstrap" +- PATCH_COMMAND ${PATCH_EXECUTABLE} -p1 -N < ${CMAKE_CURRENT_SOURCE_DIR}/hamlib.patch +- CONFIGURE_COMMAND /configure --prefix= --disable-shared --enable-static --without-cxx-binding ${EXTRA_FLAGS} # LIBUSB_LIBS=${USB_LIBRARY} +- BUILD_COMMAND $(MAKE) all V=1 # $(MAKE) is ExternalProject_Add() magic to do recursive make +- INSTALL_COMMAND $(MAKE) install-strip V=1 DESTDIR="" +- STEP_TARGETS update install +- ) +- +-# + # custom target to make a hamlib source tarball + # + add_custom_target (hamlib_sources +@@ -161,7 +143,6 @@ + # build and optionally install WSJT-X using the hamlib package built + # above + # +-ExternalProject_Get_Property (hamlib INSTALL_DIR) + ExternalProject_Add (wsjtx + GIT_REPOSITORY ${wsjtx_repo} + GIT_TAG ${WSJTX_TAG} +@@ -186,14 +167,8 @@ + DEPENDEES build + ) + +-set_target_properties (hamlib PROPERTIES EXCLUDE_FROM_ALL 1) + set_target_properties (wsjtx PROPERTIES EXCLUDE_FROM_ALL 1) + +-add_dependencies (wsjtx-configure hamlib-install) +-add_dependencies (wsjtx-build hamlib-install) +-add_dependencies (wsjtx-install hamlib-install) +-add_dependencies (wsjtx-package hamlib-install) +- + # export traditional targets + add_custom_target (build ALL DEPENDS wsjtx-build) + add_custom_target (install DEPENDS wsjtx-install) diff --git a/openwebrx/docker/files/wsjtx/wsjtx.patch b/openwebrx/docker/files/wsjtx/wsjtx.patch new file mode 100644 index 0000000..61ef53e --- /dev/null +++ b/openwebrx/docker/files/wsjtx/wsjtx.patch @@ -0,0 +1,316 @@ +diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamlib.cmake +--- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2021-05-31 18:56:20.657682124 +0200 ++++ wsjtx/CMake/Modules/Findhamlib.cmake 2021-05-31 18:57:03.963994898 +0200 +@@ -85,4 +85,4 @@ + # Handle the QUIETLY and REQUIRED arguments and set HAMLIB_FOUND to + # TRUE if all listed variables are TRUE + include (FindPackageHandleStandardArgs) +-find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES hamlib_LIBRARY_DIRS) ++find_package_handle_standard_args (hamlib DEFAULT_MSG hamlib_INCLUDE_DIRS hamlib_LIBRARIES) +diff -ur wsjtx-orig/CMakeLists.txt wsjtx/CMakeLists.txt +--- wsjtx-orig/CMakeLists.txt 2021-05-31 18:56:20.657682124 +0200 ++++ wsjtx/CMakeLists.txt 2021-05-31 19:08:02.768474060 +0200 +@@ -122,7 +122,7 @@ + option (WSJT_QDEBUG_TO_FILE "Redirect Qt debuging messages to a trace file.") + option (WSJT_SOFT_KEYING "Apply a ramp to CW keying envelope to reduce transients." ON) + option (WSJT_SKIP_MANPAGES "Skip *nix manpage generation.") +-option (WSJT_GENERATE_DOCS "Generate documentation files." ON) ++option (WSJT_GENERATE_DOCS "Generate documentation files.") + option (WSJT_RIG_NONE_CAN_SPLIT "Allow split operation with \"None\" as rig.") + option (WSJT_TRACE_UDP "Debugging option that turns on UDP message protocol diagnostics.") + option (WSJT_BUILD_UTILS "Build simulators and code demonstrators." ON) +@@ -169,74 +169,7 @@ + ) + + set (wsjt_qt_CXXSRCS +- qt_helpers.cpp +- widgets/MessageBox.cpp +- MetaDataRegistry.cpp +- Network/NetworkServerLookup.cpp + revision_utils.cpp +- L10nLoader.cpp +- WFPalette.cpp +- Radio.cpp +- RadioMetaType.cpp +- NonInheritingProcess.cpp +- models/IARURegions.cpp +- models/Bands.cpp +- models/Modes.cpp +- models/FrequencyList.cpp +- models/StationList.cpp +- widgets/FrequencyLineEdit.cpp +- widgets/FrequencyDeltaLineEdit.cpp +- item_delegates/CandidateKeyFilter.cpp +- item_delegates/ForeignKeyDelegate.cpp +- validators/LiveFrequencyValidator.cpp +- GetUserId.cpp +- Audio/AudioDevice.cpp +- Transceiver/Transceiver.cpp +- Transceiver/TransceiverBase.cpp +- Transceiver/EmulateSplitTransceiver.cpp +- Transceiver/TransceiverFactory.cpp +- Transceiver/PollingTransceiver.cpp +- Transceiver/HamlibTransceiver.cpp +- Transceiver/HRDTransceiver.cpp +- Transceiver/DXLabSuiteCommanderTransceiver.cpp +- Network/NetworkMessage.cpp +- Network/MessageClient.cpp +- widgets/LettersSpinBox.cpp +- widgets/HintedSpinBox.cpp +- widgets/RestrictedSpinBox.cpp +- widgets/HelpTextWindow.cpp +- SampleDownloader.cpp +- SampleDownloader/DirectoryDelegate.cpp +- SampleDownloader/Directory.cpp +- SampleDownloader/FileNode.cpp +- SampleDownloader/RemoteFile.cpp +- DisplayManual.cpp +- MultiSettings.cpp +- validators/MaidenheadLocatorValidator.cpp +- validators/CallsignValidator.cpp +- widgets/SplashScreen.cpp +- EqualizationToolsDialog.cpp +- widgets/DoubleClickablePushButton.cpp +- widgets/DoubleClickableRadioButton.cpp +- Network/LotWUsers.cpp +- models/DecodeHighlightingModel.cpp +- widgets/DecodeHighlightingListView.cpp +- models/FoxLog.cpp +- widgets/AbstractLogWindow.cpp +- widgets/FoxLogWindow.cpp +- widgets/CabrilloLogWindow.cpp +- item_delegates/CallsignDelegate.cpp +- item_delegates/MaidenheadLocatorDelegate.cpp +- item_delegates/FrequencyDelegate.cpp +- item_delegates/FrequencyDeltaDelegate.cpp +- item_delegates/SQLiteDateTimeDelegate.cpp +- models/CabrilloLog.cpp +- logbook/AD1CCty.cpp +- logbook/WorkedBefore.cpp +- logbook/Multiplier.cpp +- Network/NetworkAccessManager.cpp +- widgets/LazyFillComboBox.cpp +- widgets/CheckableItemComboBox.cpp + ) + + set (wsjt_qtmm_CXXSRCS +@@ -857,7 +790,7 @@ + # + # libhamlib setup + # +-set (hamlib_STATIC 1) ++set (hamlib_STATIC 0) + find_package (hamlib 3 REQUIRED) + find_program (RIGCTL_EXE rigctl) + find_program (RIGCTLD_EXE rigctld) +@@ -895,9 +828,6 @@ + if (WSJT_GENERATE_DOCS) + add_subdirectory (doc) + endif (WSJT_GENERATE_DOCS) +-if (EXISTS ${CMAKE_SOURCE_DIR}/tests AND IS_DIRECTORY ${CMAKE_SOURCE_DIR}/tests) +- add_subdirectory (tests) +-endif () + + # + # Library building setup +@@ -1380,60 +1310,6 @@ + target_link_libraries (jt9 wsjt_fort wsjt_cxx fort_qt) + endif (${OPENMP_FOUND} OR APPLE) + +-# build the main application +-generate_version_info (wsjtx_VERSION_RESOURCES +- NAME wsjtx +- BUNDLE ${PROJECT_BUNDLE_NAME} +- ICON ${WSJTX_ICON_FILE} +- ) +- +-add_executable (wsjtx MACOSX_BUNDLE +- ${wsjtx_CXXSRCS} +- ${wsjtx_GENUISRCS} +- ${WSJTX_ICON_FILE} +- ${wsjtx_RESOURCES_RCC} +- ${wsjtx_VERSION_RESOURCES} +- ) +- +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (wsjtx PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- +-set_target_properties (wsjtx PROPERTIES +- MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Darwin/Info.plist.in" +- MACOSX_BUNDLE_INFO_STRING "${PROJECT_DESCRIPTION}" +- MACOSX_BUNDLE_ICON_FILE "${WSJTX_ICON_FILE}" +- MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH} +- MACOSX_BUNDLE_SHORT_VERSION_STRING "v${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}" +- MACOSX_BUNDLE_LONG_VERSION_STRING "Version ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${SCS_VERSION_STR}" +- MACOSX_BUNDLE_BUNDLE_NAME "${PROJECT_BUNDLE_NAME}" +- MACOSX_BUNDLE_BUNDLE_EXECUTABLE_NAME "${PROJECT_NAME}" +- MACOSX_BUNDLE_COPYRIGHT "${PROJECT_COPYRIGHT}" +- MACOSX_BUNDLE_GUI_IDENTIFIER "org.k1jt.wsjtx" +- ) +- +-target_include_directories (wsjtx PRIVATE ${FFTW3_INCLUDE_DIRS}) +-if (APPLE) +- target_link_libraries (wsjtx wsjt_fort) +-else () +- target_link_libraries (wsjtx wsjt_fort_omp) +- if (OpenMP_C_FLAGS) +- set_target_properties (wsjtx PROPERTIES +- COMPILE_FLAGS "${OpenMP_C_FLAGS}" +- LINK_FLAGS "${OpenMP_C_FLAGS}" +- ) +- endif () +- set_target_properties (wsjtx PROPERTIES +- Fortran_MODULE_DIRECTORY ${CMAKE_BINARY_DIR}/fortran_modules_omp +- ) +- if (WIN32) +- set_target_properties (wsjtx PROPERTIES +- LINK_FLAGS -Wl,--stack,0x1000000,--heap,0x20000000 +- ) +- endif () +-endif () +-target_link_libraries (wsjtx Qt5::SerialPort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES} ${LIBM_LIBRARIES}) +- + # make a library for WSJT-X UDP servers + # add_library (wsjtx_udp SHARED ${UDP_library_CXXSRCS}) + add_library (wsjtx_udp-static STATIC ${UDP_library_CXXSRCS}) +@@ -1473,47 +1349,9 @@ + add_executable (wsjtx_app_version AppVersion/AppVersion.cpp ${wsjtx_app_version_VERSION_RESOURCES}) + target_link_libraries (wsjtx_app_version wsjt_qt) + +-generate_version_info (message_aggregator_VERSION_RESOURCES +- NAME message_aggregator +- BUNDLE ${PROJECT_BUNDLE_NAME} +- ICON ${WSJTX_ICON_FILE} +- FILE_DESCRIPTION "Example WSJT-X UDP Message Protocol application" +- ) +-add_resources (message_aggregator_RESOURCES /qss ${message_aggregator_STYLESHEETS}) +-configure_file (UDPExamples/message_aggregator.qrc.in message_aggregator.qrc @ONLY) +-qt5_add_resources (message_aggregator_RESOURCES_RCC +- ${CMAKE_CURRENT_BINARY_DIR}/message_aggregator.qrc +- contrib/QDarkStyleSheet/qdarkstyle/style.qrc +- ) +-add_executable (message_aggregator +- ${message_aggregator_CXXSRCS} +- ${message_aggregator_RESOURCES_RCC} +- ${message_aggregator_VERSION_RESOURCES} +- ) +-target_link_libraries (message_aggregator wsjt_qt Qt5::Widgets wsjtx_udp-static) +- +-if (WSJT_CREATE_WINMAIN) +- set_target_properties (message_aggregator PROPERTIES WIN32_EXECUTABLE ON) +-endif (WSJT_CREATE_WINMAIN) +- +-if (UNIX) +- if (NOT WSJT_SKIP_MANPAGES) +- add_subdirectory (manpages) +- add_dependencies (wsjtx manpages) +- endif (NOT WSJT_SKIP_MANPAGES) +- if (NOT APPLE) +- add_subdirectory (debian) +- add_dependencies (wsjtx debian) +- endif (NOT APPLE) +-endif (UNIX) +- + # + # installation + # +-install (TARGETS wsjtx +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION . COMPONENT runtime +- ) + + # install (TARGETS wsjtx_udp EXPORT udp + # RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +@@ -1532,12 +1370,7 @@ + # DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx + # ) + +-install (TARGETS udp_daemon message_aggregator wsjtx_app_version +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- ) +- +-install (TARGETS jt9 wsprd fmtave fcal fmeasure ++install (TARGETS wsjtx_app_version jt9 wsprd + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + ) +@@ -1549,38 +1382,6 @@ + ) + endif(WSJT_BUILD_UTILS) + +-install (PROGRAMS +- ${RIGCTL_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctl-wsjtx${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (PROGRAMS +- ${RIGCTLD_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctld-wsjtx${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (PROGRAMS +- ${RIGCTLCOM_EXE} +- DESTINATION ${CMAKE_INSTALL_BINDIR} +- #COMPONENT runtime +- RENAME rigctlcom-wsjtx${CMAKE_EXECUTABLE_SUFFIX} +- ) +- +-install (FILES +- README +- COPYING +- AUTHORS +- THANKS +- NEWS +- BUGS +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- #COMPONENT runtime +- ) +- + install (FILES + cty.dat + cty.dat_copyright.txt +@@ -1589,13 +1390,6 @@ + #COMPONENT runtime + ) + +-install (DIRECTORY +- example_log_configurations +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- FILES_MATCHING REGEX "^.*[^~]$" +- #COMPONENT runtime +- ) +- + # + # Mac installer files + # +@@ -1648,22 +1442,6 @@ + ) + + +-if (NOT WIN32 AND NOT APPLE) +- # install a desktop file so wsjtx appears in the application start +- # menu with an icon +- install ( +- FILES wsjtx.desktop message_aggregator.desktop +- DESTINATION share/applications +- #COMPONENT runtime +- ) +- install ( +- FILES icons/Unix/wsjtx_icon.png +- DESTINATION share/pixmaps +- #COMPONENT runtime +- ) +-endif (NOT WIN32 AND NOT APPLE) +- +- + # + # bundle fixup only done in non-Debug configurations + # +Only in wsjtx/: CMakeLists.txt.orig +Only in wsjtx/: .idea diff --git a/openwebrx/docker/scripts/install-connectors.sh b/openwebrx/docker/scripts/install-connectors.sh new file mode 100644 index 0000000..8386bc8 --- /dev/null +++ b/openwebrx/docker/scripts/install-connectors.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +BUILD_PACKAGES="git cmake make gcc g++" + +apt-get update +apt-get -y install --no-install-recommends $BUILD_PACKAGES + +git clone https://github.com/jketterl/owrx_connector.git +cmakebuild owrx_connector 0.5.0 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-airspy.sh b/openwebrx/docker/scripts/install-dependencies-airspy.sh new file mode 100644 index 0000000..72032ba --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-airspy.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/airspy/airspyone_host.git +# latest from master as of 2020-09-04 +cmakebuild airspyone_host 652fd7f1a8f85687641e0bd91f739694d7258ecc + +git clone https://github.com/pothosware/SoapyAirspy.git +cmakebuild SoapyAirspy 10d697b209e7f1acc8b2c8d24851d46170ef77e3 + +git clone https://github.com/airspy/airspyhf.git +# latest from master as of 2020-09-04 +cmakebuild airspyhf 8891387edddcd185e2949e9814e9ef35f46f0722 + +git clone https://github.com/pothosware/SoapyAirspyHF.git +# latest from master as of 2020-09-04 +cmakebuild SoapyAirspyHF 5488dac5b44f1432ce67b40b915f7e61d3bd4853 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-fcdpp.sh b/openwebrx/docker/scripts/install-dependencies-fcdpp.sh new file mode 100644 index 0000000..49f1439 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-fcdpp.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libhidapi-hidraw0 libhidapi-libusb0 libasound2" +BUILD_PACKAGES="git cmake make gcc g++ libhidapi-dev libasound2-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pothosware/SoapyFCDPP.git +cmakebuild SoapyFCDPP soapy-fcdpp-0.1.1 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-hackrf.sh b/openwebrx/docker/scripts/install-dependencies-hackrf.sh new file mode 100644 index 0000000..03704a3 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-hackrf.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libfftw3-3 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev libfftw3-dev pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/mossmann/hackrf.git +cd hackrf +# latest from master as of 2020-09-04 +git checkout 6e5cbda2945c3bab0e6e1510eae418eda60c358e +cmakebuild host +cd .. +rm -rf hackrf + +git clone https://github.com/pothosware/SoapyHackRF.git +# latest from master as of 2020-09-04 +cmakebuild SoapyHackRF 7d530872f96c1cbe0ed62617c32c48ce7e103e1d + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-hpsdr.sh b/openwebrx/docker/scripts/install-dependencies-hpsdr.sh new file mode 100644 index 0000000..03ff176 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-hpsdr.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +BUILD_PACKAGES="git wget gcc libc6-dev" + +apt-get update +apt-get -y install --no-install-recommends $BUILD_PACKAGES + +pushd /tmp + +ARCH=$(uname -m) +GOVERSION=1.15.5 + +case ${ARCH} in + x86_64) + PACKAGE=go${GOVERSION}.linux-amd64.tar.gz + ;; + armv*) + PACKAGE=go${GOVERSION}.linux-armv6l.tar.gz + ;; + aarch64) + PACKAGE=go${GOVERSION}.linux-arm64.tar.gz + ;; +esac + +wget https://golang.org/dl/${PACKAGE} +tar xfz $PACKAGE + +git clone https://github.com/jancona/hpsdrconnector.git +pushd hpsdrconnector +git checkout v0.4.2 +/tmp/go/bin/go build +install -m 0755 hpsdrconnector /usr/local/bin + +popd + +rm -rf hpsdrconnector +rm -rf go +rm $PACKAGE + +popd + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-limesdr.sh b/openwebrx/docker/scripts/install-dependencies-limesdr.sh new file mode 100644 index 0000000..4f83298 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-limesdr.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libatomic1" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +SIMD_FLAGS="" +if [[ 'x86_64' == `uname -m` ]] ; then + SIMD_FLAGS="-DDEFAULT_SIMD_FLAGS=SSE3" +fi + +git clone https://github.com/myriadrf/LimeSuite.git +cd LimeSuite +# latest from master as of 2020-09-04 +git checkout 9526621f8b4c9e2a7f638b5ef50c45560dcad22a +mkdir builddir +cd builddir +cmake .. -DENABLE_EXAMPLES=OFF -DENABLE_DESKTOP=OFF -DENABLE_LIME_UTIL=OFF -DENABLE_QUICKTEST=OFF -DENABLE_OCTAVE=OFF -DENABLE_GUI=OFF -DCMAKE_CXX_STANDARD_LIBRARIES="-latomic" ${SIMD_FLAGS} +make +make install +cd ../.. +rm -rf LimeSuite + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-perseus.sh b/openwebrx/docker/scripts/install-dependencies-perseus.sh new file mode 100644 index 0000000..1d8f1c9 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-perseus.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libudev1" +BUILD_PACKAGES="git make gcc autoconf automake libtool libusb-1.0-0-dev xxd" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/Microtelecom/libperseus-sdr.git +cd libperseus-sdr +# latest from master as of 2020-09-04 +git checkout c2c95daeaa08bf0daed0e8ada970ab17cc264e1b +./bootstrap.sh +./configure +make +make install +ldconfig /etc/ld.so.conf.d +cd .. +rm -rf libperseus-sdr + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-plutosdr.sh b/openwebrx/docker/scripts/install-dependencies-plutosdr.sh new file mode 100644 index 0000000..aa801b5 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-plutosdr.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. ${3:-} + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libxml2" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ libxml2-dev flex bison pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/analogdevicesinc/libiio.git +cmakebuild libiio v0.21 -DCMAKE_INSTALL_PREFIX=/usr/local + +git clone https://github.com/analogdevicesinc/libad9361-iio.git +cmakebuild libad9361-iio v0.2 + +git clone https://github.com/pothosware/SoapyPlutoSDR.git +# latest from master as of 2020-09-04 +cmakebuild SoapyPlutoSDR 93717b32ef052e0dfa717aa2c1a4eb27af16111f + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-radioberry.sh b/openwebrx/docker/scripts/install-dependencies-radioberry.sh new file mode 100644 index 0000000..0172462 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-radioberry.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0 libfftw3-3 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev libfftw3-dev pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pa3gsb/Radioberry-2.x +cd Radioberry-2.x/SBC/rpi-4 + +# latest from master as of 2020-09-04 +cmakebuild SoapyRadioberrySDR 8d17de6b4dc076e628900a82f05c7cf0b16cbe24 +cd ../../.. +rm -rf Radioberry-2.x + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh b/openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh new file mode 100644 index 0000000..695f31d --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-rtlsdr-soapy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0-0" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/osmocom/rtl-sdr.git +# latest from master as of 2020-09-04 +cmakebuild rtl-sdr ed0317e6a58c098874ac58b769cf2e609c18d9a5 + +git clone https://github.com/pothosware/SoapyRTLSDR.git +cmakebuild SoapyRTLSDR soapy-rtl-sdr-0.3.1 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-rtlsdr.sh b/openwebrx/docker/scripts/install-dependencies-rtlsdr.sh new file mode 100644 index 0000000..942241a --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-rtlsdr.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0.0" +BUILD_PACKAGES="git libusb-1.0.0-dev cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/osmocom/rtl-sdr.git +# latest from master as of 2020-09-04 +cmakebuild rtl-sdr ed0317e6a58c098874ac58b769cf2e609c18d9a5 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-runds.sh b/openwebrx/docker/scripts/install-dependencies-runds.sh new file mode 100644 index 0000000..9a4be02 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-runds.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="" +BUILD_PACKAGES="git cmake make gcc g++ pkg-config" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/jketterl/runds_connector.git +cmakebuild runds_connector 0.2.0 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-sdrplay.sh b/openwebrx/docker/scripts/install-dependencies-sdrplay.sh new file mode 100644 index 0000000..d91ec6c --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-sdrplay.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0.0 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +ARCH=$(uname -m) + +case $ARCH in + x86_64) + BINARY=SDRplay_RSP_API-Linux-3.07.1.run + ;; + armv*) + BINARY=SDRplay_RSP_API-ARM32-3.07.2.run + ;; + aarch64) + BINARY=SDRplay_RSP_API-ARM64-3.07.1.run + ;; +esac + +wget https://www.sdrplay.com/software/$BINARY +sh $BINARY --noexec --target sdrplay +patch --verbose -Np0 < /install-lib.$ARCH.patch + +cd sdrplay +./install_lib.sh +cd .. +rm -rf sdrplay +rm $BINARY + +git clone https://github.com/pothosware/SoapySDRPlay3.git +# latest from master as of 2021-06-19 (reliability fixes) +cmakebuild SoapySDRPlay3 a869f25364a1f0d5b16169ff908aa21a2ace475d + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-soapyremote.sh b/openwebrx/docker/scripts/install-dependencies-soapyremote.sh new file mode 100644 index 0000000..a74c465 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-soapyremote.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="avahi-daemon libavahi-client3" +BUILD_PACKAGES="git cmake make gcc g++ libavahi-client-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pothosware/SoapyRemote.git +cmakebuild SoapyRemote soapy-remote-0.5.2 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-soapysdr.sh b/openwebrx/docker/scripts/install-dependencies-soapysdr.sh new file mode 100644 index 0000000..bd312b4 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-soapysdr.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libudev1" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/pothosware/SoapySDR +# latest from master as of 2020-09-04 +cmakebuild SoapySDR 580b94f3dad46899f34ec0a060dbb4534e844e57 + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies-uhd.sh b/openwebrx/docker/scripts/install-dependencies-uhd.sh new file mode 100644 index 0000000..c71ff4e --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies-uhd.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -euo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libusb-1.0.0 libboost-chrono1.67.0 libboost-date-time1.67.0 libboost-filesystem1.67.0 libboost-program-options1.67.0 libboost-regex1.67.0 libboost-test1.67.0 libboost-serialization1.67.0 libboost-thread1.67.0 libboost-system1.67.0 python3-numpy python3-mako" +BUILD_PACKAGES="git cmake make gcc g++ libusb-1.0-0-dev libboost-dev libboost-chrono-dev libboost-date-time-dev libboost-filesystem-dev libboost-program-options-dev libboost-regex-dev libboost-test-dev libboost-serialization-dev libboost-thread-dev libboost-system-dev" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/EttusResearch/uhd.git +# 3.15.0.0 Release +mkdir -p uhd/host/build +cd uhd/host/build +git checkout v3.15.0.0 +# see https://github.com/EttusResearch/uhd/issues/350 +case `uname -m` in + arm*) + cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_UTILS=OFF -DENABLE_PYTHON_API=OFF -DENABLE_EXAMPLES=OFF -DENABLE_TESTS=OFF -DENABLE_OCTOCLOCK=OFF -DENABLE_MAN_PAGES=OFF -DSTRIP_BINARIES=ON \ + -DCMAKE_CXX_FLAGS:STRING="-march=armv7-a -mfloat-abi=hard -mfpu=neon -mtune=cortex-a8 -Wno-psabi" \ + -DCMAKE_C_FLAGS:STRING="-march=armv7-a -mfloat-abi=hard -mfpu=neon -mtune=cortex-a8 -Wno-psabi" \ + -DCMAKE_ASM_FLAGS:STRING="-march=armv7-a -mfloat-abi=hard -mfpu=neon -mtune=cortex-a8 -g" .. + ;; + aarch64*) + cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_UTILS=OFF -DENABLE_PYTHON_API=OFF -DENABLE_EXAMPLES=OFF -DENABLE_TESTS=OFF -DENABLE_OCTOCLOCK=OFF -DENABLE_MAN_PAGES=OFF -DSTRIP_BINARIES=ON \ + -DCMAKE_CXX_FLAGS:STRING="-march=armv8-a -mtune=cortex-a72 -Wno-psabi" \ + -DCMAKE_C_FLAGS:STRING="-march=armv8-a -mtune=cortex-a72 -Wno-psabi" \ + -DCMAKE_ASM_FLAGS:STRING="-march=armv8-a -mtune=cortex-a72 -g" .. + ;; + x86_64) + cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_UTILS=OFF -DENABLE_PYTHON_API=OFF -DENABLE_EXAMPLES=OFF -DENABLE_TESTS=OFF -DENABLE_OCTOCLOCK=OFF -DENABLE_MAN_PAGES=OFF -DSTRIP_BINARIES=ON .. + ;; +esac +make +make install +cd ../../.. +rm -rf uhd + +git clone https://github.com/pothosware/SoapyUHD.git +cmakebuild SoapyUHD soapy-uhd-0.4.1 + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-dependencies.sh b/openwebrx/docker/scripts/install-dependencies.sh new file mode 100644 index 0000000..8820f93 --- /dev/null +++ b/openwebrx/docker/scripts/install-dependencies.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake ${CMAKE_ARGS:-} .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="sox libfftw3-bin python3 python3-setuptools netcat-openbsd libsndfile1 liblapack3 libusb-1.0-0 libqt5core5a libreadline7 libgfortran4 libgomp1 libasound2 libudev1 ca-certificates libqt5gui5 libqt5sql5 libqt5printsupport5 libpulse0 libfaad2 libopus0 libboost-program-options1.67.0 libboost-log1.67.0" +BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev libudev-dev libhamlib-dev patch xsltproc qt5-default libfaad-dev libopus-dev libboost-dev libboost-program-options-dev libboost-log-dev libboost-regex-dev" +apt-get update +apt-get -y install auto-apt-proxy +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +case `uname -m` in + arm*) + PLATFORM=armhf + ;; + aarch64*) + PLATFORM=aarch64 + ;; + x86_64*) + PLATFORM=amd64 + ;; +esac + +wget https://github.com/just-containers/s6-overlay/releases/download/v1.21.8.0/s6-overlay-${PLATFORM}.tar.gz +tar xzf s6-overlay-${PLATFORM}.tar.gz -C / +rm s6-overlay-${PLATFORM}.tar.gz + +JS8CALL_VERSION=2.2.0 +JS8CALL_DIR=js8call +JS8CALL_TGZ=js8call-${JS8CALL_VERSION}.tgz +wget http://files.js8call.com/${JS8CALL_VERSION}/${JS8CALL_TGZ} +tar xfz ${JS8CALL_TGZ} +# patch allows us to build against the packaged hamlib +patch -Np1 -d ${JS8CALL_DIR} < /js8call-hamlib.patch +rm /js8call-hamlib.patch +CMAKE_ARGS="-D CMAKE_CXX_FLAGS=-DJS8_USE_HAMLIB_THREE" cmakebuild ${JS8CALL_DIR} +rm ${JS8CALL_TGZ} + +WSJT_DIR=wsjtx-2.4.0 +WSJT_TGZ=${WSJT_DIR}.tgz +wget http://physics.princeton.edu/pulsar/k1jt/${WSJT_TGZ} +tar xfz ${WSJT_TGZ} +patch -Np0 -d ${WSJT_DIR} < /wsjtx-hamlib.patch +mv /wsjtx.patch ${WSJT_DIR} +cmakebuild ${WSJT_DIR} +rm ${WSJT_TGZ} + +git clone --depth 1 -b 1.6 https://github.com/wb2osz/direwolf.git +cd direwolf +# hamlib is present (necessary for the wsjt-x and js8call builds) and would be used, but there's no real need. +# this patch prevents direwolf from linking to it, and it can be stripped at the end of the script. +patch -Np1 < /direwolf-hamlib.patch +mkdir build +cd build +cmake .. +make +make install +cd ../.. +rm -rf direwolf +# strip lots of generic documentation that will never be read inside a docker container +rm /usr/local/share/doc/direwolf/*.pdf +# examples are pointless, too +rm -rf /usr/local/share/doc/direwolf/examples/ + +git clone https://github.com/drowe67/codec2.git +cd codec2 +# latest commit from master as of 2020-10-04 +git checkout 55d7bb8d1bddf881bdbfcb971a718b83e6344598 +mkdir build +cd build +cmake .. +make +make install +install -m 0755 src/freedv_rx /usr/local/bin +cd ../.. +rm -rf codec2 + +wget https://downloads.sourceforge.net/project/drm/dream/2.1.1/dream-2.1.1-svn808.tar.gz +tar xvfz dream-2.1.1-svn808.tar.gz +pushd dream +patch -Np0 < /dream.patch +qmake CONFIG+=console +make +make install +popd +rm -rf dream +rm dream-2.1.1-svn808.tar.gz + +git clone https://github.com/mobilinkd/m17-cxx-demod.git +cmakebuild m17-cxx-demod v2.0 + +git clone https://github.com/hessu/aprs-symbols /usr/share/aprs-symbols +pushd /usr/share/aprs-symbols +git checkout 5c2abe2658ee4d2563f3c73b90c6f59124839802 +# remove unused files (including git meta information) +rm -rf .git aprs-symbols.ai aprs-sym-export.js +popd + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/install-owrx-tools.sh b/openwebrx/docker/scripts/install-owrx-tools.sh new file mode 100644 index 0000000..1cdfb32 --- /dev/null +++ b/openwebrx/docker/scripts/install-owrx-tools.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -euxo pipefail +export MAKEFLAGS="-j4" + +function cmakebuild() { + cd $1 + if [[ ! -z "${2:-}" ]]; then + git checkout $2 + fi + mkdir build + cd build + cmake ${CMAKE_ARGS:-} .. + make + make install + cd ../.. + rm -rf $1 +} + +cd /tmp + +STATIC_PACKAGES="libfftw3-bin libprotobuf17" +BUILD_PACKAGES="git autoconf automake libtool libfftw3-dev pkg-config cmake make gcc g++ libprotobuf-dev protobuf-compiler" +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/jketterl/js8py.git +pushd js8py +git checkout 0.1.0 +python3 setup.py install +popd +rm -rf js8py + +git clone https://github.com/jketterl/csdr.git +cd csdr +git checkout 0.17.0 +autoreconf -i +./configure +make +make install +cd .. +rm -rf csdr + +git clone https://github.com/jketterl/codecserver.git +mkdir -p /usr/local/etc/codecserver +cp codecserver/conf/codecserver.conf /usr/local/etc/codecserver +cmakebuild codecserver 0.1.0 + +git clone https://github.com/jketterl/digiham.git +cmakebuild digiham 0.5.0 + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/openwebrx/docker/scripts/run.sh b/openwebrx/docker/scripts/run.sh new file mode 100644 index 0000000..cde6fdf --- /dev/null +++ b/openwebrx/docker/scripts/run.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +mkdir -p /etc/openwebrx/openwebrx.conf.d +mkdir -p /var/lib/openwebrx +mkdir -p /tmp/openwebrx/ +if [[ ! -f /etc/openwebrx/openwebrx.conf.d/20-temporary-directory.conf ]] ; then + cat << EOF > /etc/openwebrx/openwebrx.conf.d/20-temporary-directory.conf +[core] +temporary_directory = /tmp/openwebrx +EOF +fi +if [[ ! -f /etc/openwebrx/bands.json ]] ; then + cp bands.json /etc/openwebrx/ +fi +if [[ ! -f /etc/openwebrx/openwebrx.conf ]] ; then + cp openwebrx.conf /etc/openwebrx/ +fi +if [[ ! -z "${OPENWEBRX_ADMIN_USER:-}" ]] && [[ ! -z "${OPENWEBRX_ADMIN_PASSWORD:-}" ]] ; then + if ! python3 openwebrx.py admin --silent hasuser "${OPENWEBRX_ADMIN_USER}" ; then + OWRX_PASSWORD="${OPENWEBRX_ADMIN_PASSWORD}" python3 openwebrx.py admin --noninteractive adduser "${OPENWEBRX_ADMIN_USER}" + fi +fi + + +_term() { + echo "Caught signal!" + kill -TERM "$child" 2>/dev/null +} + +trap _term SIGTERM SIGINT + +python3 openwebrx.py $@ & + +child=$! +wait "$child" + diff --git a/openwebrx/htdocs/__init__.py b/openwebrx/htdocs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/htdocs/__pycache__/__init__.cpython-37.pyc b/openwebrx/htdocs/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5957a11bb2b1dd392ed22ba1fe5c4fbbee50b5c GIT binary patch literal 120 zcmZ?b<>g`kg7dFp5<&E15CH>>K!yVl7qb9~6oz01O-8?!3`HPe1o2BzKfj;^h*R^* rQUf zpTpj3uQk`4^BN>4BZiKGj{<=}(7%d*Rsf&XFF&sk!QcFYh%Dd(*+yL59s+^QfBAtq zJa+N{2k{)fs5vNF8#(;=W@iZb@#6=hsg=3C{x=&#Mr%9cq$56j2!s^!^|P>&bMm2w zOElJ(+H0n&Lf0I14HFYN7^Fx`!Wu)j;Pj5YznFnvY?O>_Qc+J%x%+8OEZAnirr7v$C_q+Z~PTmZ;E!nRbs2Hgj zEVy~ut}hWpCbY?3U_O2p9Q}VE@>x;m`kX=$+b$fIy;u0dwRjx3++q5fx_&(hl-|w6 zRWYHbiI^0an2$T&9Mqwy>!pJMNHRCI-T!e~8X(+7hotFuX=)PQk8XU=Btaj+_vaS4 zZdmS{H}fpIYFG|R;`nn$Zz$sRtaiqoQTcH+9M;7DFPDFOt;6!()j*3`(Y)*eUw82N zdjgl|?mxrGF`?)wazgj;m4gD0g4ddcjvFO|Erl(~G)GjoY{@e3((w#;u{s}BQWPg+ zpzKw{Gy!xAHPu7^qzUrb;Nl%u9xFCPh70y27B1j@c$~?sGGVGh?JR<|;+o17V+;R9 z7UZ)z{ki0<^|cT_pbg`Ub(caEnuw$%A3jgUgxM3V8$o2Y54KD2fKJkcSq4-q!>HA; ztLLx(HY{L9#oP6^pd)Y4sl`joj6yet*fEl8nkfkxXHcutl&*3rr)qhLD#nx11=11E zT4Tu>(~!$B(&EiJxT1W{$qg8VfY0;qLlPbGmkAyN+^ux z>Wkava`loHl%$sB&t}G)YVR-d`5|J(2Z2bIF@-FcL^a}=OeLY%I+!gkBr5`c+gL8j z80SiXQvz|-Kl0x^-M>iUfnyLIjx+0+-!r*w^yD%sVGe|HaJ=Stw>hFP)&80F@1tnH zG79InmsEW^i^x+@!0L{{GTaM(pJE5QN8eujM>2wNJk;#$@$)=)|9}sdglEXmvG{cV z<=Ap4#$GZU4=VG1^aot*az_=7S5i6o)M0dAy}1kXC6`obXr;di<+kpyu8U|R$GCqQ zuvPBPYsxQXMcSaIfetZ=He#aeCBx29#aqT;eWI*aMp+L~j>-J_hphMU)r3fORFf#> zY`2l{V9(D#B+QC6kCrv1T}7jRX#_LdA24`O>)+)c4RL zP4*xxo9jpeE{UHuKlFRVv5!HW^!6OogQ-OY@ukO3E5vW2l zDvWopob=H+qF!sE+^5GY|~uU#p2>8t!MFv|YK(8s}6Zf;}~cv$c@OKTGb-F@-yK{({D8be2{{yRB>o;AVK zKP9Syp^T**dq5QorFmD-D2$CQnj6D;>$kw|!iBvO;rCgY=xl`2z7XcBAt~{aE5Q>_meDHy2$=;v4Yl$};br4Q<=Q`#z0lC>gNr zu|HpTlu?u6((y!)4r%mTPUJ8f_7}t1zaKOF!rPjBn_il*Ac~#dvBFTz*fxR9Y}c2t zKT0a3S&yGwsZ@%nEcbrQhe0#V0>VHSj3`~}N!p#oMJdFPm*e0)bvwvmg&2d4z`mWj z3n@8E^Ix{W@3m;7N4Z}9Cj(dFP)c8INt&;Kr7ZXMfhHKPT1K8Q{t=RRMx$vmyC{bc zJi%hm^2bjIGd`s4ho_+38a-PmtCS}f=el@!Z#vF1_SwU!he?owPkJ%4GHDOZ*Qb)R z*UADZ7$b!l6I(r-*|xzpi#5g9y>I{T3RLRDOw0S8Q~PZ|cb77z2ks&C`Y`%DtUn49r(Wkl7PS zQjnn)>gOAa6gv^aJ(4T>H~CFsxEID=V63DZWgv58f&Vgt8_pZ3Ams4po#W6kCJ6DAY(iYUceq=w)ui9j@`giT|{u*hn#vHd{udt}F z6uMU^VaHITAjEURh03rUMDwn5qM_+mH{!^`grFn45+13H05p=G>&MYTuKo59!)%qY zY`)|ER7E7Yn2PR?MaRj~{l48n(y(x&1#!y$Uz$DHbQ9R;@va00Igi-|3ZEgC`jOm{ zTJY&y4VHfv;zACb@{46q(v_8!eO5aH<4V=5DAQ`MRa@_rFZ-EVo0#6b>M`Yw)GUdT zU{Kr>fBRe%7u@rzr|5de57yTL){sZj43;f!O)BT`dxe_GK>SBb9goY&@3cuUaEL$S z7&M*ov$KC{s3Zo)8Y)H6PbMmeY)~0c_6OUGDIk&(2sLg=j`gnX8fYISXod(;DJp&8 zws+p>iG1*ZL#o|qLxYsH+?kyYB(O?mkl=BpW8no`eOS#s+I4U&J?@t--55;fu8M|Z zg%pF=_8LhSW(X(dm!K-mZ?e49XU*zw5WuTHJEg70x_$Ed%QFspW0zy!#^aZ|0d+F{D(LI87WjQs$b{g@!Uj1Mj%E==Sv~{@I@sSd4wlT-Km?p zyg~m!?wCuaT)9Z))Z_jNZ?V7i`SI9iG)pY5>2@ng*+)pCSgTaMCRfYKkBN4w=J$bnd`=Y=57duFLNDEXSh}q9#n#om? z>3SI~pmWFMBy!ORLH{O3DEKPW)u;jGWF7mrx{3_F|D=l?7-y%z) zybpS-V8HqFC@^w2wWrl^T11VBd;ERg{?FrL6r~3F5L9MmBEgs}LAwp{a@|%>1kGBD z3J$$0qrt+)-P|Ozpx;tf-5(*!2K|Qo2D*tx0Xu9BOm$iBy|;|uHi@Mfh_^Z8NHIs) zw5g(TVc)nPH(#GI)vx%jnM@4rJgniio~RV7y``a}iyBveev5+DWW<`Ov>rqVf*qOI zTsb>sThD^6oO~5<;oflH@mdfb5gi|EuG%yzW=JIC%~Xk|>SE)MV`df)Q$`OeI1NO0 zTvC+VUEhxA1^muUz2d5-vLu-G2PyT0g@`T;WiVvGBh;w zRUL;Sn^9OUz`2;)2|7&AOms?H8ym2hqXa{l{zSLmtJ z;T+5Z3KO(N=n@0v78_{-J>!Y(GV2jas3mXclAku9&QD>|^8Dm>u{YVj*z8s(A6vTr zeX>w_66E4Ck=%LtKq)~DCr;YP)V?elzLfdM7?P9{|61wg0xgUo1bLd?(9Z8im(#ks z25?(zYu}rm?)G&Woerhc-z9YlYX$$-I3UX};n)_)*Fu3Bs9f6k^|BB!=()HwR2lUC zSC*gOLp|)w7uFj^h1<1mE0b6GKFd@ZT1Ld*rr*bTi%`ASRj5_n8<#sp2{!;)z}0ci z(A%FXQL*$mYS?b78WzyBVzSKAofhXrr-yX=B%TK;d$QDtFT)!w%9Rof5kd0v^V`5> z=7yIX)=LLZht^*GNY?o~uLgJ3HhGiAoLUwm0`5jA_w)8f39E{~@9pm|-XArFYi9|Y z9?VqKpY_odKrDDu3BtSKWCb4`6f;sWRj~%_?~+K24($S%9KvNX=SX1v`*vXpz%#z7|@_3Ky8$ zh}E>BVXLWVY9dVHwC?&!l@e{nVdyuTD`9C_@}m=5^=B-TZtBPLnFjmq9Z*%hOctRv z$$$TQ`fypoohEmcp^S$!t14qpb9KC&=GBgX8*RDV!mm$ex-*hd@g)T33koV~4Gvxc z)CscE<^#7gnuP?elr0)c?HeUzi5)xJ1WDP4UjqsGG)N8&oSc7bsLl_UEcYC(^Qj)hPCnn^~yHAsa88mDEc|CM{<;&!a z{>EcHBai$=D|1$&YY;{`S34)i;jMz)zmawKZ31S6QLDaOZ@I-o6mgjMnD6(ykDn%G zl;?>cGHm#crS)sUFDbsdQVr^0Dy*y=V+Kw3)rfP;ZO?Y{kq1b?WOpoQPUdll_qd*1 zEDWvl`^Oajtqlm|9lIIU)28mzmmjE*wib{3h0YLMT+&7D^3jjm|IPlUY-vxCmJ_ez zA+`Ulc0FU9<}uax!*j(4sd5ztlAL7SG7D;>zr4-m<>gG{kFq8QWvAEm*fv-*w50a3 z>)A(DKaQ80?(SU46|GxtbM5;X>Uv_s&I)tzSdG-Q<0gXA-X?S@c2$#^x@95wLxU}g zs|WIB-Z4UqL9uTCvFyQVU^Y?yt%F&w4bp1q1mR+8xyd}AP?S|ire|V$Z8=}-7(qN} z&-iOE=)Xxt%7tpDJ>r*xXJ=;?iNB~)o&P3qTBp*~e-FRjpMKpDfX=$4f4MiALB#6> zpMl73+$Ke;Qro|#g^vegc_UR;I1-QqNp9bH*^oOfTg77&1&Y$R6^F}J#;+-5Q!N*`MD|x*R{+WeH5s}A&=Q}J?o{?2Pmbs508Y$;T7kiL)57C z*3Ms4rqA0$DWTWShc(kb=il!Sl$C};YE4;lKtr%BKHZ^|p3Bb$Hvlplis$8|szsM@ zE`fE^s6AnK?5 z8f{tY6ZGRdG(^Apt2y&3vm490MI(x0Fyc3`EZ?PnF_we=Pt{?3+BDq?Z1eLeP3zA7 z{{9l@CQv&nVgKEnuD|Cm$;{?lc2}$&^VAv^tr+mdhc%h=*203=EV=G8kf*y{ABDy; zXx>@v>`fM(KHVP^lz|~S);dGhdxhlK*YbrdHFUn>;_c$R%Q^A@6UiHb%M=#)sY?xy zfWx8;Bi0AWO>(aRIQWYZYEB_j2%jS>J9OrJR&5^5ug`(Y?sn91-Y~-+7 z*ttBI9mhRK!XOj^C-nOEo%SY>HS4S{KS<`g29ejXO~S2sfQJnEBA~*8`ht42+`0_o z&0#tB@BVThmw??&=>lvXga=I~B=L=ZjSjnO?&pJ?8UknM=M_JWbwEWhE6ch7Q|1Oh zAba0c<8jOUk3O2bUz_P`$T)W!5)>?XZ4FdvJ6Y!H{LO#p;{5)|e4I;da5*tT9)uFMZCPr>+ARzTnAOZ9DXRk)j)-@1s z0FLm=8nzP6(~+UkzQE z(WhVokvkY-B6-i+O_0iM^H}Eb4j&rJfyz-KO~m6+ZXWYF&FiV*G_k3P@8WQNIv2|3 zDR_Uty1f~vJEZZJsrlkh6ue2?UXzMX$6B0UJ3#{RTY&)2g9Q4;sw+&J0dKO zdU*gNx_A;fEcY6h!0w-O9>gOC z(xNBLcKQ%|I>CYv0cuJp#o<&*(rF^-iNC+Z$Ntk~nWv$0YcSO5FcCZbB_s(2+-ID% zIV|s214({QvyE)NiB^=`Mj1InVViWZ67a_$GvV|iW5ExYUpuMuh~ag z(l>3OQGeJfhxy=JW}NCmStK-_ThiItsV@C_wrA#(YOO2hYIh<&>tUv=Vq}wSoPMVH zbZM8vXO2>zedx|&W8Enf^lZ1K1Q0~Dcix~;pW8EiE$kx%vELdHZuNYugl_#8;&nGL2zzkl}W5UrI@j-R~NX>%aLjo{}%$kfqrN23-8m=sS zhVGYkR2uf{Rb5o(=f460$m4lGzii}K24FB20jCw4j&YS*8aV;p_0Fisjzp1SA!?qwNrS$c|tRKJIl^Um7#Go4$WM%pJaoJ;1e6dcEXUQd+gCp6m6nPNFg!b+p(h7Exf|ilY%ozPvIt^~o63XuW)*#(X+I zR4`$XXPI@F4|A-@DW>|RoT^uK{dn}XaNo6fuoOOSI>*p)J7iyX#Ky)}XQ-(;9RwXB z)M1xQ(}Y0VekJ!FsxR7SC?|ex`L3V(_V)G^j5ThOQw75Y(eiq{>@w#Y?)Zs}X#;wI z;-k@cC1quQP)CRMGzfIKDjN_FKyVu|F_;%eS@3@P>t^M-SiE(D0I5kY!U@z)k}|X@eki? z4!2;}wcn-_N$r@0C zyNos-RC{yhlP{gSjtnhjK2JCsH)stcC1H7I?`_+ZruT zVV_E)!>~113@MD`s*Q(VWg~AioevdRk7oKuR?w4-cV~Z%vH=xLP5b^IrtAvHiij5y zJrot!4S?1-eL<4SBN-w{3(o7I>WdGv#%b(2V;c`spmO*GbRcK~X$28Bwzm6K!7hxK2@)c_*GtLE>>STI_uan2H_I46GXKWdQ+) z9l2F28`s>snu;;PfXYm*>_h5bj{K@mL$zVE_50A%)AeowzmB~t_In4m>ZY4ORQ{#D7-?oZ>H>d~IZgi^nsNeO@ehi=y&IKE| zI2}bAwvuL#yNjSQKzGc6=&60uWB%1m>?ZXzP=PW*3~|PB5!l67<}YyLznanSn;pkt z(9r7)#vavacHI?yErXazRKFTPP^=TlVKfkb2WtP=aH%a%c3Ogpx<$&jl{u?yEhBE~ z2<4R+-7c{@yq4+5>t$VY%!<~_Db0yKH6Lylk}}mYoeGdt0su9W!11rndU;8+(%|c3 zy0Bk9nI%j%xptC*A=s9q6q2zMR17x3u2%cej6v%2 zBHRjK))@+&`$Vo;J~hal7{ko^)iGoVUA%FiN|Bre!xR`Vob&Mt}-WCW4ddxfg z&kyI09K#5(Z6wb(VpI)sx}3iMu&={Rf*EHrMDl{9juNCbO;(+ydiA<~<2mnS1QxqL z`TD~00g|Vt*VKxDbbk)ppLn2BN+!O)J#sLjv~r}jRE$ul2`u5L+fhSz-zCI*cRa7L zZqX?TwHaHfrXNwL@cl%r5@X^rMlSx+xDda77uq%`A9Yi=Af8E%05ek}M#UKuS!p{i zlk8u6>zh}PwJXyC)Ir}7z1t^;g3P8*KY;H_I|>ZVUY;W_Z$3Ci;pJoYy~~C z8%N#SW9qGJt#of*{nG_&ZxhF;UF>!)@|qtjnbTUw+w}Exn5%RJjQv93>yo`2tchF> zQ?W3BT@Z*}ci!(~Sx%Q~ub*!Z$K};qeh;+}LXU?@`K#q87+Rd)CVLb^J4hLyek6ow z0(enkH_)E%Nk~eT2=jx&kv@UgDpVR*D(|CVmBp~j(}ZR;e`QbY@Tm%le#h#Qh)})lf}x+#Fic^SMk20EZYkZ-EWw=Yg^#r2xHm!50+cA% zdB;}3Z6iCU=D}eggg+E~jPf7K>Hnsm@nznGs^27O+ob|zWwi!;03OupZN5F4=B0>= zVGsxkW|6`7MU@Ms`~b3+qQj7Bu+^k2CCXd$U_S^j#+LH(yv!^vM%{TDT-S$ZoA z&>pGlTpTcQy^AXW6IzJ=g|)NQbR81BVVG4eS?1rqy~knF(OPcx@``yos%|W!`n})L z#_E&!C9)y2CWKE%`!;(u_bgPIBtz8`MtK48F174GV*a#B(l{-M_-$ls`w>hR4l##?8rC0+$O3|x`HZTb3H*H{ zPHw&g=9mEOIN}H^sXAJ{{xl?~j9V4+Ojc?AXEm%I3ER)khr^Lk@VNhYI) zY9JRgksn2Dv;KySX~20Goy2Tfk6Wu1`F@pgrj;CmZSs148ok_~p1#_j zW{UmZWy@^P6LAL)kN~#4xqL*+$p1Wpd?UFeJRUt=Ieir}sBSse1r$xd)183pUSr^~ zWG~rgTK(V^Oxk*<^ z_EkKgzcQUCC9?0qxzHSB@k@@9aSQF^3Cu3h^4-vw+u!Kw`)%_#tp9h=&UC*#YQ5>(r9+>|$;sK7EQ$-fkdLbEWU{8!r$WZrfB4;6~casz@FUWAG*NxyZ~f*uk@6L~zg{zcj9${n zmMRt^s`SzB*X<3r1fjv(u208yzl9lC*nhpv*6)M2Q0LT&NU9v6WwsZn?8D#JLWj|T zF~ZI_>>u=*@3OO>O7KmDRg2-Rfgx<}+7hgFwEa*gT|4!WFiu@*ZVsaPumu^o-7F*x z*D&-(YSGH?r-IY8K=4u$#Y^9!XPefWdxKF=7{N^^T~Y(#teL9C(OgnhHzAPQCv#E` zG!C>AlcAudb_p$vS4d?!_T7Fr=0gy(7ujBF&uu}^VjRP*8VcJW;KCnggw+;1jQSWN zLnA>O5|ds1Hblcj`&bc%L$;y}DuZAV6JDb5Q|yX6O_B3$+h$|KFYV2MSv3L7f;DW+ z@&?G)+OfhCYPP7AKTZX(oPZA%%B^2*qt7EY^uv)q8J0K8{sU72TV>&)oSrS5hB2eX zk2S(ErGZ#AV+Rv$suBWMF;gf?hyNkZXE@K_DdF#;q*d7fXZCV(e2&xsJ@l3&?bx^i4aCxks%h`#|HoM!FD#MmBv z$}V4b_CWd}^Du&ok{lb4s-eA-K+AqdU@ULc0ad?>dhI{Ed!=?Y5m+VYq#<8^^5@u3 zL)Z2VUVC;ur2+J^QHDlhM6;jl+tUxMUcKr6g2)jUpeXI4m_Ei=HzbBlnmsDuh^HhG z5wjY(?|2k|F;F;UM8?BoXBF8;3+<^ME0jV;vgzaOaZHvfc=%jS(`JXm+iwmCryH8sQ#~oNh8~Vr@7aU5p`N0!z%X@OZjInkILApo6O4clij*^nF;H zO$i6^{(}8_vBC_+)ijS?InDLUc19wGhVWUHHI6y@6R>*^a?0LWRU@>|OR-8;uNU*Y zv7#s^3hhfFF$@S42++QqsQtK2`Z=NT3?8|DOxj99ZszejlN8hRSjF$OY4T3o8)R+t z!3SbRxz);TIiGU>#`x3Mo-q@MQO27_bgJ_v{nA!e5`K@K?eIAUH{!~%G~hsFzBqx| zM{_$>u1?0u(SR8tULR`)c7zP|Zv$Q>U;QooQ3caS2{m;Se5|py2)OEn_Z!uk0nGb* zdkcs2b%9q$iv(P*7y0|p((z(-)@0Ycf~8^4D^?~f6x&#JY8mE1^yLYb{%8A~{f%`k zzIK?TyL(fi9=pAy{a3Wh%HF}6@bKAiY34k^51dAdPEUI$EaM#wb) ze#1ca(`g6bHmT0AKQ?_&guPww4In&DLl~jdfc36-1`d#&f3WX_fBl6~*OD#aK=^G^ zga)s;)%gh2NH~DGUN-@SQy3d<9deEMN|KxC~Cw`EusAdS53mhsR8a0{lIPJs@c>+`s?yP`{ zYyoH}PQi?HG6H^0ZR}NF--bIq51Z}yk~jWH;crO^yi?}|kIfj>Pp<_ZY)-~y1uxeH z^LCQ$hfb{;4{LXNBFV{n;~H(A@~ntZ5ZEG2bChR4aOeP=ky?|p?TW0|o#G0By%+1E zIr)Gof2rA1I*yad>^?$5Jz6`TWfuKmCMYBsyzwEEy>>5V6-&pnMEqT^Z;;EdZI{gz zzZNn=Bx~d_KMlm{dKE~b_I49kyoVr6Dvn;9p6I))LxMC`W0{Kq*5M6zZtd)n#H+0U z2jwk_nnlpsopcdc58ML-Mx_E*PH=GW&B1KdKIlC0*iSzBeNYztj9}KMn&|&@zx;f( zWYdA(4)7Eym3zty)&NLWImPSg_Ob^Jqmw=ZVub!5a8Z?h;;Ao+nx0(aO7HLieFLY6 zQ6eKrQ<@Zi@`3? zWY5!Z#zG71Xg4owuH6MpQ*hO7PgBs}Rc1=5_%}cYwxh-gu~q+%u_wn|%tTCO&??se zGo%yN>!yPQ#j5GFt4aLBAN!WfQEDq7nKdmx9{oUw6#tB!JXf8j$(nVM!9Q#d3<9Ym zWnPc_a+a2sL$|@5-|GQE5>g5x-U!grwEQC!n6#N)q5D}RPk}vq z`G!$~$eSn9M{bkR(M65tL*5mYbfVIC_^N$aK|zxIckm88s9XgPH{47ZgfplX&+s=V z3@ag_-dlqZu4TMj0u*P@B7Y?R6v4^UA0j0eX!vLipRKa!b9+o!{1eZrSvILeUL%vw}RweS+b68D184_~{P!;YNF^4CQD z7s`V12M0>d;wSv}Rqz$%BxDO!$o9z zci0>M$x(m}QzF^;VFXNyMtn6$ z$$!BHSJ)II6me;r`~lOhtC1<|b`jkUcj%3Eso6O5; z1P_Ep9h4_L!NrZQFt@UAgkDUG3{FQ2y{~)yrM;i;9sUmX#LC=rk5urU^h%JXa^oqg zss@UGSVO~O`6&~-^PoRf7VQ;IA}DZkyPb9}@8$Xa#OsOtTS`^VmA23Lu*^xPH_v}T zD?ku@aeB}KPpCV0um>(^2+=v=1)py8Q_JLtnRji|r$-KMc^X(pYK;FXh_S-$sL=yo z8-dTBfPe`e7RF+(nkM#P#>nsX4hR$rfQCa_qt(4mbn`wBFm8tzNoz&oWA`Ug*PiZl zJ8u3_4(ipU_-i!oxGWa+u&84wdV|DHovD-x=o9gH(Um5FIUgM^NwxWTC$*M>m{RJqG zaxVmawV}s02a&hi797kyS}L|O3BnPSFu(WW8PF&|yIzXFaPpe0lu96k>Z+>;f!Pw0 zmX_X{WSEB?$E6I|(o)cN^erpwz{?8`38?^Dl3Elc&3q)buA`&li@;3CZTI1P&xpb2 z9+yh1Kuh5R+5$f;*Mmq`0T4i~hg12N0jaE@X1pp^M9+qfrZ~PrrksUN%!U9-&XJ76 zp@nOIKfOgfAVgMIRBjZALG%~uxNjj!GWij>5i05Co&!uUXwmnXsY}Ho2uB`$p12(s0na;l zk9F9HU)T%dxYGs8gCCk%Sy^d&ulK1awxuUlAAZNOvW{4AegbWYN_07j48YToTc?Ytif_gC^%PQ*XF26xV7kX_% z2Xy0jDCZ{oCmZ2{^!(Ff;ewC8loAMclW?J*1X*-7=+zbcV6@fL@^#=>^bOUgz!<0c z3sq0uEB&7S`d8O-Wqsq{d;d6ibCh60AXwPgF5u6@E&206i(##Uv)B5v-`KNS3yb|f zZYs*x=r1})D$Gc34Htw3rbP2!mR2E1B4ST8(_Zccrn+G}B=ZE%#CX?yFz$y>Z*O?s zUMuZ{=!w_S$Kubi*&y|PO{f>Nn|21GL#X@hW?Vu-7?YsXz%rd(CweXve`R0#paA54 z{2xm$d4p2;Vi=GGz;+I0ehFb2wtD_h;Y&aB3X@Lg`_A&yM>jKI!gdDMt>3Z18PQ&@ z&yUxaS1`PFgb*FIz80SmoGH8rbT0YBoy<6>75 z+nt4UW);gt+4EglG?OkL#eN&m`dM#-e`2yc#u1jTN0{~_uDlZznFZY7TeclE>WHvG zwEe?BQCj{Bu(#Fwfv-t)^4#nCG@0^e%-?nDx1qEg0VZ0@Vf0HaA9X)4(=-VQoqG2L zw^kIb;Y;Zdsy*?!(Y{0bezMZu;tc7y2d+PU(-cXb5@-4Q>aTt#w_cTjAnh5(X{(?< zzk_cOxK#U375VxA--Ck*qir9UzOsS*_kfx80v4+OzMglgzBANYX1tmJ$$t+-`EN`_ zkFE%}E#^*L-~i;k9pK8y7F)eQZ$8ugm{c-K19`AF^Ke1k z`F3A)tzmKabNKjO&I}g)Dr-C8-lVaa{+ZJnyvO8~OkjW1=Ph56-`vIQnl6%s=vrHG z3Hn|g!s;Yu@bTNO{WCiunt)&Xy*{zoBPs{DPM`*}8zN^mV%e9e#yk`B7-z)RiF8UAj;UynO}m76;ZDMKUvQzi#+iLA3Om@6nBPKyYia zNR`QYru-ZC`aUq9{RSkYY`dz*C*9|TMVpo|Vu;aV@-Nv=8r>j2 zvNYYMPE_K1(rL%_7u&Vc-I(A5ob!p9o+=#uTv#id*ByhLO9Vd=8sh1Ue)yO8+Oh9< ze=LHG}1qPHznD7|9Q;)LL*&Pq-_cf|NLI zbUQ8gMYqcN-8ua> z0xuXHcX88ph#qi%j{?cG-@A5Gx*Hd`Ee>5$w}BTRV4LngnG!*5xvBur;Ssg9X3O&lQVUj)c^2vD zN8pBviufU-o3(&)!>^GP3;QSrgAM0%7M8Hh|9a*T`{Z=K4cgh~5S|Xy&3^#asZupt z1kBsLWl7gzVrVt!!V_?JZU<(;3@;|JsI8#x4q8N5ORp~7Dr2!VpBZ-4-|pH@vxXHf z@?t~jJheOc4So~zL-khw`n$FSC&E5@#fU4{@)E6xt{u02&3;_c&L{kWpbd6$BoM<1 zk>O#_LX7^IqbV^Z7JO)B)tTFIPMs$1PTPou8EoeMim+478BWaS%sc_i@i^f78P!Sh zG^UZxN=08oKF<~I$&p3m*J@I_3~_J&0}gWClw_Vs1S}Q^d~ZlsjI1Y|4ILfbqJYO8 z764I$VP$JZK|>m2WT%aLHlKF!izpOl2eQY<@K9TjC-QE97GP6fU0t1FEyELV!Aa#$G<+^2yp<%jIlZI6 zT%?nibVa)e#JKIycONr%#YjA&qd3C~cfhFO&3Ai6lR*3C==f+nsD$&|Bk)Y{`BwG^ zNYkAl1zRe$yfopO)h6-%oqLiUc|g6*D23fa|b(1h6FRH|U{9V{IO<>RL;5 zYs{1l)zuUBP;~HUmeU&0%GZhBk)ET zGD*R{y=PNQM=ky@(t&j4WgefR6=hBZ9!@&+7^hBQU%ZfOvV5nXFE4h-X>>1A`Yy<0 z33b`}Gw@3k?o0Pwftw}F0VrZmqhcfoAPnsPs=i1(;e2P}mw-dj!bb7u(%De;8O2cV zKj@zh4oh$y{q5}6`s(1Pt`?d>Q@gHbNb5-*E361}nRQ8FA8yFdpYq#A92?Iz2H_A1-ibGD;rKr{DnO@!}g= zM#igLsRRW#AOWl3Vq@QKhCoROby;L-m&R^T0z3ZqWin+1xpg|3^7<|Q`5qJdl zj+(ER^nuo_>?p%X8og{u)q0`j#J=%kE~o9#IaL+$$-LrkTb%G=`UWw6AySw4*{exPhvNB%Dh<<)wTA9NlQBh%o;C?*+f3{g=R~282eiJKGLP)@dLsmmj5gG zarbQl{uhFZq1~$`)Hm_ICHRQfpDeG$^|C-wQE~lc(lcHx#@8S#^SR%YuR|FV+0D07 z?D}X%8MUFG>1b)m03vhvh1JTPvrmanw5yr`8?Fy9z;i)HUQ7uu zW{#J;XlZHx;bzr4;)S;3ZVEpkhRqcEenaxc#0Xpj_5`jAV4HylJq8gUpinWxyGLIf zLZC000ZvghNxD(7eVFb5{R~z&1FeUA7Ni)>3~zui zeREjB##$pT=Cu)BPgL!KD`pBpR?7!mp`PHpj>^Htpa!l_6Nde`5g4Bfos}*0`s3i1 zSQM9($lrv9ZoKRL7bl@Q;g#!Ig=T^`!NVMa94r22mtWldT-u$bNTi49x3g_UM0^c$>6ZVL7{(X+obfk5D)FaIxqe2Y?6cwb88ret;KS;yep3EJA^ zn4uMIH4`KwImHliI$^8qJ|Z&?wlQ3jMC zzET3!61U0zLlnBuNU`p?zE2?o0ah2QYdKvbab{QJZ0iN~+zQ?}oKrrD9Elp3qg*b% z1++6gITVUHmGv3wh{4M%K~>*tW2LqRqrwaccIHkteUPe$Qdk_7h;&)3X^62e z<&-SQ<;`pq7iHfh|-(Z!dCFiH<_> z7dHZVCtzd7(pS4Z%Cfv)eb05PZf4s#4&4G}q|)Y#pS~2iqv^XOHo4kaNaCg(auA%i zJ0k|KP?2|uZ=Em(ANQaJvJqs!pz^iK^tq8N0^c5H2{FJH!fr)S~e=^mt!d1(kY91IU zwb>Pc?9|sJ#j~jhxrebAxn1L-=ck0reeZ5dG3}o4JE9a$k>AK0Ph3r9G^v2uTgv>9 z0xyOjOR@gzu9kJ)UwLG4P9=K^KNmaem@~(|r{g}{nvsSz)R$MVC5nZs!04fAF+`OX zW(X7J5i9xi<`bH0yBy3eyhyKpZL&Ale(rz1S|pd~rj5$?&r!Eyw}YYWaivvbVFbo| zGK;bh4Z;=r8XG|axeyN+GnrnV{hTU2Px$OA$i=%nhdD%xe_D`tpLj^>%ihCc~AJ6G=(%o%B_+?@N|;7h1$mPD0*PqmYH|R zmK0OYUTUY!l03q~KWfV40oDGcbU}XkUFS>7{Z27X~ zGmYQm8?(|Ir~$RS&k05MTlHVRqyn|r+U(PMWU}eN+`hb}`!@7r@FlkYo%pillkTU$ ztZZZ!z@}T1K(YZepk~Te(JG~L?fT!umu^e<{ZK>r@&)A7F=b0KLCsySV_qkaTnLE% zHVMF_bU`Mt{=}C(Wy_cDe~AV$UnHk&{zuXO2j(u5K{BF? Q`Tzg`07*qoM6N<$g6oD~)c^nh literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/css/admin.css b/openwebrx/htdocs/css/admin.css new file mode 100644 index 0000000..b956e9e --- /dev/null +++ b/openwebrx/htdocs/css/admin.css @@ -0,0 +1,162 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +html, body { + height: unset; +} + +body { + margin-bottom: 5rem; +} + +hr { + background: #444; +} + +.buttons { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: #222; + z-index: 2; + padding: 10px; + text-align: right; + border-top: 1px solid #444; +} + +.row .map-input { + margin: 15px 15px 0; +} + +.settings-section h3 { + margin-top: 1em; + margin-bottom: 1em; +} + +h1 { + margin: 1em 0; + text-align: center; +} + +.matrix { + display: grid; +} + +.q65-matrix { + grid-template-columns: repeat(5, auto); +} + +.imageupload .image-container { + max-width: 100%; + padding: 7px; +} + +.imageupload img.webrx-top-photo { + max-height: 350px; + max-width: 100%; +} + +.settings-grid > div { + padding: 20px; +} + +.settings-grid .btn { + width: 100%; + height: 100px; + padding: 20px; + font-size: 1.2rem; +} + +.tab-body { + overflow: auto; + border: 1px solid #444; + border-top: none; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.tab-body .form-group { + padding-right: 15px; +} + +.bookmarks table .frequency, .bookmark-list table .frequency { + text-align: right; +} + +.bookmarks table input, .bookmarks table select { + width: initial; + text-align: inherit; + display: initial; +} + +.bookmark-list table .form-check-input { + margin-left: 0; +} + +.actions { + margin: 1rem 0; +} + +.actions .btn { + width: 100%; +} + +.wsjt-decoding-depths-table { + width: auto; + margin: 0; +} + +.wsjt-decoding-depths-table td:first-child { + padding-left: 0; +} + +.sdr-device-list .list-group-item, +.sdr-profile-list .list-group-item { + background: initial; +} + +.sdr-device-list .sdr-profile-list { + max-height: 20rem; + overflow-y: auto; +} + +.removable-group.removable, .add-group { + display: flex; + flex-direction: row; +} + +.removable-group.removable .removable-item, .add-group .add-group-select { + flex: 1 0 auto; + margin-right: .25rem; +} + +.removable-group.removable .option-remove-button, .add-group .option-add-button { + flex: 0 0 70px; +} + +.option-add-button, .option-remove-button { + width: 70px; +} + +.scheduler-static-time-inputs { + display: flex; + flex-direction: row; +} + +.scheduler-static-time-inputs > * { + flex: 0 0 auto; + width: unset; +} + +.scheduler-static-time-inputs > select { + flex: 1 0 auto; +} + +.breadcrumb { + margin-top: .5rem; +} + +.imageupload.is-invalid ~ .invalid-feedback { + display: block; +} \ No newline at end of file diff --git a/openwebrx/htdocs/css/bootstrap.min.css b/openwebrx/htdocs/css/bootstrap.min.css new file mode 100644 index 0000000..43d80a0 --- /dev/null +++ b/openwebrx/htdocs/css/bootstrap.min.css @@ -0,0 +1,12 @@ +/*! + * Bootswatch v4.5.0 + * Homepage: https://bootswatch.com + * Copyright 2012-2020 Thomas Park + * Licensed under MIT + * Based on Bootstrap +*//*! + * Bootstrap v4.5.0 (https://getbootstrap.com/) + * Copyright 2011-2020 The Bootstrap Authors + * Copyright 2011-2020 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */@import url("https://fonts.googleapis.com/css?family=Lato:400,700,400italic&display=swap");:root{--blue: #375a7f;--indigo: #6610f2;--purple: #6f42c1;--pink: #e83e8c;--red: #E74C3C;--orange: #fd7e14;--yellow: #F39C12;--green: #00bc8c;--teal: #20c997;--cyan: #3498DB;--white: #fff;--gray: #888;--gray-dark: #303030;--primary: #375a7f;--secondary: #444;--success: #00bc8c;--info: #3498DB;--warning: #F39C12;--danger: #E74C3C;--light: #adb5bd;--dark: #303030;--breakpoint-xs: 0;--breakpoint-sm: 576px;--breakpoint-md: 768px;--breakpoint-lg: 992px;--breakpoint-xl: 1200px;--font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";--font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*,*::before,*::after{-webkit-box-sizing:border-box;box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:"Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";font-size:0.9375rem;font-weight:400;line-height:1.5;color:#fff;text-align:left;background-color:#222}[tabindex="-1"]:focus:not(:focus-visible){outline:0 !important}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:0.5rem}p{margin-top:0;margin-bottom:1rem}abbr[title],abbr[data-original-title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#00bc8c;text-decoration:none;background-color:transparent}a:hover{color:#007053;text-decoration:underline}a:not([href]){color:inherit;text-decoration:none}a:not([href]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:0.75rem;padding-bottom:0.75rem;color:#888;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:0.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role="button"]{cursor:pointer}select{word-wrap:normal}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{padding:0;border-style:none}input[type="radio"],input[type="checkbox"]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{outline-offset:-2px;-webkit-appearance:none}[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none !important}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{margin-bottom:0.5rem;font-weight:500;line-height:1.2}h1,.h1{font-size:3rem}h2,.h2{font-size:2.5rem}h3,.h3{font-size:2rem}h4,.h4{font-size:1.40625rem}h5,.h5{font-size:1.171875rem}h6,.h6{font-size:0.9375rem}.lead{font-size:1.171875rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,0.1)}small,.small{font-size:80%;font-weight:400}mark,.mark{padding:0.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:0.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.171875rem}.blockquote-footer{display:block;font-size:80%;color:#888}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:0.25rem;background-color:#222;border:1px solid #dee2e6;border-radius:0.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:0.5rem;line-height:1}.figure-caption{font-size:90%;color:#888}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:0.2rem 0.4rem;font-size:87.5%;color:#fff;background-color:#222;border-radius:0.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:inherit}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container{max-width:960px}}@media (min-width: 1200px){.container{max-width:1140px}}.container-fluid,.container-sm,.container-md,.container-lg,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width: 576px){.container,.container-sm{max-width:540px}}@media (min-width: 768px){.container,.container-sm,.container-md{max-width:720px}}@media (min-width: 992px){.container,.container-sm,.container-md,.container-lg{max-width:960px}}@media (min-width: 1200px){.container,.container-sm,.container-md,.container-lg,.container-xl{max-width:1140px}}.row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*="col-"]{padding-right:0;padding-left:0}.col-1,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-10,.col-11,.col-12,.col,.col-auto,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm,.col-sm-auto,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-md,.col-md-auto,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg,.col-lg-auto,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-1{margin-left:8.3333333333%}.offset-2{margin-left:16.6666666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.3333333333%}.offset-5{margin-left:41.6666666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.3333333333%}.offset-8{margin-left:66.6666666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.3333333333%}.offset-11{margin-left:91.6666666667%}@media (min-width: 576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-sm-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-sm-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-sm-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-sm-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-sm-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-sm-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-sm-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-sm-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-sm-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-sm-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-sm-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-sm-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-sm-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-sm-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-sm-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-sm-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-sm-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-sm-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-sm-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-sm-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-sm-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-sm-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-sm-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-sm-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.3333333333%}.offset-sm-2{margin-left:16.6666666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.3333333333%}.offset-sm-5{margin-left:41.6666666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.3333333333%}.offset-sm-8{margin-left:66.6666666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.3333333333%}.offset-sm-11{margin-left:91.6666666667%}}@media (min-width: 768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-md-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-md-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-md-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-md-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-md-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-md-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-md-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-md-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-md-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-md-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-md-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-md-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-md-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-md-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-md-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-md-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-md-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-md-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-md-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-md-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-md-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-md-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-md-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-md-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.3333333333%}.offset-md-2{margin-left:16.6666666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.3333333333%}.offset-md-5{margin-left:41.6666666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.3333333333%}.offset-md-8{margin-left:66.6666666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.3333333333%}.offset-md-11{margin-left:91.6666666667%}}@media (min-width: 992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-lg-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-lg-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-lg-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-lg-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-lg-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-lg-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-lg-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-lg-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-lg-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-lg-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-lg-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-lg-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-lg-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-lg-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-lg-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-lg-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-lg-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-lg-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-lg-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-lg-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-lg-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-lg-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-lg-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-lg-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.3333333333%}.offset-lg-2{margin-left:16.6666666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.3333333333%}.offset-lg-5{margin-left:41.6666666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.3333333333%}.offset-lg-8{margin-left:66.6666666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.3333333333%}.offset-lg-11{margin-left:91.6666666667%}}@media (min-width: 1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.row-cols-xl-1>*{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.row-cols-xl-4>*{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-webkit-box-flex:0;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-auto{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-webkit-box-flex:0;-ms-flex:0 0 8.3333333333%;flex:0 0 8.3333333333%;max-width:8.3333333333%}.col-xl-2{-webkit-box-flex:0;-ms-flex:0 0 16.6666666667%;flex:0 0 16.6666666667%;max-width:16.6666666667%}.col-xl-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-webkit-box-flex:0;-ms-flex:0 0 33.3333333333%;flex:0 0 33.3333333333%;max-width:33.3333333333%}.col-xl-5{-webkit-box-flex:0;-ms-flex:0 0 41.6666666667%;flex:0 0 41.6666666667%;max-width:41.6666666667%}.col-xl-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-webkit-box-flex:0;-ms-flex:0 0 58.3333333333%;flex:0 0 58.3333333333%;max-width:58.3333333333%}.col-xl-8{-webkit-box-flex:0;-ms-flex:0 0 66.6666666667%;flex:0 0 66.6666666667%;max-width:66.6666666667%}.col-xl-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-webkit-box-flex:0;-ms-flex:0 0 83.3333333333%;flex:0 0 83.3333333333%;max-width:83.3333333333%}.col-xl-11{-webkit-box-flex:0;-ms-flex:0 0 91.6666666667%;flex:0 0 91.6666666667%;max-width:91.6666666667%}.col-xl-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.order-xl-last{-webkit-box-ordinal-group:14;-ms-flex-order:13;order:13}.order-xl-0{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.order-xl-1{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.order-xl-2{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2}.order-xl-3{-webkit-box-ordinal-group:4;-ms-flex-order:3;order:3}.order-xl-4{-webkit-box-ordinal-group:5;-ms-flex-order:4;order:4}.order-xl-5{-webkit-box-ordinal-group:6;-ms-flex-order:5;order:5}.order-xl-6{-webkit-box-ordinal-group:7;-ms-flex-order:6;order:6}.order-xl-7{-webkit-box-ordinal-group:8;-ms-flex-order:7;order:7}.order-xl-8{-webkit-box-ordinal-group:9;-ms-flex-order:8;order:8}.order-xl-9{-webkit-box-ordinal-group:10;-ms-flex-order:9;order:9}.order-xl-10{-webkit-box-ordinal-group:11;-ms-flex-order:10;order:10}.order-xl-11{-webkit-box-ordinal-group:12;-ms-flex-order:11;order:11}.order-xl-12{-webkit-box-ordinal-group:13;-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.3333333333%}.offset-xl-2{margin-left:16.6666666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.3333333333%}.offset-xl-5{margin-left:41.6666666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.3333333333%}.offset-xl-8{margin-left:66.6666666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.3333333333%}.offset-xl-11{margin-left:91.6666666667%}}.table{width:100%;margin-bottom:1rem;color:#fff}.table th,.table td{padding:0.75rem;vertical-align:top;border-top:1px solid #444}.table thead th{vertical-align:bottom;border-bottom:2px solid #444}.table tbody+tbody{border-top:2px solid #444}.table-sm th,.table-sm td{padding:0.3rem}.table-bordered{border:1px solid #444}.table-bordered th,.table-bordered td{border:1px solid #444}.table-bordered thead th,.table-bordered thead td{border-bottom-width:2px}.table-borderless th,.table-borderless td,.table-borderless thead th,.table-borderless tbody+tbody{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:#303030}.table-hover tbody tr:hover{color:#fff;background-color:rgba(0,0,0,0.075)}.table-primary,.table-primary>th,.table-primary>td{background-color:#c7d1db}.table-primary th,.table-primary td,.table-primary thead th,.table-primary tbody+tbody{border-color:#97a9bc}.table-hover .table-primary:hover{background-color:#b7c4d1}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#b7c4d1}.table-secondary,.table-secondary>th,.table-secondary>td{background-color:#cbcbcb}.table-secondary th,.table-secondary td,.table-secondary thead th,.table-secondary tbody+tbody{border-color:#9e9e9e}.table-hover .table-secondary:hover{background-color:#bebebe}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#bebebe}.table-success,.table-success>th,.table-success>td{background-color:#b8ecdf}.table-success th,.table-success td,.table-success thead th,.table-success tbody+tbody{border-color:#7adcc3}.table-hover .table-success:hover{background-color:#a4e7d6}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#a4e7d6}.table-info,.table-info>th,.table-info>td{background-color:#c6e2f5}.table-info th,.table-info td,.table-info thead th,.table-info tbody+tbody{border-color:#95c9ec}.table-hover .table-info:hover{background-color:#b0d7f1}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#b0d7f1}.table-warning,.table-warning>th,.table-warning>td{background-color:#fce3bd}.table-warning th,.table-warning td,.table-warning thead th,.table-warning tbody+tbody{border-color:#f9cc84}.table-hover .table-warning:hover{background-color:#fbd9a5}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#fbd9a5}.table-danger,.table-danger>th,.table-danger>td{background-color:#f8cdc8}.table-danger th,.table-danger td,.table-danger thead th,.table-danger tbody+tbody{border-color:#f3a29a}.table-hover .table-danger:hover{background-color:#f5b8b1}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f5b8b1}.table-light,.table-light>th,.table-light>td{background-color:#e8eaed}.table-light th,.table-light td,.table-light thead th,.table-light tbody+tbody{border-color:#d4d9dd}.table-hover .table-light:hover{background-color:#dadde2}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#dadde2}.table-dark,.table-dark>th,.table-dark>td{background-color:#c5c5c5}.table-dark th,.table-dark td,.table-dark thead th,.table-dark tbody+tbody{border-color:#939393}.table-hover .table-dark:hover{background-color:#b8b8b8}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b8b8b8}.table-active,.table-active>th,.table-active>td{background-color:rgba(0,0,0,0.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,0.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,0.075)}.table .thead-dark th{color:#fff;background-color:#303030;border-color:#434343}.table .thead-light th{color:#444;background-color:#ebebeb;border-color:#444}.table-dark{color:#fff;background-color:#303030}.table-dark th,.table-dark td,.table-dark thead th{border-color:#434343}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,0.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,0.075)}@media (max-width: 575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width: 767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width: 991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width: 1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + 0.75rem + 2px);padding:0.375rem 0.75rem;font-size:0.9375rem;font-weight:400;line-height:1.5;color:#444;background-color:#fff;background-clip:padding-box;border:1px solid #222;border-radius:0.25rem;-webkit-transition:border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{-webkit-transition:none;transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #444}.form-control:focus{color:#444;background-color:#fff;border-color:#739ac2;outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.form-control::-webkit-input-placeholder{color:#888;opacity:1}.form-control::-ms-input-placeholder{color:#888;opacity:1}.form-control::placeholder{color:#888;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#ebebeb;opacity:1}input[type="date"].form-control,input[type="time"].form-control,input[type="datetime-local"].form-control,input[type="month"].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#444;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(0.375rem + 1px);padding-bottom:calc(0.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(0.5rem + 1px);padding-bottom:calc(0.5rem + 1px);font-size:1.171875rem;line-height:1.5}.col-form-label-sm{padding-top:calc(0.25rem + 1px);padding-bottom:calc(0.25rem + 1px);font-size:0.8203125rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:0.375rem 0;margin-bottom:0;font-size:0.9375rem;line-height:1.5;color:#fff;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-sm,.form-control-plaintext.form-control-lg{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + 0.5rem + 2px);padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5;border-radius:0.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:0.5rem 1rem;font-size:1.171875rem;line-height:1.5;border-radius:0.3rem}select.form-control[size],select.form-control[multiple]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:0.25rem}.form-row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*="col-"]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:0.3rem;margin-left:-1.25rem}.form-check-input[disabled] ~ .form-check-label,.form-check-input:disabled ~ .form-check-label{color:#888}.form-check-label{margin-bottom:0}.form-check-inline{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:0.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:0.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:0.25rem;font-size:80%;color:#00bc8c}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:0.25rem 0.5rem;margin-top:.1rem;font-size:0.8203125rem;line-height:1.5;color:#fff;background-color:rgba(0,188,140,0.9);border-radius:0.25rem}.was-validated :valid ~ .valid-feedback,.was-validated :valid ~ .valid-tooltip,.is-valid ~ .valid-feedback,.is-valid ~ .valid-tooltip{display:block}.was-validated .form-control:valid,.form-control.is-valid{border-color:#00bc8c;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2300bc8c' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:valid:focus,.form-control.is-valid:focus{border-color:#00bc8c;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .custom-select:valid,.custom-select.is-valid{border-color:#00bc8c;padding-right:calc(0.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23303030' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2300bc8c' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .custom-select:valid:focus,.custom-select.is-valid:focus{border-color:#00bc8c;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.was-validated .form-check-input:valid ~ .form-check-label,.form-check-input.is-valid ~ .form-check-label{color:#00bc8c}.was-validated .form-check-input:valid ~ .valid-feedback,.was-validated .form-check-input:valid ~ .valid-tooltip,.form-check-input.is-valid ~ .valid-feedback,.form-check-input.is-valid ~ .valid-tooltip{display:block}.was-validated .custom-control-input:valid ~ .custom-control-label,.custom-control-input.is-valid ~ .custom-control-label{color:#00bc8c}.was-validated .custom-control-input:valid ~ .custom-control-label::before,.custom-control-input.is-valid ~ .custom-control-label::before{border-color:#00bc8c}.was-validated .custom-control-input:valid:checked ~ .custom-control-label::before,.custom-control-input.is-valid:checked ~ .custom-control-label::before{border-color:#00efb2;background-color:#00efb2}.was-validated .custom-control-input:valid:focus ~ .custom-control-label::before,.custom-control-input.is-valid:focus ~ .custom-control-label::before{-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.was-validated .custom-control-input:valid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-valid:focus:not(:checked) ~ .custom-control-label::before{border-color:#00bc8c}.was-validated .custom-file-input:valid ~ .custom-file-label,.custom-file-input.is-valid ~ .custom-file-label{border-color:#00bc8c}.was-validated .custom-file-input:valid:focus ~ .custom-file-label,.custom-file-input.is-valid:focus ~ .custom-file-label{border-color:#00bc8c;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.25)}.invalid-feedback{display:none;width:100%;margin-top:0.25rem;font-size:80%;color:#E74C3C}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:0.25rem 0.5rem;margin-top:.1rem;font-size:0.8203125rem;line-height:1.5;color:#fff;background-color:rgba(231,76,60,0.9);border-radius:0.25rem}.was-validated :invalid ~ .invalid-feedback,.was-validated :invalid ~ .invalid-tooltip,.is-invalid ~ .invalid-feedback,.is-invalid ~ .invalid-tooltip{display:block}.was-validated .form-control:invalid,.form-control.is-invalid{border-color:#E74C3C;padding-right:calc(1.5em + 0.75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23E74C3C' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23E74C3C' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(0.375em + 0.1875rem) center;background-size:calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .form-control:invalid:focus,.form-control.is-invalid:focus{border-color:#E74C3C;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + 0.75rem);background-position:top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem)}.was-validated .custom-select:invalid,.custom-select.is-invalid{border-color:#E74C3C;padding-right:calc(0.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23303030' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23E74C3C' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23E74C3C' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem)}.was-validated .custom-select:invalid:focus,.custom-select.is-invalid:focus{border-color:#E74C3C;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.was-validated .form-check-input:invalid ~ .form-check-label,.form-check-input.is-invalid ~ .form-check-label{color:#E74C3C}.was-validated .form-check-input:invalid ~ .invalid-feedback,.was-validated .form-check-input:invalid ~ .invalid-tooltip,.form-check-input.is-invalid ~ .invalid-feedback,.form-check-input.is-invalid ~ .invalid-tooltip{display:block}.was-validated .custom-control-input:invalid ~ .custom-control-label,.custom-control-input.is-invalid ~ .custom-control-label{color:#E74C3C}.was-validated .custom-control-input:invalid ~ .custom-control-label::before,.custom-control-input.is-invalid ~ .custom-control-label::before{border-color:#E74C3C}.was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before,.custom-control-input.is-invalid:checked ~ .custom-control-label::before{border-color:#ed7669;background-color:#ed7669}.was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before,.custom-control-input.is-invalid:focus ~ .custom-control-label::before{-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.was-validated .custom-control-input:invalid:focus:not(:checked) ~ .custom-control-label::before,.custom-control-input.is-invalid:focus:not(:checked) ~ .custom-control-label::before{border-color:#E74C3C}.was-validated .custom-file-input:invalid ~ .custom-file-label,.custom-file-input.is-invalid ~ .custom-file-label{border-color:#E74C3C}.was-validated .custom-file-input:invalid:focus ~ .custom-file-label,.custom-file-input.is-invalid:focus ~ .custom-file-label{border-color:#E74C3C;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.25)}.form-inline{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width: 576px){.form-inline label{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .input-group,.form-inline .custom-select{width:auto}.form-inline .form-check{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:0.25rem;margin-left:0}.form-inline .custom-control{-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#fff;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:0.375rem 0.75rem;font-size:0.9375rem;line-height:1.5;border-radius:0.25rem;-webkit-transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{-webkit-transition:none;transition:none}}.btn:hover{color:#fff;text-decoration:none}.btn:focus,.btn.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.btn.disabled,.btn:disabled{opacity:0.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-primary:hover{color:#fff;background-color:#2b4764;border-color:#28415b}.btn-primary:focus,.btn-primary.focus{color:#fff;background-color:#2b4764;border-color:#28415b;-webkit-box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5);box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-primary:not(:disabled):not(.disabled):active,.btn-primary:not(:disabled):not(.disabled).active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#28415b;border-color:#243a53}.btn-primary:not(:disabled):not(.disabled):active:focus,.btn-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-primary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5);box-shadow:0 0 0 0.2rem rgba(85,115,146,0.5)}.btn-secondary{color:#fff;background-color:#444;border-color:#444}.btn-secondary:hover{color:#fff;background-color:#313131;border-color:#2b2a2a}.btn-secondary:focus,.btn-secondary.focus{color:#fff;background-color:#313131;border-color:#2b2a2a;-webkit-box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5);box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#444;border-color:#444}.btn-secondary:not(:disabled):not(.disabled):active,.btn-secondary:not(:disabled):not(.disabled).active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#2b2a2a;border-color:#242424}.btn-secondary:not(:disabled):not(.disabled):active:focus,.btn-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-secondary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5);box-shadow:0 0 0 0.2rem rgba(96,96,96,0.5)}.btn-success{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-success:hover{color:#fff;background-color:#009670;border-color:#008966}.btn-success:focus,.btn-success.focus{color:#fff;background-color:#009670;border-color:#008966;-webkit-box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5);box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-success:not(:disabled):not(.disabled):active,.btn-success:not(:disabled):not(.disabled).active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#008966;border-color:#007c5d}.btn-success:not(:disabled):not(.disabled):active:focus,.btn-success:not(:disabled):not(.disabled).active:focus,.show>.btn-success.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5);box-shadow:0 0 0 0.2rem rgba(38,198,157,0.5)}.btn-info{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-info:hover{color:#fff;background-color:#2384c6;border-color:#217dbb}.btn-info:focus,.btn-info.focus{color:#fff;background-color:#2384c6;border-color:#217dbb;-webkit-box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5);box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-info:not(:disabled):not(.disabled):active,.btn-info:not(:disabled):not(.disabled).active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#217dbb;border-color:#1f76b0}.btn-info:not(:disabled):not(.disabled):active:focus,.btn-info:not(:disabled):not(.disabled).active:focus,.show>.btn-info.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5);box-shadow:0 0 0 0.2rem rgba(82,167,224,0.5)}.btn-warning{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-warning:hover{color:#fff;background-color:#d4860b;border-color:#c87f0a}.btn-warning:focus,.btn-warning.focus{color:#fff;background-color:#d4860b;border-color:#c87f0a;-webkit-box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5);box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5)}.btn-warning.disabled,.btn-warning:disabled{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-warning:not(:disabled):not(.disabled):active,.btn-warning:not(:disabled):not(.disabled).active,.show>.btn-warning.dropdown-toggle{color:#fff;background-color:#c87f0a;border-color:#bc770a}.btn-warning:not(:disabled):not(.disabled):active:focus,.btn-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-warning.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5);box-shadow:0 0 0 0.2rem rgba(245,171,54,0.5)}.btn-danger{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-danger:hover{color:#fff;background-color:#e12e1c;border-color:#d62c1a}.btn-danger:focus,.btn-danger.focus{color:#fff;background-color:#e12e1c;border-color:#d62c1a;-webkit-box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5);box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-danger:not(:disabled):not(.disabled):active,.btn-danger:not(:disabled):not(.disabled).active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#d62c1a;border-color:#ca2a19}.btn-danger:not(:disabled):not(.disabled):active:focus,.btn-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-danger.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5);box-shadow:0 0 0 0.2rem rgba(235,103,89,0.5)}.btn-light{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-light:hover{color:#fff;background-color:#98a2ac;border-color:#919ca6}.btn-light:focus,.btn-light.focus{color:#fff;background-color:#98a2ac;border-color:#919ca6;-webkit-box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5);box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5)}.btn-light.disabled,.btn-light:disabled{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-light:not(:disabled):not(.disabled):active,.btn-light:not(:disabled):not(.disabled).active,.show>.btn-light.dropdown-toggle{color:#fff;background-color:#919ca6;border-color:#8a95a1}.btn-light:not(:disabled):not(.disabled):active:focus,.btn-light:not(:disabled):not(.disabled).active:focus,.show>.btn-light.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5);box-shadow:0 0 0 0.2rem rgba(152,159,166,0.5)}.btn-dark{color:#fff;background-color:#303030;border-color:#303030}.btn-dark:hover{color:#fff;background-color:#1d1d1d;border-color:#171616}.btn-dark:focus,.btn-dark.focus{color:#fff;background-color:#1d1d1d;border-color:#171616;-webkit-box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5);box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#303030;border-color:#303030}.btn-dark:not(:disabled):not(.disabled):active,.btn-dark:not(:disabled):not(.disabled).active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#171616;border-color:#101010}.btn-dark:not(:disabled):not(.disabled):active:focus,.btn-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-dark.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5);box-shadow:0 0 0 0.2rem rgba(79,79,79,0.5)}.btn-outline-primary{color:#375a7f;border-color:#375a7f}.btn-outline-primary:hover{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-outline-primary:focus,.btn-outline-primary.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#375a7f;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled):active,.btn-outline-primary:not(:disabled):not(.disabled).active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#375a7f;border-color:#375a7f}.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5)}.btn-outline-secondary{color:#444;border-color:#444}.btn-outline-secondary:hover{color:#fff;background-color:#444;border-color:#444}.btn-outline-secondary:focus,.btn-outline-secondary.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5);box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#444;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled):active,.btn-outline-secondary:not(:disabled):not(.disabled).active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#444;border-color:#444}.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5);box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5)}.btn-outline-success{color:#00bc8c;border-color:#00bc8c}.btn-outline-success:hover{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-outline-success:focus,.btn-outline-success.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#00bc8c;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled):active,.btn-outline-success:not(:disabled):not(.disabled).active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#00bc8c;border-color:#00bc8c}.btn-outline-success:not(:disabled):not(.disabled):active:focus,.btn-outline-success:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-success.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5)}.btn-outline-info{color:#3498DB;border-color:#3498DB}.btn-outline-info:hover{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-outline-info:focus,.btn-outline-info.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5);box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#3498DB;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled):active,.btn-outline-info:not(:disabled):not(.disabled).active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#3498DB;border-color:#3498DB}.btn-outline-info:not(:disabled):not(.disabled):active:focus,.btn-outline-info:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-info.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5);box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5)}.btn-outline-warning{color:#F39C12;border-color:#F39C12}.btn-outline-warning:hover{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-outline-warning:focus,.btn-outline-warning.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5);box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#F39C12;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled):active,.btn-outline-warning:not(:disabled):not(.disabled).active,.show>.btn-outline-warning.dropdown-toggle{color:#fff;background-color:#F39C12;border-color:#F39C12}.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5);box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5)}.btn-outline-danger{color:#E74C3C;border-color:#E74C3C}.btn-outline-danger:hover{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-outline-danger:focus,.btn-outline-danger.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#E74C3C;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled):active,.btn-outline-danger:not(:disabled):not(.disabled).active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#E74C3C;border-color:#E74C3C}.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5)}.btn-outline-light{color:#adb5bd;border-color:#adb5bd}.btn-outline-light:hover{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-outline-light:focus,.btn-outline-light.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5);box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#adb5bd;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled):active,.btn-outline-light:not(:disabled):not(.disabled).active,.show>.btn-outline-light.dropdown-toggle{color:#222;background-color:#adb5bd;border-color:#adb5bd}.btn-outline-light:not(:disabled):not(.disabled):active:focus,.btn-outline-light:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-light.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5);box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5)}.btn-outline-dark{color:#303030;border-color:#303030}.btn-outline-dark:hover{color:#fff;background-color:#303030;border-color:#303030}.btn-outline-dark:focus,.btn-outline-dark.focus{-webkit-box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5);box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#303030;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled):active,.btn-outline-dark:not(:disabled):not(.disabled).active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#303030;border-color:#303030}.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{-webkit-box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5);box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5)}.btn-link{font-weight:400;color:#00bc8c;text-decoration:none}.btn-link:hover{color:#007053;text-decoration:underline}.btn-link:focus,.btn-link.focus{text-decoration:underline}.btn-link:disabled,.btn-link.disabled{color:#888;pointer-events:none}.btn-lg,.btn-group-lg>.btn{padding:0.5rem 1rem;font-size:1.171875rem;line-height:1.5;border-radius:0.3rem}.btn-sm,.btn-group-sm>.btn{padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5;border-radius:0.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:0.5rem}input[type="submit"].btn-block,input[type="reset"].btn-block,input[type="button"].btn-block{width:100%}.fade{-webkit-transition:opacity 0.15s linear;transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{-webkit-transition:none;transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition:height 0.35s ease;transition:height 0.35s ease}@media (prefers-reduced-motion: reduce){.collapsing{-webkit-transition:none;transition:none}}.dropup,.dropright,.dropdown,.dropleft{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:"";border-top:0.3em solid;border-right:0.3em solid transparent;border-bottom:0;border-left:0.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:0.5rem 0;margin:0.125rem 0 0;font-size:0.9375rem;color:#fff;text-align:left;list-style:none;background-color:#222;background-clip:padding-box;border:1px solid #444;border-radius:0.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width: 576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width: 768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width: 992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width: 1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:0.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:"";border-top:0;border-right:0.3em solid transparent;border-bottom:0.3em solid;border-left:0.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:0.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:"";border-top:0.3em solid transparent;border-right:0;border-bottom:0.3em solid transparent;border-left:0.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:0.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:0.255em;vertical-align:0.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:0.255em;vertical-align:0.255em;content:"";border-top:0.3em solid transparent;border-right:0.3em solid;border-bottom:0.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^="top"],.dropdown-menu[x-placement^="right"],.dropdown-menu[x-placement^="bottom"],.dropdown-menu[x-placement^="left"]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:0.5rem 0;overflow:hidden;border-top:1px solid #444}.dropdown-item{display:block;width:100%;padding:0.25rem 1.5rem;clear:both;font-weight:400;color:#fff;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:hover,.dropdown-item:focus{color:#fff;text-decoration:none;background-color:#375a7f}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#375a7f}.dropdown-item.disabled,.dropdown-item:disabled{color:#888;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:0.5rem 1.5rem;margin-bottom:0;font-size:0.8203125rem;color:#888;white-space:nowrap}.dropdown-item-text{display:block;padding:0.25rem 1.5rem;color:#fff}.btn-group,.btn-group-vertical{position:relative;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group>.btn,.btn-group-vertical>.btn{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group>.btn:hover,.btn-group-vertical>.btn:hover{z-index:1}.btn-group>.btn:focus,.btn-group>.btn:active,.btn-group>.btn.active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn.active{z-index:1}.btn-toolbar{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child){margin-left:-1px}.btn-group>.btn:not(:last-child):not(.dropdown-toggle),.btn-group>.btn-group:not(:last-child)>.btn{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:not(:first-child),.btn-group>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:0.5625rem;padding-left:0.5625rem}.dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-sm+.dropdown-toggle-split,.btn-group-sm>.btn+.dropdown-toggle-split{padding-right:0.375rem;padding-left:0.375rem}.btn-lg+.dropdown-toggle-split,.btn-group-lg>.btn+.dropdown-toggle-split{padding-right:0.75rem;padding-left:0.75rem}.btn-group-vertical{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle),.btn-group-vertical>.btn-group:not(:last-child)>.btn{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:not(:first-child),.btn-group-vertical>.btn-group:not(:first-child)>.btn{border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type="radio"],.btn-group-toggle>.btn input[type="checkbox"],.btn-group-toggle>.btn-group>.btn input[type="radio"],.btn-group-toggle>.btn-group>.btn input[type="checkbox"]{position:absolute;clip:rect(0, 0, 0, 0);pointer-events:none}.input-group{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-control-plaintext,.input-group>.custom-select,.input-group>.custom-file{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.form-control+.form-control,.input-group>.form-control+.custom-select,.input-group>.form-control+.custom-file,.input-group>.form-control-plaintext+.form-control,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.custom-file,.input-group>.custom-select+.form-control,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.custom-file,.input-group>.custom-file+.form-control,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.custom-file{margin-left:-1px}.input-group>.form-control:focus,.input-group>.custom-select:focus,.input-group>.custom-file .custom-file-input:focus ~ .custom-file-label{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.form-control:not(:last-child),.input-group>.custom-select:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.form-control:not(:first-child),.input-group>.custom-select:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-prepend,.input-group-append{display:-webkit-box;display:-ms-flexbox;display:flex}.input-group-prepend .btn,.input-group-append .btn{position:relative;z-index:2}.input-group-prepend .btn:focus,.input-group-append .btn:focus{z-index:3}.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.input-group-text,.input-group-append .input-group-text+.btn{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:0.375rem 0.75rem;margin-bottom:0;font-size:0.9375rem;font-weight:400;line-height:1.5;color:#adb5bd;text-align:center;white-space:nowrap;background-color:#444;border:1px solid #222;border-radius:0.25rem}.input-group-text input[type="radio"],.input-group-text input[type="checkbox"]{margin-top:0}.input-group-lg>.form-control:not(textarea),.input-group-lg>.custom-select{height:calc(1.5em + 1rem + 2px)}.input-group-lg>.form-control,.input-group-lg>.custom-select,.input-group-lg>.input-group-prepend>.input-group-text,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-append>.btn{padding:0.5rem 1rem;font-size:1.171875rem;line-height:1.5;border-radius:0.3rem}.input-group-sm>.form-control:not(textarea),.input-group-sm>.custom-select{height:calc(1.5em + 0.5rem + 2px)}.input-group-sm>.form-control,.input-group-sm>.custom-select,.input-group-sm>.input-group-prepend>.input-group-text,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-append>.btn{padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5;border-radius:0.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text,.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;display:block;min-height:1.40625rem;padding-left:1.5rem}.custom-control-inline{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.203125rem;opacity:0}.custom-control-input:checked ~ .custom-control-label::before{color:#fff;border-color:#375a7f;background-color:#375a7f}.custom-control-input:focus ~ .custom-control-label::before{-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-control-input:focus:not(:checked) ~ .custom-control-label::before{border-color:#739ac2}.custom-control-input:not(:disabled):active ~ .custom-control-label::before{color:#fff;background-color:#97b3d2;border-color:#97b3d2}.custom-control-input[disabled] ~ .custom-control-label,.custom-control-input:disabled ~ .custom-control-label{color:#888}.custom-control-input[disabled] ~ .custom-control-label::before,.custom-control-input:disabled ~ .custom-control-label::before{background-color:#ebebeb}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:0.203125rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:0.203125rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50% / 50% 50%}.custom-checkbox .custom-control-label::before{border-radius:0.25rem}.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before{border-color:#375a7f;background-color:#375a7f}.custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked ~ .custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:0.5rem}.custom-switch .custom-control-label::after{top:calc(0.203125rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:0.5rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-transform 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-switch .custom-control-label::after{-webkit-transition:none;transition:none}}.custom-switch .custom-control-input:checked ~ .custom-control-label::after{background-color:#fff;-webkit-transform:translateX(0.75rem);transform:translateX(0.75rem)}.custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before{background-color:rgba(55,90,127,0.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + 0.75rem + 2px);padding:0.375rem 1.75rem 0.375rem 0.75rem;font-size:0.9375rem;font-weight:400;line-height:1.5;color:#444;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23303030' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 0.75rem center/8px 10px;border:1px solid #222;border-radius:0.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#739ac2;outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-select:focus::-ms-value{color:#444;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:0.75rem;background-image:none}.custom-select:disabled{color:#888;background-color:#ebebeb}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #444}.custom-select-sm{height:calc(1.5em + 0.5rem + 2px);padding-top:0.25rem;padding-bottom:0.25rem;padding-left:0.5rem;font-size:0.8203125rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:0.5rem;padding-bottom:0.5rem;padding-left:1rem;font-size:1.171875rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + 0.75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + 0.75rem + 2px);margin:0;opacity:0}.custom-file-input:focus ~ .custom-file-label{border-color:#739ac2;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-file-input[disabled] ~ .custom-file-label,.custom-file-input:disabled ~ .custom-file-label{background-color:#ebebeb}.custom-file-input:lang(en) ~ .custom-file-label::after{content:"Browse"}.custom-file-input ~ .custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + 0.75rem + 2px);padding:0.375rem 0.75rem;font-weight:400;line-height:1.5;color:#adb5bd;background-color:#fff;border:1px solid #222;border-radius:0.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + 0.75rem);padding:0.375rem 0.75rem;line-height:1.5;color:#adb5bd;content:"Browse";background-color:#444;border-left:inherit;border-radius:0 0.25rem 0.25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:none}.custom-range:focus::-webkit-slider-thumb{-webkit-box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #222,0 0 0 0.2rem rgba(55,90,127,0.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-0.25rem;background-color:#375a7f;border:0;border-radius:1rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#97b3d2}.custom-range::-webkit-slider-runnable-track{width:100%;height:0.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#375a7f;border:0;border-radius:1rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-moz-range-thumb{-webkit-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#97b3d2}.custom-range::-moz-range-track{width:100%;height:0.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:0.2rem;margin-left:0.2rem;background-color:#375a7f;border:0;border-radius:1rem;-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;appearance:none}@media (prefers-reduced-motion: reduce){.custom-range::-ms-thumb{-webkit-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#97b3d2}.custom-range::-ms-track{width:100%;height:0.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:0.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{-webkit-transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.custom-control-label::before,.custom-file-label,.custom-select{-webkit-transition:none;transition:none}}.nav{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:0.5rem 2rem}.nav-link:hover,.nav-link:focus{text-decoration:none}.nav-link.disabled{color:#adb5bd;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #444}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:0.25rem;border-top-right-radius:0.25rem}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{border-color:#444 #444 transparent}.nav-tabs .nav-link.disabled{color:#adb5bd;background-color:transparent;border-color:transparent}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:#fff;background-color:#222;border-color:#444 #444 transparent}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:0.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#375a7f}.nav-fill .nav-item{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-sm,.navbar .container-md,.navbar .container-lg,.navbar .container-xl{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:0.32421875rem;padding-bottom:0.32421875rem;margin-right:1rem;font-size:1.171875rem;line-height:inherit;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{text-decoration:none}.navbar-nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:0.5rem;padding-bottom:0.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:0.25rem 0.75rem;font-size:1.171875rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:0.25rem}.navbar-toggler:hover,.navbar-toggler:focus{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width: 575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 576px){.navbar-expand-sm{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width: 767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-md,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 768px){.navbar-expand-md{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-md,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width: 991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 992px){.navbar-expand-lg{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width: 1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width: 1200px){.navbar-expand-xl{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-sm,.navbar-expand>.container-md,.navbar-expand>.container-lg,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:0.5rem;padding-left:0.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-sm,.navbar-expand>.container-md,.navbar-expand>.container-lg,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:#222}.navbar-light .navbar-brand:hover,.navbar-light .navbar-brand:focus{color:#222}.navbar-light .navbar-nav .nav-link{color:rgba(34,34,34,0.7)}.navbar-light .navbar-nav .nav-link:hover,.navbar-light .navbar-nav .nav-link:focus{color:#222}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,0.3)}.navbar-light .navbar-nav .show>.nav-link,.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .nav-link.active{color:#222}.navbar-light .navbar-toggler{color:rgba(34,34,34,0.7);border-color:rgba(34,34,34,0.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2834, 34, 34, 0.7%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(34,34,34,0.7)}.navbar-light .navbar-text a{color:#222}.navbar-light .navbar-text a:hover,.navbar-light .navbar-text a:focus{color:#222}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:hover,.navbar-dark .navbar-brand:focus{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,0.6)}.navbar-dark .navbar-nav .nav-link:hover,.navbar-dark .navbar-nav .nav-link:focus{color:#fff}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,0.25)}.navbar-dark .navbar-nav .show>.nav-link,.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .nav-link.active{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,0.6);border-color:rgba(255,255,255,0.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.6%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,0.6)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:hover,.navbar-dark .navbar-text a:focus{color:#fff}.card{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#303030;background-clip:border-box;border:1px solid rgba(0,0,0,0.125);border-radius:0.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(0.25rem - 1px);border-top-right-radius:calc(0.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(0.25rem - 1px);border-bottom-left-radius:calc(0.25rem - 1px)}.card-body{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:0.75rem}.card-subtitle{margin-top:-0.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:0.75rem 1.25rem;margin-bottom:0;background-color:#444;border-bottom:1px solid rgba(0,0,0,0.125)}.card-header:first-child{border-radius:calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:0.75rem 1.25rem;background-color:#444;border-top:1px solid rgba(0,0,0,0.125)}.card-footer:last-child{border-radius:0 0 calc(0.25rem - 1px) calc(0.25rem - 1px)}.card-header-tabs{margin-right:-0.625rem;margin-bottom:-0.75rem;margin-left:-0.625rem;border-bottom:0}.card-header-pills{margin-right:-0.625rem;margin-left:-0.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem}.card-img,.card-img-top,.card-img-bottom{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(0.25rem - 1px);border-top-right-radius:calc(0.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(0.25rem - 1px);border-bottom-left-radius:calc(0.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width: 576px){.card-deck{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width: 576px){.card-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-img-top,.card-group>.card:not(:last-child) .card-header{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-img-bottom,.card-group>.card:not(:last-child) .card-footer{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-img-top,.card-group>.card:not(:first-child) .card-header{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-img-bottom,.card-group>.card:not(:first-child) .card-footer{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:0.75rem}@media (min-width: 576px){.card-columns{-webkit-column-count:3;column-count:3;-webkit-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:0.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#444;border-radius:0.25rem}.breadcrumb-item{display:-webkit-box;display:-ms-flexbox;display:flex}.breadcrumb-item+.breadcrumb-item{padding-left:0.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:0.5rem;color:#888;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#888}.pagination{display:-webkit-box;display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:0.25rem}.page-link{position:relative;display:block;padding:0.5rem 0.75rem;margin-left:0;line-height:1.25;color:#fff;background-color:#00bc8c;border:0 solid transparent}.page-link:hover{z-index:2;color:#fff;text-decoration:none;background-color:#00efb2;border-color:transparent}.page-link:focus{z-index:3;outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:0.25rem;border-bottom-left-radius:0.25rem}.page-item:last-child .page-link{border-top-right-radius:0.25rem;border-bottom-right-radius:0.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#00efb2;border-color:transparent}.page-item.disabled .page-link{color:#fff;pointer-events:none;cursor:auto;background-color:#007053;border-color:transparent}.pagination-lg .page-link{padding:0.75rem 1.5rem;font-size:1.171875rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:0.3rem;border-bottom-left-radius:0.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:0.3rem;border-bottom-right-radius:0.3rem}.pagination-sm .page-link{padding:0.25rem 0.5rem;font-size:0.8203125rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:0.2rem;border-bottom-left-radius:0.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:0.2rem;border-bottom-right-radius:0.2rem}.badge{display:inline-block;padding:0.25em 0.4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:0.25rem;-webkit-transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;transition:color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.badge{-webkit-transition:none;transition:none}}a.badge:hover,a.badge:focus{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:0.6em;padding-left:0.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#375a7f}a.badge-primary:hover,a.badge-primary:focus{color:#fff;background-color:#28415b}a.badge-primary:focus,a.badge-primary.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5);box-shadow:0 0 0 0.2rem rgba(55,90,127,0.5)}.badge-secondary{color:#fff;background-color:#444}a.badge-secondary:hover,a.badge-secondary:focus{color:#fff;background-color:#2b2a2a}a.badge-secondary:focus,a.badge-secondary.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5);box-shadow:0 0 0 0.2rem rgba(68,68,68,0.5)}.badge-success{color:#fff;background-color:#00bc8c}a.badge-success:hover,a.badge-success:focus{color:#fff;background-color:#008966}a.badge-success:focus,a.badge-success.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5);box-shadow:0 0 0 0.2rem rgba(0,188,140,0.5)}.badge-info{color:#fff;background-color:#3498DB}a.badge-info:hover,a.badge-info:focus{color:#fff;background-color:#217dbb}a.badge-info:focus,a.badge-info.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5);box-shadow:0 0 0 0.2rem rgba(52,152,219,0.5)}.badge-warning{color:#fff;background-color:#F39C12}a.badge-warning:hover,a.badge-warning:focus{color:#fff;background-color:#c87f0a}a.badge-warning:focus,a.badge-warning.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5);box-shadow:0 0 0 0.2rem rgba(243,156,18,0.5)}.badge-danger{color:#fff;background-color:#E74C3C}a.badge-danger:hover,a.badge-danger:focus{color:#fff;background-color:#d62c1a}a.badge-danger:focus,a.badge-danger.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5);box-shadow:0 0 0 0.2rem rgba(231,76,60,0.5)}.badge-light{color:#222;background-color:#adb5bd}a.badge-light:hover,a.badge-light:focus{color:#222;background-color:#919ca6}a.badge-light:focus,a.badge-light.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5);box-shadow:0 0 0 0.2rem rgba(173,181,189,0.5)}.badge-dark{color:#fff;background-color:#303030}a.badge-dark:hover,a.badge-dark:focus{color:#fff;background-color:#171616}a.badge-dark:focus,a.badge-dark.focus{outline:0;-webkit-box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5);box-shadow:0 0 0 0.2rem rgba(48,48,48,0.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#303030;border-radius:0.3rem}@media (min-width: 576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:0.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:0.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3.90625rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:0.75rem 1.25rem;color:inherit}.alert-primary{color:#1d2f42;background-color:#d7dee5;border-color:#c7d1db}.alert-primary hr{border-top-color:#b7c4d1}.alert-primary .alert-link{color:#0d161f}.alert-secondary{color:#232323;background-color:#dadada;border-color:#cbcbcb}.alert-secondary hr{border-top-color:#bebebe}.alert-secondary .alert-link{color:#0a0909}.alert-success{color:#006249;background-color:#ccf2e8;border-color:#b8ecdf}.alert-success hr{border-top-color:#a4e7d6}.alert-success .alert-link{color:#002f23}.alert-info{color:#1b4f72;background-color:#d6eaf8;border-color:#c6e2f5}.alert-info hr{border-top-color:#b0d7f1}.alert-info .alert-link{color:#113249}.alert-warning{color:#7e5109;background-color:#fdebd0;border-color:#fce3bd}.alert-warning hr{border-top-color:#fbd9a5}.alert-warning .alert-link{color:#4e3206}.alert-danger{color:#78281f;background-color:#fadbd8;border-color:#f8cdc8}.alert-danger hr{border-top-color:#f5b8b1}.alert-danger .alert-link{color:#4f1a15}.alert-light{color:#5a5e62;background-color:#eff0f2;border-color:#e8eaed}.alert-light hr{border-top-color:#dadde2}.alert-light .alert-link{color:#424547}.alert-dark{color:#191919;background-color:#d6d6d6;border-color:#c5c5c5}.alert-dark hr{border-top-color:#b8b8b8}.alert-dark .alert-link{color:black}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-webkit-box;display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:0.703125rem;background-color:#444;border-radius:0.25rem}.progress-bar{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#375a7f;-webkit-transition:width 0.6s ease;transition:width 0.6s ease}@media (prefers-reduced-motion: reduce){.progress-bar{-webkit-transition:none;transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion: reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.media-body{-webkit-box-flex:1;-ms-flex:1;flex:1}.list-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:0.25rem}.list-group-item-action{width:100%;color:#444;text-align:inherit}.list-group-item-action:hover,.list-group-item-action:focus{z-index:1;color:#444;text-decoration:none;background-color:#444}.list-group-item-action:active{color:#fff;background-color:#ebebeb}.list-group-item{position:relative;display:block;padding:0.75rem 1.25rem;background-color:#303030;border:1px solid #444}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#888;pointer-events:none;background-color:#303030}.list-group-item.active{z-index:2;color:#fff;background-color:#375a7f;border-color:#375a7f}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width: 576px){.list-group-horizontal-sm{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width: 768px){.list-group-horizontal-md{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width: 992px){.list-group-horizontal-lg{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width: 1200px){.list-group-horizontal-xl{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:0.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:0.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#1d2f42;background-color:#c7d1db}.list-group-item-primary.list-group-item-action:hover,.list-group-item-primary.list-group-item-action:focus{color:#1d2f42;background-color:#b7c4d1}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#1d2f42;border-color:#1d2f42}.list-group-item-secondary{color:#232323;background-color:#cbcbcb}.list-group-item-secondary.list-group-item-action:hover,.list-group-item-secondary.list-group-item-action:focus{color:#232323;background-color:#bebebe}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#232323;border-color:#232323}.list-group-item-success{color:#006249;background-color:#b8ecdf}.list-group-item-success.list-group-item-action:hover,.list-group-item-success.list-group-item-action:focus{color:#006249;background-color:#a4e7d6}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#006249;border-color:#006249}.list-group-item-info{color:#1b4f72;background-color:#c6e2f5}.list-group-item-info.list-group-item-action:hover,.list-group-item-info.list-group-item-action:focus{color:#1b4f72;background-color:#b0d7f1}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#1b4f72;border-color:#1b4f72}.list-group-item-warning{color:#7e5109;background-color:#fce3bd}.list-group-item-warning.list-group-item-action:hover,.list-group-item-warning.list-group-item-action:focus{color:#7e5109;background-color:#fbd9a5}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#7e5109;border-color:#7e5109}.list-group-item-danger{color:#78281f;background-color:#f8cdc8}.list-group-item-danger.list-group-item-action:hover,.list-group-item-danger.list-group-item-action:focus{color:#78281f;background-color:#f5b8b1}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#78281f;border-color:#78281f}.list-group-item-light{color:#5a5e62;background-color:#e8eaed}.list-group-item-light.list-group-item-action:hover,.list-group-item-light.list-group-item-action:focus{color:#5a5e62;background-color:#dadde2}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#5a5e62;border-color:#5a5e62}.list-group-item-dark{color:#191919;background-color:#c5c5c5}.list-group-item-dark.list-group-item-action:hover,.list-group-item-dark.list-group-item-action:focus{color:#191919;background-color:#b8b8b8}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#191919;border-color:#191919}.close{float:right;font-size:1.40625rem;font-weight:700;line-height:1;color:#fff;text-shadow:none;opacity:.5}.close:hover{color:#fff;text-decoration:none}.close:not(:disabled):not(.disabled):hover,.close:not(:disabled):not(.disabled):focus{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{max-width:350px;overflow:hidden;font-size:0.875rem;background-color:#444;background-clip:padding-box;border:1px solid rgba(0,0,0,0.1);-webkit-box-shadow:0 0.25rem 0.75rem rgba(0,0,0,0.1);box-shadow:0 0.25rem 0.75rem rgba(0,0,0,0.1);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);opacity:0;border-radius:0.25rem}.toast:not(:last-child){margin-bottom:0.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:0.25rem 0.75rem;color:#888;background-color:#303030;background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,0.05)}.toast-body{padding:0.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:0.5rem;pointer-events:none}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform 0.3s ease-out;transition:-webkit-transform 0.3s ease-out;transition:transform 0.3s ease-out;transition:transform 0.3s ease-out, -webkit-transform 0.3s ease-out;-webkit-transform:translate(0, -50px);transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{-webkit-transition:none;transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-webkit-box;display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-header,.modal-dialog-scrollable .modal-footer{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#303030;background-clip:padding-box;border:1px solid #444;border-radius:0.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:0.5}.modal-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #444;border-top-left-radius:calc(0.3rem - 1px);border-top-right-radius:calc(0.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;padding:0.75rem;border-top:1px solid #444;border-bottom-right-radius:calc(0.3rem - 1px);border-bottom-left-radius:calc(0.3rem - 1px)}.modal-footer>*{margin:0.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width: 576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width: 992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width: 1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:"Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:0.8203125rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:0.9}.tooltip .arrow{position:absolute;display:block;width:0.8rem;height:0.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-top,.bs-tooltip-auto[x-placement^="top"]{padding:0.4rem 0}.bs-tooltip-top .arrow,.bs-tooltip-auto[x-placement^="top"] .arrow{bottom:0}.bs-tooltip-top .arrow::before,.bs-tooltip-auto[x-placement^="top"] .arrow::before{top:0;border-width:0.4rem 0.4rem 0;border-top-color:#000}.bs-tooltip-right,.bs-tooltip-auto[x-placement^="right"]{padding:0 0.4rem}.bs-tooltip-right .arrow,.bs-tooltip-auto[x-placement^="right"] .arrow{left:0;width:0.4rem;height:0.8rem}.bs-tooltip-right .arrow::before,.bs-tooltip-auto[x-placement^="right"] .arrow::before{right:0;border-width:0.4rem 0.4rem 0.4rem 0;border-right-color:#000}.bs-tooltip-bottom,.bs-tooltip-auto[x-placement^="bottom"]{padding:0.4rem 0}.bs-tooltip-bottom .arrow,.bs-tooltip-auto[x-placement^="bottom"] .arrow{top:0}.bs-tooltip-bottom .arrow::before,.bs-tooltip-auto[x-placement^="bottom"] .arrow::before{bottom:0;border-width:0 0.4rem 0.4rem;border-bottom-color:#000}.bs-tooltip-left,.bs-tooltip-auto[x-placement^="left"]{padding:0 0.4rem}.bs-tooltip-left .arrow,.bs-tooltip-auto[x-placement^="left"] .arrow{right:0;width:0.4rem;height:0.8rem}.bs-tooltip-left .arrow::before,.bs-tooltip-auto[x-placement^="left"] .arrow::before{left:0;border-width:0.4rem 0 0.4rem 0.4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:0.25rem 0.5rem;color:#fff;text-align:center;background-color:#000;border-radius:0.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:"Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:0.8203125rem;word-wrap:break-word;background-color:#303030;background-clip:padding-box;border:1px solid rgba(0,0,0,0.2);border-radius:0.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:0.5rem;margin:0 0.3rem}.popover .arrow::before,.popover .arrow::after{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-top,.bs-popover-auto[x-placement^="top"]{margin-bottom:0.5rem}.bs-popover-top>.arrow,.bs-popover-auto[x-placement^="top"]>.arrow{bottom:calc(-0.5rem - 1px)}.bs-popover-top>.arrow::before,.bs-popover-auto[x-placement^="top"]>.arrow::before{bottom:0;border-width:0.5rem 0.5rem 0;border-top-color:rgba(0,0,0,0.25)}.bs-popover-top>.arrow::after,.bs-popover-auto[x-placement^="top"]>.arrow::after{bottom:1px;border-width:0.5rem 0.5rem 0;border-top-color:#303030}.bs-popover-right,.bs-popover-auto[x-placement^="right"]{margin-left:0.5rem}.bs-popover-right>.arrow,.bs-popover-auto[x-placement^="right"]>.arrow{left:calc(-0.5rem - 1px);width:0.5rem;height:1rem;margin:0.3rem 0}.bs-popover-right>.arrow::before,.bs-popover-auto[x-placement^="right"]>.arrow::before{left:0;border-width:0.5rem 0.5rem 0.5rem 0;border-right-color:rgba(0,0,0,0.25)}.bs-popover-right>.arrow::after,.bs-popover-auto[x-placement^="right"]>.arrow::after{left:1px;border-width:0.5rem 0.5rem 0.5rem 0;border-right-color:#303030}.bs-popover-bottom,.bs-popover-auto[x-placement^="bottom"]{margin-top:0.5rem}.bs-popover-bottom>.arrow,.bs-popover-auto[x-placement^="bottom"]>.arrow{top:calc(-0.5rem - 1px)}.bs-popover-bottom>.arrow::before,.bs-popover-auto[x-placement^="bottom"]>.arrow::before{top:0;border-width:0 0.5rem 0.5rem 0.5rem;border-bottom-color:rgba(0,0,0,0.25)}.bs-popover-bottom>.arrow::after,.bs-popover-auto[x-placement^="bottom"]>.arrow::after{top:1px;border-width:0 0.5rem 0.5rem 0.5rem;border-bottom-color:#303030}.bs-popover-bottom .popover-header::before,.bs-popover-auto[x-placement^="bottom"] .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-0.5rem;content:"";border-bottom:1px solid #444}.bs-popover-left,.bs-popover-auto[x-placement^="left"]{margin-right:0.5rem}.bs-popover-left>.arrow,.bs-popover-auto[x-placement^="left"]>.arrow{right:calc(-0.5rem - 1px);width:0.5rem;height:1rem;margin:0.3rem 0}.bs-popover-left>.arrow::before,.bs-popover-auto[x-placement^="left"]>.arrow::before{right:0;border-width:0.5rem 0 0.5rem 0.5rem;border-left-color:rgba(0,0,0,0.25)}.bs-popover-left>.arrow::after,.bs-popover-auto[x-placement^="left"]>.arrow::after{right:1px;border-width:0.5rem 0 0.5rem 0.5rem;border-left-color:#303030}.popover-header{padding:0.5rem 0.75rem;margin-bottom:0;font-size:0.9375rem;background-color:#444;border-bottom:1px solid #373737;border-top-left-radius:calc(0.3rem - 1px);border-top-right-radius:calc(0.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:0.5rem 0.75rem;color:#fff}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transition:-webkit-transform 0.6s ease-in-out;transition:-webkit-transform 0.6s ease-in-out;transition:transform 0.6s ease-in-out;transition:transform 0.6s ease-in-out, -webkit-transform 0.6s ease-in-out}@media (prefers-reduced-motion: reduce){.carousel-item{-webkit-transition:none;transition:none}}.carousel-item.active,.carousel-item-next,.carousel-item-prev{display:block}.carousel-item-next:not(.carousel-item-left),.active.carousel-item-right{-webkit-transform:translateX(100%);transform:translateX(100%)}.carousel-item-prev:not(.carousel-item-right),.active.carousel-item-left{-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;-webkit-transition-property:opacity;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item.active,.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;-webkit-transition:opacity 0s 0.6s;transition:opacity 0s 0.6s}@media (prefers-reduced-motion: reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{-webkit-transition:none;transition:none}}.carousel-control-prev,.carousel-control-next{position:absolute;top:0;bottom:0;z-index:1;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:0.5;-webkit-transition:opacity 0.15s ease;transition:opacity 0.15s ease}@media (prefers-reduced-motion: reduce){.carousel-control-prev,.carousel-control-next{-webkit-transition:none;transition:none}}.carousel-control-prev:hover,.carousel-control-prev:focus,.carousel-control-next:hover,.carousel-control-next:focus{color:#fff;text-decoration:none;outline:0;opacity:0.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-prev-icon,.carousel-control-next-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50% / 100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{-webkit-box-sizing:content-box;box-sizing:content-box;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;-webkit-transition:opacity 0.6s ease;transition:opacity 0.6s ease}@media (prefers-reduced-motion: reduce){.carousel-indicators li{-webkit-transition:none;transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:0.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:0.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline !important}.align-top{vertical-align:top !important}.align-middle{vertical-align:middle !important}.align-bottom{vertical-align:bottom !important}.align-text-bottom{vertical-align:text-bottom !important}.align-text-top{vertical-align:text-top !important}.bg-primary{background-color:#375a7f !important}a.bg-primary:hover,a.bg-primary:focus,button.bg-primary:hover,button.bg-primary:focus{background-color:#28415b !important}.bg-secondary{background-color:#444 !important}a.bg-secondary:hover,a.bg-secondary:focus,button.bg-secondary:hover,button.bg-secondary:focus{background-color:#2b2a2a !important}.bg-success{background-color:#00bc8c !important}a.bg-success:hover,a.bg-success:focus,button.bg-success:hover,button.bg-success:focus{background-color:#008966 !important}.bg-info{background-color:#3498DB !important}a.bg-info:hover,a.bg-info:focus,button.bg-info:hover,button.bg-info:focus{background-color:#217dbb !important}.bg-warning{background-color:#F39C12 !important}a.bg-warning:hover,a.bg-warning:focus,button.bg-warning:hover,button.bg-warning:focus{background-color:#c87f0a !important}.bg-danger{background-color:#E74C3C !important}a.bg-danger:hover,a.bg-danger:focus,button.bg-danger:hover,button.bg-danger:focus{background-color:#d62c1a !important}.bg-light{background-color:#adb5bd !important}a.bg-light:hover,a.bg-light:focus,button.bg-light:hover,button.bg-light:focus{background-color:#919ca6 !important}.bg-dark{background-color:#303030 !important}a.bg-dark:hover,a.bg-dark:focus,button.bg-dark:hover,button.bg-dark:focus{background-color:#171616 !important}.bg-white{background-color:#fff !important}.bg-transparent{background-color:transparent !important}.border{border:1px solid #dee2e6 !important}.border-top{border-top:1px solid #dee2e6 !important}.border-right{border-right:1px solid #dee2e6 !important}.border-bottom{border-bottom:1px solid #dee2e6 !important}.border-left{border-left:1px solid #dee2e6 !important}.border-0{border:0 !important}.border-top-0{border-top:0 !important}.border-right-0{border-right:0 !important}.border-bottom-0{border-bottom:0 !important}.border-left-0{border-left:0 !important}.border-primary{border-color:#375a7f !important}.border-secondary{border-color:#444 !important}.border-success{border-color:#00bc8c !important}.border-info{border-color:#3498DB !important}.border-warning{border-color:#F39C12 !important}.border-danger{border-color:#E74C3C !important}.border-light{border-color:#adb5bd !important}.border-dark{border-color:#303030 !important}.border-white{border-color:#fff !important}.rounded-sm{border-radius:0.2rem !important}.rounded{border-radius:0.25rem !important}.rounded-top{border-top-left-radius:0.25rem !important;border-top-right-radius:0.25rem !important}.rounded-right{border-top-right-radius:0.25rem !important;border-bottom-right-radius:0.25rem !important}.rounded-bottom{border-bottom-right-radius:0.25rem !important;border-bottom-left-radius:0.25rem !important}.rounded-left{border-top-left-radius:0.25rem !important;border-bottom-left-radius:0.25rem !important}.rounded-lg{border-radius:0.3rem !important}.rounded-circle{border-radius:50% !important}.rounded-pill{border-radius:50rem !important}.rounded-0{border-radius:0 !important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none !important}.d-inline{display:inline !important}.d-inline-block{display:inline-block !important}.d-block{display:block !important}.d-table{display:table !important}.d-table-row{display:table-row !important}.d-table-cell{display:table-cell !important}.d-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}@media (min-width: 576px){.d-sm-none{display:none !important}.d-sm-inline{display:inline !important}.d-sm-inline-block{display:inline-block !important}.d-sm-block{display:block !important}.d-sm-table{display:table !important}.d-sm-table-row{display:table-row !important}.d-sm-table-cell{display:table-cell !important}.d-sm-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-sm-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 768px){.d-md-none{display:none !important}.d-md-inline{display:inline !important}.d-md-inline-block{display:inline-block !important}.d-md-block{display:block !important}.d-md-table{display:table !important}.d-md-table-row{display:table-row !important}.d-md-table-cell{display:table-cell !important}.d-md-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-md-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 992px){.d-lg-none{display:none !important}.d-lg-inline{display:inline !important}.d-lg-inline-block{display:inline-block !important}.d-lg-block{display:block !important}.d-lg-table{display:table !important}.d-lg-table-row{display:table-row !important}.d-lg-table-cell{display:table-cell !important}.d-lg-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-lg-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media (min-width: 1200px){.d-xl-none{display:none !important}.d-xl-inline{display:inline !important}.d-xl-inline-block{display:inline-block !important}.d-xl-block{display:block !important}.d-xl-table{display:table !important}.d-xl-table-row{display:table-row !important}.d-xl-table-cell{display:table-cell !important}.d-xl-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-xl-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}@media print{.d-print-none{display:none !important}.d-print-inline{display:inline !important}.d-print-inline-block{display:inline-block !important}.d-print-block{display:block !important}.d-print-table{display:table !important}.d-print-table-row{display:table-row !important}.d-print-table-cell{display:table-cell !important}.d-print-flex{display:-webkit-box !important;display:-ms-flexbox !important;display:flex !important}.d-print-inline-flex{display:-webkit-inline-box !important;display:-ms-inline-flexbox !important;display:inline-flex !important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive iframe,.embed-responsive embed,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.8571428571%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}@media (min-width: 576px){.flex-sm-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-sm-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-sm-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-sm-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-sm-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-sm-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-sm-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-sm-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-sm-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-sm-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-sm-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-sm-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-sm-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-sm-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-sm-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-sm-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-sm-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-sm-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-sm-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-sm-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-sm-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-sm-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-sm-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-sm-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-sm-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-sm-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-sm-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-sm-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-sm-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-sm-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-sm-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-sm-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-sm-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 768px){.flex-md-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-md-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-md-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-md-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-md-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-md-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-md-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-md-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-md-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-md-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-md-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-md-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-md-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-md-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-md-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-md-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-md-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-md-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-md-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-md-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-md-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-md-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-md-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-md-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-md-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-md-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-md-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-md-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-md-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-md-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-md-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-md-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-md-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 992px){.flex-lg-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-lg-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-lg-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-lg-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-lg-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-lg-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-lg-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-lg-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-lg-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-lg-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-lg-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-lg-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-lg-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-lg-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-lg-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-lg-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-lg-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-lg-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-lg-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-lg-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-lg-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-lg-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-lg-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-lg-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-lg-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-lg-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-lg-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-lg-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-lg-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-lg-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-lg-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-lg-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-lg-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}@media (min-width: 1200px){.flex-xl-row{-webkit-box-orient:horizontal !important;-webkit-box-direction:normal !important;-ms-flex-direction:row !important;flex-direction:row !important}.flex-xl-column{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.flex-xl-row-reverse{-webkit-box-orient:horizontal !important;-webkit-box-direction:reverse !important;-ms-flex-direction:row-reverse !important;flex-direction:row-reverse !important}.flex-xl-column-reverse{-webkit-box-orient:vertical !important;-webkit-box-direction:reverse !important;-ms-flex-direction:column-reverse !important;flex-direction:column-reverse !important}.flex-xl-wrap{-ms-flex-wrap:wrap !important;flex-wrap:wrap !important}.flex-xl-nowrap{-ms-flex-wrap:nowrap !important;flex-wrap:nowrap !important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse !important;flex-wrap:wrap-reverse !important}.flex-xl-fill{-webkit-box-flex:1 !important;-ms-flex:1 1 auto !important;flex:1 1 auto !important}.flex-xl-grow-0{-webkit-box-flex:0 !important;-ms-flex-positive:0 !important;flex-grow:0 !important}.flex-xl-grow-1{-webkit-box-flex:1 !important;-ms-flex-positive:1 !important;flex-grow:1 !important}.flex-xl-shrink-0{-ms-flex-negative:0 !important;flex-shrink:0 !important}.flex-xl-shrink-1{-ms-flex-negative:1 !important;flex-shrink:1 !important}.justify-content-xl-start{-webkit-box-pack:start !important;-ms-flex-pack:start !important;justify-content:flex-start !important}.justify-content-xl-end{-webkit-box-pack:end !important;-ms-flex-pack:end !important;justify-content:flex-end !important}.justify-content-xl-center{-webkit-box-pack:center !important;-ms-flex-pack:center !important;justify-content:center !important}.justify-content-xl-between{-webkit-box-pack:justify !important;-ms-flex-pack:justify !important;justify-content:space-between !important}.justify-content-xl-around{-ms-flex-pack:distribute !important;justify-content:space-around !important}.align-items-xl-start{-webkit-box-align:start !important;-ms-flex-align:start !important;align-items:flex-start !important}.align-items-xl-end{-webkit-box-align:end !important;-ms-flex-align:end !important;align-items:flex-end !important}.align-items-xl-center{-webkit-box-align:center !important;-ms-flex-align:center !important;align-items:center !important}.align-items-xl-baseline{-webkit-box-align:baseline !important;-ms-flex-align:baseline !important;align-items:baseline !important}.align-items-xl-stretch{-webkit-box-align:stretch !important;-ms-flex-align:stretch !important;align-items:stretch !important}.align-content-xl-start{-ms-flex-line-pack:start !important;align-content:flex-start !important}.align-content-xl-end{-ms-flex-line-pack:end !important;align-content:flex-end !important}.align-content-xl-center{-ms-flex-line-pack:center !important;align-content:center !important}.align-content-xl-between{-ms-flex-line-pack:justify !important;align-content:space-between !important}.align-content-xl-around{-ms-flex-line-pack:distribute !important;align-content:space-around !important}.align-content-xl-stretch{-ms-flex-line-pack:stretch !important;align-content:stretch !important}.align-self-xl-auto{-ms-flex-item-align:auto !important;align-self:auto !important}.align-self-xl-start{-ms-flex-item-align:start !important;align-self:flex-start !important}.align-self-xl-end{-ms-flex-item-align:end !important;align-self:flex-end !important}.align-self-xl-center{-ms-flex-item-align:center !important;align-self:center !important}.align-self-xl-baseline{-ms-flex-item-align:baseline !important;align-self:baseline !important}.align-self-xl-stretch{-ms-flex-item-align:stretch !important;align-self:stretch !important}}.float-left{float:left !important}.float-right{float:right !important}.float-none{float:none !important}@media (min-width: 576px){.float-sm-left{float:left !important}.float-sm-right{float:right !important}.float-sm-none{float:none !important}}@media (min-width: 768px){.float-md-left{float:left !important}.float-md-right{float:right !important}.float-md-none{float:none !important}}@media (min-width: 992px){.float-lg-left{float:left !important}.float-lg-right{float:right !important}.float-lg-none{float:none !important}}@media (min-width: 1200px){.float-xl-left{float:left !important}.float-xl-right{float:right !important}.float-xl-none{float:none !important}}.user-select-all{-webkit-user-select:all !important;-moz-user-select:all !important;-ms-user-select:all !important;user-select:all !important}.user-select-auto{-webkit-user-select:auto !important;-moz-user-select:auto !important;-ms-user-select:auto !important;user-select:auto !important}.user-select-none{-webkit-user-select:none !important;-moz-user-select:none !important;-ms-user-select:none !important;user-select:none !important}.overflow-auto{overflow:auto !important}.overflow-hidden{overflow:hidden !important}.position-static{position:static !important}.position-relative{position:relative !important}.position-absolute{position:absolute !important}.position-fixed{position:fixed !important}.position-sticky{position:-webkit-sticky !important;position:sticky !important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports (position: -webkit-sticky) or (position: sticky){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{-webkit-box-shadow:0 0.125rem 0.25rem rgba(0,0,0,0.075) !important;box-shadow:0 0.125rem 0.25rem rgba(0,0,0,0.075) !important}.shadow{-webkit-box-shadow:0 0.5rem 1rem rgba(0,0,0,0.15) !important;box-shadow:0 0.5rem 1rem rgba(0,0,0,0.15) !important}.shadow-lg{-webkit-box-shadow:0 1rem 3rem rgba(0,0,0,0.175) !important;box-shadow:0 1rem 3rem rgba(0,0,0,0.175) !important}.shadow-none{-webkit-box-shadow:none !important;box-shadow:none !important}.w-25{width:25% !important}.w-50{width:50% !important}.w-75{width:75% !important}.w-100{width:100% !important}.w-auto{width:auto !important}.h-25{height:25% !important}.h-50{height:50% !important}.h-75{height:75% !important}.h-100{height:100% !important}.h-auto{height:auto !important}.mw-100{max-width:100% !important}.mh-100{max-height:100% !important}.min-vw-100{min-width:100vw !important}.min-vh-100{min-height:100vh !important}.vw-100{width:100vw !important}.vh-100{height:100vh !important}.m-0{margin:0 !important}.mt-0,.my-0{margin-top:0 !important}.mr-0,.mx-0{margin-right:0 !important}.mb-0,.my-0{margin-bottom:0 !important}.ml-0,.mx-0{margin-left:0 !important}.m-1{margin:0.25rem !important}.mt-1,.my-1{margin-top:0.25rem !important}.mr-1,.mx-1{margin-right:0.25rem !important}.mb-1,.my-1{margin-bottom:0.25rem !important}.ml-1,.mx-1{margin-left:0.25rem !important}.m-2{margin:0.5rem !important}.mt-2,.my-2{margin-top:0.5rem !important}.mr-2,.mx-2{margin-right:0.5rem !important}.mb-2,.my-2{margin-bottom:0.5rem !important}.ml-2,.mx-2{margin-left:0.5rem !important}.m-3{margin:1rem !important}.mt-3,.my-3{margin-top:1rem !important}.mr-3,.mx-3{margin-right:1rem !important}.mb-3,.my-3{margin-bottom:1rem !important}.ml-3,.mx-3{margin-left:1rem !important}.m-4{margin:1.5rem !important}.mt-4,.my-4{margin-top:1.5rem !important}.mr-4,.mx-4{margin-right:1.5rem !important}.mb-4,.my-4{margin-bottom:1.5rem !important}.ml-4,.mx-4{margin-left:1.5rem !important}.m-5{margin:3rem !important}.mt-5,.my-5{margin-top:3rem !important}.mr-5,.mx-5{margin-right:3rem !important}.mb-5,.my-5{margin-bottom:3rem !important}.ml-5,.mx-5{margin-left:3rem !important}.p-0{padding:0 !important}.pt-0,.py-0{padding-top:0 !important}.pr-0,.px-0{padding-right:0 !important}.pb-0,.py-0{padding-bottom:0 !important}.pl-0,.px-0{padding-left:0 !important}.p-1{padding:0.25rem !important}.pt-1,.py-1{padding-top:0.25rem !important}.pr-1,.px-1{padding-right:0.25rem !important}.pb-1,.py-1{padding-bottom:0.25rem !important}.pl-1,.px-1{padding-left:0.25rem !important}.p-2{padding:0.5rem !important}.pt-2,.py-2{padding-top:0.5rem !important}.pr-2,.px-2{padding-right:0.5rem !important}.pb-2,.py-2{padding-bottom:0.5rem !important}.pl-2,.px-2{padding-left:0.5rem !important}.p-3{padding:1rem !important}.pt-3,.py-3{padding-top:1rem !important}.pr-3,.px-3{padding-right:1rem !important}.pb-3,.py-3{padding-bottom:1rem !important}.pl-3,.px-3{padding-left:1rem !important}.p-4{padding:1.5rem !important}.pt-4,.py-4{padding-top:1.5rem !important}.pr-4,.px-4{padding-right:1.5rem !important}.pb-4,.py-4{padding-bottom:1.5rem !important}.pl-4,.px-4{padding-left:1.5rem !important}.p-5{padding:3rem !important}.pt-5,.py-5{padding-top:3rem !important}.pr-5,.px-5{padding-right:3rem !important}.pb-5,.py-5{padding-bottom:3rem !important}.pl-5,.px-5{padding-left:3rem !important}.m-n1{margin:-0.25rem !important}.mt-n1,.my-n1{margin-top:-0.25rem !important}.mr-n1,.mx-n1{margin-right:-0.25rem !important}.mb-n1,.my-n1{margin-bottom:-0.25rem !important}.ml-n1,.mx-n1{margin-left:-0.25rem !important}.m-n2{margin:-0.5rem !important}.mt-n2,.my-n2{margin-top:-0.5rem !important}.mr-n2,.mx-n2{margin-right:-0.5rem !important}.mb-n2,.my-n2{margin-bottom:-0.5rem !important}.ml-n2,.mx-n2{margin-left:-0.5rem !important}.m-n3{margin:-1rem !important}.mt-n3,.my-n3{margin-top:-1rem !important}.mr-n3,.mx-n3{margin-right:-1rem !important}.mb-n3,.my-n3{margin-bottom:-1rem !important}.ml-n3,.mx-n3{margin-left:-1rem !important}.m-n4{margin:-1.5rem !important}.mt-n4,.my-n4{margin-top:-1.5rem !important}.mr-n4,.mx-n4{margin-right:-1.5rem !important}.mb-n4,.my-n4{margin-bottom:-1.5rem !important}.ml-n4,.mx-n4{margin-left:-1.5rem !important}.m-n5{margin:-3rem !important}.mt-n5,.my-n5{margin-top:-3rem !important}.mr-n5,.mx-n5{margin-right:-3rem !important}.mb-n5,.my-n5{margin-bottom:-3rem !important}.ml-n5,.mx-n5{margin-left:-3rem !important}.m-auto{margin:auto !important}.mt-auto,.my-auto{margin-top:auto !important}.mr-auto,.mx-auto{margin-right:auto !important}.mb-auto,.my-auto{margin-bottom:auto !important}.ml-auto,.mx-auto{margin-left:auto !important}@media (min-width: 576px){.m-sm-0{margin:0 !important}.mt-sm-0,.my-sm-0{margin-top:0 !important}.mr-sm-0,.mx-sm-0{margin-right:0 !important}.mb-sm-0,.my-sm-0{margin-bottom:0 !important}.ml-sm-0,.mx-sm-0{margin-left:0 !important}.m-sm-1{margin:0.25rem !important}.mt-sm-1,.my-sm-1{margin-top:0.25rem !important}.mr-sm-1,.mx-sm-1{margin-right:0.25rem !important}.mb-sm-1,.my-sm-1{margin-bottom:0.25rem !important}.ml-sm-1,.mx-sm-1{margin-left:0.25rem !important}.m-sm-2{margin:0.5rem !important}.mt-sm-2,.my-sm-2{margin-top:0.5rem !important}.mr-sm-2,.mx-sm-2{margin-right:0.5rem !important}.mb-sm-2,.my-sm-2{margin-bottom:0.5rem !important}.ml-sm-2,.mx-sm-2{margin-left:0.5rem !important}.m-sm-3{margin:1rem !important}.mt-sm-3,.my-sm-3{margin-top:1rem !important}.mr-sm-3,.mx-sm-3{margin-right:1rem !important}.mb-sm-3,.my-sm-3{margin-bottom:1rem !important}.ml-sm-3,.mx-sm-3{margin-left:1rem !important}.m-sm-4{margin:1.5rem !important}.mt-sm-4,.my-sm-4{margin-top:1.5rem !important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem !important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem !important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem !important}.m-sm-5{margin:3rem !important}.mt-sm-5,.my-sm-5{margin-top:3rem !important}.mr-sm-5,.mx-sm-5{margin-right:3rem !important}.mb-sm-5,.my-sm-5{margin-bottom:3rem !important}.ml-sm-5,.mx-sm-5{margin-left:3rem !important}.p-sm-0{padding:0 !important}.pt-sm-0,.py-sm-0{padding-top:0 !important}.pr-sm-0,.px-sm-0{padding-right:0 !important}.pb-sm-0,.py-sm-0{padding-bottom:0 !important}.pl-sm-0,.px-sm-0{padding-left:0 !important}.p-sm-1{padding:0.25rem !important}.pt-sm-1,.py-sm-1{padding-top:0.25rem !important}.pr-sm-1,.px-sm-1{padding-right:0.25rem !important}.pb-sm-1,.py-sm-1{padding-bottom:0.25rem !important}.pl-sm-1,.px-sm-1{padding-left:0.25rem !important}.p-sm-2{padding:0.5rem !important}.pt-sm-2,.py-sm-2{padding-top:0.5rem !important}.pr-sm-2,.px-sm-2{padding-right:0.5rem !important}.pb-sm-2,.py-sm-2{padding-bottom:0.5rem !important}.pl-sm-2,.px-sm-2{padding-left:0.5rem !important}.p-sm-3{padding:1rem !important}.pt-sm-3,.py-sm-3{padding-top:1rem !important}.pr-sm-3,.px-sm-3{padding-right:1rem !important}.pb-sm-3,.py-sm-3{padding-bottom:1rem !important}.pl-sm-3,.px-sm-3{padding-left:1rem !important}.p-sm-4{padding:1.5rem !important}.pt-sm-4,.py-sm-4{padding-top:1.5rem !important}.pr-sm-4,.px-sm-4{padding-right:1.5rem !important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem !important}.pl-sm-4,.px-sm-4{padding-left:1.5rem !important}.p-sm-5{padding:3rem !important}.pt-sm-5,.py-sm-5{padding-top:3rem !important}.pr-sm-5,.px-sm-5{padding-right:3rem !important}.pb-sm-5,.py-sm-5{padding-bottom:3rem !important}.pl-sm-5,.px-sm-5{padding-left:3rem !important}.m-sm-n1{margin:-0.25rem !important}.mt-sm-n1,.my-sm-n1{margin-top:-0.25rem !important}.mr-sm-n1,.mx-sm-n1{margin-right:-0.25rem !important}.mb-sm-n1,.my-sm-n1{margin-bottom:-0.25rem !important}.ml-sm-n1,.mx-sm-n1{margin-left:-0.25rem !important}.m-sm-n2{margin:-0.5rem !important}.mt-sm-n2,.my-sm-n2{margin-top:-0.5rem !important}.mr-sm-n2,.mx-sm-n2{margin-right:-0.5rem !important}.mb-sm-n2,.my-sm-n2{margin-bottom:-0.5rem !important}.ml-sm-n2,.mx-sm-n2{margin-left:-0.5rem !important}.m-sm-n3{margin:-1rem !important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem !important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem !important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem !important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem !important}.m-sm-n4{margin:-1.5rem !important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem !important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem !important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem !important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem !important}.m-sm-n5{margin:-3rem !important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem !important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem !important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem !important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem !important}.m-sm-auto{margin:auto !important}.mt-sm-auto,.my-sm-auto{margin-top:auto !important}.mr-sm-auto,.mx-sm-auto{margin-right:auto !important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto !important}.ml-sm-auto,.mx-sm-auto{margin-left:auto !important}}@media (min-width: 768px){.m-md-0{margin:0 !important}.mt-md-0,.my-md-0{margin-top:0 !important}.mr-md-0,.mx-md-0{margin-right:0 !important}.mb-md-0,.my-md-0{margin-bottom:0 !important}.ml-md-0,.mx-md-0{margin-left:0 !important}.m-md-1{margin:0.25rem !important}.mt-md-1,.my-md-1{margin-top:0.25rem !important}.mr-md-1,.mx-md-1{margin-right:0.25rem !important}.mb-md-1,.my-md-1{margin-bottom:0.25rem !important}.ml-md-1,.mx-md-1{margin-left:0.25rem !important}.m-md-2{margin:0.5rem !important}.mt-md-2,.my-md-2{margin-top:0.5rem !important}.mr-md-2,.mx-md-2{margin-right:0.5rem !important}.mb-md-2,.my-md-2{margin-bottom:0.5rem !important}.ml-md-2,.mx-md-2{margin-left:0.5rem !important}.m-md-3{margin:1rem !important}.mt-md-3,.my-md-3{margin-top:1rem !important}.mr-md-3,.mx-md-3{margin-right:1rem !important}.mb-md-3,.my-md-3{margin-bottom:1rem !important}.ml-md-3,.mx-md-3{margin-left:1rem !important}.m-md-4{margin:1.5rem !important}.mt-md-4,.my-md-4{margin-top:1.5rem !important}.mr-md-4,.mx-md-4{margin-right:1.5rem !important}.mb-md-4,.my-md-4{margin-bottom:1.5rem !important}.ml-md-4,.mx-md-4{margin-left:1.5rem !important}.m-md-5{margin:3rem !important}.mt-md-5,.my-md-5{margin-top:3rem !important}.mr-md-5,.mx-md-5{margin-right:3rem !important}.mb-md-5,.my-md-5{margin-bottom:3rem !important}.ml-md-5,.mx-md-5{margin-left:3rem !important}.p-md-0{padding:0 !important}.pt-md-0,.py-md-0{padding-top:0 !important}.pr-md-0,.px-md-0{padding-right:0 !important}.pb-md-0,.py-md-0{padding-bottom:0 !important}.pl-md-0,.px-md-0{padding-left:0 !important}.p-md-1{padding:0.25rem !important}.pt-md-1,.py-md-1{padding-top:0.25rem !important}.pr-md-1,.px-md-1{padding-right:0.25rem !important}.pb-md-1,.py-md-1{padding-bottom:0.25rem !important}.pl-md-1,.px-md-1{padding-left:0.25rem !important}.p-md-2{padding:0.5rem !important}.pt-md-2,.py-md-2{padding-top:0.5rem !important}.pr-md-2,.px-md-2{padding-right:0.5rem !important}.pb-md-2,.py-md-2{padding-bottom:0.5rem !important}.pl-md-2,.px-md-2{padding-left:0.5rem !important}.p-md-3{padding:1rem !important}.pt-md-3,.py-md-3{padding-top:1rem !important}.pr-md-3,.px-md-3{padding-right:1rem !important}.pb-md-3,.py-md-3{padding-bottom:1rem !important}.pl-md-3,.px-md-3{padding-left:1rem !important}.p-md-4{padding:1.5rem !important}.pt-md-4,.py-md-4{padding-top:1.5rem !important}.pr-md-4,.px-md-4{padding-right:1.5rem !important}.pb-md-4,.py-md-4{padding-bottom:1.5rem !important}.pl-md-4,.px-md-4{padding-left:1.5rem !important}.p-md-5{padding:3rem !important}.pt-md-5,.py-md-5{padding-top:3rem !important}.pr-md-5,.px-md-5{padding-right:3rem !important}.pb-md-5,.py-md-5{padding-bottom:3rem !important}.pl-md-5,.px-md-5{padding-left:3rem !important}.m-md-n1{margin:-0.25rem !important}.mt-md-n1,.my-md-n1{margin-top:-0.25rem !important}.mr-md-n1,.mx-md-n1{margin-right:-0.25rem !important}.mb-md-n1,.my-md-n1{margin-bottom:-0.25rem !important}.ml-md-n1,.mx-md-n1{margin-left:-0.25rem !important}.m-md-n2{margin:-0.5rem !important}.mt-md-n2,.my-md-n2{margin-top:-0.5rem !important}.mr-md-n2,.mx-md-n2{margin-right:-0.5rem !important}.mb-md-n2,.my-md-n2{margin-bottom:-0.5rem !important}.ml-md-n2,.mx-md-n2{margin-left:-0.5rem !important}.m-md-n3{margin:-1rem !important}.mt-md-n3,.my-md-n3{margin-top:-1rem !important}.mr-md-n3,.mx-md-n3{margin-right:-1rem !important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem !important}.ml-md-n3,.mx-md-n3{margin-left:-1rem !important}.m-md-n4{margin:-1.5rem !important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem !important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem !important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem !important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem !important}.m-md-n5{margin:-3rem !important}.mt-md-n5,.my-md-n5{margin-top:-3rem !important}.mr-md-n5,.mx-md-n5{margin-right:-3rem !important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem !important}.ml-md-n5,.mx-md-n5{margin-left:-3rem !important}.m-md-auto{margin:auto !important}.mt-md-auto,.my-md-auto{margin-top:auto !important}.mr-md-auto,.mx-md-auto{margin-right:auto !important}.mb-md-auto,.my-md-auto{margin-bottom:auto !important}.ml-md-auto,.mx-md-auto{margin-left:auto !important}}@media (min-width: 992px){.m-lg-0{margin:0 !important}.mt-lg-0,.my-lg-0{margin-top:0 !important}.mr-lg-0,.mx-lg-0{margin-right:0 !important}.mb-lg-0,.my-lg-0{margin-bottom:0 !important}.ml-lg-0,.mx-lg-0{margin-left:0 !important}.m-lg-1{margin:0.25rem !important}.mt-lg-1,.my-lg-1{margin-top:0.25rem !important}.mr-lg-1,.mx-lg-1{margin-right:0.25rem !important}.mb-lg-1,.my-lg-1{margin-bottom:0.25rem !important}.ml-lg-1,.mx-lg-1{margin-left:0.25rem !important}.m-lg-2{margin:0.5rem !important}.mt-lg-2,.my-lg-2{margin-top:0.5rem !important}.mr-lg-2,.mx-lg-2{margin-right:0.5rem !important}.mb-lg-2,.my-lg-2{margin-bottom:0.5rem !important}.ml-lg-2,.mx-lg-2{margin-left:0.5rem !important}.m-lg-3{margin:1rem !important}.mt-lg-3,.my-lg-3{margin-top:1rem !important}.mr-lg-3,.mx-lg-3{margin-right:1rem !important}.mb-lg-3,.my-lg-3{margin-bottom:1rem !important}.ml-lg-3,.mx-lg-3{margin-left:1rem !important}.m-lg-4{margin:1.5rem !important}.mt-lg-4,.my-lg-4{margin-top:1.5rem !important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem !important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem !important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem !important}.m-lg-5{margin:3rem !important}.mt-lg-5,.my-lg-5{margin-top:3rem !important}.mr-lg-5,.mx-lg-5{margin-right:3rem !important}.mb-lg-5,.my-lg-5{margin-bottom:3rem !important}.ml-lg-5,.mx-lg-5{margin-left:3rem !important}.p-lg-0{padding:0 !important}.pt-lg-0,.py-lg-0{padding-top:0 !important}.pr-lg-0,.px-lg-0{padding-right:0 !important}.pb-lg-0,.py-lg-0{padding-bottom:0 !important}.pl-lg-0,.px-lg-0{padding-left:0 !important}.p-lg-1{padding:0.25rem !important}.pt-lg-1,.py-lg-1{padding-top:0.25rem !important}.pr-lg-1,.px-lg-1{padding-right:0.25rem !important}.pb-lg-1,.py-lg-1{padding-bottom:0.25rem !important}.pl-lg-1,.px-lg-1{padding-left:0.25rem !important}.p-lg-2{padding:0.5rem !important}.pt-lg-2,.py-lg-2{padding-top:0.5rem !important}.pr-lg-2,.px-lg-2{padding-right:0.5rem !important}.pb-lg-2,.py-lg-2{padding-bottom:0.5rem !important}.pl-lg-2,.px-lg-2{padding-left:0.5rem !important}.p-lg-3{padding:1rem !important}.pt-lg-3,.py-lg-3{padding-top:1rem !important}.pr-lg-3,.px-lg-3{padding-right:1rem !important}.pb-lg-3,.py-lg-3{padding-bottom:1rem !important}.pl-lg-3,.px-lg-3{padding-left:1rem !important}.p-lg-4{padding:1.5rem !important}.pt-lg-4,.py-lg-4{padding-top:1.5rem !important}.pr-lg-4,.px-lg-4{padding-right:1.5rem !important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem !important}.pl-lg-4,.px-lg-4{padding-left:1.5rem !important}.p-lg-5{padding:3rem !important}.pt-lg-5,.py-lg-5{padding-top:3rem !important}.pr-lg-5,.px-lg-5{padding-right:3rem !important}.pb-lg-5,.py-lg-5{padding-bottom:3rem !important}.pl-lg-5,.px-lg-5{padding-left:3rem !important}.m-lg-n1{margin:-0.25rem !important}.mt-lg-n1,.my-lg-n1{margin-top:-0.25rem !important}.mr-lg-n1,.mx-lg-n1{margin-right:-0.25rem !important}.mb-lg-n1,.my-lg-n1{margin-bottom:-0.25rem !important}.ml-lg-n1,.mx-lg-n1{margin-left:-0.25rem !important}.m-lg-n2{margin:-0.5rem !important}.mt-lg-n2,.my-lg-n2{margin-top:-0.5rem !important}.mr-lg-n2,.mx-lg-n2{margin-right:-0.5rem !important}.mb-lg-n2,.my-lg-n2{margin-bottom:-0.5rem !important}.ml-lg-n2,.mx-lg-n2{margin-left:-0.5rem !important}.m-lg-n3{margin:-1rem !important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem !important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem !important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem !important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem !important}.m-lg-n4{margin:-1.5rem !important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem !important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem !important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem !important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem !important}.m-lg-n5{margin:-3rem !important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem !important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem !important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem !important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem !important}.m-lg-auto{margin:auto !important}.mt-lg-auto,.my-lg-auto{margin-top:auto !important}.mr-lg-auto,.mx-lg-auto{margin-right:auto !important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto !important}.ml-lg-auto,.mx-lg-auto{margin-left:auto !important}}@media (min-width: 1200px){.m-xl-0{margin:0 !important}.mt-xl-0,.my-xl-0{margin-top:0 !important}.mr-xl-0,.mx-xl-0{margin-right:0 !important}.mb-xl-0,.my-xl-0{margin-bottom:0 !important}.ml-xl-0,.mx-xl-0{margin-left:0 !important}.m-xl-1{margin:0.25rem !important}.mt-xl-1,.my-xl-1{margin-top:0.25rem !important}.mr-xl-1,.mx-xl-1{margin-right:0.25rem !important}.mb-xl-1,.my-xl-1{margin-bottom:0.25rem !important}.ml-xl-1,.mx-xl-1{margin-left:0.25rem !important}.m-xl-2{margin:0.5rem !important}.mt-xl-2,.my-xl-2{margin-top:0.5rem !important}.mr-xl-2,.mx-xl-2{margin-right:0.5rem !important}.mb-xl-2,.my-xl-2{margin-bottom:0.5rem !important}.ml-xl-2,.mx-xl-2{margin-left:0.5rem !important}.m-xl-3{margin:1rem !important}.mt-xl-3,.my-xl-3{margin-top:1rem !important}.mr-xl-3,.mx-xl-3{margin-right:1rem !important}.mb-xl-3,.my-xl-3{margin-bottom:1rem !important}.ml-xl-3,.mx-xl-3{margin-left:1rem !important}.m-xl-4{margin:1.5rem !important}.mt-xl-4,.my-xl-4{margin-top:1.5rem !important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem !important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem !important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem !important}.m-xl-5{margin:3rem !important}.mt-xl-5,.my-xl-5{margin-top:3rem !important}.mr-xl-5,.mx-xl-5{margin-right:3rem !important}.mb-xl-5,.my-xl-5{margin-bottom:3rem !important}.ml-xl-5,.mx-xl-5{margin-left:3rem !important}.p-xl-0{padding:0 !important}.pt-xl-0,.py-xl-0{padding-top:0 !important}.pr-xl-0,.px-xl-0{padding-right:0 !important}.pb-xl-0,.py-xl-0{padding-bottom:0 !important}.pl-xl-0,.px-xl-0{padding-left:0 !important}.p-xl-1{padding:0.25rem !important}.pt-xl-1,.py-xl-1{padding-top:0.25rem !important}.pr-xl-1,.px-xl-1{padding-right:0.25rem !important}.pb-xl-1,.py-xl-1{padding-bottom:0.25rem !important}.pl-xl-1,.px-xl-1{padding-left:0.25rem !important}.p-xl-2{padding:0.5rem !important}.pt-xl-2,.py-xl-2{padding-top:0.5rem !important}.pr-xl-2,.px-xl-2{padding-right:0.5rem !important}.pb-xl-2,.py-xl-2{padding-bottom:0.5rem !important}.pl-xl-2,.px-xl-2{padding-left:0.5rem !important}.p-xl-3{padding:1rem !important}.pt-xl-3,.py-xl-3{padding-top:1rem !important}.pr-xl-3,.px-xl-3{padding-right:1rem !important}.pb-xl-3,.py-xl-3{padding-bottom:1rem !important}.pl-xl-3,.px-xl-3{padding-left:1rem !important}.p-xl-4{padding:1.5rem !important}.pt-xl-4,.py-xl-4{padding-top:1.5rem !important}.pr-xl-4,.px-xl-4{padding-right:1.5rem !important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem !important}.pl-xl-4,.px-xl-4{padding-left:1.5rem !important}.p-xl-5{padding:3rem !important}.pt-xl-5,.py-xl-5{padding-top:3rem !important}.pr-xl-5,.px-xl-5{padding-right:3rem !important}.pb-xl-5,.py-xl-5{padding-bottom:3rem !important}.pl-xl-5,.px-xl-5{padding-left:3rem !important}.m-xl-n1{margin:-0.25rem !important}.mt-xl-n1,.my-xl-n1{margin-top:-0.25rem !important}.mr-xl-n1,.mx-xl-n1{margin-right:-0.25rem !important}.mb-xl-n1,.my-xl-n1{margin-bottom:-0.25rem !important}.ml-xl-n1,.mx-xl-n1{margin-left:-0.25rem !important}.m-xl-n2{margin:-0.5rem !important}.mt-xl-n2,.my-xl-n2{margin-top:-0.5rem !important}.mr-xl-n2,.mx-xl-n2{margin-right:-0.5rem !important}.mb-xl-n2,.my-xl-n2{margin-bottom:-0.5rem !important}.ml-xl-n2,.mx-xl-n2{margin-left:-0.5rem !important}.m-xl-n3{margin:-1rem !important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem !important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem !important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem !important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem !important}.m-xl-n4{margin:-1.5rem !important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem !important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem !important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem !important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem !important}.m-xl-n5{margin:-3rem !important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem !important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem !important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem !important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem !important}.m-xl-auto{margin:auto !important}.mt-xl-auto,.my-xl-auto{margin-top:auto !important}.mr-xl-auto,.mx-xl-auto{margin-right:auto !important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto !important}.ml-xl-auto,.mx-xl-auto{margin-left:auto !important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important}.text-justify{text-align:justify !important}.text-wrap{white-space:normal !important}.text-nowrap{white-space:nowrap !important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left !important}.text-right{text-align:right !important}.text-center{text-align:center !important}@media (min-width: 576px){.text-sm-left{text-align:left !important}.text-sm-right{text-align:right !important}.text-sm-center{text-align:center !important}}@media (min-width: 768px){.text-md-left{text-align:left !important}.text-md-right{text-align:right !important}.text-md-center{text-align:center !important}}@media (min-width: 992px){.text-lg-left{text-align:left !important}.text-lg-right{text-align:right !important}.text-lg-center{text-align:center !important}}@media (min-width: 1200px){.text-xl-left{text-align:left !important}.text-xl-right{text-align:right !important}.text-xl-center{text-align:center !important}}.text-lowercase{text-transform:lowercase !important}.text-uppercase{text-transform:uppercase !important}.text-capitalize{text-transform:capitalize !important}.font-weight-light{font-weight:300 !important}.font-weight-lighter{font-weight:lighter !important}.font-weight-normal{font-weight:400 !important}.font-weight-bold{font-weight:700 !important}.font-weight-bolder{font-weight:bolder !important}.font-italic{font-style:italic !important}.text-white{color:#fff !important}.text-primary{color:#375a7f !important}a.text-primary:hover,a.text-primary:focus{color:#20344a !important}.text-secondary{color:#444 !important}a.text-secondary:hover,a.text-secondary:focus{color:#1e1e1e !important}.text-success{color:#00bc8c !important}a.text-success:hover,a.text-success:focus{color:#007053 !important}.text-info{color:#3498DB !important}a.text-info:hover,a.text-info:focus{color:#1d6fa5 !important}.text-warning{color:#F39C12 !important}a.text-warning:hover,a.text-warning:focus{color:#b06f09 !important}.text-danger{color:#E74C3C !important}a.text-danger:hover,a.text-danger:focus{color:#bf2718 !important}.text-light{color:#adb5bd !important}a.text-light:hover,a.text-light:focus{color:#838f9b !important}.text-dark{color:#303030 !important}a.text-dark:hover,a.text-dark:focus{color:#0a0a0a !important}.text-body{color:#fff !important}.text-muted{color:#888 !important}.text-black-50{color:rgba(0,0,0,0.5) !important}.text-white-50{color:rgba(255,255,255,0.5) !important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none !important}.text-break{word-wrap:break-word !important}.text-reset{color:inherit !important}.visible{visibility:visible !important}.invisible{visibility:hidden !important}@media print{*,*::before,*::after{text-shadow:none !important;-webkit-box-shadow:none !important;box-shadow:none !important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap !important}pre,blockquote{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px !important}.container{min-width:992px !important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse !important}.table td,.table th{background-color:#fff !important}.table-bordered th,.table-bordered td{border:1px solid #dee2e6 !important}.table-dark{color:inherit}.table-dark th,.table-dark td,.table-dark thead th,.table-dark tbody+tbody{border-color:#444}.table .thead-dark th{color:inherit;border-color:#444}}.blockquote-footer{color:#888}.table-primary,.table-primary>th,.table-primary>td{background-color:#375a7f}.table-secondary,.table-secondary>th,.table-secondary>td{background-color:#444}.table-light,.table-light>th,.table-light>td{background-color:#adb5bd}.table-dark,.table-dark>th,.table-dark>td{background-color:#303030}.table-success,.table-success>th,.table-success>td{background-color:#00bc8c}.table-info,.table-info>th,.table-info>td{background-color:#3498DB}.table-danger,.table-danger>th,.table-danger>td{background-color:#E74C3C}.table-warning,.table-warning>th,.table-warning>td{background-color:#F39C12}.table-active,.table-active>th,.table-active>td{background-color:rgba(0,0,0,0.075)}.table-hover .table-primary:hover,.table-hover .table-primary:hover>th,.table-hover .table-primary:hover>td{background-color:#2f4d6d}.table-hover .table-secondary:hover,.table-hover .table-secondary:hover>th,.table-hover .table-secondary:hover>td{background-color:#373737}.table-hover .table-light:hover,.table-hover .table-light:hover>th,.table-hover .table-light:hover>td{background-color:#9fa8b2}.table-hover .table-dark:hover,.table-hover .table-dark:hover>th,.table-hover .table-dark:hover>td{background-color:#232323}.table-hover .table-success:hover,.table-hover .table-success:hover>th,.table-hover .table-success:hover>td{background-color:#00a379}.table-hover .table-info:hover,.table-hover .table-info:hover>th,.table-hover .table-info:hover>td{background-color:#258cd1}.table-hover .table-danger:hover,.table-hover .table-danger:hover>th,.table-hover .table-danger:hover>td{background-color:#e43725}.table-hover .table-warning:hover,.table-hover .table-warning:hover>th,.table-hover .table-warning:hover>td{background-color:#e08e0b}.table-hover .table-active:hover,.table-hover .table-active:hover>th,.table-hover .table-active:hover>td{background-color:rgba(0,0,0,0.075)}.input-group-addon{color:#fff}.nav-tabs .nav-link,.nav-tabs .nav-link.active,.nav-tabs .nav-link.active:focus,.nav-tabs .nav-link.active:hover,.nav-tabs .nav-item.open .nav-link,.nav-tabs .nav-item.open .nav-link:focus,.nav-tabs .nav-item.open .nav-link:hover,.nav-pills .nav-link,.nav-pills .nav-link.active,.nav-pills .nav-link.active:focus,.nav-pills .nav-link.active:hover,.nav-pills .nav-item.open .nav-link,.nav-pills .nav-item.open .nav-link:focus,.nav-pills .nav-item.open .nav-link:hover{color:#fff}.breadcrumb a{color:#fff}.pagination a:hover{text-decoration:none}.close{opacity:0.4}.close:hover,.close:focus{opacity:1}.alert{border:none;color:#fff}.alert a,.alert .alert-link{color:#fff;text-decoration:underline}.alert-primary{background-color:#375a7f}.alert-secondary{background-color:#444}.alert-success{background-color:#00bc8c}.alert-info{background-color:#3498DB}.alert-warning{background-color:#F39C12}.alert-danger{background-color:#E74C3C}.alert-light{background-color:#adb5bd}.alert-dark{background-color:#303030}.list-group-item-action{color:#fff}.list-group-item-action:hover,.list-group-item-action:focus{background-color:#444;color:#fff}.list-group-item-action .list-group-item-heading{color:#fff} diff --git a/openwebrx/htdocs/css/login.css b/openwebrx/htdocs/css/login.css new file mode 100644 index 0000000..ccd6c02 --- /dev/null +++ b/openwebrx/htdocs/css/login.css @@ -0,0 +1,34 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +body { + display: flex; + flex-direction: column; +} + +.login-container { + flex: 1; + position: relative; +} + +.login { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + + width: 500px; + + padding: 20px; + border-radius: 10px; + border: 1px solid #575757; + box-shadow: 0 0 20px #000; +} + +.login .btn { + width: 100%; +} + +.btn-login { + height: 50px; +} \ No newline at end of file diff --git a/openwebrx/htdocs/css/map.css b/openwebrx/htdocs/css/map.css new file mode 100644 index 0000000..70702b9 --- /dev/null +++ b/openwebrx/htdocs/css/map.css @@ -0,0 +1,65 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +body { + display: flex; + flex-direction: column; +} + +.openwebrx-map { + flex: 1 1 auto; +} + +h3 { + margin: 10px 0; + text-align: center; +} + +ul { + margin-block-start: 5px; + margin-block-end: 5px; + padding-inline-start: 25px; +} + +/* don't show the filter in it's initial position */ +.openwebrx-map-legend { + display: none; + background-color: #fff; + padding: 10px; + margin: 10px; + user-select: none; +} + +/* show it as soon as google maps has moved it to its container */ +.openwebrx-map .openwebrx-map-legend { + display: block; +} + +.openwebrx-map-legend ul { + list-style-type: none; + padding: 0; +} + +.openwebrx-map-legend ul li { + cursor: pointer; +} + +.openwebrx-map-legend ul li.disabled { + opacity: .3; + filter: grayscale(70%); +} + +.openwebrx-map-legend li.square .illustration { + display: inline-block; + width: 30px; + height: 20px; + margin-right: 10px; + border-width: 2px; + border-style: solid; +} + +.openwebrx-map-legend select { + background-color: #FFF; + border-color: #DDD; + padding: 5px; +} diff --git a/openwebrx/htdocs/css/openwebrx-globals.css b/openwebrx/htdocs/css/openwebrx-globals.css new file mode 100644 index 0000000..5759847 --- /dev/null +++ b/openwebrx/htdocs/css/openwebrx-globals.css @@ -0,0 +1,7 @@ +html, body +{ + margin: 0; + padding: 0; + height: 100%; + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; +} diff --git a/openwebrx/htdocs/css/openwebrx-header.css b/openwebrx/htdocs/css/openwebrx-header.css new file mode 100644 index 0000000..4e8601b --- /dev/null +++ b/openwebrx/htdocs/css/openwebrx-header.css @@ -0,0 +1,227 @@ +.webrx-top-container { + position: relative; + z-index:1000; + background-color: #575757; + + background-image: url(../gfx/openwebrx-top-photo.jpg); + background-position-x: center; + background-position-y: top; + background-repeat: no-repeat; + background-size: cover; + + overflow: hidden; +} + +.openwebrx-description-container { + transition-property: height, opacity; + transition-duration: 1s; + transition-timing-function: ease-out; + opacity: 0; + height: 0; + /* originally, top-bar + description was 350px */ + max-height: 283px; + overflow: hidden; +} + +.openwebrx-description-container.expanded { + opacity: 1; + height: 283px; +} + +.webrx-top-bar { + height:67px; + + background: rgba(128, 128, 128, 0.15); + margin:0; + padding:0; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + overflow: hidden; + + display: flex; + flex-direction: row; +} + +.webrx-top-bar > * { + flex: 0; +} + +.webrx-top-container, .webrx-top-container * { + line-height: initial; + box-sizing: initial; +} + +.webrx-top-logo { + width: 261px; + padding: 12px; + filter: drop-shadow(0 0 2.5px rgba(0, 0, 0, .9)); + /* overwritten by media queries */ + display: none; +} + +.webrx-rx-avatar { + background-color: rgba(154, 154, 154, .5); + margin: 7px; + + width: 46px; + height: 46px; + padding: 4px; + border-radius: 8px; + box-sizing: content-box; +} + +.webrx-rx-texts { + /* minimum layout width */ + width: 0; + /* will be getting wider with flex */ + flex: 1; + overflow: hidden; + margin: auto 0; +} + +.webrx-rx-texts div, .webrx-rx-texts h1 { + margin: 0 10px; + padding: 3px; + white-space:nowrap; + overflow: hidden; + color: #909090; + text-align: left; +} + +.webrx-rx-title { + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; + font-size: 11pt; + font-weight: bold; +} + +.webrx-rx-desc { + font-size: 10pt; +} + +.openwebrx-main-buttons .button { + display: block; + width: 55px; + cursor:pointer; +} + +.openwebrx-main-buttons .button[data-toggle-panel] { + /* will be enabled by javascript if the panel is present in the DOM */ + display: none; +} + +.openwebrx-main-buttons .button img, +.openwebrx-main-buttons .button svg { + height: 38px; + filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5)); +} + +.openwebrx-main-buttons a { + color: inherit; + text-decoration: inherit; +} + +.openwebrx-main-buttons .button:hover { + background-color: rgba(255, 255, 255, 0.3); +} + +.openwebrx-main-buttons .button:active { + background-color: rgba(255, 255, 255, 0.55); +} + + +.openwebrx-main-buttons { + padding: 5px 15px; + display: flex; + list-style: none; + margin:0; + color: white; + text-shadow: 0px 0px 4px #000000; + text-align: center; + font-size: 9pt; + font-weight: bold; +} + +.webrx-rx-photo-title { + margin: 10px 15px; + color: white; + font-size: 16pt; + text-shadow: 1px 1px 4px #444; + opacity: 1; +} + +.webrx-rx-photo-desc { + margin: 10px 15px; + color: white; + font-size: 10pt; + font-weight: bold; + text-shadow: 0px 0px 6px #444; + opacity: 1; + line-height: 1.5em; +} + +.webrx-rx-photo-desc a { + color: #5ca8ff; + text-shadow: none; +} + +.openwebrx-photo-trigger { + cursor: pointer; +} + +/* + * Responsive stuff + */ + +@media (min-width: 576px) { + .webrx-rx-texts { + display: initial; + } +} + +@media (min-width: 768px) { +} + +@media (min-width: 992px) { + .webrx-top-logo { + display: initial; + } +} + +@media (min-width: 1200px) { +} + +/* + * RX details arrow up/down switching + */ + +.openwebrx-rx-details-arrow { + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%, 0); + + margin: 0; + padding: 0; + line-height: 0; + display: block; +} + +.openwebrx-rx-details-arrow svg { + height: 12px; +} + +.openwebrx-rx-details-arrow .up { + display: none; +} + +.openwebrx-rx-details-arrow--up .down { + display: none; +} + +.openwebrx-rx-details-arrow--up .up { + display: initial; +} \ No newline at end of file diff --git a/openwebrx/htdocs/css/openwebrx.css b/openwebrx/htdocs/css/openwebrx.css new file mode 100644 index 0000000..cb1e954 --- /dev/null +++ b/openwebrx/htdocs/css/openwebrx.css @@ -0,0 +1,1364 @@ +/* + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +*/ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +html, body { + overflow: hidden; +} + +select +{ + font-family: "DejaVu Sans", Verdana, Geneva, sans-serif; +} + +input +{ + vertical-align:middle; +} + +input[type=range] { + -webkit-appearance: none; + margin: 0 0; + background: transparent !important; + --track-background: #B6B6B6; +} + +input[type=range]:focus { + outline: none; +} + +input[type=range]::-webkit-slider-runnable-track +{ + height: 5px; + cursor: pointer; + animate: 0.2s; + box-shadow: 0px 0px 0px #000000; + background: #B6B6B6; + /*border-radius: 11px;*/ + border: 1px solid #8A8A8A; + background: var(--track-background); +} + +input[type=range]::-webkit-slider-thumb +{ + box-shadow: 1px 1px 1px #828282; + border: 1px solid #8A8A8A; + height: 15px; + width: 15px; + border-radius: 10px; + background: #FFFFFF; + cursor: pointer; + -webkit-appearance: none; + margin-top: -7px; +} + +input[type=range]:focus::-webkit-slider-runnable-track +{ + background: #B6B6B6; + background: var(--track-background); +} + +input[type=range]::-moz-range-track +{ + height: 3px; + cursor: pointer; + animate: 0.2s; + box-shadow: 0px 0px 0px #000000; + background: #B6B6B6; + background: var(--track-background); + border-radius: 11px; + border: 1px solid #8A8A8A; +} + +input[type=range]::-moz-range-thumb +{ + box-shadow: 1px 1px 1px #828282; + border: 1px solid #8A8A8A; + height: 12px; + width: 12px; + border-radius: 10px; + background: #FFFFFF; + cursor: pointer; +} + +input[type=range]::-ms-track +{ + width: 100%; + height: 7px; + cursor: pointer; + animate: 0.2s; + background: transparent; + border-color: transparent; + color: transparent; +} + +input[type=range]::-ms-fill-lower + { + background: #B6B6B6; + border: 1px solid #8A8A8A; + border-radius: 22px; + box-shadow: 0px 0px 0px #000000; +} + +input[type=range]::-ms-fill-upper +{ + background: #B6B6B6; + border: 1px solid #8A8A8A; + border-radius: 22px; + box-shadow: 0px 0px 0px #000000; +} + +input[type=range]::-ms-thumb +{ + box-shadow: 1px 1px 1px #828282; + border: 1px solid #8A8A8A; + height: 24px; + width: 7px; + border-radius: 0px; + background: #FFFFFF; + cursor: pointer; +} + +input[type=range]:focus::-ms-fill-lower +{ + background: #B6B6B6; +} + +input[type=range]:focus::-ms-fill-upper +{ + background: #B6B6B6; +} + +input[type=range]:disabled { + opacity: 0.5; +} + +#webrx-page-container +{ + height: 100%; + position: relative; + display: flex; + flex-direction: column; +} + +#openwebrx-scale-container +{ + height: 47px; + overflow: hidden; + z-index:1000; + position: relative; +} + +#openwebrx-frequency-container { + background-image: url("../gfx/openwebrx-scale-background.png"); + background-repeat: repeat-x; + background-size: cover; + background-color: #444; + z-index: 1001; +} + +#openwebrx-bookmarks-container +{ + height: 25px; + position: relative; + z-index: 1000; +} + +#openwebrx-bookmarks-container .bookmark { + font-size: 12px; + background-color: #FFFF00; + border: 1px solid #000; + border-radius: 5px; + padding: 2px 5px; + cursor: pointer; + white-space: nowrap; + max-height: 14px; + max-width: 50px; + + position: absolute; + bottom: 5px; + transform: translate(-50%, 0); +} + +#openwebrx-bookmarks-container .bookmark .bookmark-content { + overflow: hidden; + text-overflow: ellipsis; +} + +#openwebrx-bookmarks-container .bookmark .bookmark-actions { + display: none; + text-align: right; +} + +.bookmark-actions .action { + line-height: 0; +} + +.bookmark-actions .action img { + width: 14px; +} + +#openwebrx-bookmarks-container .bookmark.selected { + z-index: 1010; +} + +#openwebrx-bookmarks-container .bookmark:hover { + z-index: 1011; + max-height: none; + max-width: none; +} + +#openwebrx-bookmarks-container .bookmark[editable]:hover .bookmark-actions { + display: block; + margin-bottom: 5px; +} + +#openwebrx-bookmarks-container .bookmark:after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + border: 5px solid transparent; + border-top-color: #FFFF00; + border-bottom: 0; + margin-left: -5px; + margin-bottom: -5px; +} + +#openwebrx-bookmarks-container .bookmark[data-source=local] { + background-color: #0FF; +} + +#openwebrx-bookmarks-container .bookmark[data-source=local]:after { + border-top-color: #0FF; +} + +#openwebrx-bookmarks-container .bookmark[data-source=dial_frequencies] { + background-color: #0F0; +} + +#openwebrx-bookmarks-container .bookmark[data-source=dial_frequencies]:after { + border-top-color: #0F0; +} + +#webrx-canvas-background { + flex-grow: 1; + background-image: url('../gfx/openwebrx-background-cool-blue.png'); + background-repeat: no-repeat; + background-color: #1e5f7f; + background-size: cover; + display: flex; + flex-direction: column; +} + +@supports(background-image: -webkit-image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x)) { + #webrx-canvas-background { + background-image: -webkit-image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x); + } +} + +@supports(background-image: image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x)) { + #webrx-canvas-background { + background-image: image-set(url('../gfx/openwebrx-background-cool-blue.webp') 1x); + } +} + +#webrx-canvas-container +{ + position: relative; + overflow: visible; + cursor: crosshair; + flex-grow: 1; +} + +#webrx-canvas-container canvas +{ + position: absolute; + top: 0; + border-style: none; + image-rendering: crisp-edges; + image-rendering: -webkit-optimize-contrast; + width: 100%; + height: 200px; + will-change: transform; +} + +#openwebrx-log-scroll +{ + /*overflow-y:auto;*/ + height: 125px; + width: 619px +} + +.nano .nano-pane { background: #444; } +.nano .nano-slider { background: #eee !important; } + +.webrx-error +{ + font-weight: bold; + color: #ff6262; +} + +@font-face { + font-family: 'roboto-mono'; + src: url('../fonts/RobotoMono-Regular.woff2') format('woff2'), + url('../fonts/RobotoMono-Regular.woff') format('woff'), + url('../fonts/RobotoMono-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +.webrx-actual-freq { + width: 100%; + text-align: left; + padding: 0; + margin: 0; + display: flex; + flex-direction: row; + cursor: pointer; +} + +.webrx-actual-freq > * { + flex: 1; +} + +.webrx-actual-freq .input-group { + display: flex; + flex-direction: row; +} + +.webrx-actual-freq .input-group > * { + flex: 0 0 auto; +} + +.webrx-actual-freq .input-group input { + flex: 1 0 auto; + margin-right: 0; + border-right: 1px solid #373737; + -moz-appearance: textfield; +} + +.webrx-actual-freq .input-group input::-webkit-outer-spin-button, +.webrx-actual-freq .input-group input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.input-group > :not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > :not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group :first-child { + padding-left: 5px; +} + +.input-group :last-child { + padding-right: 5px +} + +.webrx-actual-freq .input-group input, .webrx-actual-freq .input-group select { + outline: none; + font-size: 16pt; +} + +.webrx-actual-freq input { + font-family: 'roboto-mono'; + width: 0; + box-sizing: border-box; + border: 0; + padding: 0; + background-color: inherit; + color: inherit; +} + +.webrx-actual-freq, .webrx-actual-freq input { + font-size: 16pt; + font-family: 'roboto-mono'; +} + +.webrx-actual-freq .digit { + cursor: ns-resize; +} + +.webrx-actual-freq .digit:hover { + color: #FFFF50; + border-radius: 5px; + background: -webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #373737), color-stop(1, #4F4F4F) ); + background: -moz-linear-gradient( center top, #373737 0%, #4F4F4F 100% ); +} + +.webrx-mouse-freq { + width: 100%; + text-align: left; + font-size: 10pt; + color: #AAA; + font-family: 'roboto-mono'; + margin-bottom: 5px; +} + +#openwebrx-panels-container-left, +#openwebrx-panels-container-right { + position: absolute; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + height: 0; + overflow: visible; +} + +#openwebrx-panels-container-left { + left: 0; + align-items: flex-start; +} + +#openwebrx-panels-container-right { + right: 0; + align-items: flex-end; +} + +.openwebrx-panel +{ + transform: perspective( 600px ) rotateX( 90deg ); + background-color: #575757; + padding: 10px; + color: white; + font-size: 10pt; + border-radius: 15px; + -moz-border-radius: 15px; + margin: 5.9px; + box-sizing: content-box; +} + +.openwebrx-panel a +{ + color: #5ca8ff; + text-shadow: none; +} + +.openwebrx-panel-inner +{ + overflow-y: auto; + overflow-x: hidden; + height: 100%; +} + +.openwebrx-button +{ + background-color: #373737; + padding: 4.2px; + border-radius: 5px; + -moz-border-radius: 5px; + color: White; + font-weight: bold; + margin-right: 1px; + cursor: pointer; + background:-webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #373737), color-stop(1, #4F4F4F) ); + background:-moz-linear-gradient( center top, #373737 0%, #4F4F4F 100% ); + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + display: inline-block; +} + +.openwebrx-button:hover, .openwebrx-demodulator-button.highlighted, .openwebrx-button.highlighted +{ + /*background:-webkit-gradient( linear, left top, left bottom, color-stop(0.0 , #3F3F3F), color-stop(1, #777777) ); + background:-moz-linear-gradient( center top, #373737 5%, #4F4F4F 100% );*/ + background: #474747; + color: #FFFF50; +} + +.openwebrx-button:active +{ + background: #777777; + color: #FFFF50; +} + +.openwebrx-button:last-child { + margin-right: 0; +} + +.openwebrx-button.disabled { + opacity: 0.5; +} + +.openwebrx-demodulator-button +{ + height: 19px; + font-size: 12pt; + text-align: center; + flex: 1; + margin-right: 5px; +} + +.openwebrx-demodulator-button.same-mod { + color: #FFC; +} + +.openwebrx-square-button img +{ + height: 27px; +} + +.openwebrx-round-button +{ + margin-right: -2px; + width: 35px; + height: 35px; + border-radius: 25px; +} + +.openwebrx-round-button img +{ + height: 30px; +} + +.openwebrx-round-button-small +{ + margin-right: -3px; + width: 20px; + height: 20px; + border-radius: 25px; +} + +.openwebrx-round-button-small img +{ + height: 20px; +} + +img.openwebrx-mirror-img +{ + transform: scale(-1, 1); +} + + +.openwebrx-round-rightarrow img +{ + position: relative; + left: 12px; + top: 3px; +} + +.openwebrx-round-leftarrow img +{ + position: relative; + left: 7px; + top: 3px; +} + +#openwebrx-client-log-title +{ + margin-bottom: 5px; + font-weight: bold; +} + +.openwebrx-progressbar +{ + position: relative; + border-radius: 5px; + background-color: #003850; /*#006235;*/ + display: inline-block; + text-align: center; + font-size: 8pt; + font-weight: bold; + text-shadow: 0px 0px 4px #000000; + cursor: default; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + overflow: hidden; + z-index: 1 +} + +.openwebrx-progressbar-bar { + background-color: #00aba6; + border-radius: 5px; + height: 100%; + width: 100%; + transition-property: transform, background-color; + transition-duration: 1s; + transition-timing-function: ease-in-out; + transform: translate(-100%) translateZ(0); + will-change: transform, background-color; + z-index: 0; +} + +.openwebrx-progressbar--over .openwebrx-progressbar-bar { + background-color: #ff6262; +} + +.openwebrx-progressbar-text +{ + position: absolute; + left:50%; + top:50%; + transform: translate(-50%, -50%); + white-space: nowrap; + z-index: 2; +} + +#openwebrx-panel-status +{ + margin: 0 0 0 5.9px; + padding: 0px; + background-color:rgba(0, 0, 0, 0); +} + +#openwebrx-panel-status div.openwebrx-progressbar +{ + width: 200px; + height: 20px; +} + +#openwebrx-panel-receiver +{ + width:110px; +} + + +#openwebrx-panel-receiver .frequencies-container { + display: flex; + flex-direction: row; + gap: 5px; +} + +#openwebrx-panel-receiver .frequencies { + flex-grow: 1; +} + +#openwebrx-panel-receiver .openwebrx-bookmark-button { + width: 27px; + height: 27px; + text-align: center; +} + +.openwebrx-panel-slider +{ + position: relative; + top: -2px; + width: 95px; +} + +.openwebrx-panel-line +{ + padding-top: 5px; +} + +.openwebrx-panel-flex-line { + display: flex; + flex-direction: row; +} + +.openwebrx-panel-line:first-child { + padding-top: 0; +} + +.openwebrx-modes-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: -5px -5px 0 0; +} + +.openwebrx-modes-grid .openwebrx-demodulator-button { + margin: 0; + white-space: nowrap; + flex: 1 0 38px; + margin: 5px 5px 0 0; +} + +@supports(gap: 5px) { + .openwebrx-modes-grid { + margin: 0; + gap: 5px; + } + + .openwebrx-modes-grid .openwebrx-demodulator-button { + margin: 0; + } +} + +#openwebrx-smeter { + border-color: #888; + border-style: solid; + border-width: 0px; + width: 255px; + height: 7px; + background-color: #373737; + border-radius: 3px; + overflow: hidden; +} + +.openwebrx-smeter-bar { + transition-property: transform; + transition-duration: 0.2s; + transition-timing-function: linear; + will-change: transform; + transform: translate(-100%) translateZ(0); + width: 100%; + height: 100%; + background: linear-gradient(to top, #ff5939 , #961700); + margin: 0; + padding: 0; + border-radius: 3px; +} + +#openwebrx-smeter-db +{ + color: #aaa; + display: inline-block; + font-size: 10pt; + float: right; + margin-right: 5px; + margin-top: 24px; + font-family: 'roboto-mono'; +} + +.openwebrx-overlay { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + opacity: 0.8; + background-color: #777; + left: 0; + top: 0; + z-index: 1001; + color: white; + font-weight: bold; + font-size: 20pt; +} + +#openwebrx-autoplay-overlay +{ + cursor: pointer; + transition: opacity 0.3s linear; +} + +#openwebrx-autoplay-overlay svg { + width: 150px; +} + +.openwebrx-overlay .overlay-content { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +#openwebrx-error-overlay .overlay-content { + background-color: #000; + padding: 50px; + border-radius: 20px; +} + +#openwebrx-digimode-canvas-container +{ + /*margin: -10px -10px 10px -10px;*/ + margin: -10px -10px 0px -10px; + border-radius: 15px; + height: 150px; + background-color: #333; + position: relative; + overflow: hidden; +} + +#openwebrx-digimode-canvas-container canvas +{ + position: absolute; + top: 0; + pointer-events: none; + transition: width 500ms, left 500ms; + will-change: transform; +} + +.openwebrx-panel select, +.openwebrx-panel input, +.openwebrx-dialog select, +.openwebrx-dialog input { + border-radius: 5px; + background-color: #373737; + color: White; + font-weight: normal; + font-size: 13pt; + margin-right: 1px; + background:linear-gradient(#373737, #4F4F4F); + border-color: transparent; + border-width: 0px; +} + +@supports(-moz-appearance: none) { + .openwebrx-panel select, + .openwebrx-dialog select { + -moz-appearance: none; + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%20%20xmlns%3Av%3D%22https%3A%2F%2Fvecta.io%2Fnano%22%3E%3Cpath%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8s-1.9-9.2-5.5-12.8z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E'), + linear-gradient(#373737, #4F4F4F); + background-repeat: no-repeat, repeat; + background-position: right .3em top 50%, 0 0; + background-size: .65em auto, 100%; + } + + .openwebrx-panel .input-group select, + .openwebrx-dialog .input-group select { + padding-right: 1em; + } +} + +.openwebrx-panel select option, +.openwebrx-dialog select option { + border-width: 0px; + background-color: #373737; + color: White; +} + +.openwebrx-secondary-demod-listbox { + width: 173px; + height: 27px; + padding-left:3px; + flex: 4; +} + +#openwebrx-sdr-profiles-listbox { + width: 100%; + font-size: 10pt; + height: 27px; +} + +#openwebrx-cursor-blink +{ + animation: cursor-blink 1s infinite; + /*animation: cursor-3d 2s infinite;*/ + animation-timing-function: linear; + animation-direction: alternate; + height: 1em; + width: 8px; + background-color: White; + display: inline-block; + position: relative; + top: 1px; + /*perspective: 60px;*/ + +} + +@keyframes cursor-blink +{ + 0%{ opacity: 0; } + 50% { opacity: 1; } + 100%{ opacity: 0; } +} + +@keyframes cursor-3d +{ + 0%{ transform: rotateX(0deg) rotateX(Ydeg); } + 50% { transform: rotateX(180deg) rotateY(360deg); opacity: 0.1; } + 100%{ transform: rotateX(360deg) rotateY(720deg); } +} + +#openwebrx-digimode-content +{ + word-wrap: break-word; + position: absolute; + bottom: 0; + width: 100%; +} + +#openwebrx-digimode-content-container +{ + overflow-y: hidden; + display: block; + height: 50px; + position: relative; +} + +#openwebrx-digimode-content-container .gradient +{ + width: 100%; + height: 20px; + background: linear-gradient(to top, rgba(87,87,87,0) 0%,rgba(87,87,87,1) 100%); + position: absolute; + top: 0; + z-index: 10; +} + + +#openwebrx-digimode-content .part +{ + perspective: 700px; +} + +#openwebrx-digimode-content .part +{ + animation: new-digimode-data-3d 100ms; + animation-timing-function: linear; + display: inline-block; + perspective-origin: 50% 50%; + transform-origin: 0% 50%; +} + +@keyframes new-digimode-data +{ + 0%{ opacity: 0; } + 100%{ opacity: 1; } +} + +@keyframes new-digimode-data-3d +{ + 0%{ transform: rotateX(0deg) rotateY(-90deg) translateX(-5px) scale(1.3); } + 100%{ transform: rotateX(0deg) rotateY(0deg) translateX(0) scale(1); } +} + +#openwebrx-digimode-select-channel +{ + transition: all 500ms; + background-color: Yellow; + display: block; + position: absolute; + pointer-events: none; + height: 100%; + width: 0px; + top: 0px; + left: 0px; + opacity: 0.7; + border-style: solid; + border-width: 0px; + border-color: Red; +} + +.openwebrx-meta-panel { + display: flex; + flex-direction: row; + gap: 10px; + /* compatibility with iOS 14.2 */ + flex: 0 0 auto; +} + +.openwebrx-meta-slot { + flex: 1; + width: 145px; + height: 196px; + + background-color: #676767; + padding: 2px 0; + color: #333; + + text-align: center; + + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; +} + +.openwebrx-meta-slot > * { + flex: 1 0 0; + line-height: 1.2em; +} + +.openwebrx-meta-slot, .openwebrx-meta-slot .mute { + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} + +.openwebrx-meta-slot .mute { + display: none; + cursor: pointer; + + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: rgba(0,0,0,.3); +} + +.openwebrx-meta-slot .mute svg { + position: absolute; + top: 50%; + left: 0; + transform: translate(0, -50%); +} + +.openwebrx-meta-slot.muted .mute { + display: block; +} + +.openwebrx-meta-slot.active { + background-color: #95bbdf; +} + +.openwebrx-meta-slot.sync .openwebrx-dmr-slot:before { + content:""; + display: inline-block; + margin: 0 5px; + width: 12px; + height: 12px; + background-color: #ABFF00; + border-radius: 50%; + box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 7px 1px, inset #304701 0 -1px 9px, #89FF00 0 2px 12px; +} + +.openwebrx-meta-slot .openwebrx-meta-user-image { + flex: 0 1 100%; + background-position: center; + background-repeat: no-repeat; + line-height: 0; + overflow: hidden; +} + +.openwebrx-meta-slot .openwebrx-meta-user-image img { + max-width: 100%; + max-height: 100%; + display: none; +} + +.openwebrx-meta-slot.active.direct .openwebrx-meta-user-image .directcall, +.openwebrx-meta-slot.active.individual .openwebrx-meta-user-image .directcall, +#openwebrx-panel-metadata-ysf .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall, +#openwebrx-panel-metadata-dstar .openwebrx-meta-slot.active .openwebrx-meta-user-image .directcall { + display: initial; +} + +.openwebrx-meta-slot.active.group .openwebrx-meta-user-image .groupcall, +.openwebrx-meta-slot.active.conference .openwebrx-meta-user-image .groupcall { + display: initial; +} + +.openwebrx-meta-slot.group .openwebrx-dmr-target:not(:empty):before { + content: "Talkgroup: "; +} + +.openwebrx-meta-slot.direct .openwebrx-dmr-target:not(:empty):before { + content: "Direct: "; +} + +.openwebrx-dmr-timeslot-panel * { + cursor: pointer; + user-select: none; +} + +.openwebrx-ysf-mode:not(:empty):before { + content: "Mode: "; +} + +.openwebrx-ysf-up:not(:empty):before { + content: "Up: "; +} + +.openwebrx-ysf-down:not(:empty):before { + content: "Down: "; +} + +.openwebrx-dstar-yourcall:not(:empty):before { + content: "UR: "; +} + +.openwebrx-dstar-departure:not(:empty):before { + content: "RPT1: "; +} + +.openwebrx-dstar-destination:not(:empty):before { + content: "RPT2: "; +} + +.openwebrx-meta-slot.individual .openwebrx-nxdn-destination:not(:empty):before { + content: "Direct: "; +} + +.openwebrx-meta-slot.conference .openwebrx-nxdn-destination:not(:empty):before { + content: "Conference: "; +} + +.openwebrx-maps-pin svg { + width: 15px; + height: 15px; + vertical-align: middle; +} + +.openwebrx-message-panel { + height: 180px; + position: relative; +} + +.openwebrx-message-panel tbody { + display: block; + overflow: auto; + height: 150px; + width: 100%; +} + +.openwebrx-message-panel thead tr { + display: block; +} + +.openwebrx-message-panel th, +.openwebrx-message-panel td { + width: 50px; + text-align: left; + padding: 1px 3px; +} + +#openwebrx-panel-wsjt-message .message { + width: 380px; +} + +#openwebrx-panel-wsjt-message .decimal { + text-align: right; + width: 35px; +} + +#openwebrx-panel-wsjt-message .decimal.freq { + width: 70px; +} + +#openwebrx-panel-js8-message .message { + width: 465px; + max-width: 465px; +} + +#openwebrx-panel-js8-message td.message { + white-space: nowrap; + overflow: hidden; + display: flex; + flex-direction: row-reverse; +} + +#openwebrx-panel-js8-message .message div { + flex: 1; +} + +#openwebrx-panel-js8-message .decimal { + text-align: right; + width: 35px; +} + +#openwebrx-panel-js8-message .decimal.freq { + width: 70px; +} + +#openwebrx-panel-packet-message .message { + width: 410px; + max-width: 410px; +} + +#openwebrx-panel-packet-message .callsign { + width: 80px; +} + +#openwebrx-panel-packet-message .coord { + width: 40px; + text-align: center; +} + +#openwebrx-panel-pocsag-message .address { + width: 100px; +} + +#openwebrx-panel-pocsag-message .message { + width: 486px; + max-width: 486px; + white-space: pre; +} + +.aprs-symbol { + display: inline-block; + width: 15px; + height: 15px; + background-size: 240px 90px; +} + +.aprs-symboltable-normal { + background-image: url(../../aprs-symbols/aprs-symbols-24-0.png) +} + +.aprs-symboltable-alternate { + background-image: url(../../aprs-symbols/aprs-symbols-24-1.png) +} + +.aprs-symboltable-overlay { + background-image: url(../../aprs-symbols/aprs-symbols-24-2.png) +} + +.openwebrx-dialog { + background-color: #575757; + padding: 10px; + color: white; + position: fixed; + font-size: 10pt; + border-radius: 15px; + -moz-border-radius: 15px; + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, 0); +} + +.openwebrx-dialog .form-field { + padding: 5px; + display: flex; + flex-direction: row; +} + +.openwebrx-dialog .form-field:first-child { + padding-top: 0; +} + +.openwebrx-dialog label { + display: inline-block; + flex-grow: 0; + width: 70px; + padding-right: 20px; + margin-top: auto; + margin-bottom: auto; +} + +.openwebrx-dialog .form-field input, +.openwebrx-dialog .form-field select { + flex-grow: 1; + height: 27px; +} + +.openwebrx-dialog .form-field input { + padding: 0 5px; +} + +.openwebrx-dialog .buttons { + text-align: right; + padding: 5px 5px 0; + border-top: 1px solid #666; +} + +.openwebrx-dialog .buttons .openwebrx-button { + font-size: 12pt; + min-width: 50px; + text-align: center; + padding: 5px 10px; +} + +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-content-container, +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-select-channel +{ + display: none; +} + +#openwebrx-panel-digimodes[data-mode="ft8"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="wspr"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="jt65"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="jt9"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="ft4"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="packet"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="pocsag"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="fst4"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="fst4w"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="q65"] #openwebrx-digimode-canvas-container +{ + height: 200px; + margin: -10px; +} + +.openwebrx-zoom-button svg { + height: 27px; +} + +.openwebrx-slider-button svg { + position:relative; + top: 1px; + height: 14px; +} + +.openwebrx-mute-button svg.muted { + display: none; +} + +.openwebrx-mute-button.muted svg.muted { + display: initial; +} + +.openwebrx-mute-button.muted svg.unmuted { + display: none; +} + +.bookmark .bookmark-actions .openwebrx-button svg { + height: 14px; +} + +#openwebrx-waterfall-colors-auto .continuous { + display: none; +} + +#openwebrx-waterfall-colors-auto.highlighted .continuous { + display: initial; +} + +#openwebrx-waterfall-colors-auto.highlighted .auto { + display: none; +} + +.openwebrx-waterfall-container { + flex-grow: 1; + display: flex; + flex-direction: column; + position: relative; +} + +.openwebrx-waterfall-container > * { + flex: 0 0 auto; +} \ No newline at end of file diff --git a/openwebrx/htdocs/favicon.ico b/openwebrx/htdocs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6a07f1b1ec7b0c3e5e7a1230c2cf9b4853d4b733 GIT binary patch literal 5430 zcmbuD32+zH9mh8ZM<7KKAmI*F0p*O!Ap&xS!-7bmNCi5rEePchm~zOWQ3#N5R}cgf zELXLFPAi862Lid|P^So5Py*qzm#0feoJ}f*4}Qh@-LZtj?b|YkX05c6OTDiN(dm zXO}LTeKR_0&drjNl5^;?va&ACojI;3ZsVGKV#H`MXUEn}*P>nyH2+>^nGu7Zv`?Hk zkvrj~Usvo{;A^(c_cas$IJ^=aHhdP2t7-neJ@%?%XNmdCqZj0Do9AmzY_Lp3x5jpK zbadf>UTy57Yb*l;d$+Y?Vq%K=J>AI02j5l8-pEtzTrv69ty^cNP8eSHhZml%NJ>0J z-!5Fdn3g}NcPo2PuU2+?`o#iti3y+P|9)iG3VaI-3%`=?&mvqfAA3cTh!;D#FI~Dc z6})wllLfxSbP&}=8}W+RGI8R>uTGsh^?~G=DO0BG17viQCQUkxkC-ZmA=XDU6ANQw zW0U&!?VGM=uxsPSjUP{&Hthf)qwCYBPexo^T!MVx6k&o`Xel=K>(}q<$&)9KjUGMv z;<|O~z8pV({CBW+?b^@Bj2V-5{P^)>5fKsD_=*<76W>a5T!#)F3UnoR?AY;!>K*OW zsZ&np&Yg4g+~d1+>5?m3Qh0bc^BX6q>4_)TJHv(z%i6zx|EX7Bef9i|88g1g&(HrC zOrJje>$!91p4+>3?^h#6jL5`Sc-L>D_G?Fu97$Fm@^f=@KS@qbUMt;S02y8T_U#K2 z6BC*L1%iFx!=}Sr^p7}vWhYcS;@7SL!M{3j%uPmH1%|tK3 zdGu44>e3(jNq?$}YF^Mrj~zH$Jbu_bpu^_z^Z5K&F7?!TM)S0zFa9R}D&7$DMYPx`wh8Qjj1C(< zk%Aavv6G9OqE|%mo3xsktQ~^ z+?vz|*2Q_|eaqZ?V`h!AOzm3LO;}i%L%n+SOjv#2d-_Q3CLun?vAKRhiXql}Eac`n zP|K}JZT84};w;Z`RA2u)F^sW8ivY7^$zu8pJZC#V&t$7tKgo+OBO}A{!8a;uksaPL zz#LnDw+}D$sbU5{8^Nl2?s*OsF@zQx3nW6I=7#TxOkI4$TeV=ofq83v$JZG2$^nEoyqdgZTzwP9})mB5mi+ zon_?Fx0$KXB^~ogKE{;QrcIkNKz4Q5it!yje7JLl@7}!|d#WJkctH(n-TBr*7WAo; zm?aMCU69qhd2_pA!-huRmr@y9-;kBT!NDdWA%S&OptV;4$mp;gIBAe!Y|HqmS4=#=dgpie0N#Ed$8t^saP#v|jNs`fcH~m8($AWU)~66x6z3{a(^X zi4^@NDWN{|$fb4Yf&~4kc%^APG<(+!-=FP*0_kq)%J$p9mmhqFjr?_8z z&Y@?-n<7bj=oY#6?c1lOK5F9Qy~oD$FVXLkB0v@uo;i|EO=I@#+2h3MciJsAlLWb` zK`rn3XZ;6>u42B3*SK^1{QS(QQKQtneGM5h#2NFULx&horr^wjOmy@YAGMup2$mpnT0Gk4?{&C5h)IeH2r@f zUwK$>Z0aw>PO(`0Nep&DMu+VSe2F1e?NmW7a`qO~pw^xGZXbN5<6L}H@X%L_1L8|@ zN_-|h5c9?J;tBCf7i4tU@WGcDV#z@+a*{hx-18gtdvn0vcw8`7uZy?D2C+(THV+lN zr|{$V3m~Jzh7Z2P5c`@S7di3!!Pu$KB^z^4&Gmas_h-bf#B<_V(Ot9@!2&jQw`?+kdf(s)BhUroL$C-f5%9ekTvIt9#%W@0#D2m5cRAPJh9C sd7zE{p0z_fI@k8VF~mjk?}7j0BK|>%w=6$Mv#e@)K9LWV_{E3)4=mDZBLDyZ literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/features.html b/openwebrx/htdocs/features.html new file mode 100644 index 0000000..53099b6 --- /dev/null +++ b/openwebrx/htdocs/features.html @@ -0,0 +1,25 @@ + + OpenWebRX Feature report + + + + + + + + + ${header} +
+ ${breadcrumb} +

OpenWebRX Feature Report

+ + + + + + + +
FeatureRequirementDescriptionAvailable
+ ${breadcrumb} +
+ \ No newline at end of file diff --git a/openwebrx/htdocs/features.js b/openwebrx/htdocs/features.js new file mode 100644 index 0000000..fef2817 --- /dev/null +++ b/openwebrx/htdocs/features.js @@ -0,0 +1,23 @@ +$(function(){ + var converter = new showdown.Converter(); + $.ajax('api/features').done(function(data){ + var $table = $('table.features'); + $.each(data, function(name, details) { + var requirements = $.map(details.requirements, function(r, name){ + return '' + + '' + + '' + name + '' + + '' + converter.makeHtml(r.description) + '' + + '' + (r.available ? 'YES' : 'NO') + '' + + ''; + }); + $table.append( + '' + + '' + name + '' + + '' + (details.available ? 'YES' : 'NO') + '' + + '' + + requirements.join("") + ); + }) + }); +}); diff --git a/openwebrx/htdocs/fonts/RobotoMono-Regular.ttf b/openwebrx/htdocs/fonts/RobotoMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7c4ce36a442d14eeb12444ad707c2afa19422fd8 GIT binary patch literal 86908 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 literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/fonts/RobotoMono-Regular.woff2 b/openwebrx/htdocs/fonts/RobotoMono-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..dab25851b08a83fb5e729dd36bb12150471c7022 GIT binary patch literal 40752 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 yQ^c%e(PmXnDx2#?W4Xm7!=uvuuLwN<``}L^{Rw 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 E0Ch*1Z2$lO literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/gfx/favicon128.png b/openwebrx/htdocs/gfx/favicon128.png new file mode 100644 index 0000000000000000000000000000000000000000..ad42441f1dc838252b62398f61c39160ae85b9c3 GIT binary patch literal 11679 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%0AEn}w0%BxE{eW$C+UuZ@EnYRZry%%Gs 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 literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/gfx/favicon44.png b/openwebrx/htdocs/gfx/favicon44.png new file mode 100644 index 0000000000000000000000000000000000000000..d21f326e73ccd61c08f58b8f0b629d98c7f347cc GIT binary patch literal 3555 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 literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/gfx/favicon96.png b/openwebrx/htdocs/gfx/favicon96.png new file mode 100644 index 0000000000000000000000000000000000000000..ee94129882f0b4276b583e67f237820204e517e7 GIT binary patch literal 8665 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 JDYx>V{|mNJ(~JNB literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.png b/openwebrx/htdocs/gfx/openwebrx-background-cool-blue.png new file mode 100644 index 0000000000000000000000000000000000000000..236b366b33b822bed602f0907c18ab835cedb13f GIT binary patch literal 66934 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%0DZGM*PZhqa#rmq&db4a{qoE6 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 \ No newline at end of file diff --git a/openwebrx/htdocs/gfx/openwebrx-groupcall.svg b/openwebrx/htdocs/gfx/openwebrx-groupcall.svg new file mode 100644 index 0000000..5083a57 --- /dev/null +++ b/openwebrx/htdocs/gfx/openwebrx-groupcall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/openwebrx/htdocs/gfx/openwebrx-scale-background.png b/openwebrx/htdocs/gfx/openwebrx-scale-background.png new file mode 100644 index 0000000000000000000000000000000000000000..91453c589d8ed2da112dcba43eef159d0199d6fc GIT binary patch literal 21578 zcmV(xKL7v6kihpv<(DZ*UIj&=@ow~P-aK$#;@!zxR7VMsz9+6JerqHtLKq8;4t?@-E2*{tA2H2Ys5E*w!}QGlfC zk5m~5W}4=W+a|_ZXrV#Q8QY~y(x(~92o36A;()CIBl^@JG6TbstjIc$kqh`4EE?9s zF#G@(ZH20LsK8-_5GH5D7!0|5BiOZFdMuC*k07+33Ax*-Kf=~Vwg5mxoeQ9{zSmg~ z>F}m5YY@`A2!>`!C4vP5OtA(ijD|KmKeSu#vTNGR9vZMEX@$|3wFBfz7MG$4`@1#T z0Fph3HoEF&R#vdEvYufO3|9w)wQd;y13GM>5*j_ro^7+_C8>pMrW`84n>bmg#AARW zYbpua^)5=}yC$BX@S79~McQUx&|9t0yvY8U^^DjOKWIH>Lnvg-kpL?7!k!2<;28yY z#fCmYifl|^mfRGfk?;p|R4 z%@B24Lu3@7adGiCJu#XX$0`s`$M(iHVUij(@uo0b+5sRTZ@fc$#iEEC&t_MfmjsAN z1)b@e*G&(v$ca__)2pLPfkM2;Eer?n0B#vXIyTn|(!0^+|qZ4RS9T68MBX;IM(gF7- z5NX-sSq{PtR5gOaYeJ*u7-;cCaD*sawJ2N8F~AfMo#BEH5Rn0d$aTSx;xejoCa!ri z)lwMBxaucRTpOEaOEi_%tw#X>2{a&|3yMwd=4=F5O<^?aAxYPXeDD)jOuVaMaicHi zXnJqJ_uU~}Wo9JrrT>M1Hz+`T1{+XZy778>Txb{PAcp!Dh zCZ8rG^I$_a2|nup$2qkq5kNZ$jjvdT&0~XDDt1u=m0k3<4wh_~kEr|g0PA(Kh)5e zACqtX<`NePra47gIvO+?j)}rjr1d{KcfFT01{)ndc&KNqFW%~tRzdFMo>~$QSpA62 zCL}lt8<$qoh-0Q3EF~>_mD6ABgIzqLU1o%-;$xGVy}f9+0B^$UsaKq}z|%aU^MXBh z_avq9o{HAbY(~*FZ!D<}N=Ym9oM#$-9KD@$I1t`Pw^w&7Pig=;OH??zG@+JYq<#$qw2#t z#j2WKH(pArU2I5THC<~upki?b{Mn_fTiEnu9rc8M@L9vbpQ9bKf}N@ThU4^@Q0~#|3sGu9BWDj+nn$p(tvE%a>0_|k(wgCHO^8%MtM!4_8*!() z0>h9D>n)ACH3bA}*@V0|A6E+AM*F63M>JH!mm!W9(>NFHDF98<6GSHGBE)Em=wr|O ztXn8{JzKWZU7eoIr3LLpPPHz}mVtzF*_2huC1E#cluiJ3&P6H7_SuRO9d>LQeW+qR(;#u6%KbZ& z8u1BU9cQ?MD?$IRB^uUiAFY-&y=MdjEtm+LJT#;w=^;1So{ffm4fxphRd|LmXPgz= zemVf6nOQ=^4|3$aU-?+X^Z>(_1ut>(4|27225gt}hLPhyloRKFANrYeK$)xzg%fke zmQgd2hZ);$jJJHTmS(oH7>o&o3eH>tF#D3`Zm;iNh>}Rf5m=kd;Z=t&hI|AVX(-h5 z)++DLB|W>>*c!e^0}QQ0njmwqY3Ac{>W!&aeuH}2e!^<>$AYAzl+RXetn*!d*8Idc zOSW!~KSR7J7GhNFe1Wu&4U0n7G-P-!Q2nbGKG66o#ouax7Sv3&+1ntNK7`$C+H1ay zTjsMWM{rjiS>C-+^=~be5-UH{-9M?p0o~moS;_G_%raz2siUmIVBCSBzr(*Fc;nEM z3t9-=v00~TX(FlsUYzCnMZ`sG&x2suQ(aO$TO74q*~Wf^SaBM0B+r%kc6=(`MCO;oIlx0G zPMDZJ4?NN5#eVqg5liGx%@GE1m}AyOBS0=JTfioO@vtMePlK%-q{;~k5#Q554+=p8 zZ;n5pw_L(HK*+e)pNeeKZy)ixNUeA>VI++eB*0e|bpJ;-5fyu;WZaJK1o}xh>%6?;O11I6atSbmbGIK!@D;sVh3xzcdvdp4`e!tQ{ZpqWs zbHqrNsag1kG!jd)Mo>}uOuH3s#rCN9Dt-qNh^v?v3NM8lMrdTR<-qoO&vdq%P%T<5 zea}$dw~C>Ni%0jBPO;RYv##w4<=XLb2YpuBn9Yn?PmAfPNnh|}>eBjIG&$&{U1trh zB`B>r>)WBXEu*0~Lu>0nzQhFr7KyCBiB}w~g|NywXNO-biVP+@dA15A#F>rCJVd%z zCX%6tWey?vc9weRBb3(KzZqmRX`PTgY+y3KM$n?f&|^tRsu>pcP)vogxd2B27?Dhi zB1P5!ilMMR89wE_%0+ne2Mogo`gM05pU6WN6R)db(w&vNQ4l^Czcvid(Dnhu^J*Bz zBO7fY&<`%K1M)ve*T31q3Hc=6d4l*+8{q!Yk2?iQY2%QK5}*f#uhC){iI0)`CLt)l z;uKfk69f3m{1*2$k;sIWBXTF+W+sUWr7vnk(u~Qw)ihfJf|YBCAx>(joAE})40nx+ zo8i`H?7^U6QcHjuD3aS|BWldBCv3I9| zFT$?jcTyCflxL~aSe)d!BHfyx;TSkAwtAwa52L2cB_(5s{G%`tc=^zx)2pL3)aGS7 zn}Fy(<9-vO^!|6@Yi{sgiIk|+ASuYIjiX9A1qf+hPG5Eo`4-@KOKI1`f8 zv(i9nU6Dldz2#`IiNX}s@wa`iF1TZ*GPf4D5I`mQo5BpVXLE6X8v-c%RocN)O>WEn zB44t;w{PVb{Dkl-rR^ImdEra_?`@Y?W=~iZt6i?M6R}{8}2jM zbgrp<3F+CffS)UN!fF1&>2hpB8xprJ&rK=SR!U#-9rle9OJ?!@FHV2ab9&>*T8;6P z3>5>RMSx7}HotAPL#-ry3>cJyW&t$M@lu>lxyhns0{E(NyUSC>2NaWFJ%U?Oxj|{xJ za8~5CHmICWf&ip(J_786O1O-a5Or6ZC$FgzV1ck<9-kq(+%6u?YcLhE`7dY)*loqGd@%70J3;uA8kyzCv@z}<=VIA;ORHl6-vaH|frvR`VDk^)G(qSlKz*+(9$b+9KgE}>em zq2*Xis)e-{Bh9_F-c!|#wk(7bczzgPIyPe*0+MID=`WN3L;kfS2ckBtse%_xxR(d#Ot3?kv8F-Xrg+4qcR<=@f*yxin{v6%F(RQkZVI6~x=U3Roneyiljl2JimWxAIkNR z4O?rbn>LZl(uNF*ckC(wi%XlNbMMB7Kyr5TjQt?Hq}WecC}8Ou@RQ^k@|UI0hWKj? zolS-|0~OW6jI)}$6g3Gg5vgFBiev&7+jSDGNSpM4aGoGe;iQaJ{{b-1pp4(|fp!1f z$|5aOvmwiqBQ4V<$kktl%LnbRPiC@{aUd3))-Y4SI{rmvJn2K-V(kGG@nQB&h0pp; z%cCWk*xXwC;hrrG+5V%sxHyzhF;0|ku)p%(&S+-$mF?0tq&<1DAyl=V9 zvn*!wNSDz#I)kSF3OL*u<_$_pIVv)%2BeS%|J49Mx1TA*XDd8cVk#X0h>kcy&n+#g z=#dHI7J-X4Cv5GguJ?nMkhXf=f|rrnPuqSWE6D1fC-`qhQ>pVJ-EKn6GH38D)bErF zp$^>5bZ=iVAy+3z62L&UFe8ks?jR(5KHAyyF+*M}l_V!&P1TFE_BhS5UPdKOGIik7@wp7osQ0pKX8;n9080(b9&1C!!9{^VGw3; zJI12ibbG9{$+L%hpHY{BPmh(YGFsY9OAI(W-Up#%Q?%+}W0qh1EzAKEZB~@v^*$IX zhj%N6KH9(~pztLB+C_|igF=%_f(+Ndujbli5fV!pXF5!_`M?0Q`8p);2@LA z-I%!7C4aTo8xGtdgq-idpA3OJuxF41@+Kj86C)m%&{87Of9C_z8h znpzCfzEsSJcGjKMNDHz}6uofsY%;RtvCyawV9v|_4n*H~M*A%J3$iG)t=5oY0N)M6=w}hq?K2S60>)|d z)5uniMgDgxYpa#e=xkD<1C5CQAGV`J%<=?`8M5<6)^b9I>Yi<|y@|M#t)s}rb#TMt zwTIhE5=3#hh^}e~Jt=aDZ_2W?jbdKY3AX*3y*aAa+eKXB_}v)3BN{g^<|U7|53$6fRnvlBb2#!w`0{0tJ^taiM~Mq4>rD zPgeG)T4yUQ1#?nb!iM?0{3IeVQ!Rg2PmFS@Pob{%1~_pVcMyPyEE@W99d2v0Uh7Wl zNTlw>x?d{~V8uO%%7E^1=@XzT$7`A8*H+C&0;R_V554GX>3r(lgZ@x$+d)m*70-); z?@wTu4QS}9D6ZnR)w?x2w+7_U?DfomQ#T(BbaxK1HOEsK2Lj8g>Mw;~hw4k`B7@Ne z+LZJ*0o9+#FrkafZ3AM)I-1BxSK5z9ehnJ$EdZ}Zg!YJYK`RhIfhBshUN{2=_GP$6 zymDzht_;2rL+7BTk0pd;@%yjm`aI9mIlsB_kdd|}VWcKQp>4KFps<6CWC5>jd`^-Q}iY@s2SCUzfG>Umauj_`^^*I3h zGamK*Y++aJJEo7w&V1OY5+J}k;I*emqzYx8HFt>~{Ttu2l6=-W&irVQY^dYLhx?ZW zLi*e11*ouopGF8e7g=HtCKdD7AvoHmu!vB2I%1cUAgr0O%eh{3 zHbAHPu^~w6Y0LuH9v%V2=Ypx_uP%=g(K5I3RbqjX>FP&gM#wDi3GYr5Od&aq zwXFPoW0NEzqC&O3d;L9AxI)hRsA(R=kE|6SewdcxH#w|~A~Pn?=?|mAZX{3>2RCXB z-Ao&UjkHA-P69q;BANyo_-9`uF{lIaIvR^OSyspCnWmqi#&qp~5O7r~^recb5I9)~ zZGvMySr1G=Fvf1~6`scpq!O~QtHr7qu=@=+{t7ygX$pp)pUn@ViwD)7&h)v$wV6ef zR^v86{$Ot*X(n9L%xJ}X_6a8&53y!%IhT+ELQ*uZ>cuty%?$^|9*w*hLFzlm-Dgkp zCbd}u%YMT$GoZ3UL6Sm@S9=a#xrvh!zkve#lWAggQ~qe3Z^$P8rwk)4n7r>2r*fSGs!U!-e(9Kmw;0 z-if>45Rwzc;T5D}3aoRX;yBt*4MaNUiB{FM&n*QOZ)1oh&Qdf9c=mQN%R>&IR>Yhk zIDqlbh5MZW;ALUO2M5($LB<`JZwJ87ad8F_dGKr1Cf8Y>&{HcyjV z@FPbuLJW`{pm-O=%VDhPR3jE}jNcNn`hHFB_3_a5G%SxbobjA&aB4uM#lH^6u-3t> zg0jk{9eO`EUjN);(N>m7W9c`B{(KrDr-`0(x435uGzbMt5A4}X%cI2$g7TPBv$M&g zLJ;b%T{g{%lupk{I20;YKxe9a?K4YaKJ1)6duUBk_oo2=tok7nkbpw~Cd5KIyhBPo z22((w&4oOXUxsI|wS@d4^6)hPn{psmgx@*`*rF*AvCPJ>Dm|j^8o%bugUFjkHx0F} z=&;Pco`mr%@XlP|w1J+6$U1T_A!{B)$;ES!Zfmz$_PqqxeSIOydP@?j3|oZvF8Wb z14W9yA8WFpl<&A)qzTG;GaSc$(3wL4IH1~m$y@;)!{NDc-rHNo**KHdM7oaVEw=Et z^9qHqpIrbO5{>zAqCLbuWT9IdeJpRCOcBkV{mgn+Fz{G~eI>=4O*0@COY%Vip2r|R z@WvWRms`~Q7*=&UYO*C`i?KcEh)`NjX+29a@1+WnwTi8!TNr~L&QM3A8p@w8zeyBzOhhalB33bSiZ{re}KW|rJG4n z|CP3I?tKTax*N7Z5W|}%Fo}Lw!!y88qg6yRbY;;I!tD=9AS?8qjgM_#QI5;A-=W9L2@E(&PkhD<`3&(dsp^gw* zyclJknK)^NaxRwr>{lRZ1!Bl8>Ce=YEzv=eY&lWjYfS38aD6yXE0=cW6rd1TSTVJr zF+JLFwC~~|q2mEq`c{S3(!C(TiZ@g*{vAbIZ40zP7Nt(p8y=!iy8s!xsnzx@_4CB@7}_)GIh| zm%(1zf*n5d!;*yX=v3Kpr4mj*C*DF0*=BV_qEiZtO+1BLRk`cj{wTc?G=G;B5E%Q>DHmt z*Kj64&@@~~5G(vCPb_FCz}s-Ct3(8t1xWLx1=ysa_af(#M7&z@R>urrE9yFnF)?J* zwZ&}0aHV5lWt)Q0dX?|@BH~GMg%0qm*bffa(-&h%XCP)a|SS0Mn;oK^zGh#gACj;Cw}`+^sk z>m~7y92t}SOR8+5K`zOG@b3i?4~!$=7hAg(^%b;q0Mx4|ALv?*cAewkT&zmJ_DRDr zK!TTi#YEV|6A)Dh=c^-pvrQh>N=t6S8++->{43rS~>$t#Hg5O0G2G4zQXW~eN+ z4x>^z&WEguN+HW-6C@;esb_g2c1{;@#oLYJFZo=+OH^+}Yra6-qd4WLJa_@0RT;vN zU}cVv!+cK5D7%fnJ*W`p4w5AWUs>WJA=ky=DXHpR)H4kSx}ikn_@LM zH)NvDS*FOLIn;pE{Fz%)?nkYWy>{rvme2l!%i61C0yjR#0ZkkbWdf{_r8C41bz>fI=qh|Pqx$=>_;(;%u~N< z*w8Fdw^>{DR1-gPK03*K(6Fyi}Do2shM=_bW=yVc~6{9WpQke7v2W%($l5FPN z%P{MvHh@ZOKwF|(p$1AhElZ!&I#H4Cg5=YVPV|>muG$~@meoST0W?X^t1{6Hd^E3_ zj!m7~nT5KiHHo!WvMauz^qXknX_azs8CF}OR_sz|mnW)?iD+m)sH`VxtH_nx6zcDq{eim?V* zu!tGyMA@R6>y{=qI*SAT7`_kj)#KuY9o%*aFijWxyU&igBq7eqW9hm|>Co)`0Jt9u zihkc6z`DJ-uOagJS>F9^5NDCG`EP!i&x_4Mzx-QAgw;GA;as;w6~@Q)VtCRe&-)MW z7_)1ZS;jiB=U4x1W7!C$q9v&!boT8o)VH>^W+Ods=R zm0}-|#_pxzbc@2(j74g@&*9fwCWZqxx}5X2sk(Q5+DErQFm#txKhbZgCEA~n^-wXkVl(y1g`2sXYzIu}XtW!$q+c^WEsTi!IKuHM-)_*27X?MlYa6Fx99Y z75MA@Juno31|1MQFf$Er?0o#f5^~4W#;$D4b*>wOTPkHhi{J<^A*`C5*7LHw3Fys# zaJvGBX2-3jXpT(NJ!@PhBi~009?AfIwKVK@*o^UPh185ulnX$%4E=7Tv&vyS8A~@P zA%Bawda!}HdsyAq0*#b&Y1w+Dk8>9)Qnq%}cxVSNuVD%KzWC==f-+MsxIO7i4AZ zp03r;33L{MzVbjJGTek;-Z3u5f*2m7&Lwie%XSqhVppa8GAH^uLlZl$ZxSe{>|voT zk8a*1FE50`BgBxv0a!w%oIQ@VXJhNqsMr}MhExiP z>?#N7R#;x8%(N`~0r1G_j;hw@LrSPaYKZ!?ribt>rkJa8yO+pITh6>A7?d7925`z*Gvwhj zpyq`oCU}MfaDclVgj&HcUSujV0wD9r)Dz`FpzX4PI1y_ki2kS|>VW{~=!?c+jKP<7 zuQX@#Vk>J?jO;RC-zwQ;44S-%Q}3IYRk9HZ(E?w;5otT$rMoKbnARl3?kU27nGa`hjpb0q3YE{A@+$QHQ9)_$Zw!W3peBzA3JAS{m{{P zgf~l?qpBv`z-l9lu_pwF*;*96NnZiD3oTtt7V#8!MrKvzS2`^SN6>Fo9d8-AVnbq5 zVT_;nXbs3j6b!d4H6#ZMAY9DG6_qck=_x?~${=)-N0k7CF_)~R=5 zK?uB>^EL^prD=zVnpi5KXOItX>VIzeLhDf2YPn|e3Z!~u!<>npQS+Yy%f@UAGHB&w zOZQsJM0NTTY$!7q7ZO1}wR28^+-z~)tGsAi5|WJLd7U(yf~Fjg7S$0ZQ+eU^y*o^i zjuwwHIZrY z#Qd(ptg>sfm{f1Th^n&G?1IE_RSz(@0KVkZsYp}FZVcHO8C@59S@VlwmlMa1x*5kR zOayyp)V>S0BNtvM455Oi#L-{f30m#?`k`2rQx%8Hf`HhzkRB%tge&1V%827yXXP@e~Pd{_P^#uVad(5!ZkYBN33qi zeSy^ihG>BY;V4kDl`~*f!zpX_8#}anht;b^5wiDq)8Ejq8~R<$q>G{ro|XVui-Ff0 zQaq@QjEJT2qZV}L|8A$-8*QchR4KzL#cftBjfN$30;x>LaK@(!McJGHi^_sxTdz&a zk$P@kvvN1VRO~bbp7o%xaVS2^!u+N3NuX zr-)%klDg(EG#mG`KWV}%S^ij(O>R$^+azrWrcJ2r20w>c@(^*vKCN)1!2mIVy90t!5Yi9z^F0NH^k&<}$~h@O zA5jg(=tezIQi6d?auWf-M49m$@JI*X{oSUBNI9%x7FT@4Eo&65PDP~=v!AM3L`3i` zHoQedREPu>|Ka#vRHpsA>B>l3NrPC4&~fPORVW0$5dYgb+<&@KZ%L&f2}n!=&clfU zn#7chYaz(fbC%pSQyB@@L8L*OLi!1KyRONW9Z9f~B(a$(3+xHY0bdNXp4-It(X!`6 z=G_Z1>{CWJFj3KvkbRB%PhaZ@5t21rM(SqUO9!w;Us;{s|d?I0R8s+_Rzib9QH^3P3FpAK3ACI^U!zr3@U(?c56X#r< z>yhku!1kES0B14J5!%FS*iTB5k+FJHL;WMrJtQtpQvjPwJB!~2k;B8&OwkX7$olaw zlW}BwIN*t|;a2O(&bKf?F4WFBg}L7pgKd2NE^_srX78N-dN&!6F?4qVhC-=C)F`@> z-BR*m|2)@&Xm2}nqspK|p%F-`#V|-cIiF{|JViMc9kR<$4LJX$(M*|d{a=gj|3vW> z_^4tLIG*-ISJQPrA(J0{vx_Jzta}KZ-27dJmRHiQO&==Bq}+v{|7D)k^B=pZpb)8{ zw%GxKzKY|QE!lz25dK6uz9snBJN4l3r|?d(P0XK(G1goIwSNKSK`MmAPeu_VKEsaa z`+Q+l+{zv4o3+y@+vNZJxs_f4%jK8pGpup45l#6AOL zEfB+mFY;#$^=lTX->13%KGQgl_O*Q0d=6CJdbs7487BNH6TH2Dvx8Oy{0zKG5qgF? z6va~@pCt{+gxPZ4fF!Z#;SxqgxUp4)0k8SRmuFJm-y&ZB%sXPPDpSxIDJl>~-x@jW zn}`(tExITa3`Aac#H%`{D?fkpzSd3A;#>+&~wCykO~FyNs@%qB&a+!%qncgsB7)#)4iCo zhG}6rXS&Qj-ZPu+ol+!d1^>7pDDK5rM+U;lei>|#u-T%bCmo(SK?FFPGxV17kx90! z4A>C#A)ym2KHKCa+=7}jLsuaZ)%CKGwvWG)P#p9jOUG#10G!=YK*(`1)11U#sOLzD z(~SR&ADWC9jZ^7hYC@Fs4c0U?MO5Pt0!W`p{%rM? zrXAi{^JkZ0L%y9y@}tV?5bHMw*B%&Xr>I!u))eEHfCune^$9Zc1g+(@mw)!31~WKe zjESd7$yyg3D~Z{rHEVP#6>>01e6n)lX{1&8x=rY-mX-8@1})l z$&6OGKCW#vHxcK^8JrCQQ1A!Q8GVq%+=-4d!a|84(lkeTxOhtjWHE0v^^LPuJ@T<` z8PlW~l)M&Z^5}f(HWGj>1BXm?0gcFO{#MpeAoNdLP)pRs@M2QI=*HrBJ@5S)MfAba zt%MnPKFic~I9+RHO+(4Tg3( z5LL4jIp)>-q|O@e1v{+8?jmqvbNTCbWdf>cLD*KDsVYbXHc^r%$jvwzH)qJJyC{U; z?C%pKEg~rQ!r13?x2cfaYW%H(B428?4r3h1{zH_b%^k#U8t~9IkDbS0q&FTKZlr@` zt%N^cjc@!b;xL=@X>t_n`zkY31z~Pli3(`M0=SH79N*9e*ddYJLOO&=YLTfy61V=y z9p3c}_)bX;LLhBOIhru{%Qv8oH$h?}8m^-gBNw)U5N9ArI=un=E2ataG}Ms*46y_Dz|&y3Mr)zaPBY9b>+`h?pOmAMFIx1}617Unhb?CCfcOf5nfG}%vVF==od<@RXjr~2^zdQnvk`+7S zbuj#n*oL+1*bE;?pI_JJlep}1$|5DajbLQm0Ds6SE9v(>loN=9uN_W;aO$nXIAnvyOIJ3sSj?)PqUf- z#CLnn3Q{Skp%i}F8H!pD)GjSBsHv`EC)l4|y2>CP;0>_Al0$C*Sbms}dvYc3O~_(F z_k#G1VDTkF3Z9OJ$WNt#`%7Ypt*ap&7UV*-C8jYkciGXZAS{2H?BBs{(>wTb>ey{$ zVKsrKUxsUq6`x*GhDl*#1FVso*upMVGQ*JIi)H2KIRvG#4L!1qu{Y#p&ets7BPJd} zGo-s)wU`Db3ow@Fz+qq?>9D}?uxZEI;(v+I=^KxKIwB9&CIx92;(;BR0@%vlN-L>| z^_)^KQDq>oPpv&K7gyLAt_up+j1(I&b-a%FeqIDR^3!lY{baJ=%11Z=v}5flvqO@j z2jxRW8PTrFdZel9r}2>^ie*Igd`(Tmk>NB1Q@VYx1U&ORJlk^NnJ_g7#W!hl^Kh9E z+tU6<$ADf>|qTLz)vM^A%N^8#2$UFP=X8kYNTZo^+O8uj%oN zR{Ik|ds|lH>iD#b&nSE$my=j9jyJ+XQe?vJME-!74$N&^nH+dUyA@XnGzb({$+4Ml z!*(vOx*)dq=H-afCq!0A$DSNzOBd4Cz|t|azUC1@EE(JMAHpESgrC);ycuNW`uN_; zMlx>Qm9X)8oeq7+(wc@yL?hiQf2AdITK_OC5~9y4Ozg}Dh{Nwf9~vFB*mR85P>x-b zsm+mi423mgB$Rg&LhdJ}id?zI=FbR8=xg^C(mux|19B72W&tD{zO+vHD-fJ_M^+2tyh?-nr=yA5ax{2<-r|+t(d6W!iPtQlOeuupW=!rbrcUV<9eB`j*47 zpyjeDDdeF}kWtxOB%H8qyQ@Wd(Y;?NdA?oMNgSvewn|-1T01G`>o6pPZ@EOPYCCqG zKbB@f^w%LvPV5V>cRW#gPB@`*ihXOr_DUEV%QRh87H;I+HrzVZlwB(GknhK3=7d(S_bpMqcyRi zu|_gjliKrQuun&TTuZGP?|*oRHAqCst5_yzN5DR)ht?>ts00M{v2tGKv3{Lu-$dWX zcGgSOZZ;g`W@h4?mJki@7-ecHqJna(CXP1p4jP%*V-P=uk(WV4`F7GT`LYn zQ7gA|}r115A9%-E2vx$T^SCAT=He0U}LMozd?;w>#w!4pe zkzBv|q7|l0jP<4>x9iOj%sG@?I>U$V+%*5Cw8p;DDc1qhBvU_FfxPGt959b1C0rs0Y-*S z$K%GNee}#XL~OCi7h0RZi_q+E+2isx&XprVsrZg~Iz+YenJNDZf~dCKrqEwt1Tb$jR-4MBsb{aduR z!kDl=wS>^{tS%2VYZh=Is0?r1Ft^rRs2zq9y90wci{cLvlGxhc2V7zCLSQ>IJoq`C zW?`l{bfKh*25?}7u_Uu{Z}El8I>PdEv?7m71?ZX!G+CU(Zhchc;j~R-~D9&w{4gffGE6e?j~d^7q;ffV2tRm}%98GiyXF$TtKj zC7Q{XFsX9y+a8ZSfLPX}2Wo_bl)^jz{=rBU?!xy({Yt4sMCtKvj2pIrBAuZD7SWKu z-K&_Yq#+)!)KOjgJMJg+H$tBjtu3{Qdpw9#XJck=VU}C&xm*-A!w6Wi3;4BxBzET$ zk?z9KB_ULu$x3fsxm45p-^zLq%00Zxzg}B$@kt!(+&-Qt!>f^A&b{-UJVMC217;dDodo1;JJz z^j++89XTp5b*8@&jkq;1}NxK7B~y1@<1<-M^Pe9;V|8ian929W>)U6198o9 z=2OrZMKJ@d@Be1yiZ-y?s>lK=thE&<;=?WrI&g5Njg>jmtw}Ru%M1|%94K~7Mk45i z+juZ)DL<@-!In#pFM6vc%I_O}$ORKodnEDo(Q$L~CfZ;LoPQ*&5E zn#&8TC(LT&D5do-EY=mr7F5(OTaJizl;HW?A1VaKIrmhwgaeg}4(X}Or9mi0;H(bs zg)sUZz!;eE>6rFA1JBSjGOTg^l(9N-avrFhGHhk_4|ACBc*^IqC6WFizyX1Wj9Vip zgMBssrLlS(Ywf?awIY!9vAuGzgQK7V=ALJkK{>1G9-RfhR#93~IXjPa0?20pE)Wr} zk6TZ9G%RVUayKTHeEEF^xm;$<;9+D3GbOny}`$TWmzKA@A1fQ_{x6pQ|?T+Y@#QJ9Z%UvK{)GE1y78-1_C|h{f@8VbFHnKo>Q+&VUuEK zJPf`~60%KohAH|e8j&Q&v1q!mZ)RRVK-QJlp@VC@x|8p>e?-#s@GzPud)VQ z-0Ucrlw5LjLy~zBb_EA-VgXj`$vcx*`{%0}!PjxXAqGYX^!B3?|IE)V*O36wh-`;t zFx*jr1pPY1d!_Yb6K@aoxPFLFXSB^aDL>H4N+g-x8zF#sTefjL9OSyL5?n>bT=w(z zsbH({7&iye;;mQz1u{#l> zt899c0RY1p4aw1KnaA_KwXZZB0nv0m=4vAEo>NgTeUZDA0TUhs!Pn1vl&?pzm>CHeS9C8|4{?aZ5Z;lXfV3O zS$?!!M3x4agc*D6p1Kk65h9}o1sp9ls_^8uIK6c!?MMVhDGk%3F{4^?-a+MqaU&C! z8WgPFNz=P}5WLtguc^JnbPTURo)u65@2$C}MG!cZfip|_nQRgN0-FfI-*~z;m3geP$G2gZ&$51!(DJ2K-htHZ=B-GfL9}_ zWB4I3@faTP(^$?1FnhxtNW5ARH`*)I9u4P)KNP;lJiwLvONfDWa0owGlh>rfP#j?p%!A<*Ta?7~@62$7J z&lQipvh<{}?i{;};p8sYq+J%!&~#A>Tf%!jU`l4XNALs|VLGI7mf)Y}1E+X@QH^BtCnT^n5 zQ`^3WCA1Q2NSOj$gaw~ZEO|>ngbWYB%{fT}2v8D22RR&{b8$bahbCVr%peJb;yyI< zOp!?pD|+8gKP$FThNtLD-jMAkb^(gD3a4EoJrocjT;+Z=1=41K+vIV&c4KHhW;5sx zceFEzwcPs;!gT|>dqer0#=pwEK!6rK4{tS-rVVeYjT?3?nV!)cdC6Jon;dy(Y&u&} z&V|=kV?x;dXqaj#Y3y+O{x&wkm=a*Icd>4J<^l$b2D5i1HEOQYri{aZj(GGwjmqxm z14=00$Mn6NZyht@1B36YO?eOwmX!}zu*qABXl7zyCg;=l29zQSS~!@eKnJYtC5e^O zyGblHiPWkB(eDz?lorz7C4B;nEQkay_MRTRWm~ zyCNfmrcsQWeHuv&Ky`Cm5FjsdpwV^08k3D#MWag2kYCAM{ ztOu}Z#M61bML#qOb5IvlDe9s`82;-cPr)sVs9r#j&swdZCkY=ptpXj+U?jsDZTaq1 zvc2=s8@Z+{VY{Hx+ju9}gDnF|<=tvnL{xfx@O+PQPk~kfR#MV+&KklhrB7kWI6{R? zd=T+fY95_eXF_ObHS6_VA8F}1^cdkQJ;tkD-19UKE8HL(hHZ|IOtZ|hIlEG9^SBjW zoM%={I#n;N$-LZ`9ZGRTs%(R0;9|Y_^i~wpoRR^o9Qaen*rKOlBkP z75W=z70MOM*~_e#S>ef=2yEOpr}VN3C)u53i?(<>2y*ZprV;3-vOE=ViOWsUmhiq) zN8j45Jf=fy$TM+D)YKw3MQ4497vg+tHZ@AKVE5)+`Sw}_5w(?972go)gf3e~h|4Iw z8KkXZ0uVYTw*8weO5u4}`lYk^0(Fh8>xi-hFpT2NpHf5#@%nz(kUV)*q@l5LX$bJJ z*x!aI9F^=^5|M)+jh0b8 zbu4U~z6v{?akvIiAarT$sc+JsQOob5L}DKDuzKpmAb88=vxUH#UENrz7&l%`1#OJ# zsWeVSJ>WJ^ctkmv>_TK|aON&d#9GYw*)prsZ0VMXl=Jb13L}!jI!i;tYQ@u6+nfyf z>YEZ;s2o<5!OOy1u}|Gkfb}xo-Z3kLG2i4dZMKEHkDO$CaO%EOq;~Q*+3OJu%Mx-c zGtQ*YXNTxjoA)(CpK5P1v#i2v)l@0_OodI~R#1tQ#kv|Q|2%AA3cePLp#-y`Re^uG zEPV|#P(ljc-{0R59IEPfO}i|VIg|1hTo`GfK@}seFk4s#&-3?}PV~vI9u?mPTAV;} zSgVaq$sS;I`47nC0^#o=B&H6y>BVR3&&yuD{klQPT+qT)TM+R~`^{dvnFGnl&X+Pi}UX-Z@D zD57pdn6*hDwy0Ccbs6b_%#wFi1A3}9~N&-7A(LngCZd=OT{JErHkL-Tuv7$}aikQjtUfGy}_==IT3 zeAMJM|2qW6NZlNqvBOAKE+nvrF^uc#Ztk}<3AR{sEn>t^H`cPV9_7afYmM*YBi4ZU z@+Hxa4n5u0YQAhrw9#&;k!)gK?3~mxN^c$_V;fsNC%Y-cg8__eci3=2wnqt%)N;$JSzB*$(T2BN)h#0I#?O7}l!;8%l)DEYQq z17{X#|GW&~AFFz;K;-Rdbc!4oyp~HIItzig&Vt8 z;hNnv&`VMpsG-r41087^PZ3($gZXd9y#ams=TAQ)mYpvU)Pr5~w`nLfqvJbzWIA`8 z!djF3#!Ddh*X~CXF|+=Rq9A+ZvHBIJ!43+G@FV#dyifzs(-$YZX#jS`DDoT9Hy*K<;N|D1r$MC8$6yV`e4}>0sTm71C<< zh#k?iy1zIDA&bMi4UPIG*=I$swdQAWDzIj?r8G)%Q@z-40=ujJ8*dcec#fWB7?O2W z19;nS+p%JrPOWF`3=Eqye=%aVl*%pp2E|?YUt#@5q3sSUxM9+a%_{I(L2vgO&X7yM z#~B=lKG@T2>VcL2J!gWf{?`m%o$g9i@uc$KpnU;i!KWX^wS0p*cWIldum~t7RI{Y5 zO~tzFiT>eUN!xjB>E5P1VnZV9wBiXmqs1o5PS)yshv}-y_ds*S7lLHBDS4C#b^eCJ zx|smUxthkcu}s%!w8i4F+d`b50gvbP>_D_C&)iTN3T z?SW8cDeh2|rp@>XFGwoGb`@_FE1EhlK{zf(Tc_XGLM9h?d?6mEE&c6mp2AC$B5-c| zFr-Dqn1$T)#9`*~*&Fi@8@VT8MwB@2MvfhyX>YT36Q~c>2{EPtH5f!S7e7N^Ryqgk zhcgv=cm_ATMAbEYx&$vGC-#5dxdWdjEN4X29@;JA&=g80TwKgYSq(+6ZK@qo#eNr_ zG}dOjRN10xzuWxXC&RMVw7zx@V`&z@JfA{S+b5dX*&cK(ocX@HTQi_R!fNIPJ>980 zAJL0nRhq;0wRN2+pn=y+&JtSK&gu^$QEEk%^qw(ZmE%?&!uMiUj7IXL9?u$wD~G%a ztzw)3YGbY%tleP1koz#K+S8(fIb$tmeZI2TEG;>^29D!GzpO#HTd;PiNo(y%uB;Bq zxxN}Yq~n)#k9l^7#WF}lZb_3a&6Rmw1JiLDhJZ&JwL&<;65)=pkvy=gnlOWLhCH6S zP}68rK2Xx&)N&9kE`wW0c!G&vTDs=($6lm;8Eu=?CxFZSjM_bI2My} zVZU6B*doKT#Ac-WE^u(Y464<`<0pK;W7Udk2@cF?tgWJuRIV<`<4%<4Z#fWlZG7fI z5T)tEfvtR8TUlR3Tg?@-u@z)|Q4dj0ylOz1~e2oyF`aK^C#(O=>pO)->~+7;Qy7xnaQj33yk zKudj+!EC5#_5G}t(Z!9loBL*EV9)`CSR8Wa%e;$~Bl~__#iNjkVS-8lnU#MskU~Na z*%BGQh>2diT*_gD#xeeWJ@W%dwJn5Z%zfJPF@lYHY(95w4#DOUh01wF?hE)15~)51 zn=4XB-s+|&8In(?3sT~^L$pv#2Cglo@}-&&SVG4s#wnpJVkJX}q>oC!Q%LZKc0*Y? zFcjvYLs4Tu4ecEykWMoDkAST`n00LnAz(ccfx^IORc5$I^i>B**amS$NxL&=DdOG# z>7wmAS4H+6XoLD~6GRoMQ(3XaFhoxp==dBlk={tiaH@+)%L0GFU)j?J%erUI!Fjv? zU}Rg)Cf|cbM18OcIhrngJ4SbU zueQYHFSST?Auos-fCGa^A=$&PZK_u$X`6-50k725Ifof2SR63j!GVDU!yO|wWu*SJ z?E$}TrdiwD@aDDWMpztdJi`r>9`PTS=91e`2t(0|X(b|c%>LI|aqb5zNKIcT_21XV z$#)l!RH!ftWVE;DK`;^zw>eaOB1YhqBTuma+)>d(bfn$FT_F!Aog>x?L+mBhEwW>K zU?Gw4yT}^b)=HzU+@;<|0dV3-%icnbr5!u%yJ1_c8ayi!Di{Lx?S_1}7Hl*JPwY8v zJ`N~~)v9tz1_VNtPLx3M1R^@NQRclXmW$Dg5QXU8_7grsL37hf0ZM!f{;p0{IZZi& zUmoXW*%<|nVm_b+e)7Ixq*=Mq^j$F1Okx%`EXZ}@4^#j|V_jnGlZK|bg)jW*1!e2h zAzo^%A%Yxg&m=*EVgHpjdbs88@atb>)rEb@y!<>6||8E8^o< zm_s>g(n(?wOVcpx2i>*WPM)#^0%Ms$Av|$lsobW>8zwtG(r_Aiu?PA`PO+*gCqf*@ zg(nKOf0jMPQ;H*}EQdQGMe*zMyylS)VF=@%bVW#L!9|MI6863z(m0mBu^$^*Acc%T ztH6TUp<_{Ak5Vj5i_XOX#B!srwt!#A^}yE2mqHmEs*WePGot@+lPup3d0KIYX->nd zy}~Jekzo#8JdTW86dG!8G|2(cwZZC=*DEJEu&OKHk_tYHg;ncVowZ30BS182xr2W) z^^6oO+9{wsW_SCyGu>0w=|n^IcxC9{ByNzv+C)?@8mkB)Tbh4Cz)H^5GlY>`BG2(A zDn;1feB1}_S+Aynf((PXg2!eD>nWn*S91@bz)h)W2~>TTy)AoXT4))``#pjEo^A7m z^of%+KOO$s(QPNjF4wQit6WYzFAL$d)z*nDsnyX|!)fl|jBn*V8<}Fx|Hur;h`n3~ zu!N0EjQymd9AWM#CFTlwsJ9wtga^H*O4=PLCrr!xZabp(Dt?yeD@o}Da{=o7zWqZZ z$0){-Ls7FkJRb|nZ1okcGBi~s-aBlycvUFGkeBC5>Y{0zjaegu1YFnf8_RV#t9-s3nr_lh+_SPyV@7Nke9kTIug6)SSa1sN`5b`LZo{I@&>JnZcC zrp_IaO2pSKB!3%+O*;NCd8IF(vzHi-AL(X=k@-eT)EjEXRnBKu&Ux^`UrbytWt9Dn z37CR-00GoIA}qRX-T^qxT8iw1n|1p9xZU=)mkQ7{Td!6+Cc0030RBPVQ<$2b50 N002ovPDHLkV1fYIL5lzY literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/gfx/openwebrx-top-photo.jpg b/openwebrx/htdocs/gfx/openwebrx-top-photo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..afc8e7e02166621e9f91bf56faa542584cc72db5 GIT binary patch literal 70244 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 literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/gfx/svg-defs.svg b/openwebrx/htdocs/gfx/svg-defs.svg new file mode 100644 index 0000000..251b051 --- /dev/null +++ b/openwebrx/htdocs/gfx/svg-defs.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openwebrx/htdocs/include/header.include.html b/openwebrx/htdocs/include/header.include.html new file mode 100644 index 0000000..8ae0811 --- /dev/null +++ b/openwebrx/htdocs/include/header.include.html @@ -0,0 +1,25 @@ +
+
+ + Receiver avatar +
+

${receiver_name}

+
${receiver_location} | Loc: ${locator}, ASL: ${receiver_asl} m
+
+
+

Status
+

Log
+

Receiver
+
Map
+
Settings
+
+
+
+
${photo_title}
+
${photo_desc}
+
+ + + + +
diff --git a/openwebrx/htdocs/index.html b/openwebrx/htdocs/index.html new file mode 100644 index 0000000..e4ae6bd --- /dev/null +++ b/openwebrx/htdocs/index.html @@ -0,0 +1,250 @@ + + + + + OpenWebRX | Open Source SDR Web App for Everyone! + + + + + + + + + + + + + + + + +
+ ${header} +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + + + + + + + + +
+
+
+
OpenWebRX client log
+
+ Author contact: Jakob Ketterl, DD5JFK | + OpenWebRX homepage +
+
Support and information: Groups.io Mailinglist
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+
0 dB
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + diff --git a/openwebrx/htdocs/lib/AprsMarker.js b/openwebrx/htdocs/lib/AprsMarker.js new file mode 100644 index 0000000..f6720c1 --- /dev/null +++ b/openwebrx/htdocs/lib/AprsMarker.js @@ -0,0 +1,91 @@ +function AprsMarker() {} + +AprsMarker.prototype = new google.maps.OverlayView(); + +AprsMarker.prototype.draw = function() { + var div = this.div; + var overlay = this.overlay; + if (!div || !overlay) return; + + if (this.symbol) { + var tableId = this.symbol.table === '/' ? 0 : 1; + div.style.background = 'url(aprs-symbols/aprs-symbols-24-' + tableId + '@2x.png)'; + div.style['background-size'] = '384px 144px'; + div.style['background-position-x'] = -(this.symbol.index % 16) * 24 + 'px'; + div.style['background-position-y'] = -Math.floor(this.symbol.index / 16) * 24 + 'px'; + } + + if (this.course) { + if (this.course > 180) { + div.style.transform = 'scalex(-1) rotate(' + (270 - this.course) + 'deg)' + } else { + div.style.transform = 'rotate(' + (this.course - 90) + 'deg)'; + } + } else { + div.style.transform = null; + } + + if (this.symbol.table !== '/' && this.symbol.table !== '\\') { + overlay.style.display = 'block'; + overlay.style['background-position-x'] = -(this.symbol.tableindex % 16) * 24 + 'px'; + overlay.style['background-position-y'] = -Math.floor(this.symbol.tableindex / 16) * 24 + 'px'; + } else { + overlay.style.display = 'none'; + } + + if (this.opacity) { + div.style.opacity = this.opacity; + } else { + div.style.opacity = null; + } + + var point = this.getProjection().fromLatLngToDivPixel(this.position); + + if (point) { + div.style.left = point.x - 12 + 'px'; + div.style.top = point.y - 12 + 'px'; + } +}; + +AprsMarker.prototype.setOptions = function(options) { + google.maps.OverlayView.prototype.setOptions.apply(this, arguments); + this.draw(); +}; + +AprsMarker.prototype.onAdd = function() { + var div = this.div = document.createElement('div'); + + div.style.position = 'absolute'; + div.style.cursor = 'pointer'; + div.style.width = '24px'; + div.style.height = '24px'; + + var overlay = this.overlay = document.createElement('div'); + overlay.style.width = '24px'; + overlay.style.height = '24px'; + overlay.style.background = 'url(aprs-symbols/aprs-symbols-24-2@2x.png)'; + overlay.style['background-size'] = '384px 144px'; + overlay.style.display = 'none'; + + div.appendChild(overlay); + + var self = this; + google.maps.event.addDomListener(div, "click", function(event) { + event.stopPropagation(); + google.maps.event.trigger(self, "click", event); + }); + + var panes = this.getPanes(); + panes.overlayImage.appendChild(div); +}; + +AprsMarker.prototype.remove = function() { + if (this.div) { + this.div.parentNode.removeChild(this.div); + this.div = null; + } +}; + +AprsMarker.prototype.getAnchorPoint = function() { + return new google.maps.Point(0, -12); +}; diff --git a/openwebrx/htdocs/lib/AudioEngine.js b/openwebrx/htdocs/lib/AudioEngine.js new file mode 100644 index 0000000..8a3df67 --- /dev/null +++ b/openwebrx/htdocs/lib/AudioEngine.js @@ -0,0 +1,447 @@ +// this controls if the new AudioWorklet API should be used if available. +// the engine will still fall back to the ScriptProcessorNode if this is set to true but not available in the browser. +var useAudioWorklets = true; + +function AudioEngine(maxBufferLength, audioReporter) { + this.audioReporter = audioReporter; + this.initStats(); + this.resetStats(); + + this.onStartCallbacks = []; + + this.started = false; + this.audioContext = this.buildAudioContext(); + if (!this.audioContext) { + return; + } + + var me = this; + this.audioContext.onstatechange = function() { + if (me.audioContext.state !== 'running') return; + me._start(); + } + + this.audioCodec = new ImaAdpcmCodec(); + this.compression = 'none'; + + this.setupResampling(); + this.resampler = new Interpolator(this.resamplingFactor); + this.hdResampler = new Interpolator(this.hdResamplingFactor); + + this.maxBufferSize = maxBufferLength * this.getSampleRate(); +} + +AudioEngine.prototype.buildAudioContext = function() { + var ctxClass = window.AudioContext || window.webkitAudioContext; + if (!ctxClass) { + return; + } + + // known good sample rates + var goodRates = [48000, 44100, 96000] + + // let the browser chose the sample rate, if it is good, use it + var ctx = new ctxClass({latencyHint: 'playback'}); + if (goodRates.indexOf(ctx.sampleRate) >= 0) { + return ctx; + } + + // if that didn't work, try if any of the good rates work + if (goodRates.some(function(sr) { + try { + ctx = new ctxClass({sampleRate: sr, latencyHint: 'playback'}); + return true; + } catch (e) { + return false; + } + }, this)) { + return ctx; + } + + // fallback: let the browser decide + // this may cause playback problems down the line + return new ctxClass({latencyHint: 'playback'}); +} + +AudioEngine.prototype.resume = function(){ + this.audioContext.resume(); +} + +AudioEngine.prototype._start = function() { + var me = this; + + // if failed to find a valid resampling factor... + if (me.resamplingFactor === 0) { + return; + } + + // been started before? + if (me.started) { + return; + } + + // are we allowed to play audio? + if (!me.isAllowed()) { + return; + } + me.started = true; + + var runCallbacks = function(workletType) { + var callbacks = me.onStartCallbacks; + me.onStartCallbacks = false; + callbacks.forEach(function(c) { c(workletType); }); + }; + + me.gainNode = me.audioContext.createGain(); + me.gainNode.connect(me.audioContext.destination); + + if (useAudioWorklets && me.audioContext.audioWorklet) { + me.audioContext.audioWorklet.addModule('static/lib/AudioProcessor.js').then(function(){ + me.audioNode = new AudioWorkletNode(me.audioContext, 'openwebrx-audio-processor', { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [1], + processorOptions: { + maxBufferSize: me.maxBufferSize + } + }); + me.audioNode.connect(me.gainNode); + me.audioNode.port.addEventListener('message', function(m){ + var json = JSON.parse(m.data); + if (typeof(json.buffersize) !== 'undefined') { + me.audioReporter({ + buffersize: json.buffersize + }); + } + if (typeof(json.samplesProcessed) !== 'undefined') { + me.audioSamples.add(json.samplesProcessed); + } + }); + me.audioNode.port.start(); + runCallbacks('AudioWorklet'); + }); + } else { + me.audioBuffers = []; + + if (!AudioBuffer.prototype.copyToChannel) { //Chrome 36 does not have it, Firefox does + AudioBuffer.prototype.copyToChannel = function (input, channel) //input is Float32Array + { + var cd = this.getChannelData(channel); + for (var i = 0; i < input.length; i++) cd[i] = input[i]; + } + } + + var bufferSize; + if (me.audioContext.sampleRate < 44100 * 2) + bufferSize = 4096; + else if (me.audioContext.sampleRate >= 44100 * 2 && me.audioContext.sampleRate < 44100 * 4) + bufferSize = 4096 * 2; + else if (me.audioContext.sampleRate > 44100 * 4) + bufferSize = 4096 * 4; + + + function audio_onprocess(e) { + var total = 0; + var out = new Float32Array(bufferSize); + while (me.audioBuffers.length) { + var b = me.audioBuffers.shift(); + // not enough space to fit all data, so splice and put back in the queue + if (total + b.length > bufferSize) { + var spaceLeft = bufferSize - total; + var tokeep = b.subarray(0, spaceLeft); + out.set(tokeep, total); + var tobuffer = b.subarray(spaceLeft, b.length); + me.audioBuffers.unshift(tobuffer); + total += spaceLeft; + break; + } else { + out.set(b, total); + total += b.length; + } + } + + e.outputBuffer.copyToChannel(out, 0); + me.audioSamples.add(total); + + } + + //on Chrome v36, createJavaScriptNode has been replaced by createScriptProcessor + var method = 'createScriptProcessor'; + if (me.audioContext.createJavaScriptNode) { + method = 'createJavaScriptNode'; + } + me.audioNode = me.audioContext[method](bufferSize, 0, 1); + me.audioNode.onaudioprocess = audio_onprocess; + me.audioNode.connect(me.gainNode); + runCallbacks('ScriptProcessorNode') + } + + setInterval(me.reportStats.bind(me), 1000); +}; + +AudioEngine.prototype.onStart = function(callback) { + if (this.onStartCallbacks) { + this.onStartCallbacks.push(callback); + } else { + callback(); + } +}; + +AudioEngine.prototype.isAllowed = function() { + return this.audioContext.state === 'running'; +}; + +AudioEngine.prototype.reportStats = function() { + if (this.audioNode.port) { + this.audioNode.port.postMessage(JSON.stringify({cmd:'getStats'})); + } else { + this.audioReporter({ + buffersize: this.getBuffersize() + }); + } +}; + +AudioEngine.prototype.initStats = function() { + var me = this; + var buildReporter = function(key) { + return function(v){ + var report = {}; + report[key] = v; + me.audioReporter(report); + } + + }; + + this.audioBytes = new Measurement(); + this.audioBytes.report(10000, 1000, buildReporter('audioByteRate')); + + this.audioSamples = new Measurement(); + this.audioSamples.report(10000, 1000, buildReporter('audioRate')); +}; + +AudioEngine.prototype.resetStats = function() { + this.audioBytes.reset(); + this.audioSamples.reset(); +}; + +AudioEngine.prototype.setupResampling = function() { //both at the server and the client + var targetRate = this.audioContext.sampleRate; + var audio_params = this.findRate(8000, 12000); + if (!audio_params) { + this.resamplingFactor = 0; + this.outputRate = 0; + divlog('Your audio card sampling rate (' + targetRate + ') is not supported.
Please change your operating system default settings in order to fix this.', 1); + } else { + this.resamplingFactor = audio_params.resamplingFactor; + this.outputRate = audio_params.outputRate; + } + + var hd_audio_params = this.findRate(36000, 48000); + if (!hd_audio_params) { + this.hdResamplingFactor = 0; + this.hdOutputRate = 0; + divlog('Your audio card sampling rate (' + targetRate + ') is not supported for HD audio
Please change your operating system default settings in order to fix this.', 1); + } else { + this.hdResamplingFactor = hd_audio_params.resamplingFactor; + this.hdOutputRate = hd_audio_params.outputRate; + } +}; + +AudioEngine.prototype.findRate = function(low, high) { + var targetRate = this.audioContext.sampleRate; + var i = 1; + while (true) { + var audio_server_output_rate = Math.floor(targetRate / i); + if (audio_server_output_rate < low) { + return; + } else if (audio_server_output_rate >= low && audio_server_output_rate <= high) { + return { + resamplingFactor: i, + outputRate: audio_server_output_rate + } + } + i++; + }; +} + +AudioEngine.prototype.getOutputRate = function() { + return this.outputRate; +}; + +AudioEngine.prototype.getHdOutputRate = function() { + return this.hdOutputRate; +} + +AudioEngine.prototype.getSampleRate = function() { + return this.audioContext.sampleRate; +}; + +AudioEngine.prototype.processAudio = function(data, resampler) { + if (!this.audioNode) return; + this.audioBytes.add(data.byteLength); + var buffer; + if (this.compression === "adpcm") { + //resampling & ADPCM + buffer = this.audioCodec.decode(new Uint8Array(data)); + } else { + buffer = new Int16Array(data); + } + buffer = resampler.process(buffer); + if (this.audioNode.port) { + // AudioWorklets supported + this.audioNode.port.postMessage(buffer); + } else { + // silently drop excess samples + if (this.getBuffersize() + buffer.length <= this.maxBufferSize) { + this.audioBuffers.push(buffer); + } + } +} + +AudioEngine.prototype.pushAudio = function(data) { + this.processAudio(data, this.resampler); +}; + +AudioEngine.prototype.pushHdAudio = function(data) { + this.processAudio(data, this.hdResampler); +} + +AudioEngine.prototype.setCompression = function(compression) { + this.compression = compression; +}; + +AudioEngine.prototype.setVolume = function(volume) { + this.gainNode.gain.value = volume; +}; + +AudioEngine.prototype.getBuffersize = function() { + // only available when using ScriptProcessorNode + if (!this.audioBuffers) return 0; + return this.audioBuffers.map(function(b){ return b.length; }).reduce(function(a, b){ return a + b; }, 0); +}; + +function ImaAdpcmCodec() { + this.reset(); +} + +ImaAdpcmCodec.prototype.reset = function() { + this.stepIndex = 0; + this.predictor = 0; + this.step = 0; +}; + +ImaAdpcmCodec.imaIndexTable = [ -1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8 ]; + +ImaAdpcmCodec.imaStepTable = [ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, + 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, + 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, + 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, + 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, + 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, + 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, + 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, + 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767 + ]; + +ImaAdpcmCodec.prototype.decode = function(data) { + var output = new Int16Array(data.length * 2); + for (var i = 0; i < data.length; i++) { + output[i * 2] = this.decodeNibble(data[i] & 0x0F); + output[i * 2 + 1] = this.decodeNibble((data[i] >> 4) & 0x0F); + } + return output; +}; + +ImaAdpcmCodec.prototype.decodeNibble = function(nibble) { + this.stepIndex += ImaAdpcmCodec.imaIndexTable[nibble]; + this.stepIndex = Math.min(Math.max(this.stepIndex, 0), 88); + + var diff = this.step >> 3; + if (nibble & 1) diff += this.step >> 2; + if (nibble & 2) diff += this.step >> 1; + if (nibble & 4) diff += this.step; + if (nibble & 8) diff = -diff; + + this.predictor += diff; + this.predictor = Math.min(Math.max(this.predictor, -32768), 32767); + + this.step = ImaAdpcmCodec.imaStepTable[this.stepIndex]; + + return this.predictor; +}; + +function Interpolator(factor) { + this.factor = factor; + this.lowpass = new Lowpass(factor) +} + +Interpolator.prototype.process = function(data) { + var output = new Float32Array(data.length * this.factor); + for (var i = 0; i < data.length; i++) { + output[i * this.factor] = (data[i] + 0.5) / 32768; + } + return this.lowpass.process(output); +}; + +function Lowpass(interpolation) { + this.interpolation = interpolation; + var transitionBandwidth = 0.05; + this.numtaps = Math.round(4 / transitionBandwidth); + if (this.numtaps % 2 == 0) this.numtaps += 1; + + var cutoff = 1 / interpolation; + this.coefficients = this.getCoefficients(cutoff / 2); + + this.delay = new Float32Array(this.numtaps); + for (var i = 0; i < this.numtaps; i++){ + this.delay[i] = 0; + } + this.delayIndex = 0; +} + +Lowpass.prototype.getCoefficients = function(cutoffRate) { + var middle = Math.floor(this.numtaps / 2); + // hamming window + var window_function = function(r){ + var rate = 0.5 + r / 2; + return 0.54 - 0.46 * Math.cos(2 * Math.PI * rate); + } + var output = []; + output[middle] = 2 * Math.PI * cutoffRate * window_function(0); + for (var i = 1; i <= middle; i++) { + output[middle - i] = output[middle + i] = (Math.sin(2 * Math.PI * cutoffRate * i) / i) * window_function(i / middle); + } + return this.normalizeCoefficients(output); +}; + +Lowpass.prototype.normalizeCoefficients = function(input) { + var sum = 0; + var output = []; + for (var i = 0; i < input.length; i++) { + sum += input[i]; + } + for (var i = 0; i < input.length; i++) { + output[i] = input[i] / sum; + } + return output; +}; + +Lowpass.prototype.process = function(input) { + output = new Float32Array(input.length); + for (var oi = 0; oi < input.length; oi++) { + this.delay[this.delayIndex] = input[oi]; + this.delayIndex = (this.delayIndex + 1) % this.numtaps; + + var acc = 0; + var index = this.delayIndex; + for (var i = 0; i < this.numtaps; ++i) { + var index = index != 0 ? index - 1 : this.numtaps - 1; + acc += this.delay[index] * this.coefficients[i]; + if (isNaN(acc)) debugger; + } + // gain by interpolation + output[oi] = this.interpolation * acc; + } + return output; +}; \ No newline at end of file diff --git a/openwebrx/htdocs/lib/AudioProcessor.js b/openwebrx/htdocs/lib/AudioProcessor.js new file mode 100644 index 0000000..a3e9abf --- /dev/null +++ b/openwebrx/htdocs/lib/AudioProcessor.js @@ -0,0 +1,61 @@ +class OwrxAudioProcessor extends AudioWorkletProcessor { + constructor(options){ + super(options); + // initialize ringbuffer, make sure it aligns with the expected buffer size of 128 + this.bufferSize = Math.round(options.processorOptions.maxBufferSize / 128) * 128; + this.audioBuffer = new Float32Array(this.bufferSize); + this.inPos = 0; + this.outPos = 0; + this.samplesProcessed = 0; + this.port.addEventListener('message', (m) => { + if (typeof(m.data) === 'string') { + const json = JSON.parse(m.data); + if (json.cmd && json.cmd === 'getStats') { + this.reportStats(); + } + } else { + // the ringbuffer size is aligned to the output buffer size, which means that the input buffers might + // need to wrap around the end of the ringbuffer, back to the start. + // it is better to have this processing here instead of in the time-critical process function. + if (this.inPos + m.data.length <= this.bufferSize) { + // we have enough space, so just copy data over. + this.audioBuffer.set(m.data, this.inPos); + } else { + // we don't have enough space, so we need to split the data. + const remaining = this.bufferSize - this.inPos; + this.audioBuffer.set(m.data.subarray(0, remaining), this.inPos); + this.audioBuffer.set(m.data.subarray(remaining)); + } + this.inPos = (this.inPos + m.data.length) % this.bufferSize; + } + }); + this.port.addEventListener('messageerror', console.error); + this.port.start(); + } + process(inputs, outputs) { + if (this.remaining() < 128) { + outputs[0].forEach(output => output.fill(0)); + return true; + } + outputs[0].forEach((output) => { + output.set(this.audioBuffer.subarray(this.outPos, this.outPos + 128)); + }); + this.outPos = (this.outPos + 128) % this.bufferSize; + this.samplesProcessed += 128; + return true; + } + remaining() { + const mod = (this.inPos - this.outPos) % this.bufferSize; + if (mod >= 0) return mod; + return mod + this.bufferSize; + } + reportStats() { + this.port.postMessage(JSON.stringify({ + buffersize: this.remaining(), + samplesProcessed: this.samplesProcessed + })); + this.samplesProcessed = 0; + } +} + +registerProcessor('openwebrx-audio-processor', OwrxAudioProcessor); \ No newline at end of file diff --git a/openwebrx/htdocs/lib/BookmarkBar.js b/openwebrx/htdocs/lib/BookmarkBar.js new file mode 100644 index 0000000..e0993a5 --- /dev/null +++ b/openwebrx/htdocs/lib/BookmarkBar.js @@ -0,0 +1,147 @@ +function BookmarkBar() { + var me = this; + me.localBookmarks = new BookmarkLocalStorage(); + me.$container = $("#openwebrx-bookmarks-container"); + me.bookmarks = {}; + + me.$container.on('click', '.bookmark', function(e){ + var $bookmark = $(e.target).closest('.bookmark'); + me.$container.find('.bookmark').removeClass('selected'); + var b = $bookmark.data(); + if (!b || !b.frequency || !b.modulation) return; + me.getDemodulator().set_offset_frequency(b.frequency - center_freq); + if (b.modulation) { + me.getDemodulatorPanel().setMode(b.modulation); + } + $bookmark.addClass('selected'); + }); + + me.$container.on('click', '.action[data-action=edit]', function(e){ + e.stopPropagation(); + var $bookmark = $(e.target).closest('.bookmark'); + me.showEditDialog($bookmark.data()); + }); + + me.$container.on('click', '.action[data-action=delete]', function(e){ + e.stopPropagation(); + var $bookmark = $(e.target).closest('.bookmark'); + me.localBookmarks.deleteBookmark($bookmark.data()); + me.loadLocalBookmarks(); + }); + + var $bookmarkButton = $('#openwebrx-panel-receiver').find('.openwebrx-bookmark-button'); + if (typeof(Storage) !== 'undefined') { + $bookmarkButton.show(); + } else { + $bookmarkButton.hide(); + } + $bookmarkButton.click(function(){ + me.showEditDialog(); + }); + + me.$dialog = $("#openwebrx-dialog-bookmark"); + me.$dialog.find('.openwebrx-button[data-action=cancel]').click(function(){ + me.$dialog.hide(); + }); + me.$dialog.find('.openwebrx-button[data-action=submit]').click(function(){ + me.storeBookmark(); + }); + me.$dialog.find('form').on('submit', function(e){ + e.preventDefault(); + me.storeBookmark(); + }); +} + +BookmarkBar.prototype.position = function(){ + var range = get_visible_freq_range(); + $('#openwebrx-bookmarks-container').find('.bookmark').each(function(){ + $(this).css('left', scale_px_from_freq($(this).data('frequency'), range)); + }); +}; + +BookmarkBar.prototype.loadLocalBookmarks = function(){ + var bwh = bandwidth / 2; + var start = center_freq - bwh; + var end = center_freq + bwh; + var bookmarks = this.localBookmarks.getBookmarks().filter(function(b){ + return b.frequency >= start && b.frequency <= end; + }); + this.replace_bookmarks(bookmarks, 'local', true); +}; + +BookmarkBar.prototype.replace_bookmarks = function(bookmarks, source, editable) { + editable = !!editable; + bookmarks = bookmarks.map(function(b){ + b.source = source; + b.editable = editable; + return b; + }); + this.bookmarks[source] = bookmarks; + this.render(); +}; + +BookmarkBar.prototype.render = function(){ + var bookmarks = Object.values(this.bookmarks).reduce(function(l, v){ return l.concat(v); }); + bookmarks = bookmarks.sort(function(a, b){ return a.frequency - b.frequency; }); + var elements = bookmarks.map(function(b){ + var $bookmark = $( + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + b.name + '
' + + '
' + ); + $bookmark.data(b); + return $bookmark; + }); + this.$container.find('.bookmark').remove(); + this.$container.append(elements); + this.position(); +}; + +BookmarkBar.prototype.showEditDialog = function(bookmark) { + if (!bookmark) { + bookmark = { + name: "", + frequency: center_freq + this.getDemodulator().get_offset_frequency(), + modulation: this.getDemodulator().get_secondary_demod() || this.getDemodulator().get_modulation() + } + } + this.$dialog.bookmarkDialog().setValues(bookmark); + this.$dialog.show(); + this.$dialog.find('#name').focus(); +}; + +BookmarkBar.prototype.storeBookmark = function() { + var me = this; + var bookmark = this.$dialog.bookmarkDialog().getValues(); + if (!bookmark) return; + bookmark.frequency = Number(bookmark.frequency); + + var bookmarks = me.localBookmarks.getBookmarks(); + + if (!bookmark.id) { + if (bookmarks.length) { + bookmark.id = 1 + Math.max.apply(Math, bookmarks.map(function(b){ return b.id || 0; })); + } else { + bookmark.id = 1; + } + } + + bookmarks = bookmarks.filter(function(b) { return b.id !== bookmark.id; }); + bookmarks.push(bookmark); + + me.localBookmarks.setBookmarks(bookmarks); + me.loadLocalBookmarks(); + me.$dialog.hide(); +}; + +BookmarkBar.prototype.getDemodulatorPanel = function() { + return $('#openwebrx-panel-receiver').demodulatorPanel(); +}; + +BookmarkBar.prototype.getDemodulator = function() { + return this.getDemodulatorPanel().getDemodulator(); +}; diff --git a/openwebrx/htdocs/lib/BookmarkDialog.js b/openwebrx/htdocs/lib/BookmarkDialog.js new file mode 100644 index 0000000..4a0a184 --- /dev/null +++ b/openwebrx/htdocs/lib/BookmarkDialog.js @@ -0,0 +1,36 @@ +$.fn.bookmarkDialog = function() { + var $el = this; + return { + setModes: function(modes) { + $el.find('#modulation').html(modes.filter(function(m){ + return m.isAvailable(); + }).map(function(m) { + return ''; + }).join('')); + return this; + }, + setValues: function(bookmark) { + var $form = $el.find('form'); + ['name', 'frequency', 'modulation'].forEach(function(key){ + $form.find('#' + key).val(bookmark[key]); + }); + $el.data('id', bookmark.id || false); + return this; + }, + getValues: function() { + var bookmark = {}; + var valid = true; + ['name', 'frequency', 'modulation'].forEach(function(key){ + var $input = $el.find('#' + key); + valid = valid && $input[0].checkValidity(); + bookmark[key] = $input.val(); + }); + if (!valid) { + $el.find("form :submit").click(); + return; + } + bookmark.id = $el.data('id'); + return bookmark; + } + } +} \ No newline at end of file diff --git a/openwebrx/htdocs/lib/BookmarkLocalStorage.js b/openwebrx/htdocs/lib/BookmarkLocalStorage.js new file mode 100644 index 0000000..edb4992 --- /dev/null +++ b/openwebrx/htdocs/lib/BookmarkLocalStorage.js @@ -0,0 +1,17 @@ +BookmarkLocalStorage = function(){ +}; + +BookmarkLocalStorage.prototype.getBookmarks = function(){ + return JSON.parse(window.localStorage.getItem("bookmarks")) || []; +}; + +BookmarkLocalStorage.prototype.setBookmarks = function(bookmarks){ + window.localStorage.setItem("bookmarks", JSON.stringify(bookmarks)); +}; + +BookmarkLocalStorage.prototype.deleteBookmark = function(data) { + if (data.id) data = data.id; + var bookmarks = this.getBookmarks(); + bookmarks = bookmarks.filter(function(b) { return b.id !== data; }); + this.setBookmarks(bookmarks); +}; diff --git a/openwebrx/htdocs/lib/Demodulator.js b/openwebrx/htdocs/lib/Demodulator.js new file mode 100644 index 0000000..352ebe1 --- /dev/null +++ b/openwebrx/htdocs/lib/Demodulator.js @@ -0,0 +1,362 @@ +function Filter(demodulator) { + this.demodulator = demodulator; + this.min_passband = 100; +} + +Filter.prototype.getLimits = function() { + var max_bw; + if (['pocsag', 'packet'].indexOf(this.demodulator.get_secondary_demod()) >= 0) { + max_bw = 12500; + } else if (['dmr', 'dstar', 'nxdn', 'ysf', 'm17'].indexOf(this.demodulator.get_modulation()) >= 0) { + max_bw = 6250; + } else if (this.demodulator.get_modulation() === 'wfm') { + max_bw = 100000; + } else if (this.demodulator.get_modulation() === 'drm') { + max_bw = 50000; + } else { + max_bw = (audioEngine.getOutputRate() / 2) - 1; + } + return { + high: max_bw, + low: -max_bw + }; +}; + +function Envelope(demodulator) { + this.demodulator = demodulator; + this.dragged_range = Demodulator.draggable_ranges.none; +} + +Envelope.prototype.draw = function(visible_range){ + this.visible_range = visible_range; + var line = center_freq + this.demodulator.offset_frequency; + + // ____ + // Draws a standard filter envelope like this: _/ \_ + // Parameters are given in offset frequency (Hz). + // Envelope is drawn on the scale canvas. + // A "drag range" object is returned, containing information about the draggable areas of the envelope + // (beginning, ending and the line showing the offset frequency). + var env_bounding_line_w = 5; // + var env_att_w = 5; // _______ ___env_h2 in px ___|_____ + var env_h1 = 17; // _/| \_ ___env_h1 in px _/ |_ \_ + var env_h2 = 5; // |||env_att_line_w |_env_lineplus + var env_lineplus = 1; // ||env_bounding_line_w + var env_line_click_area = 6; + //range=get_visible_freq_range(); + var from = center_freq + this.demodulator.offset_frequency + this.demodulator.low_cut; + var from_px = scale_px_from_freq(from, range); + var to = center_freq + this.demodulator.offset_frequency + this.demodulator.high_cut; + var to_px = scale_px_from_freq(to, range); + if (to_px < from_px) /* swap'em */ { + var temp_px = to_px; + to_px = from_px; + from_px = temp_px; + } + + from_px -= (env_att_w + env_bounding_line_w); + to_px += (env_att_w + env_bounding_line_w); + // do drawing: + scale_ctx.lineWidth = 3; + var color = this.color || '#ffff00'; // yellow + scale_ctx.strokeStyle = color; + scale_ctx.fillStyle = color; + var drag_ranges = {envelope_on_screen: false, line_on_screen: false}; + if (!(to_px < 0 || from_px > window.innerWidth)) // out of screen? + { + drag_ranges.beginning = {x1: from_px, x2: from_px + env_bounding_line_w + env_att_w}; + drag_ranges.ending = {x1: to_px - env_bounding_line_w - env_att_w, x2: to_px}; + drag_ranges.whole_envelope = {x1: from_px, x2: to_px}; + drag_ranges.envelope_on_screen = true; + scale_ctx.beginPath(); + scale_ctx.moveTo(from_px, env_h1); + scale_ctx.lineTo(from_px + env_bounding_line_w, env_h1); + scale_ctx.lineTo(from_px + env_bounding_line_w + env_att_w, env_h2); + scale_ctx.lineTo(to_px - env_bounding_line_w - env_att_w, env_h2); + scale_ctx.lineTo(to_px - env_bounding_line_w, env_h1); + scale_ctx.lineTo(to_px, env_h1); + scale_ctx.globalAlpha = 0.3; + scale_ctx.fill(); + scale_ctx.globalAlpha = 1; + scale_ctx.stroke(); + } + if (typeof line !== "undefined") // out of screen? + { + var line_px = scale_px_from_freq(line, range); + if (!(line_px < 0 || line_px > window.innerWidth)) { + drag_ranges.line = {x1: line_px - env_line_click_area / 2, x2: line_px + env_line_click_area / 2}; + drag_ranges.line_on_screen = true; + scale_ctx.moveTo(line_px, env_h1 + env_lineplus); + scale_ctx.lineTo(line_px, env_h2 - env_lineplus); + scale_ctx.stroke(); + } + } + this.drag_ranges = drag_ranges; +}; + +Envelope.prototype.drag_start = function(x, key_modifiers){ + this.key_modifiers = key_modifiers; + this.dragged_range = this.where_clicked(x, this.drag_ranges, key_modifiers); + this.drag_origin = { + x: x, + low_cut: this.demodulator.low_cut, + high_cut: this.demodulator.high_cut, + offset_frequency: this.demodulator.offset_frequency + }; + return this.dragged_range !== Demodulator.draggable_ranges.none; +}; + +Envelope.prototype.where_clicked = function(x, drag_ranges, key_modifiers) { // Check exactly what the user has clicked based on ranges returned by envelope_draw(). + var in_range = function (x, range) { + return range.x1 <= x && range.x2 >= x; + }; + var dr = Demodulator.draggable_ranges; + + if (key_modifiers.shiftKey) { + //Check first: shift + center drag emulates BFO knob + if (drag_ranges.line_on_screen && in_range(x, drag_ranges.line)) return dr.bfo; + //Check second: shift + envelope drag emulates PBF knob + if (drag_ranges.envelope_on_screen && in_range(x, drag_ranges.whole_envelope)) return dr.pbs; + } + if (drag_ranges.envelope_on_screen) { + // For low and high cut: + if (in_range(x, drag_ranges.beginning)) return dr.beginning; + if (in_range(x, drag_ranges.ending)) return dr.ending; + // Last priority: having clicked anything else on the envelope, without holding the shift key + if (in_range(x, drag_ranges.whole_envelope)) return dr.anything_else; + } + return dr.none; //User doesn't drag the envelope for this demodulator +}; + + +Envelope.prototype.drag_move = function(x) { + var dr = Demodulator.draggable_ranges; + var new_value; + if (this.dragged_range === dr.none) return false; // we return if user is not dragging (us) at all + var freq_change = Math.round(this.visible_range.hps * (x - this.drag_origin.x)); + + //dragging the line in the middle of the filter envelope while holding Shift does emulate + //the BFO knob on radio equipment: moving offset frequency, while passband remains unchanged + //Filter passband moves in the opposite direction than dragged, hence the minus below. + var minus = (this.dragged_range === dr.bfo) ? -1 : 1; + //dragging any other parts of the filter envelope while holding Shift does emulate the PBS knob + //(PassBand Shift) on radio equipment: PBS does move the whole passband without moving the offset + //frequency. + if (this.dragged_range === dr.beginning || this.dragged_range === dr.bfo || this.dragged_range === dr.pbs) { + //we don't let low_cut go beyond its limits + if ((new_value = this.drag_origin.low_cut + minus * freq_change) < this.demodulator.filter.getLimits().low) return true; + //nor the filter passband be too small + if (this.demodulator.high_cut - new_value < this.demodulator.filter.min_passband) return true; + //sanity check to prevent GNU Radio "firdes check failed: fa <= fb" + if (new_value >= this.demodulator.high_cut) return true; + this.demodulator.setLowCut(new_value); + } + if (this.dragged_range === dr.ending || this.dragged_range === dr.bfo || this.dragged_range === dr.pbs) { + //we don't let high_cut go beyond its limits + if ((new_value = this.drag_origin.high_cut + minus * freq_change) > this.demodulator.filter.getLimits().high) return true; + //nor the filter passband be too small + if (new_value - this.demodulator.low_cut < this.demodulator.filter.min_passband) return true; + //sanity check to prevent GNU Radio "firdes check failed: fa <= fb" + if (new_value <= this.demodulator.low_cut) return true; + this.demodulator.setHighCut(new_value); + } + if (this.dragged_range === dr.anything_else || this.dragged_range === dr.bfo) { + //when any other part of the envelope is dragged, the offset frequency is changed (whole passband also moves with it) + new_value = this.drag_origin.offset_frequency + freq_change; + if (new_value > bandwidth / 2 || new_value < -bandwidth / 2) return true; //we don't allow tuning above Nyquist frequency :-) + this.demodulator.set_offset_frequency(new_value); + } + //now do the actual modifications: + //mkenvelopes(this.visible_range); + //this.demodulator.set(); + return true; +}; + +Envelope.prototype.drag_end = function(){ + var to_return = this.dragged_range !== Demodulator.draggable_ranges.none; //this part is required for cliking anywhere on the scale to set offset + this.dragged_range = Demodulator.draggable_ranges.none; + return to_return; +}; + + +//******* class Demodulator_default_analog ******* +// This can be used as a base for basic audio demodulators. +// It already supports most basic modulations used for ham radio and commercial services: AM/FM/LSB/USB + +function Demodulator(offset_frequency, modulation) { + this.offset_frequency = offset_frequency; + this.envelope = new Envelope(this); + this.color = Demodulator.get_next_color(); + this.modulation = modulation; + this.filter = new Filter(this); + this.squelch_level = -150; + this.dmr_filter = 3; + this.started = false; + this.state = {}; + this.secondary_demod = false; + var mode = Modes.findByModulation(modulation); + if (mode) { + this.low_cut = mode.bandpass.low_cut; + this.high_cut = mode.bandpass.high_cut; + } + this.listeners = { + "frequencychange": [], + "squelchchange": [] + }; +} + +//ranges on filter envelope that can be dragged: +Demodulator.draggable_ranges = { + none: 0, + beginning: 1 /*from*/, + ending: 2 /*to*/, + anything_else: 3, + bfo: 4 /*line (while holding shift)*/, + pbs: 5 +}; //to which parameter these correspond in envelope_draw() + +Demodulator.color_index = 0; +Demodulator.colors = ["#ffff00", "#00ff00", "#00ffff", "#058cff", "#ff9600", "#a1ff39", "#ff4e39", "#ff5dbd"]; + +Demodulator.get_next_color = function() { + if (this.color_index >= this.colors.length) this.color_index = 0; + return (this.colors[this.color_index++]); +} + + + +Demodulator.prototype.on = function(event, handler) { + this.listeners[event].push(handler); +}; + +Demodulator.prototype.emit = function(event, params) { + this.listeners[event].forEach(function(fn) { + fn(params); + }); +}; + +Demodulator.prototype.set_offset_frequency = function(to_what) { + if (typeof(to_what) == 'undefined' || to_what > bandwidth / 2 || to_what < -bandwidth / 2) return; + to_what = Math.round(to_what); + if (this.offset_frequency === to_what) { + return; + } + this.offset_frequency = to_what; + this.set(); + this.emit("frequencychange", to_what); + mkenvelopes(get_visible_freq_range()); +}; + +Demodulator.prototype.get_offset_frequency = function() { + return this.offset_frequency; +}; + +Demodulator.prototype.get_modulation = function() { + return this.modulation; +}; + +Demodulator.prototype.start = function() { + this.started = true; + this.set(); + ws.send(JSON.stringify({ + "type": "dspcontrol", + "action": "start" + })); +}; + +// TODO check if this is actually used +Demodulator.prototype.stop = function() { +}; + +Demodulator.prototype.send = function(params) { + ws.send(JSON.stringify({"type": "dspcontrol", "params": params})); +} + +Demodulator.prototype.set = function () { //this function sends demodulator parameters to the server + if (!this.started) return; + var params = { + "low_cut": this.low_cut, + "high_cut": this.high_cut, + "offset_freq": this.offset_frequency, + "mod": this.modulation, + "dmr_filter": this.dmr_filter, + "squelch_level": this.squelch_level, + "secondary_mod": this.secondary_demod, + "secondary_offset_freq": this.secondary_offset_freq + }; + var to_send = {}; + for (var key in params) { + if (!(key in this.state) || params[key] !== this.state[key]) { + to_send[key] = params[key]; + } + } + if (Object.keys(to_send).length > 0) { + this.send(to_send); + for (var key in to_send) { + this.state[key] = to_send[key]; + } + } + mkenvelopes(get_visible_freq_range()); +}; + +Demodulator.prototype.setSquelch = function(squelch) { + if (this.squelch_level == squelch) { + return; + } + this.squelch_level = squelch; + this.set(); + this.emit("squelchchange", squelch); +}; + +Demodulator.prototype.getSquelch = function() { + return this.squelch_level; +}; + +Demodulator.prototype.setDmrFilter = function(dmr_filter) { + this.dmr_filter = dmr_filter; + this.set(); +}; + +Demodulator.prototype.setBandpass = function(bandpass) { + this.bandpass = bandpass; + this.low_cut = bandpass.low_cut; + this.high_cut = bandpass.high_cut; + this.set(); +}; + +Demodulator.prototype.setLowCut = function(low_cut) { + this.low_cut = low_cut; + this.set(); +}; + +Demodulator.prototype.setHighCut = function(high_cut) { + this.high_cut = high_cut; + this.set(); +}; + +Demodulator.prototype.getBandpass = function() { + return { + low_cut: this.low_cut, + high_cut: this.high_cut + }; +}; + +Demodulator.prototype.set_secondary_demod = function(secondary_demod) { + if (this.secondary_demod === secondary_demod) { + return; + } + this.secondary_demod = secondary_demod; + this.set(); +}; + +Demodulator.prototype.get_secondary_demod = function() { + return this.secondary_demod; +}; + +Demodulator.prototype.set_secondary_offset_freq = function(secondary_offset) { + if (this.secondary_offset_freq === secondary_offset) { + return; + } + this.secondary_offset_freq = secondary_offset; + this.set(); +}; diff --git a/openwebrx/htdocs/lib/DemodulatorPanel.js b/openwebrx/htdocs/lib/DemodulatorPanel.js new file mode 100644 index 0000000..8c10ff3 --- /dev/null +++ b/openwebrx/htdocs/lib/DemodulatorPanel.js @@ -0,0 +1,370 @@ +function DemodulatorPanel(el) { + var self = this; + self.el = el; + self.demodulator = null; + self.mode = null; + self.squelchMargin = 10; + self.initialParams = {}; + + var displayEl = el.find('.webrx-actual-freq') + this.tuneableFrequencyDisplay = displayEl.tuneableFrequencyDisplay(); + displayEl.on('frequencychange', function(event, freq) { + self.getDemodulator().set_offset_frequency(freq - self.center_freq); + }); + + this.mouseFrequencyDisplay = el.find('.webrx-mouse-freq').frequencyDisplay(); + + Modes.registerModePanel(this); + el.on('click', '.openwebrx-demodulator-button', function() { + var modulation = $(this).data('modulation'); + if (modulation) { + self.setMode(modulation); + } else { + self.disableDigiMode(); + } + }); + el.on('change', '.openwebrx-secondary-demod-listbox', function() { + var value = $(this).val(); + if (value === 'none') { + self.disableDigiMode(); + } else { + self.setMode(value); + } + }); + el.on('click', '.openwebrx-squelch-auto', function() { + if (!self.squelchAvailable()) return; + el.find('.openwebrx-squelch-slider').val(getLogSmeterValue(smeter_level) + self.getSquelchMargin()); + self.updateSquelch(); + }); + el.on('change', '.openwebrx-squelch-slider', function() { + self.updateSquelch(); + }); + window.addEventListener('hashchange', function() { + self.onHashChange(); + }); +}; + +DemodulatorPanel.prototype.render = function() { + var available = Modes.getModes().filter(function(m){ return m.isAvailable(); }); + var normalModes = available.filter(function(m){ return m.type === 'analog'; }); + var digiModes = available.filter(function(m){ return m.type === 'digimode'; }); + + var html = [] + + var buttons = normalModes.map(function(m){ + return $( + '
' + m.name + '
' + ); + }); + + var $modegrid = $('
'); + $modegrid.append.apply($modegrid, buttons); + html.push($modegrid); + + html.push($( + '
' + + '
DIG
' + + '' + + '
' + )); + + this.el.find(".openwebrx-modes").html(html); +}; + +DemodulatorPanel.prototype.setMode = function(requestedModulation) { + var mode = Modes.findByModulation(requestedModulation); + if (!mode) { + return; + } + if (this.mode === mode) { + return; + } + if (!mode.isAvailable()) { + divlog('Modulation "' + mode.name + '" not supported. Please check requirements', true); + return; + } + + if (mode.type === 'digimode') { + modulation = mode.underlying[0]; + } else { + if (this.mode && this.mode.type === 'digimode' && this.mode.underlying.indexOf(requestedModulation) >= 0) { + // keep the mode, just switch underlying modulation + mode = this.mode; + modulation = requestedModulation; + } else { + modulation = mode.modulation; + } + } + + var current = this.collectParams(); + if (this.demodulator) { + current.offset_frequency = this.demodulator.get_offset_frequency(); + current.squelch_level = this.demodulator.getSquelch(); + } + + this.stopDemodulator(); + this.demodulator = new Demodulator(current.offset_frequency, modulation); + this.demodulator.setSquelch(current.squelch_level); + + var self = this; + var updateFrequency = function(freq) { + self.tuneableFrequencyDisplay.setFrequency(self.center_freq + freq); + self.updateHash(); + }; + this.demodulator.on("frequencychange", updateFrequency); + updateFrequency(this.demodulator.get_offset_frequency()); + var updateSquelch = function(squelch) { + self.el.find('.openwebrx-squelch-slider') + .val(squelch) + .attr('title', 'Squelch (' + squelch + ' dB)'); + self.updateHash(); + }; + this.demodulator.on('squelchchange', updateSquelch); + updateSquelch(this.demodulator.getSquelch()); + + if (mode.type === 'digimode') { + this.demodulator.set_secondary_demod(mode.modulation); + if (mode.bandpass) { + this.demodulator.setBandpass(mode.bandpass); + } + } else { + this.demodulator.set_secondary_demod(false); + } + + this.demodulator.start(); + this.mode = mode; + + this.updateButtons(); + this.updatePanels(); + this.updateHash(); +}; + +DemodulatorPanel.prototype.disableDigiMode = function() { + // just a little trick to get out of the digimode + delete this.mode; + this.setMode(this.getDemodulator().get_modulation()); +}; + +DemodulatorPanel.prototype.updatePanels = function() { + var modulation = this.getDemodulator().get_secondary_demod(); + $('#openwebrx-panel-digimodes').attr('data-mode', modulation); + toggle_panel("openwebrx-panel-digimodes", !!modulation); + toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4', 'fst4', 'fst4w', "q65"].indexOf(modulation) >= 0); + toggle_panel("openwebrx-panel-js8-message", modulation == "js8"); + toggle_panel("openwebrx-panel-packet-message", modulation === "packet"); + toggle_panel("openwebrx-panel-pocsag-message", modulation === "pocsag"); + + modulation = this.getDemodulator().get_modulation(); + var showing = 'openwebrx-panel-metadata-' + modulation; + var metaPanels = $(".openwebrx-meta-panel"); + metaPanels.each(function (_, p) { + toggle_panel(p.id, p.id === showing); + }); + metaPanels.metaPanel().each(function() { + this.clear(); + }); +}; + +DemodulatorPanel.prototype.getDemodulator = function() { + return this.demodulator; +}; + +DemodulatorPanel.prototype.collectParams = function() { + var defaults = { + offset_frequency: 0, + squelch_level: -150, + mod: 'nfm' + } + return $.extend(new Object(), defaults, this.validateInitialParams(this.initialParams), this.transformHashParams(this.parseHash())); +}; + +DemodulatorPanel.prototype.startDemodulator = function() { + if (!Modes.initComplete() || !this.center_freq) return; + var params = this.collectParams(); + this._apply(params); +}; + +DemodulatorPanel.prototype.stopDemodulator = function() { + if (!this.demodulator) { + return; + } + this.demodulator.stop(); + this.demodulator = null; + this.mode = null; +} + +DemodulatorPanel.prototype._apply = function(params) { + this.setMode(params.mod); + this.getDemodulator().set_offset_frequency(params.offset_frequency); + this.getDemodulator().setSquelch(params.squelch_level); + this.updateButtons(); +}; + +DemodulatorPanel.prototype.setInitialParams = function(params) { + $.extend(this.initialParams, params); +}; + +DemodulatorPanel.prototype.resetInitialParams = function() { + this.initialParams = {}; +}; + +DemodulatorPanel.prototype.onHashChange = function() { + this._apply(this.transformHashParams(this.parseHash())); +}; + +DemodulatorPanel.prototype.transformHashParams = function(params) { + var ret = { + mod: params.secondary_mod || params.mod + }; + if (typeof(params.offset_frequency) !== 'undefined') ret.offset_frequency = params.offset_frequency; + if (typeof(params.sql) !== 'undefined') ret.squelch_level = parseInt(params.sql); + return ret; +}; + +DemodulatorPanel.prototype.squelchAvailable = function () { + return this.mode && this.mode.squelch; +} + +DemodulatorPanel.prototype.updateButtons = function() { + var $buttons = this.el.find(".openwebrx-demodulator-button"); + $buttons.removeClass("highlighted").removeClass('same-mod'); + var demod = this.getDemodulator() + if (!demod) return; + this.el.find('[data-modulation=' + demod.get_modulation() + ']').addClass("highlighted"); + var secondary_demod = demod.get_secondary_demod() + if (secondary_demod) { + this.el.find(".openwebrx-button-dig").addClass("highlighted"); + this.el.find('.openwebrx-secondary-demod-listbox').val(secondary_demod); + var mode = Modes.findByModulation(secondary_demod); + if (mode) { + var self = this; + mode.underlying.filter(function(m) { + return m !== demod.get_modulation(); + }).forEach(function(m) { + self.el.find('[data-modulation=' + m + ']').addClass('same-mod') + }); + } + } else { + this.el.find('.openwebrx-secondary-demod-listbox').val('none'); + } + var squelch_disabled = !this.squelchAvailable(); + this.el.find('.openwebrx-squelch-slider').prop('disabled', squelch_disabled); + this.el.find('.openwebrx-squelch-auto')[squelch_disabled ? 'addClass' : 'removeClass']('disabled'); +} + +DemodulatorPanel.prototype.setCenterFrequency = function(center_freq) { + var me = this; + if (me.centerFreqTimeout) { + clearTimeout(me.centerFreqTimeout); + me.centerFreqTimeout = false; + } + this.centerFreqTimeout = setTimeout(function() { + me.stopDemodulator(); + me.center_freq = center_freq; + me.startDemodulator(); + me.centerFreqTimeout = false; + }, 50); +}; + +DemodulatorPanel.prototype.parseHash = function() { + if (!window.location.hash) { + return {}; + } + var params = window.location.hash.substring(1).split(",").map(function(x) { + var harr = x.split('='); + return [harr[0], harr.slice(1).join('=')]; + }).reduce(function(params, p){ + params[p[0]] = p[1]; + return params; + }, {}); + + return this.validateHash(params); +}; + +DemodulatorPanel.prototype.validateHash = function(params) { + var self = this; + params = Object.keys(params).filter(function(key) { + if (key == 'freq' || key == 'mod' || key == 'secondary_mod' || key == 'sql') { + return params.freq && Math.abs(params.freq - self.center_freq) <= bandwidth / 2; + } + return true; + }).reduce(function(p, key) { + p[key] = params[key]; + return p; + }, {}); + + if (params['freq']) { + params['offset_frequency'] = params['freq'] - self.center_freq; + delete params['freq']; + } + + return params; +}; + +DemodulatorPanel.prototype.validateInitialParams = function(params) { + return Object.fromEntries( + Object.entries(params).filter(function(a) { + if (a[0] == "offset_frequency") { + return Math.abs(a[1]) <= bandwidth / 2; + } + return true; + }) + ); +}; + +DemodulatorPanel.prototype.updateHash = function() { + var demod = this.getDemodulator(); + if (!demod) return; + var self = this; + window.location.hash = $.map({ + freq: demod.get_offset_frequency() + self.center_freq, + mod: demod.get_modulation(), + secondary_mod: demod.get_secondary_demod(), + sql: demod.getSquelch(), + }, function(value, key){ + if (typeof(value) === 'undefined' || value === false) return undefined; + return key + '=' + value; + }).filter(function(v) { + return !!v; + }).join(','); +}; + +DemodulatorPanel.prototype.updateSquelch = function() { + var sliderValue = parseInt(this.el.find(".openwebrx-squelch-slider").val()); + var demod = this.getDemodulator(); + if (demod) demod.setSquelch(sliderValue); +}; + +DemodulatorPanel.prototype.setSquelchMargin = function(margin) { + if (typeof(margin) === 'undefined' || this.squelchMargin == margin) return; + this.squelchMargin = margin; +}; + +DemodulatorPanel.prototype.getSquelchMargin = function() { + return this.squelchMargin; +}; + +DemodulatorPanel.prototype.setMouseFrequency = function(freq) { + this.mouseFrequencyDisplay.setFrequency(freq); +}; + +DemodulatorPanel.prototype.setTuningPrecision = function(precision) { + this.tuneableFrequencyDisplay.setTuningPrecision(precision); + this.mouseFrequencyDisplay.setTuningPrecision(precision); +}; + +$.fn.demodulatorPanel = function(){ + if (!this.data('panel')) { + this.data('panel', new DemodulatorPanel(this)); + }; + return this.data('panel'); +}; \ No newline at end of file diff --git a/openwebrx/htdocs/lib/FrequencyDisplay.js b/openwebrx/htdocs/lib/FrequencyDisplay.js new file mode 100644 index 0000000..b24ebca --- /dev/null +++ b/openwebrx/htdocs/lib/FrequencyDisplay.js @@ -0,0 +1,183 @@ +function FrequencyDisplay(element) { + this.suffixes = { + '': 0, + 'k': 3, + 'M': 6, + 'G': 9, + 'T': 12 + }; + this.element = $(element); + this.digits = []; + this.precision = 2; + this.setupElements(); + this.setFrequency(0); +} + +FrequencyDisplay.prototype.setupElements = function() { + this.displayContainer = $('
'); + this.digitContainer = $(''); + this.unitContainer = $(' Hz'); + this.displayContainer.html([this.digitContainer, this.unitContainer]); + this.element.html(this.displayContainer); +}; + +FrequencyDisplay.prototype.getSuffix = function() { + var me = this; + return Object.keys(me.suffixes).filter(function(key){ + return me.suffixes[key] == me.exponent; + })[0] || ""; +}; + +FrequencyDisplay.prototype.setFrequency = function(freq) { + this.frequency = freq; + if (this.frequency === 0 || Number.isNaN(this.frequency)) { + this.exponent = 0 + } else { + this.exponent = Math.floor(Math.log10(this.frequency) / 3) * 3; + } + + var digits = Math.max(0, this.exponent - this.precision); + var formatted = (freq / 10 ** this.exponent).toLocaleString( + undefined, + {maximumFractionDigits: digits, minimumFractionDigits: digits} + ); + var children = this.digitContainer.children(); + for (var i = 0; i < formatted.length; i++) { + if (!this.digits[i]) { + this.digits[i] = $(''); + var before = children[i]; + if (before) { + $(before).after(this.digits[i]); + } else { + this.digitContainer.append(this.digits[i]); + } + } + this.digits[i][(isNaN(formatted[i]) ? 'remove' : 'add') + 'Class']('digit'); + this.digits[i].html(formatted[i]); + } + while (this.digits.length > formatted.length) { + this.digits.pop().remove(); + } + this.unitContainer.text(' ' + this.getSuffix() + 'Hz'); +}; + +FrequencyDisplay.prototype.setTuningPrecision = function(precision) { + if (typeof(precision) == 'undefined') return; + this.precision = precision; + this.setFrequency(this.frequency); +}; + +function TuneableFrequencyDisplay(element) { + FrequencyDisplay.call(this, element); + this.setupEvents(); +} + +TuneableFrequencyDisplay.prototype = new FrequencyDisplay(); + +TuneableFrequencyDisplay.prototype.setupElements = function() { + FrequencyDisplay.prototype.setupElements.call(this); + this.input = $(''); + this.suffixInput = $('",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext;function B(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()}var C=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,D=/^.[^:#\[\.,]*$/;function E(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):D.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(E(this,a||[],!1))},not:function(a){return this.pushStack(E(this,a||[],!0))},is:function(a){return!!E(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var F,G=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,H=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||F,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:G.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),C.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};H.prototype=r.fn,F=r(d);var I=/^(?:parents|prev(?:Until|All))/,J={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function K(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return K(a,"nextSibling")},prev:function(a){return K(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return B(a,"iframe")?a.contentDocument:(B(a,"template")&&(a=a.content||a),r.merge([],a.childNodes))}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(J[a]||r.uniqueSort(e),I.test(a)&&e.reverse()),this.pushStack(e)}});var L=/[^\x20\t\r\n\f]+/g;function M(a){var b={};return r.each(a.match(L)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?M(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=e||a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function N(a){return a}function O(a){throw a}function P(a,b,c,d){var e;try{a&&r.isFunction(e=a.promise)?e.call(a).done(b).fail(c):a&&r.isFunction(e=a.then)?e.call(a,b,c):b.apply(void 0,[a].slice(d))}catch(a){c.apply(void 0,[a])}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==O&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:N,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:N)),c[2][3].add(g(0,a,r.isFunction(d)?d:O))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(P(a,g.done(h(c)).resolve,g.reject,!b),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)P(e[c],h(c),g.reject);return g.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&Q.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var R=r.Deferred();r.fn.ready=function(a){return R.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||R.resolveWith(d,[r]))}}),r.ready.then=R.then;function S(){d.removeEventListener("DOMContentLoaded",S), +a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){X.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=W.get(a,b),c&&(!d||Array.isArray(c)?d=W.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return W.get(a,c)||W.access(a,c,{empty:r.Callbacks("once memory").add(function(){W.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,la=/^$|\/(?:java|ecma)script/i,ma={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/\s*$/g;function Ea(a,b){return B(a,"table")&&B(11!==b.nodeType?b:b.firstChild,"tr")?r(">tbody",a)[0]||a:a}function Fa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Ga(a){var b=Ca.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ha(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(W.hasData(a)&&(f=W.access(a),g=W.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Ba.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ja(f,b,c,d)});if(m&&(e=qa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(na(e,"script"),Fa),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=na(h),f=na(a),d=0,e=f.length;d0&&oa(g,!i&&na(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(U(c)){if(b=c[W.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[W.expando]=void 0}c[X.expando]&&(c[X.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ka(this,a,!0)},remove:function(a){return Ka(this,a)},text:function(a){return T(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.appendChild(a)}})},prepend:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(na(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return T(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!Aa.test(a)&&!ma[(ka.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}});function _a(a,b,c,d,e){return new _a.prototype.init(a,b,c,d,e)}r.Tween=_a,_a.prototype={constructor:_a,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||r.easing._default,this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(r.cssNumber[c]?"":"px")},cur:function(){var a=_a.propHooks[this.prop];return a&&a.get?a.get(this):_a.propHooks._default.get(this)},run:function(a){var b,c=_a.propHooks[this.prop];return this.options.duration?this.pos=b=r.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):_a.propHooks._default.set(this),this}},_a.prototype.init.prototype=_a.prototype,_a.propHooks={_default:{get:function(a){var b;return 1!==a.elem.nodeType||null!=a.elem[a.prop]&&null==a.elem.style[a.prop]?a.elem[a.prop]:(b=r.css(a.elem,a.prop,""),b&&"auto"!==b?b:0)},set:function(a){r.fx.step[a.prop]?r.fx.step[a.prop](a):1!==a.elem.nodeType||null==a.elem.style[r.cssProps[a.prop]]&&!r.cssHooks[a.prop]?a.elem[a.prop]=a.now:r.style(a.elem,a.prop,a.now+a.unit)}}},_a.propHooks.scrollTop=_a.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},r.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},_default:"swing"},r.fx=_a.prototype.init,r.fx.step={};var ab,bb,cb=/^(?:toggle|show|hide)$/,db=/queueHooks$/;function eb(){bb&&(d.hidden===!1&&a.requestAnimationFrame?a.requestAnimationFrame(eb):a.setTimeout(eb,r.fx.interval),r.fx.tick())}function fb(){return a.setTimeout(function(){ab=void 0}),ab=r.now()}function gb(a,b){var c,d=0,e={height:a};for(b=b?1:0;d<4;d+=2-b)c=ca[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function hb(a,b,c){for(var d,e=(kb.tweeners[b]||[]).concat(kb.tweeners["*"]),f=0,g=e.length;f1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?lb:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b), +null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),lb={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=mb[b]||r.find.attr;mb[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=mb[g],mb[g]=e,e=null!=c(a,b,d)?g:null,mb[g]=f),e}});var nb=/^(?:input|select|textarea|button)$/i,ob=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return T(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):nb.test(a.nodeName)||ob.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function pb(a){var b=a.match(L)||[];return b.join(" ")}function qb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,qb(this)))});if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,qb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,qb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(L)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=qb(this),b&&W.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":W.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+pb(qb(c))+" ").indexOf(b)>-1)return!0;return!1}});var rb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":Array.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(rb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:pb(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(Array.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var sb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!sb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,sb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(W.get(h,"events")||{})[b.type]&&W.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&U(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!U(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=W.access(d,b);e||d.addEventListener(a,c,!0),W.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=W.access(d,b)-1;e?W.access(d,b,e):(d.removeEventListener(a,c,!0),W.remove(d,b))}}});var tb=a.location,ub=r.now(),vb=/\?/;r.parseXML=function(b){var c;if(!b||"string"!=typeof b)return null;try{c=(new a.DOMParser).parseFromString(b,"text/xml")}catch(d){c=void 0}return c&&!c.getElementsByTagName("parsererror").length||r.error("Invalid XML: "+b),c};var wb=/\[\]$/,xb=/\r?\n/g,yb=/^(?:submit|button|image|reset|file)$/i,zb=/^(?:input|select|textarea|keygen)/i;function Ab(a,b,c,d){var e;if(Array.isArray(b))r.each(b,function(b,e){c||wb.test(a)?d(a,e):Ab(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d)});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)Ab(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(Array.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)Ab(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&zb.test(this.nodeName)&&!yb.test(a)&&(this.checked||!ja.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:Array.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(xb,"\r\n")}}):{name:b.name,value:c.replace(xb,"\r\n")}}).get()}});var Bb=/%20/g,Cb=/#.*$/,Db=/([?&])_=[^&]*/,Eb=/^(.*?):[ \t]*([^\r\n]*)$/gm,Fb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Gb=/^(?:GET|HEAD)$/,Hb=/^\/\//,Ib={},Jb={},Kb="*/".concat("*"),Lb=d.createElement("a");Lb.href=tb.href;function Mb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(L)||[];if(r.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function Nb(a,b,c,d){var e={},f=a===Jb;function g(h){var i;return e[h]=!0,r.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function Ob(a,b){var c,d,e=r.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&r.extend(!0,a,d),a}function Pb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}if(f)return f!==i[0]&&i.unshift(f),c[f]}function Qb(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}r.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:tb.href,type:"GET",isLocal:Fb.test(tb.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Kb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":r.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?Ob(Ob(a,r.ajaxSettings),b):Ob(r.ajaxSettings,a)},ajaxPrefilter:Mb(Ib),ajaxTransport:Mb(Jb),ajax:function(b,c){"object"==typeof b&&(c=b,b=void 0),c=c||{};var e,f,g,h,i,j,k,l,m,n,o=r.ajaxSetup({},c),p=o.context||o,q=o.context&&(p.nodeType||p.jquery)?r(p):r.event,s=r.Deferred(),t=r.Callbacks("once memory"),u=o.statusCode||{},v={},w={},x="canceled",y={readyState:0,getResponseHeader:function(a){var b;if(k){if(!h){h={};while(b=Eb.exec(g))h[b[1].toLowerCase()]=b[2]}b=h[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return k?g:null},setRequestHeader:function(a,b){return null==k&&(a=w[a.toLowerCase()]=w[a.toLowerCase()]||a,v[a]=b),this},overrideMimeType:function(a){return null==k&&(o.mimeType=a),this},statusCode:function(a){var b;if(a)if(k)y.always(a[y.status]);else for(b in a)u[b]=[u[b],a[b]];return this},abort:function(a){var b=a||x;return e&&e.abort(b),A(0,b),this}};if(s.promise(y),o.url=((b||o.url||tb.href)+"").replace(Hb,tb.protocol+"//"),o.type=c.method||c.type||o.method||o.type,o.dataTypes=(o.dataType||"*").toLowerCase().match(L)||[""],null==o.crossDomain){j=d.createElement("a");try{j.href=o.url,j.href=j.href,o.crossDomain=Lb.protocol+"//"+Lb.host!=j.protocol+"//"+j.host}catch(z){o.crossDomain=!0}}if(o.data&&o.processData&&"string"!=typeof o.data&&(o.data=r.param(o.data,o.traditional)),Nb(Ib,o,c,y),k)return y;l=r.event&&o.global,l&&0===r.active++&&r.event.trigger("ajaxStart"),o.type=o.type.toUpperCase(),o.hasContent=!Gb.test(o.type),f=o.url.replace(Cb,""),o.hasContent?o.data&&o.processData&&0===(o.contentType||"").indexOf("application/x-www-form-urlencoded")&&(o.data=o.data.replace(Bb,"+")):(n=o.url.slice(f.length),o.data&&(f+=(vb.test(f)?"&":"?")+o.data,delete o.data),o.cache===!1&&(f=f.replace(Db,"$1"),n=(vb.test(f)?"&":"?")+"_="+ub++ +n),o.url=f+n),o.ifModified&&(r.lastModified[f]&&y.setRequestHeader("If-Modified-Since",r.lastModified[f]),r.etag[f]&&y.setRequestHeader("If-None-Match",r.etag[f])),(o.data&&o.hasContent&&o.contentType!==!1||c.contentType)&&y.setRequestHeader("Content-Type",o.contentType),y.setRequestHeader("Accept",o.dataTypes[0]&&o.accepts[o.dataTypes[0]]?o.accepts[o.dataTypes[0]]+("*"!==o.dataTypes[0]?", "+Kb+"; q=0.01":""):o.accepts["*"]);for(m in o.headers)y.setRequestHeader(m,o.headers[m]);if(o.beforeSend&&(o.beforeSend.call(p,y,o)===!1||k))return y.abort();if(x="abort",t.add(o.complete),y.done(o.success),y.fail(o.error),e=Nb(Jb,o,c,y)){if(y.readyState=1,l&&q.trigger("ajaxSend",[y,o]),k)return y;o.async&&o.timeout>0&&(i=a.setTimeout(function(){y.abort("timeout")},o.timeout));try{k=!1,e.send(v,A)}catch(z){if(k)throw z;A(-1,z)}}else A(-1,"No Transport");function A(b,c,d,h){var j,m,n,v,w,x=c;k||(k=!0,i&&a.clearTimeout(i),e=void 0,g=h||"",y.readyState=b>0?4:0,j=b>=200&&b<300||304===b,d&&(v=Pb(o,y,d)),v=Qb(o,v,y,j),j?(o.ifModified&&(w=y.getResponseHeader("Last-Modified"),w&&(r.lastModified[f]=w),w=y.getResponseHeader("etag"),w&&(r.etag[f]=w)),204===b||"HEAD"===o.type?x="nocontent":304===b?x="notmodified":(x=v.state,m=v.data,n=v.error,j=!n)):(n=x,!b&&x||(x="error",b<0&&(b=0))),y.status=b,y.statusText=(c||x)+"",j?s.resolveWith(p,[m,x,y]):s.rejectWith(p,[y,x,n]),y.statusCode(u),u=void 0,l&&q.trigger(j?"ajaxSuccess":"ajaxError",[y,o,j?m:n]),t.fireWith(p,[y,x]),l&&(q.trigger("ajaxComplete",[y,o]),--r.active||r.event.trigger("ajaxStop")))}return y},getJSON:function(a,b,c){return r.get(a,b,c,"json")},getScript:function(a,b){return r.get(a,void 0,b,"script")}}),r.each(["get","post"],function(a,b){r[b]=function(a,c,d,e){return r.isFunction(c)&&(e=e||d,d=c,c=void 0),r.ajax(r.extend({url:a,type:b,dataType:e,data:c,success:d},r.isPlainObject(a)&&a))}}),r._evalUrl=function(a){return r.ajax({url:a,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},r.ajaxSettings.xhr=function(){try{return new a.XMLHttpRequest}catch(b){}};var Rb={0:200,1223:204},Sb=r.ajaxSettings.xhr();o.cors=!!Sb&&"withCredentials"in Sb,o.ajax=Sb=!!Sb,r.ajaxTransport(function(b){var c,d;if(o.cors||Sb&&!b.crossDomain)return{send:function(e,f){var g,h=b.xhr();if(h.open(b.type,b.url,b.async,b.username,b.password),b.xhrFields)for(g in b.xhrFields)h[g]=b.xhrFields[g];b.mimeType&&h.overrideMimeType&&h.overrideMimeType(b.mimeType),b.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest");for(g in e)h.setRequestHeader(g,e[g]);c=function(a){return function(){c&&(c=d=h.onload=h.onerror=h.onabort=h.onreadystatechange=null,"abort"===a?h.abort():"error"===a?"number"!=typeof h.status?f(0,"error"):f(h.status,h.statusText):f(Rb[h.status]||h.status,h.statusText,"text"!==(h.responseType||"text")||"string"!=typeof h.responseText?{binary:h.response}:{text:h.responseText},h.getAllResponseHeaders()))}},h.onload=c(),d=h.onerror=c("error"),void 0!==h.onabort?h.onabort=d:h.onreadystatechange=function(){4===h.readyState&&a.setTimeout(function(){c&&d()})},c=c("abort");try{h.send(b.hasContent&&b.data||null)}catch(i){if(c)throw i}},abort:function(){c&&c()}}}),r.ajaxPrefilter(function(a){a.crossDomain&&(a.contents.script=!1)}),r.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(a){return r.globalEval(a),a}}}),r.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),r.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(e,f){b=r(" + + + + + ${header} + + \ No newline at end of file diff --git a/openwebrx/htdocs/map.html b/openwebrx/htdocs/map.html new file mode 100644 index 0000000..f93d2e2 --- /dev/null +++ b/openwebrx/htdocs/map.html @@ -0,0 +1,26 @@ + + + + OpenWebRX Map + + + + + + + + + + + ${header} +
+
+

Colors

+ +
+
+ + diff --git a/openwebrx/htdocs/map.js b/openwebrx/htdocs/map.js new file mode 100644 index 0000000..af879d0 --- /dev/null +++ b/openwebrx/htdocs/map.js @@ -0,0 +1,476 @@ +$(function(){ + var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){ + var s = v.split('='); + var r = {}; + r[s[0]] = s.slice(1).join('='); + return r; + }).reduce(function(a, b){ + return a.assign(b); + }); + + var expectedCallsign; + if (query.callsign) expectedCallsign = decodeURIComponent(query.callsign); + var expectedLocator; + if (query.locator) expectedLocator = query.locator; + + var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws'; + + var href = window.location.href; + var index = href.lastIndexOf('/'); + if (index > 0) { + href = href.substr(0, index + 1); + } + href = href.split("://")[1]; + href = protocol + "://" + href; + if (!href.endsWith('/')) { + href += '/'; + } + var ws_url = href + "ws/"; + + var map; + var markers = {}; + var rectangles = {}; + var receiverMarker; + var updateQueue = []; + + // reasonable default; will be overriden by server + var retention_time = 2 * 60 * 60 * 1000; + var strokeOpacity = 0.8; + var fillOpacity = 0.35; + + var colorKeys = {}; + var colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl'); + var getColor = function(id){ + if (!id) return "#000000"; + if (!colorKeys[id]) { + var keys = Object.keys(colorKeys); + keys.push(id); + keys.sort(function(a, b) { + var pa = parseFloat(a); + var pb = parseFloat(b); + if (isNaN(pa) || isNaN(pb)) return a.localeCompare(b); + return pa - pb; + }); + var colors = colorScale.colors(keys.length); + colorKeys = {}; + keys.forEach(function(key, index) { + colorKeys[key] = colors[index]; + }); + reColor(); + updateLegend(); + } + return colorKeys[id]; + } + + // when the color palette changes, update all grid squares with new color + var reColor = function() { + $.each(rectangles, function(_, r) { + var color = getColor(colorAccessor(r)); + r.setOptions({ + strokeColor: color, + fillColor: color + }); + }); + } + + var colorMode = 'byband'; + var colorAccessor = function(r) { + switch (colorMode) { + case 'byband': + return r.band; + case 'bymode': + return r.mode; + } + }; + + $(function(){ + $('#openwebrx-map-colormode').on('change', function(){ + colorMode = $(this).val(); + colorKeys = {}; + filterRectangles(allRectangles); + reColor(); + updateLegend(); + }); + }); + + var updateLegend = function() { + var lis = $.map(colorKeys, function(value, key) { + // fake rectangle to test if the filter would match + var fakeRectangle = Object.fromEntries([[colorMode.slice(2), key]]); + var disabled = rectangleFilter(fakeRectangle) ? '' : ' disabled'; + return '
  • ' + key + '
  • '; + }); + $(".openwebrx-map-legend .content").html('
      ' + lis.join('') + '
    '); + } + + var processUpdates = function(updates) { + if (typeof(AprsMarker) == 'undefined') { + updateQueue = updateQueue.concat(updates); + return; + } + updates.forEach(function(update){ + + switch (update.location.type) { + case 'latlon': + var pos = new google.maps.LatLng(update.location.lat, update.location.lon); + var marker; + var markerClass = google.maps.Marker; + var aprsOptions = {} + if (update.location.symbol) { + markerClass = AprsMarker; + aprsOptions.symbol = update.location.symbol; + aprsOptions.course = update.location.course; + aprsOptions.speed = update.location.speed; + } + if (markers[update.callsign]) { + marker = markers[update.callsign]; + } else { + marker = new markerClass(); + marker.addListener('click', function(){ + showMarkerInfoWindow(update.callsign, pos); + }); + markers[update.callsign] = marker; + } + marker.setOptions($.extend({ + position: pos, + map: map, + title: update.callsign + }, aprsOptions, getMarkerOpacityOptions(update.lastseen) )); + marker.lastseen = update.lastseen; + marker.mode = update.mode; + marker.band = update.band; + marker.comment = update.location.comment; + + if (expectedCallsign && expectedCallsign == update.callsign) { + map.panTo(pos); + showMarkerInfoWindow(update.callsign, pos); + expectedCallsign = false; + } + + if (infowindow && infowindow.callsign && infowindow.callsign == update.callsign) { + showMarkerInfoWindow(infowindow.callsign, pos); + } + break; + case 'locator': + var loc = update.location.locator; + var lat = (loc.charCodeAt(1) - 65 - 9) * 10 + Number(loc[3]); + var lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]) * 2; + var center = new google.maps.LatLng({lat: lat + .5, lng: lon + 1}); + var rectangle; + // the accessor is designed to work on the rectangle... but it should work on the update object, too + var color = getColor(colorAccessor(update)); + if (rectangles[update.callsign]) { + rectangle = rectangles[update.callsign]; + } else { + rectangle = new google.maps.Rectangle(); + rectangle.addListener('click', function(){ + showLocatorInfoWindow(this.locator, this.center); + }); + rectangles[update.callsign] = rectangle; + } + rectangle.lastseen = update.lastseen; + rectangle.locator = update.location.locator; + rectangle.mode = update.mode; + rectangle.band = update.band; + rectangle.center = center; + + rectangle.setOptions($.extend({ + strokeColor: color, + strokeWeight: 2, + fillColor: color, + map: rectangleFilter(rectangle) ? map : undefined, + bounds:{ + north: lat, + south: lat + 1, + west: lon, + east: lon + 2 + } + }, getRectangleOpacityOptions(update.lastseen) )); + + if (expectedLocator && expectedLocator == update.location.locator) { + map.panTo(center); + showLocatorInfoWindow(expectedLocator, center); + expectedLocator = false; + } + + if (infowindow && infowindow.locator && infowindow.locator == update.location.locator) { + showLocatorInfoWindow(infowindow.locator, center); + } + break; + } + }); + }; + + var clearMap = function(){ + var reset = function(callsign, item) { item.setMap(); }; + $.each(markers, reset); + $.each(rectangles, reset); + receiverMarker.setMap(); + markers = {}; + rectangles = {}; + }; + + var reconnect_timeout = false; + + var config = {} + + var connect = function(){ + var ws = new WebSocket(ws_url); + ws.onopen = function(){ + ws.send("SERVER DE CLIENT client=map.js type=map"); + reconnect_timeout = false + }; + + ws.onmessage = function(e){ + if (typeof e.data != 'string') { + console.error("unsupported binary data on websocket; ignoring"); + return + } + if (e.data.substr(0, 16) == "CLIENT DE SERVER") { + return + } + try { + var json = JSON.parse(e.data); + switch (json.type) { + case "config": + Object.assign(config, json.value); + if ('receiver_gps' in config) { + var receiverPos = { + lat: config.receiver_gps.lat, + lng: config.receiver_gps.lon + }; + if (!map) $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ + map = new google.maps.Map($('.openwebrx-map')[0], { + center: receiverPos, + zoom: 5, + }); + + $.getScript("static/lib/nite-overlay.js").done(function(){ + nite.init(map); + setInterval(function() { nite.refresh() }, 10000); // every 10s + }); + $.getScript('static/lib/AprsMarker.js').done(function(){ + processUpdates(updateQueue); + updateQueue = []; + }); + + var $legend = $(".openwebrx-map-legend"); + setupLegendFilters($legend); + map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push($legend[0]); + + if (!receiverMarker) { + receiverMarker = new google.maps.Marker(); + receiverMarker.addListener('click', function() { + showReceiverInfoWindow(receiverMarker); + }); + } + receiverMarker.setOptions({ + map: map, + position: receiverPos, + title: config['receiver_name'], + config: config + }); + }); else { + receiverMarker.setOptions({ + map: map, + position: receiverPos, + config: config + }); + } + } + if ('receiver_name' in config && receiverMarker) { + receiverMarker.setOptions({ + title: config['receiver_name'] + }); + } + if ('map_position_retention_time' in config) { + retention_time = config.map_position_retention_time * 1000; + } + break; + case "update": + processUpdates(json.value); + break; + case 'receiver_details': + $('.webrx-top-container').header().setDetails(json['value']); + break; + default: + console.warn('received message of unknown type: ' + json['type']); + } + } catch (e) { + // don't lose exception + console.error(e); + } + }; + ws.onclose = function(){ + clearMap(); + if (reconnect_timeout) { + // max value: roundabout 8 and a half minutes + reconnect_timeout = Math.min(reconnect_timeout * 2, 512000); + } else { + // initial value: 1s + reconnect_timeout = 1000; + } + setTimeout(connect, reconnect_timeout); + }; + + window.onbeforeunload = function() { //http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript + ws.onclose = function () {}; + ws.close(); + }; + + /* + ws.onerror = function(){ + console.info("websocket error"); + }; + */ + }; + + connect(); + + var getInfoWindow = function() { + if (!infowindow) { + infowindow = new google.maps.InfoWindow(); + google.maps.event.addListener(infowindow, 'closeclick', function() { + delete infowindow.locator; + delete infowindow.callsign; + }); + } + delete infowindow.locator; + delete infowindow.callsign; + return infowindow; + } + + var infowindow; + var showLocatorInfoWindow = function(locator, pos) { + var infowindow = getInfoWindow(); + infowindow.locator = locator; + var inLocator = $.map(rectangles, function(r, callsign) { + return {callsign: callsign, locator: r.locator, lastseen: r.lastseen, mode: r.mode, band: r.band} + }).filter(rectangleFilter).filter(function(d) { + return d.locator == locator; + }).sort(function(a, b){ + return b.lastseen - a.lastseen; + }); + infowindow.setContent( + '

    Locator: ' + locator + '

    ' + + '
    Active Callsigns:
    ' + + '
      ' + + inLocator.map(function(i){ + var timestring = moment(i.lastseen).fromNow(); + var message = i.callsign + ' (' + timestring + ' using ' + i.mode; + if (i.band) message += ' on ' + i.band; + message += ')'; + return '
    • ' + message + '
    • ' + }).join("") + + '
    ' + ); + infowindow.setPosition(pos); + infowindow.open(map); + }; + + var showMarkerInfoWindow = function(callsign, pos) { + var infowindow = getInfoWindow(); + infowindow.callsign = callsign; + var marker = markers[callsign]; + var timestring = moment(marker.lastseen).fromNow(); + var commentString = ""; + if (marker.comment) { + commentString = '
    ' + marker.comment + '
    '; + } + infowindow.setContent( + '

    ' + callsign + '

    ' + + '
    ' + timestring + ' using ' + marker.mode + ( marker.band ? ' on ' + marker.band : '' ) + '
    ' + + commentString + ); + infowindow.open(map, marker); + } + + var showReceiverInfoWindow = function(marker) { + var infowindow = getInfoWindow() + infowindow.setContent( + '

    ' + marker.config['receiver_name'] + '

    ' + + '
    Receiver location
    ' + ); + infowindow.open(map, marker); + } + + var getScale = function(lastseen) { + var age = new Date().getTime() - lastseen; + var scale = 1; + if (age >= retention_time / 2) { + scale = (retention_time - age) / (retention_time / 2); + } + return Math.max(0, Math.min(1, scale)); + }; + + var getRectangleOpacityOptions = function(lastseen) { + var scale = getScale(lastseen); + return { + strokeOpacity: strokeOpacity * scale, + fillOpacity: fillOpacity * scale + }; + }; + + var getMarkerOpacityOptions = function(lastseen) { + var scale = getScale(lastseen); + return { + opacity: scale + }; + }; + + // fade out / remove positions after time + setInterval(function(){ + var now = new Date().getTime(); + $.each(rectangles, function(callsign, m) { + var age = now - m.lastseen; + if (age > retention_time) { + delete rectangles[callsign]; + m.setMap(); + return; + } + m.setOptions(getRectangleOpacityOptions(m.lastseen)); + }); + $.each(markers, function(callsign, m) { + var age = now - m.lastseen; + if (age > retention_time) { + delete markers[callsign]; + m.setMap(); + return; + } + m.setOptions(getMarkerOpacityOptions(m.lastseen)); + }); + }, 1000); + + var rectangleFilter = allRectangles = function() { return true; }; + + var filterRectangles = function(filter) { + rectangleFilter = filter; + $.each(rectangles, function(_, r) { + r.setMap(rectangleFilter(r) ? map : undefined); + }); + }; + + var setupLegendFilters = function($legend) { + $content = $legend.find('.content'); + $content.on('click', 'li', function() { + var $el = $(this); + $lis = $content.find('li'); + if ($lis.hasClass('disabled') && !$el.hasClass('disabled')) { + $lis.removeClass('disabled'); + filterRectangles(allRectangles); + } else { + $el.removeClass('disabled'); + $lis.filter(function() { + return this != $el[0] + }).addClass('disabled'); + + var key = colorMode.slice(2); + var selector = $el.data('selector'); + filterRectangles(function(r) { + return r[key] === selector; + }); + } + }); + } + +}); diff --git a/openwebrx/htdocs/mstile-144x144.png b/openwebrx/htdocs/mstile-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..224e4ab8067a7224145770017040701db8225dca GIT binary patch literal 13823 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 literal 0 HcmV?d00001 diff --git a/openwebrx/htdocs/openwebrx.js b/openwebrx/htdocs/openwebrx.js new file mode 100644 index 0000000..e04b9b7 --- /dev/null +++ b/openwebrx/htdocs/openwebrx.js @@ -0,0 +1,1574 @@ +/* + + This file is part of OpenWebRX, + an open-source SDR receiver software with a web UI. + Copyright (c) 2013-2015 by Andras Retzler + Copyright (c) 2019-2021 by Jakob Ketterl + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +""" + +*/ + +is_firefox = navigator.userAgent.indexOf("Firefox") >= 0; + +var bandwidth; +var center_freq; +var fft_size; +var fft_compression = "none"; +var fft_codec; +var waterfall_setup_done = 0; +var secondary_fft_size; + +function updateVolume() { + audioEngine.setVolume(parseFloat($("#openwebrx-panel-volume").val()) / 100); +} + +function toggleMute() { + var $muteButton = $('.openwebrx-mute-button'); + var $volumePanel = $('#openwebrx-panel-volume'); + if ($muteButton.hasClass('muted')) { + $muteButton.removeClass('muted'); + $volumePanel.prop('disabled', false).val(volumeBeforeMute); + } else { + $muteButton.addClass('muted'); + volumeBeforeMute = $volumePanel.val(); + $volumePanel.prop('disabled', true).val(0); + } + + updateVolume(); +} + +function zoomInOneStep() { + zoom_set(zoom_level + 1); +} + +function zoomOutOneStep() { + zoom_set(zoom_level - 1); +} + +function zoomInTotal() { + zoom_set(zoom_levels.length - 1); +} + +function zoomOutTotal() { + zoom_set(0); +} + +var waterfall_min_level; +var waterfall_max_level; +var waterfall_min_level_default; +var waterfall_max_level_default; +var waterfall_colors = buildWaterfallColors(['#000', '#FFF']); +var waterfall_auto_levels; +var waterfall_auto_min_range; + +function buildWaterfallColors(input) { + return chroma.scale(input).colors(256, 'rgb') +} + +function updateWaterfallColors(which) { + var $wfmax = $("#openwebrx-waterfall-color-max"); + var $wfmin = $("#openwebrx-waterfall-color-min"); + waterfall_max_level = parseInt($wfmax.val()); + waterfall_min_level = parseInt($wfmin.val()); + if (waterfall_min_level >= waterfall_max_level) { + if (!which) { + waterfall_min_level = waterfall_max_level -1; + } else { + waterfall_max_level = waterfall_min_level + 1; + } + } + updateWaterfallSliders(); +} + +function updateWaterfallSliders() { + $('#openwebrx-waterfall-color-max') + .val(waterfall_max_level) + .attr('title', 'Waterfall maximum level (' + Math.round(waterfall_max_level) + ' dB)'); + $('#openwebrx-waterfall-color-min') + .val(waterfall_min_level) + .attr('title', 'Waterfall minimum level (' + Math.round(waterfall_min_level) + ' dB)'); +} + +function waterfallColorsDefault() { + waterfall_min_level = waterfall_min_level_default; + waterfall_max_level = waterfall_max_level_default; + updateWaterfallSliders(); + waterfallColorsContinuousReset(); +} + +function waterfallColorsAuto(levels) { + var min_level = levels.min - waterfall_auto_levels.min; + var max_level = levels.max + waterfall_auto_levels.max; + max_level = Math.max(min_level + (waterfall_auto_min_range || 0), max_level); + waterfall_min_level = min_level; + waterfall_max_level = max_level; + updateWaterfallSliders(); +} + +var waterfall_continuous = { + min: -150, + max: 0 +}; + +function waterfallColorsContinuousReset() { + waterfall_continuous.min = waterfall_min_level; + waterfall_continuous.max = waterfall_max_level; +} + +function waterfallColorsContinuous(levels) { + if (levels.max > waterfall_continuous.max + 1) { + waterfall_continuous.max += 1; + } else if (levels.max < waterfall_continuous.max - 1) { + waterfall_continuous.max -= .1; + } + if (levels.min < waterfall_continuous.min - 1) { + waterfall_continuous.min -= 1; + } else if (levels.min > waterfall_continuous.min + 1) { + waterfall_continuous.min += .1; + } + waterfallColorsAuto(waterfall_continuous); +} + +function setSmeterRelativeValue(value) { + if (value < 0) value = 0; + if (value > 1.0) value = 1.0; + var $meter = $("#openwebrx-smeter"); + var $bar = $meter.find(".openwebrx-smeter-bar"); + $bar.css({transform: 'translate(' + ((value - 1) * 100) + '%) translateZ(0)'}); + if (value > 0.9) { + // red + $bar.css({background: 'linear-gradient(to top, #ff5939 , #961700)'}); + } else if (value > 0.7) { + // yellow + $bar.css({background: 'linear-gradient(to top, #fff720 , #a49f00)'}); + } else { + // red + $bar.css({background: 'linear-gradient(to top, #22ff2f , #008908)'}); + } +} + +function setSquelchSliderBackground(val) { + var $slider = $('#openwebrx-panel-receiver .openwebrx-squelch-slider'); + var min = Number($slider.attr('min')); + var max = Number($slider.attr('max')); + var sliderPosition = $slider.val(); + var relative = (val - min) / (max - min); + // use a brighter color when squelch is open + var color = val >= sliderPosition ? '#22ff2f' : '#008908'; + // we don't use the gradient, but separate the colors discretely using css tricks + var style = 'linear-gradient(90deg, ' + color + ', ' + color + ' ' + relative * 100 + '%, #B6B6B6 ' + relative * 100 + '%)'; + $slider.css('--track-background', style); +} + +function getLogSmeterValue(value) { + return 10 * Math.log10(value); +} + +function setSmeterAbsoluteValue(value) //the value that comes from `csdr squelch_and_smeter_cc` +{ + var logValue = getLogSmeterValue(value); + setSquelchSliderBackground(logValue); + var lowLevel = waterfall_min_level - 20; + var highLevel = waterfall_max_level + 20; + var percent = (logValue - lowLevel) / (highLevel - lowLevel); + setSmeterRelativeValue(percent); + $("#openwebrx-smeter-db").html(logValue.toFixed(1) + " dB"); +} + +function typeInAnimation(element, timeout, what, onFinish) { + if (!what) { + onFinish(); + return; + } + element.innerHTML += what[0]; + window.setTimeout(function () { + typeInAnimation(element, timeout, what.substring(1), onFinish); + }, timeout); +} + + +// ======================================================== +// ================ DEMODULATOR ROUTINES ================ +// ======================================================== + +function getDemodulators() { + return [ + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator() + ].filter(function(d) { + return !!d; + }); +} + +function mkenvelopes(visible_range) //called from mkscale +{ + var demodulators = getDemodulators(); + scale_ctx.clearRect(0, 0, scale_ctx.canvas.width, 22); //clear the upper part of the canvas (where filter envelopes reside) + for (var i = 0; i < demodulators.length; i++) { + demodulators[i].envelope.draw(visible_range); + } + if (demodulators.length) { + var bandpass = demodulators[0].getBandpass(); + secondary_demod_waterfall_set_zoom(bandpass.low_cut, bandpass.high_cut); + } +} + +function waterfallWidth() { + return $('body').width(); +} + + +// ======================================================== +// =================== SCALE ROUTINES =================== +// ======================================================== + +var scale_ctx; +var scale_canvas; + +function scale_setup() { + scale_canvas = $("#openwebrx-scale-canvas")[0]; + scale_ctx = scale_canvas.getContext("2d"); + scale_canvas.addEventListener("mousedown", scale_canvas_mousedown, false); + scale_canvas.addEventListener("mousemove", scale_canvas_mousemove, false); + scale_canvas.addEventListener("mouseup", scale_canvas_mouseup, false); + resize_scale(); + var frequency_container = $("#openwebrx-frequency-container"); + frequency_container.on("mousemove", frequency_container_mousemove, false); +} + +var scale_canvas_drag_params = { + mouse_down: false, + drag: false, + start_x: 0, + key_modifiers: {shiftKey: false, altKey: false, ctrlKey: false} +}; + +function scale_canvas_mousedown(evt) { + scale_canvas_drag_params.mouse_down = true; + scale_canvas_drag_params.drag = false; + scale_canvas_drag_params.start_x = evt.pageX; + scale_canvas_drag_params.key_modifiers.shiftKey = evt.shiftKey; + scale_canvas_drag_params.key_modifiers.altKey = evt.altKey; + scale_canvas_drag_params.key_modifiers.ctrlKey = evt.ctrlKey; + evt.preventDefault(); +} + +function scale_offset_freq_from_px(x, visible_range) { + if (typeof visible_range === "undefined") visible_range = get_visible_freq_range(); + return (visible_range.start + visible_range.bw * (x / waterfallWidth())) - center_freq; +} + +function scale_canvas_mousemove(evt) { + var event_handled = false; + var i; + var demodulators = getDemodulators(); + if (scale_canvas_drag_params.mouse_down && !scale_canvas_drag_params.drag && Math.abs(evt.pageX - scale_canvas_drag_params.start_x) > canvas_drag_min_delta) + //we can use the main drag_min_delta thing of the main canvas + { + scale_canvas_drag_params.drag = true; + //call the drag_start for all demodulators (and they will decide if they're dragged, based on X coordinate) + for (i = 0; i < demodulators.length; i++) event_handled |= demodulators[i].envelope.drag_start(evt.pageX, scale_canvas_drag_params.key_modifiers); + scale_canvas.style.cursor = "move"; + } + else if (scale_canvas_drag_params.drag) { + //call the drag_move for all demodulators (and they will decide if they're dragged) + for (i = 0; i < demodulators.length; i++) event_handled |= demodulators[i].envelope.drag_move(evt.pageX); + if (!event_handled) demodulators[0].set_offset_frequency(scale_offset_freq_from_px(evt.pageX)); + } + +} + +function frequency_container_mousemove(evt) { + var frequency = center_freq + scale_offset_freq_from_px(evt.pageX); + $('#openwebrx-panel-receiver').demodulatorPanel().setMouseFrequency(frequency); +} + +function scale_canvas_end_drag(x) { + scale_canvas.style.cursor = "default"; + scale_canvas_drag_params.drag = false; + scale_canvas_drag_params.mouse_down = false; + var event_handled = false; + var demodulators = getDemodulators(); + for (var i = 0; i < demodulators.length; i++) event_handled |= demodulators[i].envelope.drag_end(); + if (!event_handled) demodulators[0].set_offset_frequency(scale_offset_freq_from_px(x)); +} + +function scale_canvas_mouseup(evt) { + scale_canvas_end_drag(evt.pageX); +} + +function scale_px_from_freq(f, range) { + return Math.round(((f - range.start) / range.bw) * waterfallWidth()); +} + +function get_visible_freq_range() { + if (!bandwidth) return false; + var fcalc = function (x) { + var canvasWidth = waterfallWidth() * zoom_levels[zoom_level]; + return Math.round(((-zoom_offset_px + x) / canvasWidth) * bandwidth) + (center_freq - bandwidth / 2); + }; + var out = { + start: fcalc(0), + center: fcalc(waterfallWidth() / 2), + end: fcalc(waterfallWidth()), + } + out.bw = out.end - out.start; + out.hps = out.bw / waterfallWidth(); + return out; +} + +var scale_markers_levels = [ + { + "large_marker_per_hz": 10000000, //large + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 0 + }, + { + "large_marker_per_hz": 5000000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 0 + }, + { + "large_marker_per_hz": 1000000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 0 + }, + { + "large_marker_per_hz": 500000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 1 + }, + { + "large_marker_per_hz": 100000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 1 + }, + { + "large_marker_per_hz": 50000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 2 + }, + { + "large_marker_per_hz": 10000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 2 + }, + { + "large_marker_per_hz": 5000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 3 + }, + { + "large_marker_per_hz": 1000, + "estimated_text_width": 70, + "format": "{x} MHz", + "pre_divide": 1000000, + "decimals": 1 + } +]; +var scale_min_space_bw_texts = 50; +var scale_min_space_bw_small_markers = 7; + +function get_scale_mark_spacing(range) { + var out = {}; + var fcalc = function (freq) { + out.numlarge = (range.bw / freq); + out.large = waterfallWidth() / out.numlarge; //distance between large markers (these have text) + out.ratio = 5; //(ratio-1) small markers exist per large marker + out.small = out.large / out.ratio; //distance between small markers + if (out.small < scale_min_space_bw_small_markers) return false; + if (out.small / 2 >= scale_min_space_bw_small_markers && freq.toString()[0] !== "5") { + out.small /= 2; + out.ratio *= 2; + } + out.smallbw = freq / out.ratio; + return true; + }; + for (var i = scale_markers_levels.length - 1; i >= 0; i--) { + var mp = scale_markers_levels[i]; + if (!fcalc(mp.large_marker_per_hz)) continue; + //console.log(mp.large_marker_per_hz); + //console.log(out); + if (out.large - mp.estimated_text_width > scale_min_space_bw_texts) break; + } + out.params = mp; + return out; +} + +var range; + +function mkscale() { + //clear the lower part of the canvas (where frequency scale resides; the upper part is used by filter envelopes): + range = get_visible_freq_range(); + if (!range) return; + mkenvelopes(range); //when scale changes we will always have to redraw filter envelopes, too + scale_ctx.clearRect(0, 22, scale_ctx.canvas.width, scale_ctx.canvas.height - 22); + scale_ctx.strokeStyle = "#fff"; + scale_ctx.font = "bold 11px sans-serif"; + scale_ctx.textBaseline = "top"; + scale_ctx.fillStyle = "#fff"; + var spacing = get_scale_mark_spacing(range); + //console.log(spacing); + var marker_hz = Math.ceil(range.start / spacing.smallbw) * spacing.smallbw; + var text_h_pos = 22 + 10 + ((is_firefox) ? 3 : 0); + var text_to_draw = ''; + var ftext = function (f) { + text_to_draw = format_frequency(spacing.params.format, f, spacing.params.pre_divide, spacing.params.decimals); + }; + var last_large; + var x; + while ((x = scale_px_from_freq(marker_hz, range)) <= window.innerWidth) { + scale_ctx.beginPath(); + scale_ctx.moveTo(x, 22); + if (marker_hz % spacing.params.large_marker_per_hz === 0) { //large marker + if (typeof first_large === "undefined") var first_large = marker_hz; + last_large = marker_hz; + scale_ctx.lineWidth = 3.5; + scale_ctx.lineTo(x, 22 + 11); + ftext(marker_hz); + var text_measured = scale_ctx.measureText(text_to_draw); + scale_ctx.textAlign = "center"; + //advanced text drawing begins + if (zoom_level === 0 && (range.start + spacing.smallbw * spacing.ratio > marker_hz) && (x < text_measured.width / 2)) { //if this is the first overall marker when zoomed out... and if it would be clipped off the screen... + if (scale_px_from_freq(marker_hz + spacing.smallbw * spacing.ratio, range) - text_measured.width >= scale_min_space_bw_texts) { //and if we have enough space to draw it correctly without clipping + scale_ctx.textAlign = "left"; + scale_ctx.fillText(text_to_draw, 0, text_h_pos); + } + } + else if (zoom_level === 0 && (range.end - spacing.smallbw * spacing.ratio < marker_hz) && (x > window.innerWidth - text_measured.width / 2)) { // if this is the last overall marker when zoomed out... and if it would be clipped off the screen... + if (window.innerWidth - text_measured.width - scale_px_from_freq(marker_hz - spacing.smallbw * spacing.ratio, range) >= scale_min_space_bw_texts) { //and if we have enough space to draw it correctly without clipping + scale_ctx.textAlign = "right"; + scale_ctx.fillText(text_to_draw, window.innerWidth, text_h_pos); + } + } + else scale_ctx.fillText(text_to_draw, x, text_h_pos); //draw text normally + } + else { //small marker + scale_ctx.lineWidth = 2; + scale_ctx.lineTo(x, 22 + 8); + } + marker_hz += spacing.smallbw; + scale_ctx.stroke(); + } + if (zoom_level !== 0) { // if zoomed, we don't want the texts to disappear because their markers can't be seen + // on the left side + scale_ctx.textAlign = "center"; + var f = first_large - spacing.smallbw * spacing.ratio; + x = scale_px_from_freq(f, range); + ftext(f); + var w = scale_ctx.measureText(text_to_draw).width; + if (x + w / 2 > 0) scale_ctx.fillText(text_to_draw, x, 22 + 10); + // on the right side + f = last_large + spacing.smallbw * spacing.ratio; + x = scale_px_from_freq(f, range); + ftext(f); + w = scale_ctx.measureText(text_to_draw).width; + if (x - w / 2 < window.innerWidth) scale_ctx.fillText(text_to_draw, x, 22 + 10); + } +} + +function resize_scale() { + var ratio = window.devicePixelRatio || 1; + var w = window.innerWidth; + var h = 47; + scale_canvas.style.width = w + "px"; + scale_canvas.style.height = h + "px"; + w *= ratio; + h *= ratio; + scale_canvas.width = w; + scale_canvas.height = h; + scale_ctx.scale(ratio, ratio); + mkscale(); + bookmarks.position(); +} + +function canvas_get_freq_offset(relativeX) { + var rel = (relativeX / canvas_container.clientWidth); + return Math.round((bandwidth * rel) - (bandwidth / 2)); +} + +function canvas_get_frequency(relativeX) { + return center_freq + canvas_get_freq_offset(relativeX); +} + + +function format_frequency(format, freq_hz, pre_divide, decimals) { + var out = format.replace("{x}", (freq_hz / pre_divide).toFixed(decimals)); + var at = out.indexOf(".") + 4; + while (decimals > 3) { + out = out.substr(0, at) + "," + out.substr(at); + at += 4; + decimals -= 3; + } + return out; +} + +var canvas_drag = false; +var canvas_drag_min_delta = 1; +var canvas_mouse_down = false; +var canvas_drag_last_x; +var canvas_drag_last_y; +var canvas_drag_start_x; +var canvas_drag_start_y; + +function canvas_mousedown(evt) { + canvas_mouse_down = true; + canvas_drag = false; + canvas_drag_last_x = canvas_drag_start_x = evt.pageX; + canvas_drag_last_y = canvas_drag_start_y = evt.pageY; + evt.preventDefault(); //don't show text selection mouse pointer +} + +function canvas_mousemove(evt) { + if (!waterfall_setup_done) return; + var relativeX = get_relative_x(evt); + if (canvas_mouse_down) { + if (!canvas_drag && Math.abs(evt.pageX - canvas_drag_start_x) > canvas_drag_min_delta) { + canvas_drag = true; + canvas_container.style.cursor = "move"; + } + if (canvas_drag) { + var deltaX = canvas_drag_last_x - evt.pageX; + var dpx = range.hps * deltaX; + if ( + !(zoom_center_rel + dpx > (bandwidth / 2 - waterfallWidth() * (1 - zoom_center_where) * range.hps)) && + !(zoom_center_rel + dpx < -bandwidth / 2 + waterfallWidth() * zoom_center_where * range.hps) + ) { + zoom_center_rel += dpx; + } + resize_canvases(false); + canvas_drag_last_x = evt.pageX; + canvas_drag_last_y = evt.pageY; + mkscale(); + bookmarks.position(); + } + } else { + $('#openwebrx-panel-receiver').demodulatorPanel().setMouseFrequency(canvas_get_frequency(relativeX)); + } +} + +function canvas_container_mouseleave() { + canvas_end_drag(); +} + +function canvas_mouseup(evt) { + if (!waterfall_setup_done) return; + var relativeX = get_relative_x(evt); + + if (!canvas_drag) { + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().set_offset_frequency(canvas_get_freq_offset(relativeX)); + } + else { + canvas_end_drag(); + } + canvas_mouse_down = false; +} + +function canvas_end_drag() { + canvas_container.style.cursor = "crosshair"; + canvas_mouse_down = false; +} + +function zoom_center_where_calc(screenposX) { + return screenposX / waterfallWidth(); +} + +function get_relative_x(evt) { + var relativeX = evt.offsetX || evt.layerX; + if ($(evt.target).closest(canvas_container).length) return relativeX; + // compensate for the frequency scale, since that is not resized by the browser. + var relatives = $(evt.target).closest('#openwebrx-frequency-container').map(function(){ + return evt.pageX - this.offsetLeft; + }); + if (relatives.length) relativeX = relatives[0]; + + return relativeX - zoom_offset_px; +} + +function canvas_mousewheel(evt) { + if (!waterfall_setup_done) return; + var relativeX = get_relative_x(evt); + var dir = (evt.deltaY / Math.abs(evt.deltaY)) > 0; + zoom_step(dir, relativeX, zoom_center_where_calc(evt.pageX)); + evt.preventDefault(); +} + + +var zoom_max_level_hps = 33; //Hz/pixel +var zoom_levels_count = 14; + +function get_zoom_coeff_from_hps(hps) { + var shown_bw = (window.innerWidth * hps); + return bandwidth / shown_bw; +} + +var zoom_levels = [1]; +var zoom_level = 0; +var zoom_offset_px = 0; +var zoom_center_rel = 0; +var zoom_center_where = 0; + +var smeter_level = 0; + +function mkzoomlevels() { + zoom_levels = [1]; + var maxc = get_zoom_coeff_from_hps(zoom_max_level_hps); + if (maxc < 1) return; + // logarithmic interpolation + var zoom_ratio = Math.pow(maxc, 1 / zoom_levels_count); + for (var i = 1; i < zoom_levels_count; i++) + zoom_levels.push(Math.pow(zoom_ratio, i)); +} + +function zoom_step(out, where, onscreen) { + if ((out && zoom_level === 0) || (!out && zoom_level >= zoom_levels_count - 1)) return; + if (out) --zoom_level; + else ++zoom_level; + + zoom_center_rel = canvas_get_freq_offset(where); + //console.log("zoom_step || zlevel: "+zoom_level.toString()+" zlevel_val: "+zoom_levels[zoom_level].toString()+" zoom_center_rel: "+zoom_center_rel.toString()); + zoom_center_where = onscreen; + //console.log(zoom_center_where, zoom_center_rel, where); + resize_canvases(true); + mkscale(); + bookmarks.position(); +} + +function zoom_set(level) { + if (!(level >= 0 && level <= zoom_levels.length - 1)) return; + level = parseInt(level); + zoom_level = level; + //zoom_center_rel=canvas_get_freq_offset(-canvases[0].offsetLeft+waterfallWidth()/2); //zoom to screen center instead of demod envelope + zoom_center_rel = $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().get_offset_frequency(); + zoom_center_where = 0.5 + (zoom_center_rel / bandwidth); //this is a kind of hack + resize_canvases(true); + mkscale(); + bookmarks.position(); +} + +function zoom_calc() { + var winsize = waterfallWidth(); + var canvases_new_width = winsize * zoom_levels[zoom_level]; + zoom_offset_px = -((canvases_new_width * (0.5 + zoom_center_rel / bandwidth)) - (winsize * zoom_center_where)); + if (zoom_offset_px > 0) zoom_offset_px = 0; + if (zoom_offset_px < winsize - canvases_new_width) + zoom_offset_px = winsize - canvases_new_width; +} + +var networkSpeedMeasurement; +var currentprofile = { + toString: function() { + return this['sdr_id'] + '|' + this['profile_id']; + } +}; + +var COMPRESS_FFT_PAD_N = 10; //should be the same as in csdr.c + +function on_ws_recv(evt) { + if (typeof evt.data === 'string') { + // text messages + networkSpeedMeasurement.add(evt.data.length); + + if (evt.data.substr(0, 16) === "CLIENT DE SERVER") { + params = Object.fromEntries( + evt.data.slice(17).split(' ').map(function(param) { + var args = param.split('='); + return [args[0], args.slice(1).join('=')] + }) + ); + var versionInfo = 'Unknown server'; + if (params.server && params.server === 'openwebrx' && params.version) { + versionInfo = 'OpenWebRX version: ' + params.version; + } + divlog('Server acknowledged WebSocket connection, ' + versionInfo); + } else { + try { + var json = JSON.parse(evt.data); + switch (json.type) { + case "config": + var config = json['value']; + if ('waterfall_colors' in config) + waterfall_colors = buildWaterfallColors(config['waterfall_colors']); + if ('waterfall_levels' in config) { + waterfall_min_level_default = config['waterfall_levels']['min']; + waterfall_max_level_default = config['waterfall_levels']['max']; + } + if ('waterfall_auto_levels' in config) + waterfall_auto_levels = config['waterfall_auto_levels']; + if ('waterfall_auto_min_range' in config) + waterfall_auto_min_range = config['waterfall_auto_min_range']; + waterfallColorsDefault(); + + var initial_demodulator_params = {}; + if ('start_mod' in config) + initial_demodulator_params['mod'] = config['start_mod']; + if ('start_offset_freq' in config) + initial_demodulator_params['offset_frequency'] = config['start_offset_freq']; + if ('initial_squelch_level' in config) + initial_demodulator_params['squelch_level'] = Number.isInteger(config['initial_squelch_level']) ? config['initial_squelch_level'] : -150; + + if ('samp_rate' in config) + bandwidth = config['samp_rate']; + if ('center_freq' in config) + center_freq = config['center_freq']; + if ('fft_size' in config) { + fft_size = config['fft_size']; + waterfall_clear(); + } + if ('audio_compression' in config) { + var audio_compression = config['audio_compression']; + audioEngine.setCompression(audio_compression); + divlog("Audio stream is " + ((audio_compression === "adpcm") ? "compressed" : "uncompressed") + "."); + } + if ('fft_compression' in config) { + fft_compression = config['fft_compression']; + divlog("FFT stream is " + ((fft_compression === "adpcm") ? "compressed" : "uncompressed") + "."); + } + if ('max_clients' in config) + $('#openwebrx-bar-clients').progressbar().setMaxClients(config['max_clients']); + + waterfall_init(); + + var demodulatorPanel = $('#openwebrx-panel-receiver').demodulatorPanel(); + demodulatorPanel.setCenterFrequency(center_freq); + demodulatorPanel.setInitialParams(initial_demodulator_params); + if ('squelch_auto_margin' in config) + demodulatorPanel.setSquelchMargin(config['squelch_auto_margin']); + bookmarks.loadLocalBookmarks(); + + if ('sdr_id' in config || 'profile_id' in config) { + currentprofile['sdr_id'] = config['sdr_id'] || currentprofile['sdr_id']; + currentprofile['profile_id'] = config['profile_id'] || currentprofile['profile_id']; + $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); + + waterfall_clear(); + } + + if ('tuning_precision' in config) + $('#openwebrx-panel-receiver').demodulatorPanel().setTuningPrecision(config['tuning_precision']); + + break; + case "secondary_config": + var s = json['value']; + if ('secondary_fft_size' in s) + window.secondary_fft_size = s['secondary_fft_size']; + if ('secondary_bw' in s) + window.secondary_bw = s['secondary_bw']; + if ('if_samp_rate' in s) + window.if_samp_rate = s['if_samp_rate']; + secondary_demod_init_canvases(); + break; + case "receiver_details": + $('.webrx-top-container').header().setDetails(json['value']); + break; + case "smeter": + smeter_level = json['value']; + setSmeterAbsoluteValue(smeter_level); + break; + case "cpuusage": + $('#openwebrx-bar-server-cpu').progressbar().setUsage(json['value']); + break; + case "clients": + $('#openwebrx-bar-clients').progressbar().setClients(json['value']); + break; + case "profiles": + var listbox = $("#openwebrx-sdr-profiles-listbox"); + listbox.html(json['value'].map(function (profile) { + return '"; + }).join("")); + $('#openwebrx-sdr-profiles-listbox').val(currentprofile.toString()); + // this is a bit hacky since it only makes sense if the error is actually "no sdr devices" + // the only other error condition for which the overlay is used right now is "too many users" + // so there shouldn't be a problem here + if (Object.keys(json['value']).length) { + $('#openwebrx-error-overlay').hide(); + } + break; + case "features": + Modes.setFeatures(json['value']); + break; + case "metadata": + $('.openwebrx-meta-panel').metaPanel().each(function(){ + this.update(json['value']); + }); + break; + case "js8_message": + $("#openwebrx-panel-js8-message").js8().pushMessage(json['value']); + break; + case "wsjt_message": + $("#openwebrx-panel-wsjt-message").wsjtMessagePanel().pushMessage(json['value']); + break; + case "dial_frequencies": + var as_bookmarks = json['value'].map(function (d) { + return { + name: d['mode'].toUpperCase(), + modulation: d['mode'], + frequency: d['frequency'] + }; + }); + bookmarks.replace_bookmarks(as_bookmarks, 'dial_frequencies'); + break; + case "aprs_data": + $('#openwebrx-panel-packet-message').packetMessagePanel().pushMessage(json['value']); + break; + case "bookmarks": + bookmarks.replace_bookmarks(json['value'], "server"); + break; + case "sdr_error": + divlog(json['value'], true); + var $overlay = $('#openwebrx-error-overlay'); + $overlay.find('.errormessage').text(json['value']); + $overlay.show(); + $("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator(); + break; + case 'secondary_demod': + secondary_demod_push_data(json['value']); + break; + case 'log_message': + divlog(json['value'], true); + break; + case 'pocsag_data': + $('#openwebrx-panel-pocsag-message').pocsagMessagePanel().pushMessage(json['value']); + break; + case 'backoff': + divlog("Server is currently busy: " + json['reason'], true); + var $overlay = $('#openwebrx-error-overlay'); + $overlay.find('.errormessage').text(json['reason']); + $overlay.show(); + // set a higher reconnection timeout right away to avoid additional load + reconnect_timeout = 16000; + break; + case 'modes': + Modes.setModes(json['value']); + break; + default: + console.warn('received message of unknown type: ' + json['type']); + } + } catch (e) { + // don't lose exception + console.error(e) + } + } + } else if (evt.data instanceof ArrayBuffer) { + // binary messages + networkSpeedMeasurement.add(evt.data.byteLength); + + var type = new Uint8Array(evt.data, 0, 1)[0]; + var data = evt.data.slice(1); + + var waterfall_i16; + var waterfall_f32; + var i; + + switch (type) { + case 1: + // FFT data + if (fft_compression === "none") { + waterfall_add(new Float32Array(data)); + } else if (fft_compression === "adpcm") { + fft_codec.reset(); + + waterfall_i16 = fft_codec.decode(new Uint8Array(data)); + waterfall_f32 = new Float32Array(waterfall_i16.length - COMPRESS_FFT_PAD_N); + for (i = 0; i < waterfall_i16.length; i++) waterfall_f32[i] = waterfall_i16[i + COMPRESS_FFT_PAD_N] / 100; + waterfall_add(waterfall_f32); + } + break; + case 2: + // audio data + audioEngine.pushAudio(data); + break; + case 3: + // secondary FFT + if (fft_compression === "none") { + secondary_demod_waterfall_add(new Float32Array(data)); + } else if (fft_compression === "adpcm") { + fft_codec.reset(); + + waterfall_i16 = fft_codec.decode(new Uint8Array(data)); + waterfall_f32 = new Float32Array(waterfall_i16.length - COMPRESS_FFT_PAD_N); + for (i = 0; i < waterfall_i16.length; i++) waterfall_f32[i] = waterfall_i16[i + COMPRESS_FFT_PAD_N] / 100; + secondary_demod_waterfall_add(waterfall_f32); + } + break; + case 4: + // hd audio data + audioEngine.pushHdAudio(data); + break; + default: + console.warn('unknown type of binary message: ' + type) + } + } +} + +var waterfall_measure_minmax_now = false; +var waterfall_measure_minmax_continuous = false; + +function waterfall_measure_minmax_do(what) { + // this is based on an oversampling factor of about 1,25 + var ignored = .1 * what.length; + var data = what.slice(ignored, -ignored); + return { + min: Math.min.apply(Math, data), + max: Math.max.apply(Math, data) + }; +} + +function on_ws_opened() { + $('#openwebrx-error-overlay').hide(); + ws.send("SERVER DE CLIENT client=openwebrx.js type=receiver"); + divlog("WebSocket opened to " + ws.url); + if (!networkSpeedMeasurement) { + networkSpeedMeasurement = new Measurement(); + networkSpeedMeasurement.report(60000, 1000, function(rate){ + $('#openwebrx-bar-network-speed').progressbar().setSpeed(rate); + }); + } else { + networkSpeedMeasurement.reset(); + } + reconnect_timeout = false; + ws.send(JSON.stringify({ + "type": "connectionproperties", + "params": { + "output_rate": audioEngine.getOutputRate(), + "hd_output_rate": audioEngine.getHdOutputRate() + } + })); +} + +var was_error = 0; + +function divlog(what, is_error) { + is_error = !!is_error; + was_error |= is_error; + if (is_error) { + what = "" + what + ""; + toggle_panel("openwebrx-panel-log", true); //show panel if any error is present + } + $('#openwebrx-debugdiv')[0].innerHTML += what + "
    "; + var nano = $('.nano'); + nano.nanoScroller(); + nano.nanoScroller({scroll: 'bottom'}); +} + +var volumeBeforeMute = 100.0; +var mute = false; + +// Optimalise these if audio lags or is choppy: +var audio_buffer_maximal_length_sec = 1; //actual number of samples are calculated from sample rate + +function onAudioStart(apiType){ + divlog('Web Audio API succesfully initialized, using ' + apiType + ' API, sample rate: ' + audioEngine.getSampleRate() + " Hz"); + + hideOverlay(); + + // canvas_container is set after waterfall_init() has been called. we cannot initialize before. + //if (canvas_container) synchronize_demodulator_init(); + + //hide log panel in a second (if user has not hidden it yet) + window.setTimeout(function () { + toggle_panel("openwebrx-panel-log", !!was_error); + }, 2000); + + //Synchronise volume with slider + updateVolume(); +} + +var reconnect_timeout = false; + +function on_ws_closed() { + var demodulatorPanel = $("#openwebrx-panel-receiver").demodulatorPanel(); + demodulatorPanel.stopDemodulator(); + demodulatorPanel.resetInitialParams(); + if (reconnect_timeout) { + // max value: roundabout 8 and a half minutes + reconnect_timeout = Math.min(reconnect_timeout * 2, 512000); + } else { + // initial value: 1s + reconnect_timeout = 1000; + } + divlog("WebSocket has closed unexpectedly. Attempting to reconnect in " + reconnect_timeout / 1000 + " seconds...", 1); + + setTimeout(open_websocket, reconnect_timeout); +} + +function on_ws_error() { + divlog("WebSocket error.", 1); +} + +var ws; + +function open_websocket() { + var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws'; + + var href = window.location.href; + var index = href.lastIndexOf('/'); + if (index > 0) { + href = href.substr(0, index + 1); + } + href = href.split("://")[1]; + href = protocol + "://" + href; + if (!href.endsWith('/')) { + href += '/'; + } + var ws_url = href + "ws/"; + + if (!("WebSocket" in window)) + divlog("Your browser does not support WebSocket, which is required for WebRX to run. Please upgrade to a HTML5 compatible browser."); + ws = new WebSocket(ws_url); + ws.onopen = on_ws_opened; + ws.onmessage = on_ws_recv; + ws.onclose = on_ws_closed; + ws.binaryType = "arraybuffer"; + window.onbeforeunload = function () { //http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript + ws.onclose = function () { + }; + ws.close(); + }; + ws.onerror = on_ws_error; +} + +function waterfall_mkcolor(db_value, waterfall_colors_arg) { + waterfall_colors_arg = waterfall_colors_arg || waterfall_colors; + var value_percent = (db_value - waterfall_min_level) / (waterfall_max_level - waterfall_min_level); + value_percent = Math.max(0, Math.min(1, value_percent)); + + var scaled = value_percent * (waterfall_colors_arg.length - 1); + var index = Math.floor(scaled); + var remain = scaled - index; + if (remain === 0) return waterfall_colors_arg[index]; + return color_between(waterfall_colors_arg[index], waterfall_colors_arg[index + 1], remain);} + +function color_between(first, second, percent) { + return [ + first[0] + percent * (second[0] - first[0]), + first[1] + percent * (second[1] - first[1]), + first[2] + percent * (second[2] - first[2]) + ]; +} + + +var canvas_context; +var canvases = []; +var canvas_default_height = 200; +var canvas_container; +var canvas_actual_line = -1; + +function add_canvas() { + var new_canvas = document.createElement("canvas"); + new_canvas.width = fft_size; + new_canvas.height = canvas_default_height; + canvas_actual_line = canvas_default_height; + new_canvas.openwebrx_top = -canvas_default_height; + new_canvas.style.transform = 'translate(0, ' + new_canvas.openwebrx_top.toString() + 'px)'; + canvas_context = new_canvas.getContext("2d"); + canvas_container.appendChild(new_canvas); + canvases.push(new_canvas); + while (canvas_container && canvas_container.clientHeight + canvas_default_height * 2 < canvases.length * canvas_default_height) { + var c = canvases.shift(); + if (!c) break; + canvas_container.removeChild(c); + } +} + + +function init_canvas_container() { + canvas_container = $("#webrx-canvas-container")[0]; + canvas_container.addEventListener("mouseleave", canvas_container_mouseleave, false); + canvas_container.addEventListener("mousemove", canvas_mousemove, false); + canvas_container.addEventListener("mouseup", canvas_mouseup, false); + canvas_container.addEventListener("mousedown", canvas_mousedown, false); + canvas_container.addEventListener("wheel", canvas_mousewheel, false); + var frequency_container = $("#openwebrx-frequency-container"); + frequency_container.on("wheel", canvas_mousewheel, false); +} + +canvas_maxshift = 0; + +function shift_canvases() { + canvases.forEach(function (p) { + p.style.transform = 'translate(0, ' + (p.openwebrx_top++).toString() + 'px)'; + }); + canvas_maxshift++; +} + +function resize_canvases(zoom) { + if (typeof zoom === "undefined") zoom = false; + if (!zoom) mkzoomlevels(); + zoom_calc(); + $('#webrx-canvas-container').css({ + width: waterfallWidth() * zoom_levels[zoom_level] + 'px', + left: zoom_offset_px + "px" + }); +} + +function waterfall_init() { + init_canvas_container(); + resize_canvases(); + scale_setup(); + mkzoomlevels(); + waterfall_setup_done = 1; +} + +function waterfall_add(data) { + if (!waterfall_setup_done) return; + var w = fft_size; + + if (waterfall_measure_minmax_now) { + var levels = waterfall_measure_minmax_do(data); + waterfall_measure_minmax_now = false; + waterfallColorsAuto(levels); + waterfallColorsContinuousReset(); + } + + if (waterfall_measure_minmax_continuous) { + var level = waterfall_measure_minmax_do(data); + waterfallColorsContinuous(level); + } + + // create new canvas if the current one is full (or there isn't one) + if (canvas_actual_line <= 0) add_canvas(); + + //Add line to waterfall image + var oneline_image = canvas_context.createImageData(w, 1); + for (var x = 0; x < w; x++) { + var color = waterfall_mkcolor(data[x]); + for (i = 0; i < 3; i++) oneline_image.data[x * 4 + i] = color[i]; + oneline_image.data[x * 4 + 3] = 255; + } + + //Draw image + canvas_context.putImageData(oneline_image, 0, --canvas_actual_line); + shift_canvases(); +} + +function waterfall_clear() { + //delete all canvases + while (canvases.length) { + var x = canvases.shift(); + x.parentNode.removeChild(x); + } + canvas_actual_line = -1; +} + +function openwebrx_resize() { + resize_canvases(); + resize_scale(); +} + +function initProgressBars() { + $(".openwebrx-progressbar").each(function(){ + var bar = $(this).progressbar(); + if ('setSampleRate' in bar) { + bar.setSampleRate(audioEngine.getSampleRate()); + } + }) +} + +function audioReporter(stats) { + if (typeof(stats.buffersize) !== 'undefined') { + $('#openwebrx-bar-audio-buffer').progressbar().setBuffersize(stats.buffersize); + } + + if (typeof(stats.audioByteRate) !== 'undefined') { + $('#openwebrx-bar-audio-speed').progressbar().setSpeed(stats.audioByteRate * 8); + } + + if (typeof(stats.audioRate) !== 'undefined') { + $('#openwebrx-bar-audio-output').progressbar().setAudioRate(stats.audioRate); + } +} + +var bookmarks; +var audioEngine; + +function openwebrx_init() { + audioEngine = new AudioEngine(audio_buffer_maximal_length_sec, audioReporter); + var $overlay = $('#openwebrx-autoplay-overlay'); + $overlay.on('click', function(){ + audioEngine.resume(); + }); + audioEngine.onStart(onAudioStart); + if (!audioEngine.isAllowed()) { + $('body').append($overlay); + $overlay.show(); + } + fft_codec = new ImaAdpcmCodec(); + initProgressBars(); + open_websocket(); + secondary_demod_init(); + digimodes_init(); + initPanels(); + $('#openwebrx-panel-receiver').demodulatorPanel(); + window.addEventListener("resize", openwebrx_resize); + bookmarks = new BookmarkBar(); + initSliders(); + startPollingBatteryAndSolar(); +} + +function initSliders() { + $('#openwebrx-panel-receiver').on('wheel', 'input[type=range]', function(ev){ + var $slider = $(this); + if (!$slider.attr('step')) return; + var val = Number($slider.val()); + var step = Number($slider.attr('step')); + if (ev.originalEvent.deltaY > 0) { + step *= -1; + } + $slider.val(val + step); + $slider.trigger('change'); + }); + + var waterfallAutoButton = $('#openwebrx-waterfall-colors-auto'); + waterfallAutoButton.on('click', function() { + waterfall_measure_minmax_now=true; + }).on('contextmenu', function(){ + waterfall_measure_minmax_continuous = !waterfall_measure_minmax_continuous; + waterfallColorsContinuousReset(); + waterfallAutoButton[waterfall_measure_minmax_continuous ? 'addClass' : 'removeClass']('highlighted'); + $('#openwebrx-waterfall-color-min, #openwebrx-waterfall-color-max').prop('disabled', waterfall_measure_minmax_continuous); + + return false; + }); +} + +function digimodes_init() { + // initialze DMR timeslot muting + $('.openwebrx-dmr-timeslot-panel').click(function (e) { + $(e.currentTarget).toggleClass("muted"); + update_dmr_timeslot_filtering(); + }); + + $('.openwebrx-meta-panel').metaPanel(); +} + +function update_dmr_timeslot_filtering() { + var filter = $('.openwebrx-dmr-timeslot-panel').map(function (index, el) { + return (!$(el).hasClass("muted")) << index; + }).toArray().reduce(function (acc, v) { + return acc | v; + }, 0); + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().setDmrFilter(filter); +} + +function hideOverlay() { + var $overlay = $('#openwebrx-autoplay-overlay'); + $overlay.css('opacity', 0); + $overlay.on('transitionend', function() { + $overlay.hide(); + }); +} + +var rt = function (s, n) { + return s.replace(/[a-zA-Z]/g, function (c) { + return String.fromCharCode((c <= "Z" ? 90 : 122) >= (c = c.charCodeAt(0) + n) ? c : c - 26); + }); +}; + +// ======================================================== +// ======================= PANELS ======================= +// ======================================================== + +function panel_displayed(el){ + return !(el.style && el.style.display && el.style.display === 'none') && !(el.movement && el.movement === 'collapse'); +} + +function toggle_panel(what, on) { + var item = $('#' + what)[0]; + if (!item) return; + var displayed = panel_displayed(item); + if (typeof on !== "undefined" && displayed === on) { + return; + } + if (displayed) { + item.movement = 'collapse'; + item.style.transform = "perspective(600px) rotateX(90deg)"; + item.style.transitionProperty = 'transform'; + } else { + item.movement = 'expand'; + item.style.display = null; + setTimeout(function(){ + item.style.transitionProperty = 'transform'; + item.style.transform = 'perspective(600px) rotateX(0deg)'; + }, 20); + } + item.style.transitionDuration = "600ms"; + item.style.transitionDelay = "0ms"; +} + +function first_show_panel(panel) { + panel.style.transitionDuration = 0; + panel.style.transitionDelay = 0; + var rotx = (Math.random() > 0.5) ? -90 : 90; + var roty = 0; + if (Math.random() > 0.5) { + var rottemp = rotx; + rotx = roty; + roty = rottemp; + } + if (rotx !== 0 && Math.random() > 0.5) rotx = 270; + panel.style.transform = "perspective(600px) rotateX(%1deg) rotateY(%2deg)" + .replace("%1", rotx.toString()).replace("%2", roty.toString()); + window.setTimeout(function () { + panel.style.transitionDuration = "600ms"; + panel.style.transitionDelay = (Math.floor(Math.random() * 500)).toString() + "ms"; + panel.style.transform = "perspective(600px) rotateX(0deg) rotateY(0deg)"; + }, 1); +} + +function initPanels() { + $('#openwebrx-panels-container').find('.openwebrx-panel').each(function(){ + var el = this; + el.openwebrxPanelTransparent = (!!el.dataset.panelTransparent); + el.addEventListener('transitionend', function(ev){ + if (ev.target !== el) return; + el.style.transitionDuration = null; + el.style.transitionDelay = null; + el.style.transitionProperty = null; + if (el.movement && el.movement === 'collapse') { + el.style.display = 'none'; + } + delete el.movement; + }); + if (panel_displayed(el)) first_show_panel(el); + }); +} + +/* + _____ _ _ _ + | __ \(_) (_) | | + | | | |_ __ _ _ _ __ ___ ___ __| | ___ ___ + | | | | |/ _` | | '_ ` _ \ / _ \ / _` |/ _ \/ __| + | |__| | | (_| | | | | | | | (_) | (_| | __/\__ \ + |_____/|_|\__, |_|_| |_| |_|\___/ \__,_|\___||___/ + __/ | + |___/ +*/ + +var secondary_demod_fft_offset_db = 18; //need to calculate that later +var secondary_demod_canvases_initialized = false; +var secondary_demod_channel_freq = 1000; +var secondary_demod_waiting_for_set = false; +var secondary_demod_low_cut; +var secondary_demod_high_cut; +var secondary_demod_mousedown = false; +var secondary_demod_canvas_width; +var secondary_demod_canvas_left; +var secondary_demod_canvas_container; +var secondary_demod_current_canvas_actual_line; +var secondary_demod_current_canvas_context; +var secondary_demod_current_canvas_index; +var secondary_demod_canvases; + +function secondary_demod_create_canvas() { + var new_canvas = document.createElement("canvas"); + new_canvas.width = secondary_fft_size; + new_canvas.height = $(secondary_demod_canvas_container).height(); + new_canvas.style.width = $(secondary_demod_canvas_container).width() + "px"; + new_canvas.style.height = $(secondary_demod_canvas_container).height() + "px"; + secondary_demod_current_canvas_actual_line = new_canvas.height - 1; + $(secondary_demod_canvas_container).children().last().before(new_canvas); + return new_canvas; +} + +function secondary_demod_remove_canvases() { + $(secondary_demod_canvas_container).children("canvas").remove(); +} + +function secondary_demod_init_canvases() { + secondary_demod_remove_canvases(); + secondary_demod_canvases = []; + secondary_demod_canvases.push(secondary_demod_create_canvas()); + secondary_demod_canvases.push(secondary_demod_create_canvas()); + secondary_demod_canvases[0].openwebrx_top = -$(secondary_demod_canvas_container).height(); + secondary_demod_canvases[1].openwebrx_top = 0; + secondary_demod_canvases_update_top(); + secondary_demod_current_canvas_context = secondary_demod_canvases[0].getContext("2d"); + secondary_demod_current_canvas_actual_line = $(secondary_demod_canvas_container).height() - 1; + secondary_demod_current_canvas_index = 0; + secondary_demod_canvases_initialized = true; + mkscale(); //so that the secondary waterfall zoom level will be initialized +} + +function secondary_demod_canvases_update_top() { + for (var i = 0; i < 2; i++) { + secondary_demod_canvases[i].style.transform = 'translate(0, ' + secondary_demod_canvases[i].openwebrx_top + 'px)'; + } +} + +function secondary_demod_swap_canvases() { + secondary_demod_canvases[0 + !secondary_demod_current_canvas_index].openwebrx_top -= $(secondary_demod_canvas_container).height() * 2; + secondary_demod_current_canvas_index = 0 + !secondary_demod_current_canvas_index; + secondary_demod_current_canvas_context = secondary_demod_canvases[secondary_demod_current_canvas_index].getContext("2d"); + secondary_demod_current_canvas_actual_line = $(secondary_demod_canvas_container).height() - 1; +} + +function secondary_demod_init() { + secondary_demod_canvas_container = $("#openwebrx-digimode-canvas-container")[0]; + $(secondary_demod_canvas_container) + .mousemove(secondary_demod_canvas_container_mousemove) + .mouseup(secondary_demod_canvas_container_mouseup) + .mousedown(secondary_demod_canvas_container_mousedown) + .mouseenter(secondary_demod_canvas_container_mousein) + .mouseleave(secondary_demod_canvas_container_mouseleave); + $('#openwebrx-panel-wsjt-message').wsjtMessagePanel(); + $('#openwebrx-panel-packet-message').packetMessagePanel(); + $('#openwebrx-panel-pocsag-message').pocsagMessagePanel(); + $('#openwebrx-panel-js8-message').js8(); +} + +function secondary_demod_push_data(x) { + x = Array.from(x).filter(function (y) { + var c = y.charCodeAt(0); + return (c === 10 || (c >= 32 && c <= 126)); + }).map(function (y) { + if (y === "&") + return "&"; + if (y === "<") return "<"; + if (y === ">") return ">"; + if (y === " ") return " "; + return y; + }).map(function (y) { + if (y === "\n") + return "
    "; + return "" + y + ""; + }).join(""); + $("#openwebrx-cursor-blink").before(x); +} + +function secondary_demod_waterfall_add(data) { + var w = secondary_fft_size; + + //Add line to waterfall image + var oneline_image = secondary_demod_current_canvas_context.createImageData(w, 1); + for (var x = 0; x < w; x++) { + var color = waterfall_mkcolor(data[x] + secondary_demod_fft_offset_db); + for (var i = 0; i < 3; i++) oneline_image.data[x * 4 + i] = color[i]; + oneline_image.data[x * 4 + 3] = 255; + } + + //Draw image + secondary_demod_current_canvas_context.putImageData(oneline_image, 0, secondary_demod_current_canvas_actual_line--); + secondary_demod_canvases.map(function (x) { + x.openwebrx_top += 1; + }) + ; + secondary_demod_canvases_update_top(); + if (secondary_demod_current_canvas_actual_line < 0) secondary_demod_swap_canvases(); +} + +function secondary_demod_update_marker() { + var width = Math.max((secondary_bw / if_samp_rate) * secondary_demod_canvas_width, 5); + var center_at = ((secondary_demod_channel_freq - secondary_demod_low_cut) / if_samp_rate) * secondary_demod_canvas_width; + var left = center_at - width / 2; + $("#openwebrx-digimode-select-channel").width(width).css("left", left + "px") +} + +function secondary_demod_update_channel_freq_from_event(evt) { + if (typeof evt !== "undefined") { + var relativeX = (evt.offsetX) ? evt.offsetX : evt.layerX; + secondary_demod_channel_freq = secondary_demod_low_cut + + (relativeX / $(secondary_demod_canvas_container).width()) * (secondary_demod_high_cut - secondary_demod_low_cut); + } + if (!secondary_demod_waiting_for_set) { + secondary_demod_waiting_for_set = true; + window.setTimeout(function () { + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().set_secondary_offset_freq(Math.floor(secondary_demod_channel_freq)); + secondary_demod_waiting_for_set = false; + }, + 50 + ) + ; + } + secondary_demod_update_marker(); +} + +function secondary_demod_canvas_container_mousein() { + $("#openwebrx-digimode-select-channel").css("opacity", "0.7"); //.css("border-width", "1px"); +} + +function secondary_demod_canvas_container_mouseleave() { + $("#openwebrx-digimode-select-channel").css("opacity", "0"); +} + +function secondary_demod_canvas_container_mousemove(evt) { + if (secondary_demod_mousedown) secondary_demod_update_channel_freq_from_event(evt); +} + +function secondary_demod_canvas_container_mousedown(evt) { + if (evt.which === 1) secondary_demod_mousedown = true; +} + +function secondary_demod_canvas_container_mouseup(evt) { + if (evt.which === 1) secondary_demod_mousedown = false; + secondary_demod_update_channel_freq_from_event(evt); +} + + +function secondary_demod_waterfall_set_zoom(low_cut, high_cut) { + if (!secondary_demod_canvases_initialized) return; + secondary_demod_low_cut = low_cut; + secondary_demod_high_cut = high_cut; + var shown_bw = high_cut - low_cut; + secondary_demod_canvas_width = $(secondary_demod_canvas_container).width() * (if_samp_rate) / shown_bw; + secondary_demod_canvas_left = (-secondary_demod_canvas_width / 2) - (low_cut / if_samp_rate) * secondary_demod_canvas_width; + secondary_demod_canvases.map(function (x) { + $(x).css({ + left: secondary_demod_canvas_left + "px", + width: secondary_demod_canvas_width + "px" + }); + }); + secondary_demod_update_channel_freq_from_event(); +} + +function sdr_profile_changed() { + var value = $('#openwebrx-sdr-profiles-listbox').val(); + ws.send(JSON.stringify({type: "selectprofile", params: {profile: value}})); +} + +function updateBatteryAndSolar() { + $.getJSON( "http://192.168.0.21:1880/charger/battery", function(data) { + $('#openwebrx-bar-battery').progressbar().setVoltage(data.voltage); + $('#openwebrx-bar-current').progressbar().setCurrent(data.current); + }); + + $.getJSON( "http://192.168.0.21:1880/charger/solar", function(data) { + $('#openwebrx-bar-solar').progressbar().setPower(data.power); + }); +} + +function startPollingBatteryAndSolar() { + updateBatteryAndSolar(); + setInterval(function() { + updateBatteryAndSolar(); + }, 5000); +} diff --git a/openwebrx/htdocs/pwchange.html b/openwebrx/htdocs/pwchange.html new file mode 100644 index 0000000..e3c433a --- /dev/null +++ b/openwebrx/htdocs/pwchange.html @@ -0,0 +1,32 @@ + + + + OpenWebRX Password change + + + + + + + + +${header} + + \ No newline at end of file diff --git a/openwebrx/htdocs/settings.html b/openwebrx/htdocs/settings.html new file mode 100644 index 0000000..1bdaff4 --- /dev/null +++ b/openwebrx/htdocs/settings.html @@ -0,0 +1,41 @@ + + + + OpenWebRX Settings + + + + + + + +${header} + + \ No newline at end of file diff --git a/openwebrx/htdocs/settings.js b/openwebrx/htdocs/settings.js new file mode 100644 index 0000000..3a071b5 --- /dev/null +++ b/openwebrx/htdocs/settings.js @@ -0,0 +1,11 @@ +$(function(){ + $('.map-input').mapInput(); + $('.imageupload').imageUpload(); + $('.bookmarks').bookmarktable(); + $('.wsjt-decoding-depths').wsjtDecodingDepthsInput(); + $('#waterfall_scheme').waterfallDropdown(); + $('#rf_gain').gainInput(); + $('.optional-section').optionalSection(); + $('#scheduler').schedulerInput(); + $('.exponential-input').exponentialInput(); +}); \ No newline at end of file diff --git a/openwebrx/htdocs/settings/bookmarks.html b/openwebrx/htdocs/settings/bookmarks.html new file mode 100644 index 0000000..046015b --- /dev/null +++ b/openwebrx/htdocs/settings/bookmarks.html @@ -0,0 +1,69 @@ + + + + OpenWebRX Settings + + + + + + + +${header} +
    + ${breadcrumb} +
    +

    Bookmarks

    +
    +
    +
    Double-click the values in the table to edit them.
    +
    +
    + ${bookmarks} +
    + + +
    +
    + ${breadcrumb} +
    + + + \ No newline at end of file diff --git a/openwebrx/htdocs/settings/general.html b/openwebrx/htdocs/settings/general.html new file mode 100644 index 0000000..4a71fd9 --- /dev/null +++ b/openwebrx/htdocs/settings/general.html @@ -0,0 +1,23 @@ + + + + OpenWebRX Settings + + + + + + + +${header} +
    + ${breadcrumb} + ${error} +
    +

    ${title}

    +
    + ${content} + ${breadcrumb} +
    +${modal} + \ No newline at end of file diff --git a/openwebrx/inkscape files/favicon.svg b/openwebrx/inkscape files/favicon.svg new file mode 100644 index 0000000..a7a6aa5 --- /dev/null +++ b/openwebrx/inkscape files/favicon.svg @@ -0,0 +1,2388 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/google_maps_pin.svg b/openwebrx/inkscape files/google_maps_pin.svg new file mode 100644 index 0000000..1dd4961 --- /dev/null +++ b/openwebrx/inkscape files/google_maps_pin.svg @@ -0,0 +1,76 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-bookmark.svg b/openwebrx/inkscape files/openwebrx-bookmark.svg new file mode 100644 index 0000000..39b786e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-bookmark.svg @@ -0,0 +1,56 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-directcall.svg b/openwebrx/inkscape files/openwebrx-directcall.svg new file mode 100644 index 0000000..2489652 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-directcall.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-edit.svg b/openwebrx/inkscape files/openwebrx-edit.svg new file mode 100644 index 0000000..6cec2f2 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-edit.svg @@ -0,0 +1,52 @@ + +to editimage/svg+xmlShannon E Thomashttp://www.toicon.com/icons/lines-and-angles_editimage/svg+xmlto edit diff --git a/openwebrx/inkscape files/openwebrx-groupcall.svg b/openwebrx/inkscape files/openwebrx-groupcall.svg new file mode 100644 index 0000000..8775878 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-groupcall.svg @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-logo.svg b/openwebrx/inkscape files/openwebrx-logo.svg new file mode 100644 index 0000000..6b352c2 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-logo.svg @@ -0,0 +1,161 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-mute.svg b/openwebrx/inkscape files/openwebrx-mute.svg new file mode 100644 index 0000000..0c24051 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-mute.svg @@ -0,0 +1,142 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-log.svg b/openwebrx/inkscape files/openwebrx-panel-log.svg new file mode 100644 index 0000000..6648abb --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-log.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-map.svg b/openwebrx/inkscape files/openwebrx-panel-map.svg new file mode 100644 index 0000000..21ef46e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-map.svg @@ -0,0 +1,173 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-receiver.svg b/openwebrx/inkscape files/openwebrx-panel-receiver.svg new file mode 100644 index 0000000..2472760 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-receiver.svg @@ -0,0 +1,181 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-settings.svg b/openwebrx/inkscape files/openwebrx-panel-settings.svg new file mode 100644 index 0000000..c8ba6a6 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-settings.svg @@ -0,0 +1,115 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-panel-status.svg b/openwebrx/inkscape files/openwebrx-panel-status.svg new file mode 100644 index 0000000..049c564 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-panel-status.svg @@ -0,0 +1,146 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-play-button.svg b/openwebrx/inkscape files/openwebrx-play-button.svg new file mode 100644 index 0000000..5d7b756 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-play-button.svg @@ -0,0 +1,67 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg b/openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg new file mode 100644 index 0000000..4b0755d --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-rx-details-arrow-down.svg @@ -0,0 +1,84 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg b/openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg new file mode 100644 index 0000000..56a6a9c --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-rx-details-arrow-up.svg @@ -0,0 +1,82 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-speake-mutedr.svg b/openwebrx/inkscape files/openwebrx-speake-mutedr.svg new file mode 100644 index 0000000..fa25ccd --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-speake-mutedr.svg @@ -0,0 +1,129 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-speaker.svg b/openwebrx/inkscape files/openwebrx-speaker.svg new file mode 100644 index 0000000..ab5726a --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-speaker.svg @@ -0,0 +1,168 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-squelch.svg b/openwebrx/inkscape files/openwebrx-squelch.svg new file mode 100644 index 0000000..c6be1ee --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-squelch.svg @@ -0,0 +1,145 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-trashcan.svg b/openwebrx/inkscape files/openwebrx-trashcan.svg new file mode 100644 index 0000000..661fe50 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-trashcan.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-waterfall-auto.svg b/openwebrx/inkscape files/openwebrx-waterfall-auto.svg new file mode 100644 index 0000000..34ec3cd --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-waterfall-auto.svg @@ -0,0 +1,84 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-waterfall-continuous.svg b/openwebrx/inkscape files/openwebrx-waterfall-continuous.svg new file mode 100644 index 0000000..f95c97e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-waterfall-continuous.svg @@ -0,0 +1,120 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-waterfall-default.svg b/openwebrx/inkscape files/openwebrx-waterfall-default.svg new file mode 100644 index 0000000..51b5beb --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-waterfall-default.svg @@ -0,0 +1,123 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-in-total.svg b/openwebrx/inkscape files/openwebrx-zoom-in-total.svg new file mode 100644 index 0000000..dddb2bf --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-in-total.svg @@ -0,0 +1,149 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-in.svg b/openwebrx/inkscape files/openwebrx-zoom-in.svg new file mode 100644 index 0000000..d8dd9f8 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-in.svg @@ -0,0 +1,157 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-out-total.svg b/openwebrx/inkscape files/openwebrx-zoom-out-total.svg new file mode 100644 index 0000000..cd4b824 --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-out-total.svg @@ -0,0 +1,169 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/openwebrx/inkscape files/openwebrx-zoom-out.svg b/openwebrx/inkscape files/openwebrx-zoom-out.svg new file mode 100644 index 0000000..3096d4e --- /dev/null +++ b/openwebrx/inkscape files/openwebrx-zoom-out.svg @@ -0,0 +1,158 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/openwebrx/openwebrx.conf b/openwebrx/openwebrx.conf new file mode 100644 index 0000000..32a0f77 --- /dev/null +++ b/openwebrx/openwebrx.conf @@ -0,0 +1,10 @@ +[core] +data_directory = /var/lib/openwebrx +temporary_directory = /tmp + +[web] +port = 8073 + +[aprs] +# path to the aprs symbols repository (get it here: https://github.com/hessu/aprs-symbols) +symbols_path = /usr/share/aprs-symbols/png diff --git a/openwebrx/openwebrx.py b/openwebrx/openwebrx.py new file mode 100644 index 0000000..4232fae --- /dev/null +++ b/openwebrx/openwebrx.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from owrx.__main__ import main + +if __name__ == "__main__": + main() diff --git a/openwebrx/owrx/__main__.py b/openwebrx/owrx/__main__.py new file mode 100644 index 0000000..bac982f --- /dev/null +++ b/openwebrx/owrx/__main__.py @@ -0,0 +1,115 @@ +import logging + +# the linter will complain about this, but the logging must be configured before importing all the other modules +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +from http.server import HTTPServer +from owrx.http import RequestHandler +from owrx.config.core import CoreConfig +from owrx.config import Config +from owrx.config.commands import MigrateCommand +from owrx.feature import FeatureDetector +from owrx.sdr import SdrService +from socketserver import ThreadingMixIn +from owrx.service import Services +from owrx.websocket import WebSocketConnection +from owrx.reporting import ReportingEngine +from owrx.version import openwebrx_version +from owrx.audio.queue import DecoderQueue +from owrx.admin import add_admin_parser, run_admin_action +import signal +import argparse + + +class ThreadedHttpServer(ThreadingMixIn, HTTPServer): + pass + + +class SignalException(Exception): + pass + + +def handleSignal(sig, frame): + raise SignalException("Received Signal {sig}".format(sig=sig)) + + +def main(): + parser = argparse.ArgumentParser(description="OpenWebRX - Open Source SDR Web App for Everyone!") + parser.add_argument("-v", "--version", action="store_true", help="Show the software version") + parser.add_argument("--debug", action="store_true", help="Set loglevel to DEBUG") + + moduleparser = parser.add_subparsers(title="Modules", dest="module") + adminparser = moduleparser.add_parser("admin", help="Administration actions") + add_admin_parser(adminparser) + + configparser = moduleparser.add_parser("config", help="Configuration actions") + configcommandparser = configparser.add_subparsers(title="Commands", dest="command") + + migrateparser = configcommandparser.add_parser("migrate", help="Migrate configuration files") + migrateparser.set_defaults(cls=MigrateCommand) + + args = parser.parse_args() + + # set loglevel to info for CLI commands + if args.module is not None and not args.debug: + logging.getLogger().setLevel(logging.INFO) + + if args.version: + print("OpenWebRX version {version}".format(version=openwebrx_version)) + elif args.module == "admin": + run_admin_action(adminparser, args) + elif args.module == "config": + run_admin_action(configparser, args) + else: + start_receiver() + + +def start_receiver(): + print( + """ + +OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE file in the package +_________________________________________________________________________________________________ + +Author contact info: Jakob Ketterl, DD5JFK +Documentation: https://github.com/jketterl/openwebrx/wiki +Support and info: https://groups.io/g/openwebrx + + """ + ) + + logger.info("OpenWebRX version {0} starting up...".format(openwebrx_version)) + + for sig in [signal.SIGINT, signal.SIGTERM]: + signal.signal(sig, handleSignal) + + # config warmup + Config.validateConfig() + coreConfig = CoreConfig() + + featureDetector = FeatureDetector() + if not featureDetector.is_available("core"): + logger.error( + "you are missing required dependencies to run openwebrx. " + "please check that the following core requirements are installed and up to date:" + ) + logger.error(", ".join(featureDetector.get_requirements("core"))) + return + + # Get error messages about unknown / unavailable features as soon as possible + # start up "always-on" sources right away + SdrService.getAllSources() + + Services.start() + + try: + server = ThreadedHttpServer(("0.0.0.0", coreConfig.get_web_port()), RequestHandler) + server.serve_forever() + except SignalException: + pass + + WebSocketConnection.closeAll() + Services.stop() + ReportingEngine.stopAll() + DecoderQueue.stopAll() diff --git a/openwebrx/owrx/__pycache__/__main__.cpython-37.pyc b/openwebrx/owrx/__pycache__/__main__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b46ac0722f9e27fd275ca867a57f994a22cb5be5 GIT binary patch literal 3786 zcmcInOLN@D5eBeWEEfCV@-2##1X+)0V{@sb;)}}Vk}OgWTcRDVQgWt}+5$1dC6;)s z1_M%BFE631a?*cT9&__A$T_#%amXdPBI!)%Byi0g@g@_gdgjGEB#HAlk>YU@?*)Arx6Y~N%b+hk1w&B;lVNP; ziSSe=#imfgR|%?fx?G~}z!#|e)xH!S590pI;O+AmB=&SL1$zFq*zG3%fl#nV3>tz2 zr_Ah#VImbQ+KT%@jCy91p@@&fu6)}AtJ7j$eN*@e7xLGc$OL?7%j3M~@hFIULr4li*C2wnvLQ!WY4z;BH}s`%N13k0Dkic()DOMV3GT&{yHb%6k%A zcgZ3&Ca>?)xhr(s1PNKC>2ek5b5B%&vEU}FegHxNQg9t@I@ZRB7$t7V{1_5+Cf!*2b7b3r)rb>sWxe-+Ms!g^Q^&){EKSw<-x3) z<5$p^x)%SE)-?_c`8PE`HpbQopkBO2(iTYn4bsw7YN>@24bcVszp_30#g}lZAc1zi z1~5R{?IxM@h1=cSaY61r8V+5^d3Osk=3Nqtn{A!dxJZ2&&}kVN>xZN2`ua4Xn3F;V zi=L917_D^olOtE{3pY*n)R8BJdzNrfLXmtu(ZlU6A>0S^D1fh~2rlZvhXHLJ<{bowwk*=J5 z{9+o`ZI^>D%FCfR)fIF#NdLfsv( zBE1I>O|R>7ddq0)P2I*P+;zh)wLoUUhy&wr8xkjBzx;BOwID3(l<7>-?xr6Ri2c`nzBzJt=`;yt*Pq7IfW!rwG-`FS9N$AV`I|b zrBgi}gT5vQU8K3m%aa)xYvEX-A5%}wPUc9%{A2;&@2;ywZjJTH(y6v*sO1ym7{Ft4 z1;<`%@q($Tl>sH0`SIsZzHmR`{Nusi!9(szzr)3+&Sv6MrV%vILR{>+ z`%s+I2OAsxK<#I{9XOsF1L!7D*`=a;6dVLjHydJ8;X<{!*#5E#nPkJX6C@k`i=~c( zyNqsMgy0+=m6r?E*N@%YF31yNC0BJ5_KM*Ox zBlIS2a@HAi++ip@2$sJu`~wJ&rzl2yNf;(as0X#5DMQprX_p`dE5Z=e9s0Fhi8+{C2qe%KnHYczn8+bS=i9IH3cO!!?YyKSEV1~q z=Ajn`sQEDgvBYXDt2j~*(w=wd1);Yaio7hOOkjsW62z zx>Z`zP181P!vF-U1F~6$t>ceYw?CA(U@5EZdTHQKw=Yv^l(%7^XxQ2`1&?!QYKV!F zD)x&V6c=7-5f51etvQ@_DJTrAc1c`BYvGav(*pbTX_2YZ(DeI@2-+k{xh%p{PpK!L z8d5_;ibW^^Ore0WCM|*}6|*HpqY%oo{=JAsv3Qy}RF_!sAzsFVqyzsxGC`4I4(X&a zL`9P7m;4ajgsmZ+j=Xx_?-s`Z8$~J@CJ QYheUSb7sr5P5Z`w0R62HD*ylh literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/aprs.cpython-37.pyc b/openwebrx/owrx/__pycache__/aprs.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..571d864eea9b276f6637b2f6dc0eea9537617878 GIT binary patch literal 18973 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/bookmarks.cpython-37.pyc b/openwebrx/owrx/__pycache__/bookmarks.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..411dbebc94e80d9293f5f6b09eec7cf55b4d0350 GIT binary patch literal 5968 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 LTlUu4){XxFV-Es+ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/client.cpython-37.pyc b/openwebrx/owrx/__pycache__/client.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf405f67562c3cac1e0315bfb2cdb9435c3de59c GIT binary patch literal 2106 zcmZuyTW=dh6h1S1wY_$n651qHMFdra5ppGX;Q^tFP$5u>phdK-idLJQiQ{Cwo6fGu zMeYj@>LY)_e&pBiKCe8%PvD7jW}Qvqbfulk&YaorJLfxRzHGOffX`q5zKjPBz~3~v zcx)Uz!ngPq8v%l)Fk&HNH0CJ}x#_LYlGe~uRF%B(CN zpCt0A9Ol_h&(eMrW${Qxk#0uOC>P_D#z7Rl8pmn1@+T+{4>iqy_a5d)CB8B{mb>cY zVSbEFVh-*fohl1AHV6jC#%E@J4JU1&r+A6YB~J8)J#*#|y3!L|Sm!Whq5lT z@)0&A9I!vu8TA;RLtkx{FvVHKpJUj5fT!@ntl_Z?y{5=m2Z@p~`SD4f9O_Q7A1f)o z$%-=05_##eYUGo}>Ohvyu5DW-1hfX+XH9mCoo!tQ-M?XyP^jslp2S8jEgoPqfC)rU zvKgPl%o1z@2lkvJD`yHbca8=ENDw=^HHuH7Dr!Z~)81mJC~DfmU+Um^sO0ww1Fgza zy&5I^GC8cEZRg{x)J`$RHPX*88b zXNl{@Sv5tXMifn;h~flhFo)OdWF5(|ui%%?Yd+yKK4Ejruvc9Q*CI%|Rk1Q~RR@iA z`&bQ2SrfO^8hvG%X33R!nNT%$RUV5ZF3NkjX93!00Xth?0$p!R$b0kF1bXi%6k#(WHh`!;U%<~kxII^t=#k#&hNIbc(&G?*G( z-NkPPUXK~~E&))talMDHsSv=sF%JdELN?01ZP(&aFrb8ze?TwDNe`ou~NCJyvHlR6!0a~I;dM$RZ~gTIzF-#Obz z(!3aEgRMN1TS=Z}GNBUG@AqqZX+9WWW;#OdjtANy1G!K}*vRB@lu!bzx6o8njy@%J zRg$h@NxD&xZj^#vR}m?N7a=GzR4r_Dquj^(C=jc*sX{7Jiw>|nmcwKu%l%v^x~xoP zFac2S(Ub(DNSp?ZZZC>SBGdGupwM-M@_LsxkuFRPx# literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/command.cpython-37.pyc b/openwebrx/owrx/__pycache__/command.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7617f275a53ac22de461beb6e738e08fa26cd9ba GIT binary patch literal 3417 zcma)8OK%)S5bmDW&aS=A#+W!k5R6HH3Bn0nc*wGx7zHlb9E=27C0dQA$6@V#rDvSS zdiLZs@aoMyj6f>F#=dRWUDfLf)-|z!^@3jeVsV!R^?x9V1(ve!-rG@2;a-x;FiLxI`tv|Uv{yVfCflIo zK8BdJxL_?yG_+2^K*>6WjAyLPGE1;SUoLl8-^wg};hBvQU%`zx16w(JVG=992Y#LY zlSFw*8m65naFmn8-F@kTm-J|W^VuIvR`Gg${q|s(g5v)3cu&5#J$Q~GGOBG1Un;-d z?({opyG_>QsVv50i1ht88N`58ZDXY5;c!pDd! zzQDSC<(%@&I^$zoxTn^y?7+_KGi&T%=D_}{{2_CMUahh(+1SY(Sno9>4*v9zFcSP0 zD7l5<3Cq~y^+(niqB0IP?9h|M0B*g;jIiIWr(mPq5m?$o%7z!=QfuRm;)jYq3%K;b zO`nm3;@7p!ZYN3MsNvQE2_v&THo7_O6|mXZ><*%^n`~iH+$i;>TjZo&VsRCN*0(X{ z89;VuODE$-yMU`w!#d6>9vWpRh*~4rz-$qetMZz!jVi}?#%sr)j{{e^oiy$x$~hQx z`pVlM$X=KlNDvwVSKFt&XJPj!#<5TWK@~uwGw5TI1H$+y_XW8@l0cdyOBfix!k2h0 zr_tpLzKAG3^w67V2c)kOq$wtwh?fC0*O3TpqnLddzf_eR+cTD{S`w!Z4D5B_$)gN< zjIK^=FgaGDl#UW$v?3p2@?xz(=i?Q?#u_8b2quI>&YsqOEETlm3eL;-Fa(Z)ht4kY zguFz9j!hXLwJ?#NtYSaUre)sXqib){G$~ajtPp8c$>(`tDE&ot0KgikG@q4Uf&6=@ z5$|FzF+TG6=CFF^s^ODMrTMJXDx~h}O0+g5clCl?bE?b@%IRr)fsj*vmfM?W zssmsoQ|VbcTz&u!H%KV4VSKbaEmGgKo6TlWqcYbIdvUw1YVCG!Adb2;Uu?IZ9)(@A zBPq`{;C0O9by~hp!x{}F?40crEy&YE8s|Q*xqj7md>7Dk(Yhqj1 zP1H+t85`;0sHJnJpxRG`iyWsxLfrHIpf$_EpFI z#}WA<4L4{Yg^g3R#gjqvyoudUK$@q|C9NY+`H-w_P(q+A>^0V+v!b`6W1`nnl~>OF zZa8fz-PV>)(xK>wTsiwVUgQHQF>5fz^ERk}N1gazY-A*)6!Z=jx;9tka>hZx&mV)+263a1``ouB zeVOlR3fr4KEj)PK6%`ocEAOFh08{8HT|xcElpsNWC}>#50aSwVTjiPNf!~l}7{#*i z#Vzd4`hslx1~e&3myFYwR^8X*{}iY!n_J8->T~$2v{M>!a1#t$YW~Nt^R2?n@nH9eh{S zI!ULG->-fYD;xin@(P_O&*n`{Ur|2#@~|7G@vK(}AzPq?CXD;|>GCXxuUMm6?j0ay zerO02v)zG|EP|jFp5Ta%D_y5`sJ_Q?5{gDHjLNv)ZU)|L5ugB?B0%|c&X5U018jUv zjHyQgp^jgZ^ld&ysk2_hDDdSd|E_#;)QkJ+eA9j0BrKfJUpD@Wx%>=Y3d7%&iA|g- z%D`eFz*IbE)>sP1TodnL<3Yg)#g5j^psMU}FOrltx>zDH_737G)wN$2M4hYJ8T!gR Z-pb!JO=*jYdqM$g*o_+vud&cr`43QnZUX=S literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/connection.cpython-37.pyc b/openwebrx/owrx/__pycache__/connection.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e20a45fddf9734d1f0e5233787746916432a95e8 GIT binary patch literal 21144 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

    pxp+unI?z5l@Ua?}G|3md0*w%r=>F7A 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/cpu.cpython-37.pyc b/openwebrx/owrx/__pycache__/cpu.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b7a675ed93fb10e755d65aa291f970c2653b709e GIT binary patch literal 2410 zcmaJ@&2HO95Z>kQik1{7P2j}IL5HA)io$M#9C8SXwrN@f2#hvK>y$`AFx<6FnX-EZ~TEaf1q4O=_7Pns$ZVR&KPVCU*^eO2(XJAFP z5|s1Ho+;%EPy`7nC!xh@XbWfRo|4cLezVtvhU^X5q1@r_YZkV+$NkqN3^?O07*}|} zS72=OHt)dL;az?S#_k*9knj?GeN}fK467f?cp#qbN)hvAM_Tax2XL1kgHU9T{&t<> zXmPU7BeiM#Y$+E21nDn?qqSPqN76YtMow3rYX;anYNTcIn4*!efSFOG!r>*G`FmVLTVO8X`ZSm>Qn8M zBHMwn6-5c`TSid>mEGV zzpEj?GCXP5WNM$1G2JHlDuu`)c56(3BDwXP9AAf3&e!DU?%1lWsaqS{!+YC+os`vfDcjuD!poGfIUMGA#fj2D=IZwzi6^s zsU9zm@;=jERtyG!oy*0`Y9Nuex&s7=z$!8ylE@W(=}4wZy!W(=Po1MURY2IwFbP`= z6HSwM^ppsfwq=#y1f{$Rf&lLrwP+iF4?uR2g;yRdmD2_oEKo(_oQ>VufgHKl$=Iu1%ob?A@@sEGYmjT?CK)qa6S??4@^Ce`_bvGc2hVB- z{9oA`1)D=zB%6>prKvPY(`4jen%>gh8P=CXcAjB-Ji{`FJULk2nqi~K(p+da$%+z& za+sw`+i9*O_F1_CLc2Rz5vzX7-b+b{<3TQx-$z?;#nbD3SDSAy~4HSa5?vU>0U(1Y4) z>H{hYa#!AJDr;5`&?}vWonAZyc(`V18O2%p3W348=vxK?xenW%i$n)8=g=Fl1>J|& zCGv}yAVxRNIs9(rlDAkG#@l}uUcZo#FG9^!Ez zu{jxb%MH}-pg^y5XLf=lBFpBdh$%`cuizr&jfolQP~sy7q-R#!Z!^|r*O<$gdEfV? xamL4%i|1Gi3gNM7zw$0_at(y`ntu}witkV|8h%Q%`%So?W3CKqnZ>NY{uh12DJ1{^ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/details.cpython-37.pyc b/openwebrx/owrx/__pycache__/details.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d7d3538e4385a51eadd72f59281fc486c378f17 GIT binary patch literal 1004 zcmZuwy>1jS5FYP;F1HB@L4pQRP;kxV1tNr!2pV)k(5;ZHW$gbz064z?dRu>s0DjP6H4+5RaM?pN0t8dg zv5GOmk&3#wiUW>S(xp`zaH6sdb?bCAvwnT{UOUZQ>y-3^#X(5hV5+G-Mm>DN zdE2*+bAufMA0J6)QbZ_OVx^kmzQ%c@YHKY2kO`xE)jeA?9~XvCu0@$A%W z;J*bfyNw1|hBLY~Ki~gHq46sIwN#V`-HssDBLoYn=I|xRb%s570CR?Wv3AQ^iC7!7^(Kb{YaaEgBqq_sGjcEJYNjr+n9rE8u-1bvFVrC1yhVnOe$?TxH zhs$PYfDPC-`(GK0P@{~(Qm;7o1I{}wjuqh{=O2!1wfGWb3dFu#s@q7VFw+p0%qowN z0!7YoSrVpT!_tsvGFtQN)PQjlZhUBAH4L7m6rplo2G?x)0t}J(oxgnJX?PZ)^?~~) VksBp^v9~DaDP3nvZ62~P<3Fkg`6K`U literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/dsp.cpython-37.pyc b/openwebrx/owrx/__pycache__/dsp.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22f85ce41f69603c8da8fa00ae3be9c67888081b GIT binary patch literal 7115 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 US^X$2B*#E*Q(o>e zGfOEF=#vasIrKm1fjsuF>3yy}rKh5Y9(wBU&62dly0`3N-n^N4JM%t%@6GyHKN9-88z)%khMnbsJ)QuL><|gZOHDNN1IQWsEP0u6QK-b{mP5(2=|2b>fb;E z*5jseSSWc|o|H)`?YLSx z%o44!L;F}2W2wpu2&6AkI&k^83r0T&BU!`+i=5Z2&jlCGt1xnfE4)_>PVhy5-mi>^ z0#O$YjB7F!&A)Kb675%BRF@6W!DvHtF=~oUaR)Lju_dtG;a;=T?%c7w2Yz$G`{3^^_!{^v%dbwKd$aASJLdx*8r+>idydm)_sShubRXK{ zyz;^9p=jEZ8NAazQXH0lL&Vf|G8)H<0vq=GWvqvn($t9SkG0t%=BzlADoe(3KS|3% znGKpui_us~t%pT!Tuh|28$AU58rl|`{vF6QV*P-<4jk@|pVT zQE?7}&G*L_Y7-~^08Nt`jEB|hc+L;`<$Jf$xF5&Ed|1Zu{`*;xCYkZ z(js_|z8EGM!Xx(dBu(M_{v^vT_Iv!Ow~II~bJzHKf^0P5@)Q+9ArmG-JWVd7Qui=a zlmNyZ$kGJ;VOB!kORwKu>GWkuL zmfygswY14Xs<|9lSS<%PQRLBP*ktldr)oGZfh=uyNV1e8oS&$RSU`j@c%dkv%??z2 z+o}n&VlaSBzL3u*gPvzREwjFQKp**7I9-0Zvy5L=fL0-2it{!1ywX-7Dte;@` z3p5>oFjAjiZm+3pzh6~NayB)qE1_wMoPOazALq#U$wUkbdk9=e2O3_kok?LG6NYqL zUX0}dgfwl)_~m!jPrfObl85M_o+^vgU=K{mPT{K=Dm8iN)SEkk|C!JHm8Tqgx4{j- zNG~w;DvgHuzywuDj5nU3hFMH(o~vOgmzq3I@ELij=T`Q3q_nBgO%nI>b=x|?q#T~f zxUd(gRdHe5cb8i(P;gsvg$0r(3+<;r15;svYys!Y+zJ(6-NVRx%>LAR$)^ijzx>|s zQSVUu@f%U$Bd8$Y>9SZ@*HXyYGg+ZUy^H-#kS2L5vsJ*-HKWu4q&`N|_9q7rsnC77 zwPq2WLO&5omJ^k)EFyGMvlbD~C|;F*j-utXg?JNpJkNVq_e0VX{8{L|y(UmrcG%EST9 z)js>;=!m8)S}OFbCs{5Yg3wj8-rF)=`VZJo?Y}iTC9B)1d1Kn% zx9apnmtEcGHM-)coYj#6PwjQ5 mC{fgpiI8S*jH);?$%OA$e*A#4)7nLe4cpMRdCLi%hWlR#xh44k literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/http.cpython-37.pyc b/openwebrx/owrx/__pycache__/http.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cff50ff658bf4bacea9308385d7c7450639300c1 GIT binary patch literal 7375 zcmb_gS#KQ25uR&LE~({xh>}*+VXY3XEZg!KS+}f2rW8?*leVSBa=S$>IkU6s8Cud} z{Sq1Qli%_K#D_d2uK^MuK!5=G0ePR-JUKtXPx-25_99n_VPpn zURGqioXB~3k!M)S9rFsJ!1c5{?oEgZu4mi>UQraeo^>a^DKW+MoICB!h#9Wu-C1u= z%z5)--dhk0-lACamc$a{#@vJ6vRLMN!9C<17Kgb$?jG?QCw6PU%ji|+cm}e>it(AsEb=4?qU~j8FE1`eq+c* z_0daP{0!|Sc@XV`>H^xA&|a2@&_1Ltq5U!1hvgBpk8t}k+9i1u?W5fP3fe337~03w zS2?@ zt;P^w@>3P+Mtxvdbe>vQVc2MH4nr4jsix93cLXvO&96&+U47N4t2>QgV9sc|hv?56 z%>Z~$`;Wo=0Ca9mJsU|4_&#b{<9*fgH8I*B1mNYXwfgp^_IH}{x~ltfFMg>D9kCD$ zde!&0y_()0Ui{>`3WA2;9GYoz6j!siz1pVwtmXPOIYeE&?{D~nas{t*S8IX#EL1Kd13X6sg+%phNWpz% zq0up4nqNB|XUpsuRWr0uvtLr(mIiXCV3GFa=839Fr2}08mB`e2 z-9hBgrk7KZ6Dap_WMd2U2@vR$M3v`#lvP0gVCd5T{cy12w?ZVnoq+<8MIpgNgRtCW5|#)*}z;Tk`-h@ATl)CTlgetOc-FUFpQ!ARFw>30Wyfsh^V>_p(I(E})km8uEOctvNw#<(5 z_1+yDP4??|TOp2Sz_Kno@s7TQVHpr*AY(@N7tQOX-MEMpq>&jA>6F=?V0K3f6P7v_ zCb6IJ#Cw`I5csxm>)98Hx$hC4>ucFiy%Fa{9;yOY#8ew2m zUI?#&6yqJFL}vMvCdiP?h+FysQschNzBL}3EVmr40hXF@zQth|bQNTyF|uimI&&KH zSEtK>J}N1ZG6YM=V(EqEOXHOViP;@z*G>*MOD&~Y2U5XKOKDzJITZ`(J@nQ+NoiUM zF0)*Db11Ejxn*sWk`j-a zv`{lUbOgG`lGI~h-m3eA(fi`r$R zM7|S_CdmImzWMuVQ$7Em;w@pe(0FBb%w4*@tk8y#>5ktcKm;ig_9WS@9)1>x4M@l4JFxxQnU1s+Sn$(ceHR?irAzbEo%i1-+hL~U39n?&t zgNtN4=br}Nf$*KK@Kc)Dic&1QSP}S+WJR!JMP)@Gv5nG(HJUlTL?)~}-_SjFf4o9y zx+40+52?XNK;5QUNcpoS)PNvEYCyVv87Uwxd&hjAou}&=mve>>c{-MVoo+VzsHFRY zxAHJX3=x^&Tw5ru(BjgD3Rs~doiN2^Dok@p#-8XhVV2uj#v3vh<`|w2^IVRFV_X)( zf*h9<4O1TY&PKmtSYY^MIL_r%IMFTTG$?04Is2Uh%DM0WW9GwRw;ZByOgIQ^hRWv)ctKsvoU8n>c46SYjezQICh?|Xi z<<#n#^6_@@Q6;$|WCEFaRH-4jITysyO#*c)&w>i5PgNe*5L5WgRb=SzRJ>ZNovS>K zL#LoUj^2%aGh2awNa0MBhIb63FN!Vglh3Gjf-4Ncxgs-zo(XBpX|4iyiesMkWUpG; zj3b`Nxp#N{fjEG%>IegF`r5l*3u~|vbVqc@>kd!}q;41cgEZAMg3Mztk|~Iya_Wmm zm4~z`hXxQ*xhhnHXi;+vIGXN;b?xPWDSAPj zOoF=hR3F=er7Nz#*=UL}E@F|3DJ+EqcZX0hteqi3_$8tcmkBsmX+5hysWmrMPu%yO zU1I=ryFsDd4u^K4kPOP6g1 z**Ikrluc50fHDd_bdj<-%BCrsp=_42dCJJa(Tm8UG&@@X)SLqgHmk$7^;aMZev8bo zD1_Pn7ZaFe7MUV0DJP7T{CVK|^|(f`Dg6B&gEUm9F{Vr!X9}r(AhtlGtvYQIo1zs| zo-dPKzivF7f=#z#k3?rXaNwS1KkvHZIFV!~HQ7Kob-V*)x+7EfEh~FgQT}nGDXY9$ zzXwHPzY8Yp_hiCUpQ283rH@2LiP^e@O2>Ew#{!OqokTBZJN6a_$nc8cCT&?S?N|0L zOlxxju82dCx3qiyv9s3r9jtCUx8{eXoAhMSKK>%`|q=p4T>X!dFP9{3(jA;ce% zf;=(<+meOPJ!7>C$>#9Ry)I~Bu6XGFs9a;GmfZNyXlObaP`|s`lqOgmsrS+Oxr`Lh zF8BQz3Nl}k2ZLRNCzF;4IZG;bjVC1m9B;>`l#%q|HS;v-?s8N2)-ivg{*_QZ*xbPoR)CxVBi6G7RD(zr-A zm5x$UZR~8~kw!|5OQ(vb8azQoGeB4A>Z^tg#OX0KQPL>=7&fIAMovv{276VN?y{KETexes*G_o=TF0d)FUb{djmo>`IX~-6-9t^(ZiL z#Bvg~Zq#~$p#6i#XO1@PA4py49NNReu$0%zV|yMWq6}#*>?NPnSQ4MpJc6<>DEpE! znrC3{{-M7M#GjA?GHzMZG1FGoj(<(K@-;C}&u^>!XPsa*_9fYtip71{P~puJ2P(F@ zv|VChA5r2evaua81D+Z1)*jnvHqT_J#nQfE@IDyub2{5^F|&^Vekj}W5iBH*CR_Ot z%jkNA>;QpwHV;6$G2X-9TnIJzx9D9Agp5MhThB*6r%8h-_ z7&|0l<@H^<9vV9>|BZ{D!^qywi0uq;6T-D;$7ci@ZW+13>=KFPeGCn_aO^OR(1|#l zVK*)s91f!4`KtO0^X0^#>OhYCi8u~w^-FTiKxbv~l2NdW#o|~oS1c4C{Rd9f1c?9u literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/js8.cpython-37.pyc b/openwebrx/owrx/__pycache__/js8.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..625f728cc56342f1dad74efcc009b7fd6fab56e3 GIT binary patch literal 5522 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 Wl%oDq3u}RG6fb74g$&Ok?6 zzJQiS+(q*!lUfWQjlXF>q!_lZTs1MSe!9rZ{MNXB!3*n%8sc|}7|2jVCKfP6jas}U zS+oENnIyp|v`)*S(#92{Im9CiaQZ!h8fIAIT)3SXzKS;(KpoBDRnG7YNdo(69NHs4 zDt(=og>ftp5~cL0sFez_pOVwi(q@=XZJmlBy|Kf974@;HRAF=Pm3oKY-GgdUQ=0O@ z+_=Ys>Ve?SSEcVwo?MG$H`o&cU>g&B-fqnI*5WZ2F7vUW!fmC-m7ZGW9i{H4xm~R+ zr9!tUT^MVXa$6+2gcKb=oa->~CB&4Tn@Vswr*^w+tv6}jVAZi?r`gu|;IiMiTJHWA UUNM#p$>GY`5wbS_h7KmtAJD^uS^xk5 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/kiss.cpython-37.pyc b/openwebrx/owrx/__pycache__/kiss.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6c6003670881ff512431c36d769c6c6406f8d30 GIT binary patch literal 5312 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 l9=c0R0 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/locator.cpython-37.pyc b/openwebrx/owrx/__pycache__/locator.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d58fbe8bbe9649b5c9a8ecda4b75b7f145b65d1 GIT binary patch literal 791 zcmZ8eJ#Q015Z#YE=j_;o!o&e2Dw=DOk_I6l1r2gTcS1T{ZWh^Kd(GZP0pwK958!Xm z^FL_OijL}N>8N;fK8%xH&70Y`^JaHvpAQB-f${Cv>!MOZ{N%;I5g~XAtA>CVf&|Gc zm=SB8Nm8#ws!Qx_$2R{SC?k*~GG#Qyq)1=Mwag-lpz4IZm$odtb+txjiF2C~13R!& zSoH(2!fAKKrGi)RdZkH^*ei2mhA26Jk=mehmxJG9+ayWeUB^&9nIc=Ha-E4p=^M;8vpLC*>gu#5UD zUR!5?AnIovbIWV~nwq=g7;4E-F!ULY0I3eToVcK;3skDSOXF<6wARsdQFv2@n9R~o z9QWDr{{-z!^+mWli2pn|u}cp#iwkq+KAhMKz-;q1$IFl5;El8M)Bn}@7S%(5(1}bG zTO^M#5n&GH?KT3ZK8@N>KE6?9oXX}sX&jO~Ulenb=b@M9b4%wlj{AB3{=AsAJN?QR zep=3rpICAu9Nt`h2M(Fz=CD=#%0w#JFymB(jy-#8O5dz^RNMi_(vUpf9Q}m#zhUAp D*Osgh literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/map.cpython-37.pyc b/openwebrx/owrx/__pycache__/map.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7755097bc17b28144b08cdc1b735c7be9d322f7a GIT binary patch literal 5365 zcmai2TXP#p74Dv!Mv5%Uj^j94Drhf|XbYTht1WgzFmV=kso*SRNy3n=K~}eA#UqW< zGfrZa3{=Hco2_CiMe&*knKvH!CA{}5FNB}4Pkg6GGm@NaF{(b(J$*ZU&ez}R*7b#j z3d8fS|K4x?bBVEkQz3sGWHykJze5Nnc*J@vU_BmiPGu{ydUjx&yd60`H*ihfiM+r= zy%Uv!l6m`qZ{FpgEX(7HJax>2s;rI|Wcfku7^D4y30HVWOn7qfsU6f&E(srH-;|e7 zE{h7v6Ap$Ac6}_8-e__2hE=7^2r2tPc*fABLjaPQ!4KMj~YxA1>wd zt>?8{q(vC^TRjf>WJy@UK4MRK;D63`xNwAfbvRIK3^w_I!0dWlvEGt-FBeui0ssNe3Fl#%O5^z%K?q1y(sQA}LDJ-kY&qRIx{^(G6SZ%nCiw;g;|{m*tMVGJb00{^^9~<22oGV{?RT-Q z*RMw4jwrddJ{{)MpoX=F*$0}nX=^WSDPTx@q6HiEwL=@aS=Oa?)Rp}-(dAe31yR%h4OHk5@H1aBfjvqP!lleH0hz;3Y5YKPXy8uJl1`0ywA;9Jn+ zn`o!2k5xC7;r@Ww8(Wp^uf43Od3$DGl482Nt>Ttwx03Wd6b<<@IAXX!Y87>bUitPj zdJ%JwR=GAKKAvzrzS?6T{s#Ds^fVZ@w~Ad%%Ok~Z)103`bTH6oAKkYOIhMyqAfa<+ zyCKJslho4V&4n*uhv6~VkPYpR1AJg+3H32KbTiL zAbY8uZHiq@SNp(Ilyp0N^WnW!JQC)$65h)O4Q4k>L4Ri_M+9F8V+FHM& zzDL!blI>WD69)dOay9BEX*=!>uKg6vOvLa?!#BykEg?1yAg8#Vwr;waL%r4ju}-Ka zdaDM_abB9>wPo z7?*J0oXYGB5+Xzi>NKTst^U81SfdT3>-112=e)(2>=Df3CjiE80%l?&Apa zHdQr9e1`-9PW^zy4*ZKcUclh!i4gS`y?X@4cl5RCiyUEq#w5&wW? zhs$K%`Q`;2XpE50zoHg*4u8@8$;h16m|xQ95MMF=GvfLR$D-t76?W zb+K;ZUaXBhuVKA7C5`w`W-yaO-$hls-7<SnP>ZiYy(IB2(OGlZOn=py!L(??@Uho&x06)C4} z5Vj&!XMok9;*Uw(fWSs2?$g$w7nF--2S8G|Ehnc7ks;1#&eccM)hI}pogUI8y89rm zsJ}*%t15diGne9nS@^RSq zt!k-y&UgGp-&?8szVG^FbEj*gLGh6HH;|HdAo4q`w55&sX&Ed!fn)BiEWpvr9J*>Z zTj|YyCucbPi(p5slp=P&P2mvjH z32DNm9v_~aSEIN;6v(Fei5QFyWKKb`%rLyLUf74&S6Gj|6?jk|%H!1Gqz5?o7fJv) zvw6cEwvs1e%w5RUeSFK~ zA>~hMPLBHm$&dj4saZQQ>8pODdo3+ylX}rk%lrnPS;k-altiUjexk+fMbsG4>=_48ZWi?<)hc4`R4Y;gk) oqsNRl3X5p}YW82)HL`Rp=wT!xS3lOUbX9PeXm~mAE@1Tod5s; literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/meta.cpython-37.pyc b/openwebrx/owrx/__pycache__/meta.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..075a042d613e42142f8967a38d112f57bd29d5d4 GIT binary patch literal 6312 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/metrics.cpython-37.pyc b/openwebrx/owrx/__pycache__/metrics.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b4f7bd9064a79d6a57fe48b66e6e91dde7ed74e6 GIT binary patch literal 2879 zcmai0TW{P%6rS;YFWDre4NWN(yc8ByiG+|Up$J8^f+9gA+KN_Ik>z@Z#))?~89Qnl zd7l!Vk@yAKM}7_O^U72C3B17h#_LVI*@&^`%++(w`OanL>!qbOqw?q9FXG=l#{R;f zE)I3Jspij#2qt*OLbO)K2Ucit_AL{(a88+Uqh+yDa`wz1(ZkxO8-gd^Nj9y-DkKEdOPfZ%ECr3a&< zVIkE5l%R=03Cn z$0f!0SPG}KcC>3Wt{E>>+n8|m0=GGh&pm=G2-WvgK#gjF0BW@avJ7&iHvm!W&EXpX z5A3$BFqcrB73i@|HNQ>dl1G>zC4X>9Gf#IPrb;Ho)D7?_3R={e8sK;5huvL>_mxn?ls9lJ zG6JAhm}vlQ)5JE_{3(%hphH_aXYPcBp7ez!>{E8a!@vY03tQ7bY>Pm&C?GqcEjrY8 zMOQ3QyCiyIncAMoPb=E5Oq1_4IKyd-aXzC?!S?uXH#u5Mu>C;YD%c5)BI_AN?vLy- zdv4Y$EN%53{iC}SGjWlQhTo5pecj7ImlMf)`d{pE(n@=brEhT`JSWbG$rD!y8Ar zR5^LBKd4f%ZpA`WY>kB!99O~f!cJoD5EJ!RI1gg(Q>w?C zju0-b(qyNtN>1ab@6&wWHknB~NbcIJ1igSM)K@o&+@o5@$7;=YrcNU*UJA!Ow(r%^ zVVG9wP$3lNcm3?FHFX|M@M~=#Z!j)eQ*SXbVaY#KsUF{SRy0f zS~F(OsFSiWfAbQI&-jErWIsQBVU4Y^UGlM0T4(g~nDDXt=3eO{+JCjyvFG;KE4|X$ z_thhEaHsUP3|(hq=kSYXthCSU31(bjud^+7xI%NdY@8j`&j=@#CX(0!> zcJqTQElkSm`<0VE& zQnD%1WFU*(kuVWkU4iho%B66DsSiQ!g3R7#RnLSGk{Nd7xFG@(H?X>X-~={(E+uWO z_XG6_HM)Z*+(wlYOq$T%=;a<=f96$XF48K6;HO30bg)&u-@bxR=D3Lo7kkBB>8u3* E0byk)aR2}S literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/modes.cpython-37.pyc b/openwebrx/owrx/__pycache__/modes.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97e45ed3e8ce95a3dd793fb972bf15a98b414956 GIT binary patch literal 6792 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 IO=r{p2A9cB@&Et; literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/parser.cpython-37.pyc b/openwebrx/owrx/__pycache__/parser.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24c5870c4266a28476f354aac2ae2a9d90aff06e GIT binary patch literal 964 zcmZuv&2AGh5cc2xB&2Bp2_z&QU@s{TfKW*hh)aaj+X~5Y)(#2U-K6$Lp;2z_gS1Cp zgBRfLD<^maPRw{Wh(>tj@$A?$-{)^UK04|X7~g+=RNp8eKk#Qi1UP3f-3u_9Xj+nr z=CootqxjECUWr^#@{VY(#SPKI&}*JYI=UcP{1dWB#yo#LKKDa4b&XX;Q<-MI)EV{Z zSk-z}s=B~)`@z9EgBdUl$*Cqe(^Rt?l5-{3)8(pxY3hxc+ApWe4VYDcAFZx@D&?YHG*V)YLqli`-}bnrINI;u;|e`!xuW>9lb+w#9old zCUC|g6aVm&X)fl<8vVX@4K&U8bhfB9HqoYVV$V;YYKH_*oN3;Gq;FwwZR+Cc7&06d zMoqR)JGO_X3RD9Ju78z5DR<;^*EQOr)1b~FZUU^4;bTZ?rJ>tl2i9#(vJ_iYtI9~} z`%+d*y)MxoNO`$dWxHbsP{N{kc8KN?8Z7${B)$rZU9b@toDP*qX&MbgDnfLU#h$Ba jVX;x6p6}zs8R0W?LF;hte`S68vL*KlwFvT^&;kDga74#- literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/pocsag.cpython-37.pyc b/openwebrx/owrx/__pycache__/pocsag.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d433e26d058f095db299ef04b0421feb74250f8 GIT binary patch literal 1101 zcmaJ=OHUL*5bnq9%9Dm;UI1_lV-YE2IfW2z`|xY zvHk-8!0ypsG=dxMyLtDQstPu}knk$$OGK zut?z?disPC+=V-7N)O*Ykc90(9RTHA&oo3HgrRY?$|4acL%kV&7EnNj3t>Az+d%mS zun@w0dUQ@pNO>7xA98I;NC3`E^3QCUOt0R)upJdwm+HtO(PjHQWy))k4f+GHBIqS}`bzfoglkmMTc4GPceO z9cFq70q|I4hKEUEoPCkVNNKY))M26gx>UXtg)q&VAjXc;!2qN+k=rj@H`&u}e< z#@-86By(fMst|vs+o%fKguwYCCQBK;58I`Su%lH=ggusXUHQ zp7;HuV-eLa`XRWx6%I#0V~I6(2mg<%!!E7PZO6>vJk$-=8k=t%%nT}Lngdn5tFqLq a=wSsJy7`xXXkm?L6GtJpfz30QdHfgEa1h)8 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/receiverid.cpython-37.pyc b/openwebrx/owrx/__pycache__/receiverid.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd9a4539e8e6f51e2d6ba32d3b0956dc9a8db368 GIT binary patch literal 4514 zcmcIn-E!N;72X9v5Tq!Hl4aFS-B#&nQlY72*ZG;IFYGo=>DnBMyP`mm z0KI@B2_}0{dnU<6F8T)4W#6E0k^8;vmHG(2>US0-NJ^TF)0vWkgZ;zVv)}pM-!3ke z8GhgX=daBt6~_KeoypHZ<367FUm$`B-evXd&FkFst-592cHPF?>N-8A?(_=v0;hF$ z*Xt)GC3&KIGVp;{X7EQ}VYw05eL7Kt?ZaWwAZiGe?pjGzUqB)X+5pUTtUowQ^>38Jv(+uCb1!e&o4 z8oJzQ^dfQ8rFNy!cy`q6W;6d}ssv7kyn6F?)KBop@L2AtliSfTh#HF%h@JkaDqwMy z_P0PhZu)Zq()fK^um&;}P*^V+^ln2?N81pzMGNU?kDmv4$Gad28}R`@<3np;#p?-9 z_k#bO53G?btTR4f4ac-?(=M1cj0EGZU|?UUrK>^sMY9`-jYE05u^*{kGdU;V&S}qc z+R!g*50ejNTb}3w=358mD7w&A(NSN!adf0wQd@yQTeg%;fTt77?!GSJ!$*k2eK{Y=bfbPii>DwO{0P{e;JK`l>$9NzPrA7YzCR}3WMuFR>R+v! zTbQ}*8Nz{wIHCX(cJ%_pc_`z46vFt(=jcaoVs1Qzm<-73*}wwj0~^#DIG_lxV8Y3; zrieKvmX+e59X69ACH;cIZMs7J2pkoi;40L7jK^f0jJxKauFZ&;1J)upqaVG=s*S8~ zpdomN4Oj;fcOY*E(smqSed9nu^uD~bTj)8%QB$RVfQ=po+mQxFqK z83HN7=`@fEOV}@AItZr-nK4e8^lQ~>h*UiQy5osS$b!R#!g!%6UX`3h_Q388?~CE*aPdBsb+SW@S144a;*^d3x)+&X7Yo@mK?E2qsw!_!`bJ;%98gg+1Vh z#ds4S+c!}u!xpy)+%Gx26y6IjH8Z&n7vSBQ-&)3#i)PK(lLxly3~b@u1P7y{QY{yS zluc(ee)?-PPCaN8?*0hwoAAT3dY3~W&eEj>)fm-ioc$SW1V;bHKWBeoq4jyj)?^>Cz*qsr6G892xOHg)aEXQbM! zdTzk!;<=*{(%;WFjFiy+dvuOzd?)YtBgvO}NBY)xNj42SmxM7mQ%PdtVKw(7XE2V5 z$l+$&vG*uSDi;t_B+Q?t!d%+KoR~f|n{3b~9rhLLutQ5>qYg)#XYJWQY)upD?Ct=A zLfQo|KAY~ZDDmS90$bbg6(nvG1l^XoIcbOVQeQNMGfHTqrpX}kRz&XRC3?hKsKvKeQc>Y1^A8X4dw!dbF{ie?qhwShfaJ7bu+ z4!%_16l-Ht^g=IasR$)q6yjcyq(QhJoo7((1#R3fOM9n@j1;n`&+8=-v=OCnar4&| z*_w8bl2#ZU>!OnVZnGs7MGb9ZRNFzA=n~aUankJdachkl#4Og%L9dDVP#)_-Hd5SI zQSU&WC<0a&51R2oH`v2P=b(A_z4x(8pDw?drm1w9+Jd(6nJ$53vY|9GN+Scq7SVCN z1Tx;3_bwVMrJ2WgvijRvgD!7Cmt~&bn9<~1daL=x3)Ppd;Dkxg>PgfbLj95iBvYA5 zV)GTBNz%WfZOI;R2dh?A&6vNgEhTl4f)i{}eN0?FBtmFs;4aW5cqcR7%&JSU>OYup zmI-zjYju%|lAtTqGJQz}uiB>ZQhp&H2f}EtBU_2N$(dTm)P_cy8L;wLqrH3Cy?vMb aDkf#1pfvv~uUKWf4DiZ!)v1>8ul^S^7R5vW literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/sdr.cpython-37.pyc b/openwebrx/owrx/__pycache__/sdr.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..151df70919a5274cf69399608d452c8d7f87b4a3 GIT binary patch literal 10221 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/socket.cpython-37.pyc b/openwebrx/owrx/__pycache__/socket.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0f45f0448b1097041f3071322bafb5dde8a1125 GIT binary patch literal 385 zcmYjNOHRWu5S?+-wCV@CV8em~WJ51dAxc#wgeZt~RUuVrZJ|J#L~)>0qB{=KEV%|3 zVDBxfas*a97myh1J|Pb7+B=<`4F@CB2rm2QlVEf`=v^3h8YR+raij~Cn8vLt?DZs^D^r=p zsaEh~ZQLx)i&x59@ccUHq*(!|WTB?{Qzu=3eqK4umZlAp-eVZW;WSoP=(FSLCWyGr lb*v`WINwF>i`b|9Eqcb>|JSRL76wEe4^^9}TRS6l!9 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/users.cpython-37.pyc b/openwebrx/owrx/__pycache__/users.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2dadf22c416dfdd3ff270555df210808bd87d087 GIT binary patch literal 8989 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/version.cpython-37.pyc b/openwebrx/owrx/__pycache__/version.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4933d2fcd3787608c5eeb9831ccdade97f83123b GIT binary patch literal 269 zcmXv`y9&ZU5WKxS5Q&06U@4?Yv=R z!p!b0Gc%~wss!i$n$EAdABFgjg-0KuTS%O6h)DtrfN;vF071{dEN)$FVO@ba+#VC( zd7_v2kb6TZwU`K{BbgqYPS6S32YYjDpGLmR8a&cwZK7BQo1aBd%(42cl-j5$T`?~% zTFPJUE~QH5CU3}0q+792yTAQLl!N&%y_=!T45IXea;tV>*%4&>ywXi1U&o>c7||}0 IM{TIU2j%KYY5)KL literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/waterfall.cpython-37.pyc b/openwebrx/owrx/__pycache__/waterfall.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55fe0d5eee5b39d8ac42f16294d1f8fad7288bde GIT binary patch literal 5452 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 H{3rYigbq@$ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/__pycache__/websocket.cpython-37.pyc b/openwebrx/owrx/__pycache__/websocket.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29a3cb159b55e92ae88915b0bb9a3b5743720afa GIT binary patch literal 8779 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 KFBs8Z{{I1huHew6trP@o@e(2uUQ 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/admin/__init__.py b/openwebrx/owrx/admin/__init__.py new file mode 100644 index 0000000..276d17f --- /dev/null +++ b/openwebrx/owrx/admin/__init__.py @@ -0,0 +1,60 @@ +from owrx.admin.commands import NewUser, DeleteUser, ResetPassword, ListUsers, DisableUser, EnableUser, HasUser +import sys +import traceback + + +def add_admin_parser(moduleparser): + subparsers = moduleparser.add_subparsers(title="Commands", dest="command") + + adduser_parser = subparsers.add_parser("adduser", help="Add a new user") + adduser_parser.add_argument("user", help="Username to be added") + adduser_parser.set_defaults(cls=NewUser) + + removeuser_parser = subparsers.add_parser("removeuser", help="Remove an existing user") + removeuser_parser.add_argument("user", help="Username to be remvoed") + removeuser_parser.set_defaults(cls=DeleteUser) + + resetpassword_parser = subparsers.add_parser("resetpassword", help="Reset a user's password") + resetpassword_parser.add_argument("user", help="Username to be remvoed") + resetpassword_parser.set_defaults(cls=ResetPassword) + + listusers_parser = subparsers.add_parser("listusers", help="List enabled users") + listusers_parser.add_argument("-a", "--all", action="store_true", help="Show all users (including disabled ones)") + listusers_parser.set_defaults(cls=ListUsers) + + disableuser_parser = subparsers.add_parser("disableuser", help="Disable a user") + disableuser_parser.add_argument("user", help="Username to be disabled") + disableuser_parser.set_defaults(cls=DisableUser) + + enableuser_parser = subparsers.add_parser("enableuser", help="Enable a user") + enableuser_parser.add_argument("user", help="Username to be enabled") + enableuser_parser.set_defaults(cls=EnableUser) + + hasuser_parser = subparsers.add_parser("hasuser", help="Test if a user exists") + hasuser_parser.add_argument("user", help="Username to be checked") + hasuser_parser.set_defaults(cls=HasUser) + + moduleparser.add_argument( + "--noninteractive", action="store_true", help="Don't ask for any user input (useful for automation)" + ) + moduleparser.add_argument("--silent", action="store_true", help="Ignore errors (useful for automation)") + + +def run_admin_action(parser, args): + if hasattr(args, "cls"): + command = args.cls() + else: + if not hasattr(args, "silent") or not args.silent: + parser.print_help() + sys.exit(1) + sys.exit(0) + + try: + command.run(args) + except Exception: + if not hasattr(args, "silent") or not args.silent: + print("Error running command:") + traceback.print_exc() + sys.exit(1) + sys.exit(0) + diff --git a/openwebrx/owrx/admin/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/admin/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f8789bc2d138001122bc85ef57c9b59086c4107 GIT binary patch literal 2085 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 m=6#a?x8PHHO{uG>R$&RNc%0}ztLd&@X)ZOrW*x 0 is interpreted as "false" + sys.exit(1) diff --git a/openwebrx/owrx/aprs.py b/openwebrx/owrx/aprs.py new file mode 100644 index 0000000..1ce7278 --- /dev/null +++ b/openwebrx/owrx/aprs.py @@ -0,0 +1,590 @@ +from owrx.kiss import KissDeframer +from owrx.map import Map, LatLngLocation +from owrx.bands import Bandplan +from owrx.metrics import Metrics, CounterMetric +from owrx.parser import Parser +from datetime import datetime, timezone +import re +import logging + +logger = logging.getLogger(__name__) + + +# speed is in knots... convert to metric (km/h) +knotsToKilometers = 1.852 +feetToMeters = 0.3048 +milesToKilometers = 1.609344 +inchesToMilimeters = 25.4 + + +def fahrenheitToCelsius(f): + return (f - 32) * 5 / 9 + + +# not sure what the correct encoding is. it seems TAPR has set utf-8 as a standard, but not everybody is following it. +encoding = "utf-8" + +# regex for altitute in comment field +altitudeRegex = re.compile("(^.*)\\/A=([0-9]{6})(.*$)") + +# regex for parsing third-party headers +thirdpartyeRegex = re.compile("^([a-zA-Z0-9-]+)>((([a-zA-Z0-9-]+\\*?,)*)([a-zA-Z0-9-]+\\*?)):(.*)$") + +# regex for getting the message id out of message +messageIdRegex = re.compile("^(.*){([0-9]{1,5})$") + +# regex to filter pseudo "WIDE" path elements +widePattern = re.compile("^WIDE[0-9]-[0-9]$") + + +def decodeBase91(input): + base = decodeBase91(input[:-1]) * 91 if len(input) > 1 else 0 + return base + (ord(input[-1]) - 33) + + +def getSymbolData(symbol, table): + return {"symbol": symbol, "table": table, "index": ord(symbol) - 33, "tableindex": ord(table) - 33} + + +class Ax25Parser(object): + def parse(self, ax25frame): + control_pid = ax25frame.find(bytes([0x03, 0xF0])) + if control_pid % 7 > 0: + logger.warning("aprs packet framing error: control/pid position not aligned with 7-octet callsign data") + + def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i : i + n] + + return { + "destination": self.extractCallsign(ax25frame[0:7]), + "source": self.extractCallsign(ax25frame[7:14]), + "path": [self.extractCallsign(c) for c in chunks(ax25frame[14:control_pid], 7)], + "data": ax25frame[control_pid + 2 :], + } + + def extractCallsign(self, input): + cs = bytes([b >> 1 for b in input[0:6]]).decode(encoding, "replace").strip() + ssid = (input[6] & 0b00011110) >> 1 + if ssid > 0: + return "{callsign}-{ssid}".format(callsign=cs, ssid=ssid) + else: + return cs + + +class WeatherMapping(object): + def __init__(self, char, key, length, scale=None): + self.char = char + self.key = key + self.length = length + self.scale = scale + + def matches(self, input): + return self.char == input[0] and len(input) > self.length + + def updateWeather(self, weather, input): + def deepApply(obj, key, v): + keys = key.split(".") + if len(keys) > 1: + if not keys[0] in obj: + obj[keys[0]] = {} + deepApply(obj[keys[0]], ".".join(keys[1:]), v) + else: + obj[key] = v + + try: + value = int(input[1 : 1 + self.length]) + if self.scale: + value = self.scale(value) + deepApply(weather, self.key, value) + except ValueError: + pass + remain = input[1 + self.length :] + return weather, remain + + +class WeatherParser(object): + mappings = [ + WeatherMapping("c", "wind.direction", 3), + WeatherMapping("s", "wind.speed", 3, lambda x: x * milesToKilometers), + WeatherMapping("g", "wind.gust", 3, lambda x: x * milesToKilometers), + WeatherMapping("t", "temperature", 3, fahrenheitToCelsius), + WeatherMapping("r", "rain.hour", 3, lambda x: x / 100 * inchesToMilimeters), + WeatherMapping("p", "rain.day", 3, lambda x: x / 100 * inchesToMilimeters), + WeatherMapping("P", "rain.sincemidnight", 3, lambda x: x / 100 * inchesToMilimeters), + WeatherMapping("h", "humidity", 2), + WeatherMapping("b", "barometricpressure", 5, lambda x: x / 10), + WeatherMapping("s", "snowfall", 3, lambda x: x * 25.4), + ] + + def __init__(self, data, weather={}): + self.data = data + self.weather = weather + + def getWeather(self): + doWork = True + weather = self.weather + while doWork: + mapping = next((m for m in WeatherParser.mappings if m.matches(self.data)), None) + if mapping: + (weather, remain) = mapping.updateWeather(weather, self.data) + self.data = remain + doWork = len(self.data) > 0 + else: + doWork = False + return weather + + def getRemainder(self): + return self.data + + +class AprsLocation(LatLngLocation): + def __init__(self, data): + super().__init__(data["lat"], data["lon"]) + self.data = data + + def __dict__(self): + res = super(AprsLocation, self).__dict__() + for key in ["comment", "symbol", "course", "speed"]: + if key in self.data: + res[key] = self.data[key] + return res + + +class AprsParser(Parser): + def __init__(self, handler): + super().__init__(handler) + self.ax25parser = Ax25Parser() + self.deframer = KissDeframer() + self.metrics = {} + + def setDialFrequency(self, freq): + super().setDialFrequency(freq) + self.metrics = {} + + def getMetric(self, category): + if category not in self.metrics: + band = "unknown" + if self.band is not None: + band = self.band.getName() + name = "aprs.decodes.{band}.aprs.{category}".format(band=band, category=category) + metrics = Metrics.getSharedInstance() + self.metrics[category] = metrics.getMetric(name) + if self.metrics[category] is None: + self.metrics[category] = CounterMetric() + metrics.addMetric(name, self.metrics[category]) + return self.metrics[category] + + def isDirect(self, aprsData): + if "path" in aprsData and len(aprsData["path"]) > 0: + hops = [host for host in aprsData["path"] if widePattern.match(host) is None] + if len(hops) > 0: + return False + if "type" in aprsData and aprsData["type"] in ["thirdparty", "item", "object"]: + return False + return True + + def parse(self, raw): + for frame in self.deframer.parse(raw): + try: + data = self.ax25parser.parse(frame) + + # TODO how can we tell if this is an APRS frame at all? + aprsData = self.parseAprsData(data) + + logger.debug("decoded APRS data: %s", aprsData) + self.updateMap(aprsData) + self.getMetric("total").inc() + if self.isDirect(aprsData): + self.getMetric("direct").inc() + self.handler.write_aprs_data(aprsData) + except Exception: + logger.exception("exception while parsing aprs data") + + def updateMap(self, mapData): + if "type" in mapData and mapData["type"] == "thirdparty" and "data" in mapData: + mapData = mapData["data"] + if "lat" in mapData and "lon" in mapData: + loc = AprsLocation(mapData) + source = mapData["source"] + if "type" in mapData: + if mapData["type"] == "item": + source = mapData["item"] + elif mapData["type"] == "object": + source = mapData["object"] + Map.getSharedInstance().updateLocation(source, loc, "APRS", self.band) + + def hasCompressedCoordinates(self, raw): + return raw[0] == "/" or raw[0] == "\\" + + def parseUncompressedCoordinates(self, raw): + lat = int(raw[0:2]) + float(raw[2:7]) / 60 + if raw[7] == "S": + lat *= -1 + lon = int(raw[9:12]) + float(raw[12:17]) / 60 + if raw[17] == "W": + lon *= -1 + return {"lat": lat, "lon": lon, "symbol": getSymbolData(raw[18], raw[8])} + + def parseCompressedCoordinates(self, raw): + return { + "lat": 90 - decodeBase91(raw[1:5]) / 380926, + "lon": -180 + decodeBase91(raw[5:9]) / 190463, + "symbol": getSymbolData(raw[9], raw[0]), + } + + def parseTimestamp(self, raw): + now = datetime.now() + if raw[6] == "h": + ts = datetime.strptime(raw[0:6], "%H%M%S") + ts = ts.replace(year=now.year, month=now.month, day=now.month, tzinfo=timezone.utc) + else: + ts = datetime.strptime(raw[0:6], "%d%H%M") + ts = ts.replace(year=now.year, month=now.month) + if raw[6] == "z": + ts = ts.replace(tzinfo=timezone.utc) + elif raw[6] == "/": + ts = ts.replace(tzinfo=now.tzinfo) + else: + logger.warning("invalid timezone info byte: %s", raw[6]) + return int(ts.timestamp() * 1000) + + def parseStatusUpate(self, raw): + res = {"type": "status"} + if raw[6] == "z": + res["timestamp"] = self.parseTimestamp(raw[0:7]) + res["comment"] = raw[7:] + else: + res["comment"] = raw + return res + + def parseAprsData(self, data): + information = data["data"] + + # forward some of the ax25 data + aprsData = {"source": data["source"], "destination": data["destination"], "path": data["path"]} + + if information[0] == 0x1C or information[0] == ord("`") or information[0] == ord("'"): + aprsData.update(MicEParser().parse(data)) + return aprsData + + information = information.decode(encoding, "replace") + + # APRS data type identifier + dti = information[0] + + if dti == "!" or dti == "=": + # position without timestamp + aprsData.update(self.parseRegularAprsData(information[1:])) + elif dti == "/" or dti == "@": + # position with timestamp + aprsData["timestamp"] = self.parseTimestamp(information[1:8]) + aprsData.update(self.parseRegularAprsData(information[8:])) + elif dti == ">": + # status update + aprsData.update(self.parseStatusUpate(information[1:])) + elif dti == "}": + # third party + aprsData.update(self.parseThirdpartyAprsData(information[1:])) + elif dti == ":": + # message + aprsData.update(self.parseMessage(information[1:])) + elif dti == ";": + # object + aprsData.update(self.parseObject(information[1:])) + elif dti == ")": + # item + aprsData.update(self.parseItem(information[1:])) + + return aprsData + + def parseObject(self, information): + result = {"type": "object"} + if len(information) > 16: + result["object"] = information[0:9].strip() + result["live"] = information[9] == "*" + result["timestamp"] = self.parseTimestamp(information[10:17]) + result.update(self.parseRegularAprsData(information[17:])) + # override type, losing information about compression + result["type"] = "object" + return result + + def parseItem(self, information): + result = {"type": "item"} + if len(information) > 3: + indexes = [information[0:10].find(p) for p in ["!", "_"]] + filtered = [i for i in indexes if i >= 3] + filtered.sort() + if len(filtered): + index = filtered[0] + result["item"] = information[0:index] + result["live"] = information[index] == "!" + result.update(self.parseRegularAprsData(information[index + 1 :])) + # override type, losing information about compression + result["type"] = "item" + return result + + def parseMessage(self, information): + result = {"type": "message"} + if len(information) > 9 and information[9] == ":": + result["adressee"] = information[0:9] + message = information[10:] + if len(message) > 3 and message[0:3] == "ack": + result["type"] = "messageacknowledgement" + result["messageid"] = int(message[3:8]) + elif len(message) > 3 and message[0:3] == "rej": + result["type"] = "messagerejection" + result["messageid"] = int(message[3:8]) + else: + matches = messageIdRegex.match(message) + if matches: + result["messageid"] = int(matches.group(2)) + message = matches.group(1) + result["message"] = message + return result + + def parseThirdpartyAprsData(self, information): + matches = thirdpartyeRegex.match(information) + if matches: + path = matches.group(2).split(",") + destination = next((c.strip("*").upper() for c in path if c.endswith("*")), None) + data = self.parseAprsData( + { + "source": matches.group(1).upper(), + "destination": destination, + "path": path, + "data": matches.group(6).encode(encoding), + } + ) + return {"type": "thirdparty", "data": data} + + return {"type": "thirdparty"} + + def parseRegularAprsData(self, information): + if self.hasCompressedCoordinates(information): + aprsData = self.parseCompressedCoordinates(information[0:10]) + aprsData["type"] = "compressed" + if information[10] != " ": + if information[10] == "{": + # pre-calculated radio range + aprsData["range"] = 2 * 1.08 ** (ord(information[11]) - 33) * milesToKilometers + else: + aprsData["course"] = (ord(information[10]) - 33) * 4 + # speed is in knots... convert to metric (km/h) + aprsData["speed"] = (1.08 ** (ord(information[11]) - 33) - 1) * knotsToKilometers + # compression type + t = ord(information[12]) + aprsData["fix"] = (t & 0b00100000) > 0 + sources = ["other", "GLL", "GGA", "RMC"] + aprsData["nmeasource"] = sources[(t & 0b00011000) >> 3] + origins = [ + "Compressed", + "TNC BText", + "Software", + "[tbd]", + "KPC3", + "Pico", + "Other tracker", + "Digipeater conversion", + ] + aprsData["compressionorigin"] = origins[t & 0b00000111] + comment = information[13:] + else: + aprsData = self.parseUncompressedCoordinates(information[0:19]) + aprsData["type"] = "regular" + comment = information[19:] + + def decodeHeightGainDirectivity(comment): + res = {"height": 2 ** int(comment[4]) * 10 * feetToMeters, "gain": int(comment[5])} + directivity = int(comment[6]) + if directivity == 0: + res["directivity"] = "omni" + elif 0 < directivity < 9: + res["directivity"] = directivity * 45 + return res + + # aprs data extensions + # yes, weather stations are officially identified by their symbols. go figure... + if "symbol" in aprsData and aprsData["symbol"]["index"] == 62: + # weather report + weather = {} + if len(comment) > 6 and comment[3] == "/": + try: + weather["wind"] = {"direction": int(comment[0:3]), "speed": int(comment[4:7]) * milesToKilometers} + except ValueError: + pass + comment = comment[7:] + + parser = WeatherParser(comment, weather) + aprsData["weather"] = parser.getWeather() + comment = parser.getRemainder() + elif len(comment) > 6: + if comment[3] == "/": + # course and speed + # for a weather report, this would be wind direction and speed + try: + aprsData["course"] = int(comment[0:3]) + aprsData["speed"] = int(comment[4:7]) * knotsToKilometers + except ValueError: + pass + comment = comment[7:] + elif comment[0:3] == "PHG": + # station power and effective antenna height/gain/directivity + try: + powerCodes = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] + aprsData["power"] = powerCodes[int(comment[3])] + aprsData.update(decodeHeightGainDirectivity(comment)) + except ValueError: + pass + comment = comment[7:] + elif comment[0:3] == "RNG": + # pre-calculated radio range + try: + aprsData["range"] = int(comment[3:7]) * milesToKilometers + except ValueError: + pass + comment = comment[7:] + elif comment[0:3] == "DFS": + # direction finding signal strength and antenna height/gain + try: + aprsData["strength"] = int(comment[3]) + aprsData.update(decodeHeightGainDirectivity(comment)) + except ValueError: + pass + comment = comment[7:] + + matches = altitudeRegex.match(comment) + if matches: + aprsData["altitude"] = int(matches.group(2)) * feetToMeters + comment = matches.group(1) + matches.group(3) + + aprsData["comment"] = comment + + return aprsData + + +class MicEParser(object): + def extractNumber(self, input): + n = ord(input) + if n >= ord("P"): + return n - ord("P") + if n >= ord("A"): + return n - ord("A") + return n - ord("0") + + def listToNumber(self, input): + base = self.listToNumber(input[:-1]) * 10 if len(input) > 1 else 0 + return base + input[-1] + + def extractAltitude(self, comment): + if len(comment) < 4 or comment[3] != "}": + return (comment, None) + return comment[4:], decodeBase91(comment[:3]) - 10000 + + def extractDevice(self, comment): + if len(comment) > 0: + if comment[0] == ">": + if len(comment) > 1: + if comment[-1] == "=": + return comment[1:-1], {"manufacturer": "Kenwood", "device": "TH-D72"} + if comment[-1] == "^": + return comment[1:-1], {"manufacturer": "Kenwood", "device": "TH-D74"} + return comment[1:], {"manufacturer": "Kenwood", "device": "TH-D7A"} + if comment[0] == "]": + if len(comment) > 1 and comment[-1] == "=": + return comment[1:-1], {"manufacturer": "Kenwood", "device": "TM-D710"} + return comment[1:], {"manufacturer": "Kenwood", "device": "TM-D700"} + if len(comment) > 2 and (comment[0] == "`" or comment[0] == "'"): + if comment[-2] == "_": + devices = { + "b": "VX-8", + '"': "FTM-350", + "#": "VX-8G", + "$": "FT1D", + "%": "FTM-400DR", + ")": "FTM-100D", + "(": "FT2D", + "0": "FT3D", + } + return comment[1:-2], {"manufacturer": "Yaesu", "device": devices.get(comment[-1], "Unknown")} + if comment[-2:] == " X": + return comment[1:-2], {"manufacturer": "SainSonic", "device": "AP510"} + if comment[-2] == "(": + devices = {"5": "D578UV", "8": "D878UV"} + return comment[1:-2], {"manufacturer": "Anytone", "device": devices.get(comment[-1], "Unknown")} + if comment[-2] == "|": + devices = {"3": "TinyTrack3", "4": "TinyTrack4"} + return comment[1:-2], {"manufacturer": "Byonics", "device": devices.get(comment[-1], "Unknown")} + if comment[-2:] == "^v": + return comment[1:-2], {"manufacturer": "HinzTec", "device": "anyfrog"} + if comment[-2] == ":": + devices = {"4": "P4dragon DR-7400 modem", "8": "P4dragon DR-7800 modem"} + return ( + comment[1:-2], + {"manufacturer": "SCS GmbH & Co.", "device": devices.get(comment[-1], "Unknown")}, + ) + if comment[-2:] == "~v": + return comment[1:-2], {"manufacturer": "Other", "device": "Other"} + return comment[1:-2], None + return comment, None + + def parse(self, data): + information = data["data"] + destination = data["destination"] + + rawLatitude = [self.extractNumber(c) for c in destination[0:6]] + lat = self.listToNumber(rawLatitude[0:2]) + self.listToNumber(rawLatitude[2:6]) / 6000 + if ord(destination[3]) <= ord("9"): + lat *= -1 + + lon = information[1] - 28 + if ord(destination[4]) >= ord("P"): + lon += 100 + if 180 <= lon <= 189: + lon -= 80 + if 190 <= lon <= 199: + lon -= 190 + + minutes = information[2] - 28 + if minutes >= 60: + minutes -= 60 + + lon += minutes / 60 + (information[3] - 28) / 6000 + + if ord(destination[5]) >= ord("P"): + lon *= -1 + + speed = (information[4] - 28) * 10 + dc28 = information[5] - 28 + speed += int(dc28 / 10) + course = (dc28 % 10) * 100 + course += information[6] - 28 + if speed >= 800: + speed -= 800 + if course >= 400: + course -= 400 + # speed is in knots... convert to metric (km/h) + speed *= knotsToKilometers + + comment = information[9:].decode(encoding, "replace").strip() + (comment, altitude) = self.extractAltitude(comment) + + (comment, device) = self.extractDevice(comment) + + # altitude might be inside the device string, so repeat and choose one + (comment, insideAltitude) = self.extractAltitude(comment) + altitude = next((a for a in [altitude, insideAltitude] if a is not None), None) + + return { + "fix": information[0] == ord("`") or information[0] == 0x1C, + "lat": lat, + "lon": lon, + "comment": comment, + "altitude": altitude, + "speed": speed, + "course": course, + "device": device, + "type": "Mic-E", + "symbol": getSymbolData(chr(information[7]), chr(information[8])), + } diff --git a/openwebrx/owrx/audio/__init__.py b/openwebrx/owrx/audio/__init__.py new file mode 100644 index 0000000..170bde3 --- /dev/null +++ b/openwebrx/owrx/audio/__init__.py @@ -0,0 +1,86 @@ +from owrx.config import Config +from abc import ABC, ABCMeta, abstractmethod +from typing import List + +import logging + +logger = logging.getLogger(__name__) + + +class AudioChopperProfile(ABC): + @abstractmethod + def getInterval(self): + pass + + @abstractmethod + def getFileTimestampFormat(self): + pass + + @abstractmethod + def decoder_commandline(self, file): + pass + + +class ProfileSourceSubscriber(ABC): + @abstractmethod + def onProfilesChanged(self): + pass + + +class ProfileSource(ABC): + def __init__(self): + self.subscribers = [] + + @abstractmethod + def getProfiles(self) -> List[AudioChopperProfile]: + pass + + def subscribe(self, subscriber: ProfileSourceSubscriber): + if subscriber in self.subscribers: + return + self.subscribers.append(subscriber) + + def unsubscribe(self, subscriber: ProfileSourceSubscriber): + if subscriber not in self.subscribers: + return + self.subscribers.remove(subscriber) + + def fireProfilesChanged(self): + for sub in self.subscribers.copy(): + try: + sub.onProfilesChanged() + except Exception: + logger.exception("Error while notifying profile subscriptions") + + +class ConfigWiredProfileSource(ProfileSource, metaclass=ABCMeta): + def __init__(self): + super().__init__() + self.configSub = None + + @abstractmethod + def getPropertiesToWire(self) -> List[str]: + pass + + def subscribe(self, subscriber: ProfileSourceSubscriber): + super().subscribe(subscriber) + if self.subscribers and self.configSub is None: + self.configSub = Config.get().filter(*self.getPropertiesToWire()).wire(self.fireProfilesChanged) + + def unsubscribe(self, subscriber: ProfileSourceSubscriber): + super().unsubscribe(subscriber) + if not self.subscribers and self.configSub is not None: + self.configSub.cancel() + self.configSub = None + + def fireProfilesChanged(self, *args): + super().fireProfilesChanged() + + +class StaticProfileSource(ProfileSource): + def __init__(self, profiles: List[AudioChopperProfile]): + super().__init__() + self.profiles = profiles + + def getProfiles(self) -> List[AudioChopperProfile]: + return self.profiles diff --git a/openwebrx/owrx/audio/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8636f3500206ba7aac79e5e6dd4639ad52d55ff GIT binary patch literal 4057 zcmbtXO>@&m7~YlEvTVzaA!+#N2Z2JV3zN{EXooUD_?XVn44u%S)wH8gv>QiYOJ*e# z;z>_1!>K*?#34EM*YvJyPwh|Wsqee`h!y0}PCc{h{odz&_kEsS-Da?w^2-^k`zNhQX%a@9Bwt^VVuohwN(+yGh60h+^q_@leEDA%~Idz8}SF z9?r{uIuvd|impH=h)p@MHKB9LwU^`twHcIk&Q!SuWn~Mr8@vt}%w+!NnET=CRyZ07 z`9Ox7{y=yX*1{kDV_Jn2F~~T9Np$Tugv}x`*pw!;s_gFzE8!@HB!X?RA)l{=+mN}q z`ikTDfgd~0@@OY%^+o(u5DWRt9kg&=4Kkul&Qt(do*MgBPzptlN{EFNQHl+NBw2)o z?t*0x{h^3rcR0Em%Ap(2L0`WACo}f1nC2tI$K-GQTI9Xb$TgbXsN#Vn@Us$=dX462Gbu0xox)}{>Vtu?3dD7ny@_!xW zu`tM%jaIkZpfC6-#G6G?Z^o9(Z_HZj)g+$3>|%BdGvy>ag-X{PhYmDYZDmr4^^#kV zAUANp8f3X!QX<;AV4^0BQ-tfUDQCR)k^u?p!sG_jQBauO$Z9Aj%xA|A`21YtG{ zstJiIkRRi@VpnZ)s=VveDA>4pU`mOCdT##*P}rrrv`=o6AFupGcdT8yr|pBbJt9vnMq>R*Q+`3A zPD#x6YFzpAd+7TP(xWumw6)2lJ5q*nX?qJ4Zz%|4e{;tV`b(qqT3O04k77RzqMnf+ z1M|YsjzrcajXTdhp?VWz5cc~*CJm7{W&TKXSmp3J&2RcrI1F~E~UXe|zWW+pLM$|Zz5-XPy)q-+j*-cf(mx*b&UJ*uY3)s+3 zPNt8KN8mtS@=g&gNNtQ5WLboWWAXwxh0(!LYqac90PmMtG=^7%M2{s6PrYi^#~YdI z0D&8h;|<&>a-7srcsy$K=eV{q9_TeWI}6j43ds{NrG)XnO3-{NE&w?8Mf5O47kr60 z&{%koT%L)dVj|dx0`dm^mD8?4PwKQ(h=6RGyb8I_$rDpvMM~|H$Hp$%tM8Lv$esy( zjeQU@2S%i#)sz=tl{DN?z9cG=M5u`lcoG9%8Sto*A7cyK23u4>POAuDyoi&ESBP5e zCNyQrW&u+s=VqE+NKUntNVvmRMwc@y8&Z|_SP?dbWwq2Y#i7Eo#PHm}6N97irwsS+2B>mW4nk)c`{yle1dgV~0X8~og;C-dvE8~a{KX<%1# zlwJ{0DYVduia=%6!5e-`b<*%rD*YKu$RkFT-#8!~_6?*1v3aio(ZACfybd6|GlEi? z?O5^sq3(-saoa2|&QxtW{n#-Nhr+?#qbyYy&?v)|KG|wWi+fbdB!mg_HjPDQ81E literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/audio/__pycache__/chopper.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/chopper.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3c2fff9d5529c39fba6e1ce9a157a01b5924007 GIT binary patch literal 3650 zcmb6cO>Z1YwW|7KdOrOTC+vD58kQg!u}oHcN)SbmYyy#jvx=kj(n{2{r^?RQJ>BD~ zYJUvv3%0a5fCS>Yhw+iWz&k`Y%54vb_o{m)9tRM#Rc~HZy{dZO@A+Q4-5~J& z5^E!py7&GnXZb?-n9!#$SF4x~T1~ z30Kt5yzn}AH%YJYIdDXJ%y_@ZxKL*Kr#T;H_g`k?v5>gbqsD)d>v66DtL@4xA8(&* z0RE*9WwtX+#AcSuf!NHq)j$rng@mo9y<|%cwVk>j4##4E@60~8Le#GS5hSFXgiJV` zeo8+fJCrl-oVP-kyWBe`p~rn*gI~Xt0yNU#O_&F~#oM1!-r-BTr;i9QUQa(2qcS?mIJ5a1UbTibM+DcCk2VXQUGazknQ4%qur@junT(X z6b>i*f%=~I3rE+^n7XF}&Zc$TJ)^&&g)?nj;F+dwoss7Zur`d2UOFSwF%#jyF=AYGu@5TZj5&54oX9Gk>B2msYwy@XeTuu&z|O~akv_r+L2YXk_^~3 z%R9rA-#dW|^CZ^8EH#ax8m3Ce=|D&n&on-YlUzKIGLy#N$>b>3W_4HSXk1z}Qr4uV zvpAOl9&TmU8J+?oLe6HTsg0*{@EQ|D(J&qADC)V!RU+958}UF74@Cs*N{kZYT7jsk zN6{dOm5QQ2kuPrD&c+&sNRPy}JieVB0kC?wUFo4eK9O(1`DGYt4FI8j_1B=Qv_l&( zCf6@pyFX)QK#?9k{6$sWhoNo)IDV@jdqi3|r59w%&^QNdt27YZ=sB_jbjBHHV~|K^ z;~tf7!;1X!9DD@{TDTWg_P{-5J)H)0^7aB#m+9t=d2j%S^}!U7S7b_%6-{4}JvMbN zz#ln-ICg%=PH7n(+AS!=N45F^Iish{#!!t1=Rv`y-UYF%&hc8|d_Z2bPTj(t`c}ke zTcmJakqB}Ocw&8H&dN+jnS7q0&9au>Sid7*@<0&t|YI?0(U>!-oSs$VTJ4UxUjPx`$6D{r`#O z`O!JI|6P0Xt8hCw5US=1BNAW7A#VYg--puIU|fTt@EE3|7wa?W!8xphK$>D_?jJ5` z!I5}Owh`O_0FL|(#J>r^1XXye1!qP-27RFNtUkUD^Z!pD^P{~$cJU!!Wl{?+F<`Ch zRFy+X^mgMxnHVWZ9y*rV23pUv(G@P02WNRn8Q)q@mC;C#DpQxCXalgVF8dX}t}t>5 zMBoj&&Tg;QZ1uz+A3LWZhJ2gD+i&}$I+J^(PT%}L+K1-p7B zJ9VTO2u#Ce3hx75m7jb%d1pZpVw^b&%<=Ln~y(-8;QiU z@UT+!YpR#I)=0&(qZC>dKgo7?p@@21Z0Ec3yKtrqoVuE~SS?(%7G6ONtD*6>Pc-yz zP9oAWJUm;5OfyekAM7GICIaIPl1vGC9dQ?|eQ1IQPafKypz)`s(`wbLHX@KLB2@?)}pI6+5*`3T?TttpnaH47MjdHTsHSAR1tc<^(tia zO|P=rW*d`lSLq8h8K=9VTe&QM4=~6AR;N`Z?)Up3)Q{jg`C|mA)PAt;}KLz3{On~*93t{pG@q!?AIB5mJbbHbO=d!`O36)GU?d&jctanStLZLpMw;Qgt zi@XQNBwk=@Pc}=_{QI^83>fo|fY2r?0;kzi J7GR(_{{kw0a$f)d literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/audio/__pycache__/queue.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/queue.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1a4806506a9a3152d33c93de621e6af174c5f68 GIT binary patch literal 5583 zcmZ`-&2JmW72nw}E~zC&(Xy;KZ9o`KYZ|GX6#Yz^G>L7cHWEdN-6|!T5)@}eEw$XG zW|yYOQbiFN%_%L=L(xMI)TKbtThBf8Kj?j~J*DWW$DaCoGbBaI?h-RQJ74?W+u!@W zH+;8PEEu@{@vomX-??fS|D=c6Wux&nO8Qq++~6!Sx~$F^wav)vT6Igct;p^=b*Jmr z-EOX)Q+;;ib@TOnw@@#1SL!RO&x!oHkF`!ztQXb2R4=LfYJD|Wos@&Mb5^eemC2Q0 zwX=SXH@#-QS-lq z+(wN__ihp&gl)76cM}mzpDdaCAjm}6N@ekG(vP!1=x6EQ3q{b%^i$1}&PV;AA4q$< zA4Sr+-|c0?ofhpqyG%6RMoDj?3XD4AM&0C$n@^3p#Vu~*ZgYpbxH~)t**xi~O&=!v zEeDI}qC4$$7bX1)sxccI4P$JgW@8JrIkr(-+`tZOr)JAsPb3Fn6i9a_Ia4xIVPRAdPC_o z8etq}jRvhqJyZsB)o-*m(_w2ir^RR-7b%lRQOf)Wjm(%Z4uv}Axyg*oIx{BrxpB%e zr{i*yjMRpTo!rDzbJ#TlBl$5SvwdNon#0>CH^*kDaL@Sk`YFv^nfNpxr8u?@EP~7N z!#!iqh+nadtdy-Dm`4TiduGs&R!++2Xel{zH2bGUY<^-4zlO0j0@N4m)E?WsaAb+U zWfi_MxiYrT*%|wcVb3k>>2%hy8Jm-9wEJh%UDYQl?u;tE6 z!?<0gPg8w4m3ADqf|2=+SemcB`WX0f(~KwmOj@l0m-$wdq(S3=r=wfw?DeziVKc3^ zlQ^h02hCx1a7g>`pp|giwUg{`eMwFXc;~XP8(@*3uQ1wFa{^HnLDm;>6$^sbtG{^l zbhMrJ_osqYZ}md{=F8z}t;$1Qjgzbj9CgFEnFUxO$PuF98`U(3)$5MJD5|DmJ8ni> zwN2?pNxKc)IXu|!w>7{lyj)&u2U#Nvy1hg+#jwGZMK9!h)A3^VC} z49A91^pJayh;B2JrN`P%_fL9|i_6@gDPkB?S}=yRNynNaR!NUs*6oq>GB=e^+DbB0 zw3G|!YK^8^q91Rdk={mSuo5dUn{A+&%ri~qvzJ(f70fc8edeKE#vG50{J9rlgSi9I zCnRT64n#W+%LHBR9E@PT4oF%62@I;K2}7vx!M`I%dFxCvJ`vy)q--9=$&F$86$|7C_A{pvDDi>KZi} z2$wUWL$ybqW@#ktL1(NhP|qiNbcNc-CXK+L=FVvSpb1RzYL;k13W$zszW6?56J!Qy zfd^z^Hwn`u-i2Smg|nom@F4T|Pg+4Q3zJxo;blIUJS zbratyl?C-Z-%Wmnk(rxtR<{D1Fi>}c9EIDb#u=-7Xd^FC?Ywd&%C@|ELFZTX6`fTf zYx8*#&Qw%$N_-V_N~&GvYiO_X3crH!GGFIcabHtTR{=&gkW5je3WPGra@sV{4~X6} zK=`+t6rVdLHvr-5nQ?}vhJxEi#>W65jp@|K65CiK{gw!t1fchm){!iwhfUD=gE-Ba zaVuEB$DC^98u0jNY1$q((oAO(WGZa*(xS*)&n#Fn_gn`;pVFZ^*C8)%ki;_rS=MDv z)JhZa9+rzAQAK!ouIM>J(mPS~b4*wexWGp1O9D@)%~^w(jnXl?r+-I9aYUtFirtW6 zu@?+tfHu6mfedrvpwHBOU;tO8ZMkz(wULc>oO82HS*e%hi62f)q(3X%$|xZ#o-vi+ zmCk6lRW*V=JF5%5W!3ATx4h81rg~lUt}XOdR4=7@73x)H^LFG_6^;?J5o(aAHliSI zXNPj*QujcSeL9*?-%``J^z_l%Z2Gi2j7ODAT}Y=!*Dm)x3B*B^3`Xmh1`r4mQM)3D z2gI9H5!WkcN0Omskt>Zf$TS6|(jOoL*%ioxL^jkzA2d%Mg`+?gktwAu5h{jE1s%u4 z15`5K&jeLABtwo zGj|=OMSyptRaC^UWt;*^rq3#HoC=;Po0*l-)q4wmKaXn9gx$Ne;U+44V1%0^Q{YJW zXcmO02OVU#-R21?Ipcp@X{=^Kc3fbmZI69CL1n2ews8)}!O{8A7a1d+S4z4XGg z(Tu_;0c?-+)myaI?j&J6bsce!de~rrxK+rekMwI4b)Gk1Y4Q})q&xXfiMj9}@|w}w z5>N>sbA%Fclj-RopHhqC3RkaK zI-y!S$ZVvuA0d1qM|q6Br4`04O9bTpi~fKz z1zLfbY!gZXXyqj}Vap|ek)}$^DjJ!QAqj_O<^lNNHw+LkI+h04c$x<|3z90n{V^7b zHmcc&3DK4osPI`SX~6_V^M#4S*=6|<`{(izr_VFQbv%&gpD{|gwPnXl=O(5~%5X3s zf1bKy-PK-x86OSo^QXB83KgS`B@@jZdoBo_4CjK-zd09hm?agapsYsQ;rVC0cQO7K zlv&EUoI*^qZ$z?7X)B=*>G&biF8Vt_+LU;yHw#+`ABPE7fl53iAylSI)GUa8 zbb6yerM?##K#(xvK2>wVntd(hMFhl0C@N5RpmopmbNH=$j^}w5-}7D1QF&DDMY%$M zVr{k5Uw$eO%kn}G21FnTXVO>Q({QK5D}_)MQ`O-`G3tHB2+R*|9y}V z@_@VFVzE_Imk5Nh5Jo`75I2RqvzN;lc>;J*}guGReEK2~N_XwgaFTzhQ?(p`E# g(C6y6=(DQ>s*eNBI(|eUp6P*1e5?F**Q#9qAFVhA5dZ)H literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/audio/__pycache__/wav.cpython-37.pyc b/openwebrx/owrx/audio/__pycache__/wav.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..544fcae1c54ec7f0cac853185884d3d2a0cc91d4 GIT binary patch literal 4835 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 None: + logger.debug("Audio chopper starting up") + self.setup_writers() + self.profile_source.subscribe(self) + while self.doRun: + data = None + try: + data = self.read_fn(256) + except ValueError: + pass + if data is None or (isinstance(data, bytes) and len(data) == 0): + self.doRun = False + else: + for w in self.writers: + w.write(data) + + logger.debug("Audio chopper shutting down") + self.profile_source.unsubscribe(self) + self.stop_writers() + self.outputWriter.close() + self.outputWriter = None + + # drain messages left in the queue so that the queue can be successfully closed + # this is necessary since python keeps the file descriptors open otherwise + try: + while True: + self.outputReader.recv() + except EOFError: + pass + self.outputReader.close() + self.outputReader = None + + def onProfilesChanged(self): + logger.debug("profile change received, resetting writers...") + self.setup_writers() + + def read(self): + try: + return self.outputReader.recv() + except (EOFError, OSError): + return None diff --git a/openwebrx/owrx/audio/queue.py b/openwebrx/owrx/audio/queue.py new file mode 100644 index 0000000..daf27c8 --- /dev/null +++ b/openwebrx/owrx/audio/queue.py @@ -0,0 +1,172 @@ +from owrx.config import Config +from owrx.config.core import CoreConfig +from owrx.metrics import Metrics, CounterMetric, DirectMetric +from queue import Queue, Full, Empty +import subprocess +import os +import threading + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class QueueJob(object): + def __init__(self, profile, writer, file, freq): + self.profile = profile + self.writer = writer + self.file = file + self.freq = freq + + def run(self): + logger.debug("processing file %s", self.file) + tmp_dir = CoreConfig().get_temporary_directory() + decoder = subprocess.Popen( + ["nice", "-n", "10"] + self.profile.decoder_commandline(self.file), + stdout=subprocess.PIPE, + cwd=tmp_dir, + close_fds=True, + ) + try: + for line in decoder.stdout: + self.writer.send((self.profile, self.freq, line)) + except (OSError, AttributeError): + decoder.stdout.flush() + # TODO uncouple parsing from the output so that decodes can still go to the map and the spotters + logger.debug("output has gone away while decoding job.") + try: + rc = decoder.wait(timeout=10) + if rc != 0: + raise RuntimeError("decoder return code: {0}".format(rc)) + except subprocess.TimeoutExpired: + logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid) + decoder.kill() + raise + + def unlink(self): + try: + os.unlink(self.file) + except FileNotFoundError: + pass + + +PoisonPill = object() + + +class QueueWorker(threading.Thread): + def __init__(self, queue): + self.queue = queue + self.doRun = True + super().__init__() + + def run(self) -> None: + while self.doRun: + job = self.queue.get() + if job is PoisonPill: + self.stop() + else: + try: + job.run() + except Exception: + logger.exception("failed to decode job") + self.queue.onError() + finally: + job.unlink() + + self.queue.task_done() + + def stop(self): + self.doRun = False + + +class DecoderQueue(Queue): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with DecoderQueue.creationLock: + if DecoderQueue.sharedInstance is None: + DecoderQueue.sharedInstance = DecoderQueue() + return DecoderQueue.sharedInstance + + @staticmethod + def stopAll(): + with DecoderQueue.creationLock: + if DecoderQueue.sharedInstance is not None: + DecoderQueue.sharedInstance.stop() + DecoderQueue.sharedInstance = None + + def __init__(self): + pm = Config.get() + super().__init__(pm["decoding_queue_length"]) + self.workers = [] + self._setWorkers(pm["decoding_queue_workers"]) + self.subscriptions = [ + pm.wireProperty("decoding_queue_length", self._setMaxSize), + pm.wireProperty("decoding_queue_workers", self._setWorkers), + ] + metrics = Metrics.getSharedInstance() + metrics.addMetric("decoding.queue.length", DirectMetric(self.qsize)) + self.inCounter = CounterMetric() + metrics.addMetric("decoding.queue.in", self.inCounter) + self.outCounter = CounterMetric() + metrics.addMetric("decoding.queue.out", self.outCounter) + self.overflowCounter = CounterMetric() + metrics.addMetric("decoding.queue.overflow", self.overflowCounter) + self.errorCounter = CounterMetric() + metrics.addMetric("decoding.queue.error", self.errorCounter) + + def _setMaxSize(self, size): + if self.maxsize == size: + return + self.maxsize = size + + def _setWorkers(self, workers): + while len(self.workers) > workers: + logger.debug("stopping one worker") + self.workers.pop().stop() + while len(self.workers) < workers: + logger.debug("starting one worker") + self.workers.append(self.newWorker()) + + def stop(self): + logger.debug("shutting down the queue") + while self.subscriptions: + self.subscriptions.pop().cancel() + try: + # purge all remaining jobs + while not self.empty(): + job = self.get() + job.unlink() + self.task_done() + except Empty: + pass + # put() a PoisonPill for all active workers to shut them down + for w in self.workers: + if w.is_alive(): + self.put(PoisonPill) + self.join() + + def put(self, item, **kwargs): + self.inCounter.inc() + try: + super(DecoderQueue, self).put(item, block=False) + except Full: + self.overflowCounter.inc() + raise + + def get(self, **kwargs): + # super.get() is blocking, so it would mess up the stats to inc() first + out = super(DecoderQueue, self).get(**kwargs) + self.outCounter.inc() + return out + + def newWorker(self): + worker = QueueWorker(self) + worker.start() + return worker + + def onError(self): + self.errorCounter.inc() diff --git a/openwebrx/owrx/audio/wav.py b/openwebrx/owrx/audio/wav.py new file mode 100644 index 0000000..37af029 --- /dev/null +++ b/openwebrx/owrx/audio/wav.py @@ -0,0 +1,139 @@ +from owrx.config.core import CoreConfig +from owrx.audio import AudioChopperProfile +from owrx.audio.queue import QueueJob, DecoderQueue +import threading +import wave +import os +from datetime import datetime, timedelta +from queue import Full +from typing import List + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class WaveFile(object): + def __init__(self, writer_id): + self.timestamp = datetime.utcnow() + self.writer_id = writer_id + tmp_dir = CoreConfig().get_temporary_directory() + self.filename = "{tmp_dir}/openwebrx-audiochopper-master-{id}-{timestamp}.wav".format( + tmp_dir=tmp_dir, + id=self.writer_id, + timestamp=self.timestamp.strftime("%y%m%d_%H%M%S"), + ) + self.waveFile = wave.open(self.filename, "wb") + self.waveFile.setnchannels(1) + self.waveFile.setsampwidth(2) + self.waveFile.setframerate(12000) + + def close(self): + self.waveFile.close() + + def getFileName(self): + return self.filename + + def getTimestamp(self): + return self.timestamp + + def writeframes(self, data): + return self.waveFile.writeframes(data) + + def unlink(self): + os.unlink(self.filename) + self.waveFile = None + + +class AudioWriter(object): + def __init__(self, active_dsp, outputWriter, interval, profiles: List[AudioChopperProfile]): + self.dsp = active_dsp + self.outputWriter = outputWriter + self.interval = interval + self.profiles = profiles + self.wavefile = None + self.switchingLock = threading.Lock() + self.timer = None + + def getWaveFile(self): + return WaveFile(id(self)) + + def getNextDecodingTime(self): + # add one second to have the intervals tick over one second earlier + # this avoids filename collisions, but also avoids decoding wave files with less than one second of audio + t = datetime.utcnow() + timedelta(seconds=1) + zeroed = t.replace(minute=0, second=0, microsecond=0) + delta = t - zeroed + seconds = (int(delta.total_seconds() / self.interval) + 1) * self.interval + t = zeroed + timedelta(seconds=seconds) + logger.debug("scheduling: {0}".format(t)) + return t + + def cancelTimer(self): + if self.timer: + self.timer.cancel() + self.timer = None + + def _scheduleNextSwitch(self): + self.cancelTimer() + delta = self.getNextDecodingTime() - datetime.utcnow() + self.timer = threading.Timer(delta.total_seconds(), self.switchFiles) + self.timer.start() + + def switchFiles(self): + with self.switchingLock: + file = self.wavefile + self.wavefile = self.getWaveFile() + + file.close() + tmp_dir = CoreConfig().get_temporary_directory() + + for profile in self.profiles: + # create hardlinks for the individual profiles + filename = "{tmp_dir}/openwebrx-audiochopper-{pid}-{timestamp}.wav".format( + tmp_dir=tmp_dir, + pid=id(profile), + timestamp=file.getTimestamp().strftime(profile.getFileTimestampFormat()), + ) + try: + os.link(file.getFileName(), filename) + except OSError: + logger.exception("Error while linking job files") + continue + + job = QueueJob(profile, self.outputWriter, filename, self.dsp.get_operating_freq()) + try: + DecoderQueue.getSharedInstance().put(job) + except Full: + logger.warning("decoding queue overflow; dropping one file") + job.unlink() + + try: + # our master can be deleted now, the profiles will delete their hardlinked copies after processing + file.unlink() + except OSError: + logger.exception("Error while unlinking job files") + + self._scheduleNextSwitch() + + def start(self): + self.wavefile = self.getWaveFile() + self._scheduleNextSwitch() + + def write(self, data): + with self.switchingLock: + self.wavefile.writeframes(data) + + def stop(self): + self.cancelTimer() + try: + self.wavefile.close() + except Exception: + logger.exception("error closing wave file") + try: + with self.switchingLock: + self.wavefile.unlink() + except Exception: + logger.exception("error removing undecoded file") + self.wavefile = None diff --git a/openwebrx/owrx/bands.py b/openwebrx/owrx/bands.py new file mode 100644 index 0000000..1aba72e --- /dev/null +++ b/openwebrx/owrx/bands.py @@ -0,0 +1,111 @@ +from owrx.modes import Modes +from datetime import datetime, timezone +import json +import os + +import logging + +logger = logging.getLogger(__name__) + + +class Band(object): + def __init__(self, dict): + self.name = dict["name"] + self.lower_bound = dict["lower_bound"] + self.upper_bound = dict["upper_bound"] + self.frequencies = [] + if "frequencies" in dict: + availableModes = [mode.modulation for mode in Modes.getAvailableModes()] + for (mode, freqs) in dict["frequencies"].items(): + if mode not in availableModes: + logger.info( + 'Modulation "{mode}" is not available, bandplan bookmark will not be displayed'.format( + mode=mode + ) + ) + continue + if not isinstance(freqs, list): + freqs = [freqs] + for f in freqs: + if not self.inBand(f): + logger.warning( + "Frequency for {mode} on {band} is not within band limits: {frequency}".format( + mode=mode, frequency=f, band=self.name + ) + ) + continue + self.frequencies.append({"mode": mode, "frequency": f}) + + def inBand(self, freq): + return self.lower_bound <= freq <= self.upper_bound + + def getName(self): + return self.name + + def getDialFrequencies(self, range): + (low, hi) = range + return [e for e in self.frequencies if low <= e["frequency"] <= hi] + + +class Bandplan(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if Bandplan.sharedInstance is None: + Bandplan.sharedInstance = Bandplan() + return Bandplan.sharedInstance + + def __init__(self): + self.bands = [] + self.file_modified = None + self.fileList = ["/etc/openwebrx/bands.json", "bands.json"] + + def _refresh(self): + modified = self._getFileModifiedTimestamp() + if self.file_modified is None or modified > self.file_modified: + logger.debug("reloading bands from disk due to file modification") + self.bands = self._loadBands() + self.file_modified = modified + + def _getFileModifiedTimestamp(self): + timestamp = 0 + for file in self.fileList: + try: + timestamp = os.path.getmtime(file) + break + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadBands(self): + for file in self.fileList: + try: + f = open(file, "r") + bands_json = json.load(f) + f.close() + return [Band(d) for d in bands_json] + except FileNotFoundError: + pass + except json.JSONDecodeError: + logger.exception("error while parsing bandplan file %s", file) + return [] + except Exception: + logger.exception("error while processing bandplan from %s", file) + return [] + return [] + + def findBands(self, freq): + self._refresh() + return [band for band in self.bands if band.inBand(freq)] + + def findBand(self, freq): + bands = self.findBands(freq) + if bands: + return bands[0] + else: + return None + + def collectDialFrequencies(self, range): + self._refresh() + return [e for b in self.bands for e in b.getDialFrequencies(range)] diff --git a/openwebrx/owrx/bookmarks.py b/openwebrx/owrx/bookmarks.py new file mode 100644 index 0000000..90819bd --- /dev/null +++ b/openwebrx/owrx/bookmarks.py @@ -0,0 +1,145 @@ +from datetime import datetime, timezone +from owrx.config.core import CoreConfig +import json +import os + +import logging + +logger = logging.getLogger(__name__) + + +class Bookmark(object): + def __init__(self, j): + self.name = j["name"] + self.frequency = j["frequency"] + self.modulation = j["modulation"] + + def getName(self): + return self.name + + def getFrequency(self): + return self.frequency + + def getModulation(self): + return self.modulation + + def __dict__(self): + return { + "name": self.getName(), + "frequency": self.getFrequency(), + "modulation": self.getModulation(), + } + + +class BookmakrSubscription(object): + def __init__(self, subscriptee, range, subscriber: callable): + self.subscriptee = subscriptee + self.range = range + self.subscriber = subscriber + + def inRange(self, bookmark: Bookmark): + low, high = self.range + return low <= bookmark.getFrequency() <= high + + def call(self, *args, **kwargs): + self.subscriber(*args, **kwargs) + + def cancel(self): + self.subscriptee.unsubscribe(self) + + +class Bookmarks(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if Bookmarks.sharedInstance is None: + Bookmarks.sharedInstance = Bookmarks() + return Bookmarks.sharedInstance + + def __init__(self): + self.file_modified = None + self.bookmarks = [] + self.subscriptions = [] + self.fileList = [Bookmarks._getBookmarksFile(), "/etc/openwebrx/bookmarks.json", "bookmarks.json"] + + def _refresh(self): + modified = self._getFileModifiedTimestamp() + if self.file_modified is None or modified > self.file_modified: + logger.debug("reloading bookmarks from disk due to file modification") + self.bookmarks = self._loadBookmarks() + self.file_modified = modified + + def _getFileModifiedTimestamp(self): + timestamp = 0 + for file in self.fileList: + try: + timestamp = os.path.getmtime(file) + break + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadBookmarks(self): + for file in self.fileList: + try: + with open(file, "r") as f: + content = f.read() + if content: + bookmarks_json = json.loads(content) + return [Bookmark(d) for d in bookmarks_json] + except FileNotFoundError: + pass + except json.JSONDecodeError: + logger.exception("error while parsing bookmarks file %s", file) + return [] + except Exception: + logger.exception("error while processing bookmarks from %s", file) + return [] + return [] + + def getBookmarks(self, range=None): + self._refresh() + if range is None: + return self.bookmarks + else: + (lo, hi) = range + return [b for b in self.bookmarks if lo <= b.getFrequency() <= hi] + + @staticmethod + def _getBookmarksFile(): + coreConfig = CoreConfig() + return "{data_directory}/bookmarks.json".format(data_directory=coreConfig.get_data_directory()) + + def store(self): + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps([b.__dict__() for b in self.bookmarks], indent=4) + with open(Bookmarks._getBookmarksFile(), "w") as file: + file.write(jsonContent) + self.file_modified = self._getFileModifiedTimestamp() + + def addBookmark(self, bookmark: Bookmark): + self.bookmarks.append(bookmark) + self.notifySubscriptions(bookmark) + + def removeBookmark(self, bookmark: Bookmark): + if bookmark not in self.bookmarks: + return + self.bookmarks.remove(bookmark) + self.notifySubscriptions(bookmark) + + def notifySubscriptions(self, bookmark: Bookmark): + for sub in self.subscriptions: + if sub.inRange(bookmark): + try: + sub.call() + except Exception: + logger.exception("Error while calling bookmark subscriptions") + + def subscribe(self, range, callback): + self.subscriptions.append(BookmakrSubscription(self, range, callback)) + + def unsubscribe(self, subscriptions: BookmakrSubscription): + if subscriptions not in self.subscriptions: + return + self.subscriptions.remove(subscriptions) diff --git a/openwebrx/owrx/breadcrumb.py b/openwebrx/owrx/breadcrumb.py new file mode 100644 index 0000000..1a7d4f3 --- /dev/null +++ b/openwebrx/owrx/breadcrumb.py @@ -0,0 +1,44 @@ +from typing import List +from abc import ABC, abstractmethod + + +class BreadcrumbItem(object): + def __init__(self, title, href): + self.title = title + self.href = href + + def render(self, documentRoot, active=False): + return '

    '.format( + documentRoot=documentRoot, href=self.href, title=self.title, active="active" if active else "" + ) + + +class Breadcrumb(object): + def __init__(self, breadcrumbs: List[BreadcrumbItem]): + self.items = breadcrumbs + + def render(self, documentRoot): + return """ + + """.format( + crumbs="".join(item.render(documentRoot) for item in self.items[:-1]), + last_crumb="".join(item.render(documentRoot, True) for item in self.items[-1:]), + ) + + def append(self, crumb: BreadcrumbItem): + self.items.append(crumb) + return self + + +class BreadcrumbMixin(ABC): + def template_variables(self): + variables = super().template_variables() + variables["breadcrumb"] = self.get_breadcrumb().render(self.get_document_root()) + return variables + + @abstractmethod + def get_breadcrumb(self) -> Breadcrumb: + pass diff --git a/openwebrx/owrx/client.py b/openwebrx/owrx/client.py new file mode 100644 index 0000000..8ec7f4d --- /dev/null +++ b/openwebrx/owrx/client.py @@ -0,0 +1,54 @@ +from owrx.config import Config +import threading + +import logging + +logger = logging.getLogger(__name__) + + +class TooManyClientsException(Exception): + pass + + +class ClientRegistry(object): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with ClientRegistry.creationLock: + if ClientRegistry.sharedInstance is None: + ClientRegistry.sharedInstance = ClientRegistry() + return ClientRegistry.sharedInstance + + def __init__(self): + self.clients = [] + Config.get().wireProperty("max_clients", self._checkClientCount) + super().__init__() + + def broadcast(self): + n = self.clientCount() + for c in self.clients: + c.write_clients(n) + + def addClient(self, client): + pm = Config.get() + if len(self.clients) >= pm["max_clients"]: + raise TooManyClientsException() + self.clients.append(client) + self.broadcast() + + def clientCount(self): + return len(self.clients) + + def removeClient(self, client): + try: + self.clients.remove(client) + except ValueError: + pass + self.broadcast() + + def _checkClientCount(self, new_count): + for client in self.clients[new_count:]: + logger.debug("closing one connection...") + client.close() diff --git a/openwebrx/owrx/command.py b/openwebrx/owrx/command.py new file mode 100644 index 0000000..0559b72 --- /dev/null +++ b/openwebrx/owrx/command.py @@ -0,0 +1,79 @@ +from abc import ABC, abstractmethod + + +class CommandMapper(object): + def __init__(self, base=None, mappings=None, static=None): + self.base = base + self.mappings = {} if mappings is None else mappings + self.static = static + + def map(self, values): + args = [self.mappings[k].map(v) for k, v in values.items() if k in self.mappings] + args = [a for a in args if a != ""] + options = " ".join(args) + command = "{0} {1}".format(self.base, options) + if self.static is not None: + command += " " + self.static + return command + + def setMapping(self, key, mapping): + self.mappings[key] = mapping + return self + + def setMappings(self, mappings): + for k, v in mappings.items(): + self.setMapping(k, v) + return self + + def setBase(self, base): + self.base = base + return self + + def setStatic(self, static): + self.static = static + return self + + def keys(self): + return self.mappings.keys() + + +class CommandMapping(ABC): + @abstractmethod + def map(self, value): + pass + + +class Flag(CommandMapping): + def __init__(self, flag): + self.flag = flag + + def map(self, value): + if value is not None and value: + return self.flag + else: + return "" + + +class Option(CommandMapping): + def __init__(self, option): + self.option = option + self.spacer = " " + + def map(self, value): + if value is not None: + if isinstance(value, str) and " " in value: + template = '{option}{spacer}"{value}"' + else: + template = "{option}{spacer}{value}" + return template.format(option=self.option, spacer=self.spacer, value=value) + else: + return "" + + def setSpacer(self, spacer): + self.spacer = spacer + return self + + +class Argument(CommandMapping): + def map(self, value): + return str(value) diff --git a/openwebrx/owrx/config/__init__.py b/openwebrx/owrx/config/__init__.py new file mode 100644 index 0000000..bbd0d57 --- /dev/null +++ b/openwebrx/owrx/config/__init__.py @@ -0,0 +1,43 @@ +from owrx.property import PropertyStack +from owrx.config.error import ConfigError +from owrx.config.defaults import defaultConfig +from owrx.config.dynamic import DynamicConfig +from owrx.config.classic import ClassicConfig + + +class Config(PropertyStack): + sharedConfig = None + + def __init__(self): + super().__init__() + self.storableConfig = DynamicConfig() + layers = [ + self.storableConfig, + ClassicConfig(), + defaultConfig, + ] + for i, l in enumerate(layers): + self.addLayer(i, l) + + @staticmethod + def get(): + if Config.sharedConfig is None: + Config.sharedConfig = Config() + return Config.sharedConfig + + def store(self): + self.storableConfig.store() + + @staticmethod + def validateConfig(): + # no config checks atm + # just basic loading verification + Config.get() + + def __setitem__(self, key, value): + # in the config, all writes go to the json layer + return self.storableConfig.__setitem__(key, value) + + def __delitem__(self, key): + # all deletes go to the json layer, too + return self.storableConfig.__delitem__(key) diff --git a/openwebrx/owrx/config/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02a44846e33cc3be7a60f15f436a59be74a7e917 GIT binary patch literal 1573 zcmZux&2Aev5FT=Ww31d%;y7&s1ZXcUpd9QM2#O$1+e=^s$e{p{fY>Z)XJfA9aH%E| z9Pr)U@+PAA9J$#2$9Ev+kkT+ECMRx&)EPl~!Kw}wMg zG|k#j(!T2vbrYY12Eh~vu9%44!!c7_MHiWfb)sUGT)>KnG$0i{v}LOAZJ8RV;RT4k z%GC(pgZB`F7-9}PZ5luvm8=Uyq$Ma6xcMEGgB5&Wm%zaB6)ZtUOL!f*cm*ywiO$)( zpIo|REA|*(>^hs&f27zAyaK8Wi_dsl$Ij3JB^## z;-%36i%rpJ2}j%4lUc28;k3vKrJfXXZO3sNH`*MF)X-vd)( zB#t&GN9qUQ!hwT*4699kF z;NrtE+(*%0qmm$50VRuA$s^8a%nPp+k#M$Gv?BjokV3Z3LAEkB^P;x&_8|2C#?ugT zJ9wn3Nv6#7$1IUQjEkx5U(9@$OfwZS8+@PdtHe}lH>E=tA2~vw-a(Z?#3V%gC5)J4 zlAi~WmkH@faSk&UwK9?Uvh_QYzHFZhoN41~AUkd~cnJcc4!YH~{p}*rI#0K&@hIO< znR^0&<6mzn`a?7f%-I6YnBjA;W(&r^h#AaVjV%E$W4ocRTDaFt?7?f;$oIO(~8`UwY)H%B0 zcID}CGF2UXppBw^RKNn(Wv92^fHk~$ZAB>Iq;uOS`b$(NpGx)+p58lyX_v6puwZ*g zli@GA>SG4<`xy@XpHLoy@n(dW?}q&J?nY+P*Su^TjGfEq-s$>q>GNtT6b((+9Zip> z=6N;V$&1Xclisb&PIWYv-zZfnML|Gb#0XbZ9|w(EBa86%P&6svUB(gm8o|GF1^#6? z@UE`RQdolC>TVK+X;n_P?%}eglJ(*A^Zy9_Wr__q ziz-bDy*0c%ja{IVmy=4FB7YW%Ba;{QT|LRtc%-T_rp&`cO%uu)J18r8gcELwHd&vN z3&V9SJHaE(vJ0%DSW%^upK+|-!C~mBb($4)H1g8X{uNm2IxfGDq61XG*H{mKr|mIlLWe-ZTqCmG- zw1-Slp($5;lu1;7n*MVHtk>#wI2&{v@G!7_>Q%$Zvezq$jVon)(=9YvGi;h7((0OI kw6==Ekb@~ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/config/__pycache__/commands.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/commands.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..480924d14e303924d5bdea0b7f50e7d1daa02fa3 GIT binary patch literal 921 zcmY*XOK%e~5cXre*@m>DtyEAB2riX!*>H9y;X9gyGCV`0-Y-C-5q7aP)e8j1tuxU>Z7r}xo=rOt)enN07TqD2%RV}`^iU7|RY_;X>aBlq6 z`y~RZg45e!*GgVxFVmKz88nvHRUF5+2mgGN4Qt-Me{ZiVz?4m)ta>%-JJ#sDvf5MW zXr`=VIq+gM^ME#MU^Ty0Mo;)MxhIg zDlS|gbY@)GmFg%2a_p-pX3F8occc#>*q_6ry0NfT1ryLWNA)#`4gp8|Buw1Q9o4gH z2LQ&S5ROPh_HZ8$S}nrxk=wohCmjRibi`c5c_m84x$ARY)^b*OeZcwEOcb3*4j8bs|68?`O|@^WN{he9>+<2|R!P^ECap zPRL)lxp+7%K8K-E2qI`fMl_}r*Q{V8FZSHpD|pO7$BWvi7S|~Gnuwb4Pl)iPf9%C| z;eAhf^}pdQq(^OQs~qp-gRhh-6>P)V_PbPRskTAE2SwS>@bJZR=|~J3Nn$EU%mfuo zcrTdXqLx$Pi#o{Q1vn)dqIp7ME^DGC!V_{#V_&pI2Xu9@AQoW`#FAKsxgl1>J(!zf z6=y|H5BJW4#M2`1mUma9DGNVb4*^Wp}nuuJ<%#`ZTt{6F*lqyw+ zb4m^xlmGgJ5F6^lQNJv7GD*#D^(flYD$={Dl2JNQ`onWHnv4fM&+-hMupU^n9N~?>GVC;*l3@$?mss6l?Q^UxVDGSw2t>nS3+^pV(Q>;* zK_Ir_cJ(BZCc6se)oLRHMr??x9(~}J)#AntuzLL3y9!CHd=x-pqFN{d6d{TRh~AR& zV2T?`rUH_b8C8S54TRiD_X?veE4AgAAM4A5T!W^TYe=!ekXw%TtT&Lx*|D8$S7y(? zykLN{8~Xs~V`BrzU4q%SOPIi9KKngxxt7I__3^9oLEJ8%OQmulHF76zTyB3S>LHl^ zZykbRt^yL3`|2Q?U4}`?jme=6k|ZDJCP`M|hJFJCp*{;4rw?d{akfl5m%nP|isc&@ z_cDfe;K3Oq<4E=+EDRaaQ=r2c)o&S~jG#k4g%0o=J!Zd9ht?dr=}_-F3Pi*=07>*y zhx2*dQKPn9t^5q1e$*AE)Z_O|*To-Nfd|^^!3@iFcU+oo+6CRL>Z;Xi$iB)=+Am}; zP#thvEudILfuCgkol=cbqn6wz%`&MW-#;YZe$!*>O;mc%VrVk)2RWSPK-|oWexGv2o7kM99pf- z*_=OfH;d@OEf$yYZP$C+$8&Zc->`fAj4sz)H;ufe1y z&qmVhmcsE(y^Y{Hig!@Fivl~roN(mCGR3fJWNF|r>U+Ky@?a%w1Rnlei|Ylpg)PIi z)_gFlhH%zDChlL?b;eoIxL)U%{UMZ?yFF*qxqyakusQpz&yd|38*K<>5zx^4A6>T+ A0RR91 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/config/__pycache__/defaults.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/defaults.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f382e71934f5ba01a96c8acf28d4958ce645a74 GIT binary patch literal 2776 zcmb7FO>i7X6`tK0?XFf@S<=d~u^nfC;IPD&WJ#742(f+~+Zf>p$%#YTVbVLVchuI* z^tgN0tK9?T6Sqy}#37X;aa9iClyKor<>Cv4tEhsiDK5Z;1&ZQGaWSuFf3$Y_05i33 z-uvEP_j|8(hA**hn*V5uHif=v-Yy z=hBGI)+XH|t8kCWmQ92ofB7)9FAEv)Sjc8#bVkvc860mv`*9b~o(Zn7jKVFJs?PAX^g`7u>+!VG*M-lZroh|K$^%7t+Wd zzhv-#|7Pw4F~eF=$t6}u1D1@rEJRDECfU6tFKdoRd6;dYd`(8z>T#R4SwxspBJzamd-AZV7iJa^?nj7ZbmFAL{ika&s3|p&ZUZ)_O&ao zkOEEly;@??dMpxiaUtMQ=MPwgKaLpuUtjzl8(LS@%19bZ>SHX&H7RK_q2rrN==10& zAEM7cvj$)I^@oTpBlh0gK4O105&P3V#_?4(F;`LX_?H(AzTI3gTBYSz1A9XX3NBSK zf4qGMlm7gUkz{FrIm*O<6h46fK zvo5RRmTqqiXPAHXy7h^3&h-9Ss*za=YaaeQod+hA!aRJ)nu7i4Hd~G7g2Yg z#UyuQVRLQjG#jWhlnbz!^5esjz>drg%`u5JQ?cw4Zv{^Zy-Ns+@F0rv{=gTpDZh@c~4YpG+J|pet~w^&&;aqCF7{C+4xw zNUS<+o0O6rFv2Y+1 znQZhs?w4MVCKRE7Uk2HaSH4It%6?MoI3sqgk)mB`F6 zI9T8jeU9;{4$LO|K;Ps>#Y#$|ArI!TI_dwZ)E7!pkc@%|{bo`ER-<)4-V|Ytd%rN` zJnB>OpEL6!it)$72CK@0c^zjScY=2FJufvEHIu??cvR{>dtKj9>NL`EGn*NHi%&_z znl5i;hq2sK(&R{AH*7U*^S|)VkCM#{`~E-DOfH)#;@e+a|69o9@YPf!&*&}0aBu$w D&5=U{ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/config/__pycache__/dynamic.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/dynamic.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8befd7d66aea5a0dc458f384b97c5680de4b138 GIT binary patch literal 2950 zcmZ`*O>Y}T7@pY=d+peXo2H=+sER6za4Aj&f&+vIwP`s3Q6Wezt5z$svkBX*chlJk zsa@wnA{8N372?XlKJsh0&y`d82|e+=v$h*MV0mWW*`1H~eV+GYZr1BnhTmWRJZRTE z#{Q-?SH57P zB+4gDlq2`p_N&6(W=-!Uc4bYjtG6;4-OAFPco!q@>v&hTRVJI3uHTi}ek4`*t9Cb% zdSN`=jFL!20<+7v(=Zc}+zM&4@sAXzU*1C(F`o!3&CAToY+P88>0$JPe6p4uq6>`^ zt%(Bj)_zx#a}&_{5<14K+~N&>5zmIT!smI3_m-y^YK^VMc~#Om&eFE*P7z1s-J(5j zq9fu!(Z_v5-zjn|kYj_K50o=-4LRJ9(*QXm=^pEI0M56XB}tpg1?n2qouiJRmW$|` zj(iIb?Isil@(p@_k-C*h_a;d+cNs0Op<`BwFB(g0XRRFZFo_y+Od`P8MOzrV;DV3f zR09jnXgw!g?XBMBr=qRefrw=kB22qSYx&3#TI2&w?$1w8Xxh@kj=Hpy$xd7Oi}0Ob zW(v>_Cs|}CagmK;wa4R#mXsql#Prgm>gk{|A&UbKrqEOcm{=lh?oPuNobwK)m z=89$i@#S(LA)nOcpW#JPf=v|rPeq@S@*whVv6I4o1qE%O?F%a{bUhM zCMdS)JW?$N7!Q|GkT?a7VtWs57~f+oy5`x~Fq z?&M!3*1I-KP!U?K^&|`1Nxso~y$mr$o+2q$OfJRA6l7;1qZyx|jh*;R5hxCsn0*E- zQ|`@WqVQ#^%d=RK0c7}jI*2m$E@8EJ#D8HFjc^}0%WqRSkr#mM8+aI?Mh3k0W1y74 zZwyKswC@O|ccxK-ujQu@nk>QICYBtUFpbXmC=)FoV(ZVw+cVx+k5`v_RQp5mxbHN}fq3fff zA4W-nEHx>3V@{2_lw5InEvmf7YjsrlO3n33o?R=~+?s9DZ*xvJ=$gs;-(3l)m#K&r=B~^87^0*TiXWy+CM#e92^?8UnR} H=Gp%O+Ps>N literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/config/__pycache__/error.cpython-37.pyc b/openwebrx/owrx/config/__pycache__/error.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67a8805eb837d8cf84503ed41048874ea4dd6ed2 GIT binary patch literal 521 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 JezVf7{U2yJS^fY3 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/config/classic.py b/openwebrx/owrx/config/classic.py new file mode 100644 index 0000000..a91d57b --- /dev/null +++ b/openwebrx/owrx/config/classic.py @@ -0,0 +1,36 @@ +from owrx.property import PropertyReadOnly, PropertyLayer +from owrx.config.migration import Migrator +import importlib.util + + +class ClassicConfig(PropertyReadOnly): + def __init__(self): + pm = ClassicConfig._loadConfig() + Migrator.migrate(pm) + super().__init__(pm) + + @staticmethod + def _loadConfig(): + for file in ["/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: + try: + return ClassicConfig._loadPythonFile(file) + except FileNotFoundError: + pass + return PropertyLayer() + + @staticmethod + def _toLayer(dictionary: dict): + layer = PropertyLayer() + for k, v in dictionary.items(): + if isinstance(v, dict): + layer[k] = ClassicConfig._toLayer(v) + else: + layer[k] = v + return layer + + @staticmethod + def _loadPythonFile(file): + spec = importlib.util.spec_from_file_location("config_webrx", file) + cfg = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cfg) + return ClassicConfig._toLayer({k: v for k, v in cfg.__dict__.items() if not k.startswith("__")}) diff --git a/openwebrx/owrx/config/commands.py b/openwebrx/owrx/config/commands.py new file mode 100644 index 0000000..153ca78 --- /dev/null +++ b/openwebrx/owrx/config/commands.py @@ -0,0 +1,30 @@ +from owrx.admin.commands import Command +from owrx.config import Config +from owrx.bookmarks import Bookmarks + + +class MigrateCommand(Command): + # these keys have been moved to openwebrx.conf + blacklisted_keys = [ + "temporary_directory", + "web_port", + "aprs_symbols_path", + ] + + def run(self, args): + print("Migrating configuration...") + + config = Config.get() + # a key that is set will end up in the DynamicConfig, so this will transfer everything there + for key, value in config.items(): + if key not in MigrateCommand.blacklisted_keys: + config[key] = value + config.store() + + print("Migrating bookmarks...") + # bookmarks just need to be saved + b = Bookmarks.getSharedInstance() + b.getBookmarks() + b.store() + + print("Migration complete!") diff --git a/openwebrx/owrx/config/core.py b/openwebrx/owrx/config/core.py new file mode 100644 index 0000000..e22f004 --- /dev/null +++ b/openwebrx/owrx/config/core.py @@ -0,0 +1,59 @@ +from owrx.config import ConfigError +from configparser import ConfigParser +import os +from glob import glob + + +class CoreConfig(object): + defaults = { + "core": { + "data_directory": "/var/lib/openwebrx", + "temporary_directory": "/tmp", + }, + "web": { + "port": 8073, + }, + "aprs": { + "symbols_path": "/usr/share/aprs-symbols/png" + } + } + + def __init__(self): + config = ConfigParser() + # set up config defaults + config.read_dict(CoreConfig.defaults) + # check for overrides + overrides_dir = "/etc/openwebrx/openwebrx.conf.d" + if os.path.exists(overrides_dir) and os.path.isdir(overrides_dir): + overrides = glob(overrides_dir + "/*.conf") + else: + overrides = [] + # sequence things together + config.read(["./openwebrx.conf", "/etc/openwebrx/openwebrx.conf"] + overrides) + self.data_directory = config.get("core", "data_directory") + CoreConfig.checkDirectory(self.data_directory, "data_directory") + self.temporary_directory = config.get("core", "temporary_directory") + CoreConfig.checkDirectory(self.temporary_directory, "temporary_directory") + self.web_port = config.getint("web", "port") + self.aprs_symbols_path = config.get("aprs", "symbols_path") + + @staticmethod + def checkDirectory(dir, key): + if not os.path.exists(dir): + raise ConfigError(key, "{dir} doesn't exist".format(dir=dir)) + if not os.path.isdir(dir): + raise ConfigError(key, "{dir} is not a directory".format(dir=dir)) + if not os.access(dir, os.W_OK): + raise ConfigError(key, "{dir} is not writable".format(dir=dir)) + + def get_web_port(self): + return self.web_port + + def get_data_directory(self): + return self.data_directory + + def get_temporary_directory(self): + return self.temporary_directory + + def get_aprs_symbols_path(self): + return self.aprs_symbols_path diff --git a/openwebrx/owrx/config/defaults.py b/openwebrx/owrx/config/defaults.py new file mode 100644 index 0000000..f03dded --- /dev/null +++ b/openwebrx/owrx/config/defaults.py @@ -0,0 +1,176 @@ +from owrx.property import PropertyLayer + + +defaultConfig = PropertyLayer( + version=7, + max_clients=20, + receiver_name="[Callsign]", + receiver_location="Budapest, Hungary", + receiver_asl=200, + receiver_admin="example@example.com", + receiver_gps=PropertyLayer(lat=47.0, lon=19.0), + photo_title="Panorama of Budapest from Schönherz Zoltán Dormitory", + photo_desc="", + fft_fps=9, + fft_size=4096, + fft_voverlap_factor=0.3, + audio_compression="adpcm", + fft_compression="adpcm", + wfm_deemphasis_tau=50e-6, + digimodes_fft_size=2048, + digital_voice_dmr_id_lookup=True, + digital_voice_nxdn_id_lookup=True, + sdrs=PropertyLayer( + rtlsdr=PropertyLayer( + name="RTL-SDR USB Stick", + type="rtl_sdr", + profiles=PropertyLayer( + **{ + "70cm": PropertyLayer( + name="70cm Repeaters", + center_freq=438800000, + rf_gain=29, + samp_rate=2400000, + start_freq=439275000, + start_mod="nfm", + ), + "2m": PropertyLayer( + name="2m", + center_freq=145000000, + rf_gain=29, + samp_rate=2048000, + start_freq=145725000, + start_mod="nfm", + ), + } + ), + ), + airspy=PropertyLayer( + name="Airspy HF+", + type="airspyhf", + rf_gain="auto", + profiles=PropertyLayer( + **{ + "20m": PropertyLayer( + name="20m", + center_freq=14150000, + samp_rate=384000, + start_freq=14070000, + start_mod="usb", + ), + "30m": PropertyLayer( + name="30m", + center_freq=10125000, + samp_rate=192000, + start_freq=10142000, + start_mod="usb", + ), + "40m": PropertyLayer( + name="40m", + center_freq=7100000, + samp_rate=256000, + start_freq=7070000, + start_mod="lsb", + ), + "80m": PropertyLayer( + name="80m", + center_freq=3650000, + samp_rate=384000, + start_freq=3570000, + start_mod="lsb", + ), + "49m": PropertyLayer( + name="49m Broadcast", + center_freq=6050000, + samp_rate=384000, + start_freq=6070000, + start_mod="am", + ), + } + ), + ), + sdrplay=PropertyLayer( + name="SDRPlay RSP2", + type="sdrplay", + antenna="Antenna A", + profiles=PropertyLayer( + **{ + "20m": PropertyLayer( + name="20m", + center_freq=14150000, + rf_gain=0, + samp_rate=500000, + start_freq=14070000, + start_mod="usb", + ), + "30m": PropertyLayer( + name="30m", + center_freq=10125000, + rf_gain=0, + samp_rate=250000, + start_freq=10142000, + start_mod="usb", + ), + "40m": PropertyLayer( + name="40m", + center_freq=7100000, + rf_gain=0, + samp_rate=500000, + start_freq=7070000, + start_mod="lsb", + ), + "80m": PropertyLayer( + name="80m", + center_freq=3650000, + rf_gain=0, + samp_rate=500000, + start_freq=3570000, + start_mod="lsb", + ), + "49m": PropertyLayer( + name="49m Broadcast", + center_freq=6000000, + rf_gain=0, + samp_rate=500000, + start_freq=6070000, + start_mod="am", + ), + } + ), + ), + ), + waterfall_scheme="GoogleTurboWaterfall", + waterfall_levels=PropertyLayer(min=-88, max=-20), + waterfall_auto_levels=PropertyLayer(min=3, max=10), + waterfall_auto_min_range=50, + tuning_precision=2, + squelch_auto_margin=10, + google_maps_api_key="", + map_position_retention_time=2 * 60 * 60, + decoding_queue_workers=2, + decoding_queue_length=10, + wsjt_decoding_depth=3, + wsjt_decoding_depths=PropertyLayer(jt65=1), + fst4_enabled_intervals=[15, 30], + fst4w_enabled_intervals=[120, 300], + q65_enabled_combinations=["A30", "E120", "C60"], + js8_enabled_profiles=["normal", "slow"], + js8_decoding_depth=3, + services_enabled=False, + services_decoders=["ft8", "ft4", "wspr", "packet"], + aprs_callsign="N0CALL", + aprs_igate_enabled=False, + aprs_igate_server="euro.aprs2.net", + aprs_igate_password="", + aprs_igate_beacon=False, + aprs_igate_symbol="R&", + aprs_igate_comment="OpenWebRX APRS gateway", + # aprs_igate_height=None, + # aprs_igate_gain=None, + # aprs_igate_dir=None, + pskreporter_enabled=False, + pskreporter_callsign="N0CALL", + # pskreporter_antenna_information=None, + wsprnet_enabled=False, + wsprnet_callsign="N0CALL", +).readonly() diff --git a/openwebrx/owrx/config/dynamic.py b/openwebrx/owrx/config/dynamic.py new file mode 100644 index 0000000..9357e05 --- /dev/null +++ b/openwebrx/owrx/config/dynamic.py @@ -0,0 +1,62 @@ +from owrx.config.core import CoreConfig +from owrx.config.migration import Migrator +from owrx.property import PropertyLayer, PropertyDeleted +from owrx.jsons import Encoder +import json + + +class DynamicConfig(PropertyLayer): + def __init__(self): + super().__init__() + try: + with open(DynamicConfig._getSettingsFile(), "r") as f: + for k, v in json.load(f).items(): + if isinstance(v, dict): + self[k] = DynamicConfig._toLayer(v) + else: + self[k] = v + except FileNotFoundError: + pass + Migrator.migrate(self) + + @staticmethod + def _toLayer(dictionary: dict): + layer = PropertyLayer() + for k, v in dictionary.items(): + if isinstance(v, dict): + layer[k] = DynamicConfig._toLayer(v) + else: + layer[k] = v + return layer + + @staticmethod + def _getSettingsFile(): + coreConfig = CoreConfig() + return "{data_directory}/settings.json".format(data_directory=coreConfig.get_data_directory()) + + def store(self): + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps(self.__dict__(), indent=4, cls=Encoder) + with open(DynamicConfig._getSettingsFile(), "w") as file: + file.write(jsonContent) + + def __delitem__(self, key): + self.__setitem__(key, PropertyDeleted) + + def __contains__(self, item): + if not super().__contains__(item): + return False + if super().__getitem__(item) is PropertyDeleted: + return False + return True + + def __getitem__(self, item): + if self.__contains__(item): + return super().__getitem__(item) + raise KeyError('Key "{key}" does not exist'.format(key=item)) + + def __dict__(self): + return {k: v for k, v in super().__dict__().items() if v is not PropertyDeleted} + + def keys(self): + return [k for k in super().keys() if self.__contains__(k)] diff --git a/openwebrx/owrx/config/error.py b/openwebrx/owrx/config/error.py new file mode 100644 index 0000000..19e1119 --- /dev/null +++ b/openwebrx/owrx/config/error.py @@ -0,0 +1,3 @@ +class ConfigError(Exception): + def __init__(self, key, message): + super().__init__("Configuration Error (key: {0}): {1}".format(key, message)) diff --git a/openwebrx/owrx/config/migration.py b/openwebrx/owrx/config/migration.py new file mode 100644 index 0000000..ec0068b --- /dev/null +++ b/openwebrx/owrx/config/migration.py @@ -0,0 +1,134 @@ +from abc import ABC, abstractmethod +from owrx.property import PropertyLayer + +import logging + +logger = logging.getLogger(__name__) + + +class ConfigMigrator(ABC): + @abstractmethod + def migrate(self, config): + pass + + def renameKey(self, config, old, new): + if old in config and new not in config: + config[new] = config[old] + del config[old] + + +class ConfigMigratorVersion1(ConfigMigrator): + def migrate(self, config): + if "receiver_gps" in config: + gps = config["receiver_gps"] + config["receiver_gps"] = {"lat": gps[0], "lon": gps[1]} + + if "waterfall_auto_level_margin" in config: + levels = config["waterfall_auto_level_margin"] + config["waterfall_auto_level_margin"] = {"min": levels[0], "max": levels[1]} + + self.renameKey(config, "wsjt_queue_workers", "decoding_queue_workers") + self.renameKey(config, "wsjt_queue_length", "decoding_queue_length") + + config["version"] = 2 + + +class ConfigMigratorVersion2(ConfigMigrator): + def migrate(self, config): + if "waterfall_colors" in config and any(v > 0xFFFFFF for v in config["waterfall_colors"]): + config["waterfall_colors"] = [v >> 8 for v in config["waterfall_colors"]] + + config["version"] = 3 + + +class ConfigMigratorVersion3(ConfigMigrator): + def migrate(self, config): + # inline import due to circular dependencies + from owrx.waterfall import WaterfallOptions + + if "waterfall_scheme" in config: + scheme = WaterfallOptions(config["waterfall_scheme"]) + if scheme is not WaterfallOptions.CUSTOM and "waterfall_colors" in config: + del config["waterfall_colors"] + elif "waterfall_colors" in config: + scheme = WaterfallOptions.findByColors(config["waterfall_colors"]) + if scheme is not WaterfallOptions.CUSTOM: + logger.debug("detected waterfall option: %s", scheme.value) + if "waterfall_colors" in config: + del config["waterfall_colors"] + config["waterfall_scheme"] = scheme.value + + config["version"] = 4 + + +class ConfigMigratorVersion4(ConfigMigrator): + def _replaceWaterfallLevels(self, instance): + if ( + "waterfall_min_level" in instance + and "waterfall_max_level" in instance + and not "waterfall_levels" in instance + ): + instance["waterfall_levels"] = { + "min": instance["waterfall_min_level"], + "max": instance["waterfall_max_level"], + } + del instance["waterfall_min_level"] + del instance["waterfall_max_level"] + + def migrate(self, config): + # migrate root level + self._replaceWaterfallLevels(config) + if "sdrs" in config: + for device in config["sdrs"].__dict__().values(): + # migrate device level + self._replaceWaterfallLevels(device) + if "profiles" in device: + for profile in device["profiles"].__dict__().values(): + # migrate profile level + self._replaceWaterfallLevels(profile) + + config["version"] = 5 + + +class ConfigMigratorVersion5(ConfigMigrator): + def migrate(self, config): + if "frequency_display_precision" in config: + # old config was always in relation to the display in MHz (1e6 Hz, hence the 6) + config["tuning_precision"] = 6 - config["frequency_display_precision"] + del config["frequency_display_precision"] + config["version"] = 6 + + +class ConfigMigratorVersion6(ConfigMigrator): + def migrate(self, config): + if "waterfall_auto_level_margin" in config: + walm_config = config["waterfall_auto_level_margin"] + if "min_range" in walm_config: + config["waterfall_auto_min_range"] = walm_config["min_range"] + wal = {k: v for k, v in walm_config.items() if k in ["min", "max"]} + config["waterfall_auto_levels"] = PropertyLayer(**wal) + del config["waterfall_auto_level_margin"] + config["version"] = 7 + + +class Migrator(object): + currentVersion = 7 + migrators = { + 1: ConfigMigratorVersion1(), + 2: ConfigMigratorVersion2(), + 3: ConfigMigratorVersion3(), + 4: ConfigMigratorVersion4(), + 5: ConfigMigratorVersion5(), + 6: ConfigMigratorVersion6(), + } + + @staticmethod + def migrate(config): + version = config["version"] if "version" in config else 1 + if version == Migrator.currentVersion: + return config + + logger.debug("migrating config from version %i", version) + migrators = [Migrator.migrators[i] for i in range(version, Migrator.currentVersion)] + for migrator in migrators: + migrator.migrate(config) diff --git a/openwebrx/owrx/connection.py b/openwebrx/owrx/connection.py new file mode 100644 index 0000000..a991eb8 --- /dev/null +++ b/openwebrx/owrx/connection.py @@ -0,0 +1,536 @@ +from owrx.details import ReceiverDetails +from owrx.dsp import DspManager +from owrx.cpu import CpuUsageThread +from owrx.sdr import SdrService +from owrx.source import SdrSourceState, SdrClientClass, SdrSourceEventClient +from owrx.client import ClientRegistry, TooManyClientsException +from owrx.feature import FeatureDetector +from owrx.version import openwebrx_version +from owrx.bands import Bandplan +from owrx.bookmarks import Bookmarks +from owrx.map import Map +from owrx.property import PropertyStack, PropertyDeleted +from owrx.modes import Modes, DigitalMode +from owrx.config import Config +from owrx.waterfall import WaterfallOptions +from owrx.websocket import Handler +from queue import Queue, Full, Empty +from js8py import Js8Frame +from abc import ABCMeta, abstractmethod +import json +import threading + +import logging + +logger = logging.getLogger(__name__) + +PoisonPill = object() + + +class Client(Handler, metaclass=ABCMeta): + def __init__(self, conn): + self.conn = conn + self.multithreadingQueue = Queue(100) + + def mp_passthru(): + run = True + while run: + try: + data = self.multithreadingQueue.get() + if data is PoisonPill: + run = False + else: + self.send(data) + self.multithreadingQueue.task_done() + except (EOFError, OSError, ValueError): + run = False + except Exception: + logger.exception("Exception on client multithreading queue") + + # unset the queue object to free shared memory file descriptors + self.multithreadingQueue = None + + threading.Thread(target=mp_passthru, name="connection_mp_passthru").start() + + def send(self, data): + try: + self.conn.send(data) + except IOError: + self.close() + + def close(self): + if self.multithreadingQueue is not None: + while True: + try: + self.multithreadingQueue.get(block=False) + except Empty: + break + try: + self.multithreadingQueue.put(PoisonPill, block=False) + except Full: + # this shouldn't happen, we just emptied the queue, but it's not worth risking the exception + logger.exception("impossible queue state: Full after Empty") + self.conn.close() + + def mp_send(self, data): + if self.multithreadingQueue is None: + return + try: + self.multithreadingQueue.put(data, block=False) + except Full: + self.close() + + @abstractmethod + def handleTextMessage(self, conn, message): + pass + + def handleBinaryMessage(self, conn, data): + logger.error("unsupported binary message, discarding") + + def handleClose(self): + self.close() + + +class OpenWebRxClient(Client, metaclass=ABCMeta): + def __init__(self, conn): + super().__init__(conn) + + receiver_details = ReceiverDetails() + + def send_receiver_info(*args): + receiver_info = receiver_details.__dict__() + self.write_receiver_details(receiver_info) + + self._detailsSubscription = receiver_details.wire(send_receiver_info) + send_receiver_info() + + def write_receiver_details(self, details): + self.send({"type": "receiver_details", "value": details}) + + def close(self): + self._detailsSubscription.cancel() + super().close() + + +class OpenWebRxReceiverClient(OpenWebRxClient, SdrSourceEventClient): + sdr_config_keys = [ + "waterfall_levels", + "samp_rate", + "start_mod", + "start_freq", + "center_freq", + "initial_squelch_level", + "sdr_id", + "profile_id", + "squelch_auto_margin", + ] + + global_config_keys = [ + "waterfall_scheme", + "waterfall_colors", + "waterfall_auto_levels", + "waterfall_auto_min_range", + "fft_size", + "audio_compression", + "fft_compression", + "max_clients", + "tuning_precision", + ] + + def __init__(self, conn): + super().__init__(conn) + + self.dsp = None + self.dspLock = threading.Lock() + self.sdr = None + self.configSubs = [] + self.bookmarkSub = None + self.connectionProperties = {} + + try: + ClientRegistry.getSharedInstance().addClient(self) + except TooManyClientsException: + self.write_backoff_message("Too many clients") + self.close() + raise + + self.setupGlobalConfig() + self.stack = self.setupStack() + + self.setSdr() + + features = FeatureDetector().feature_availability() + self.write_features(features) + + modes = Modes.getModes() + self.write_modes(modes) + + self.configSubs.append(SdrService.getActiveSources().wire(self._onSdrDeviceChanges)) + self.configSubs.append(SdrService.getAvailableProfiles().wire(self._sendProfiles)) + self._sendProfiles() + + CpuUsageThread.getSharedInstance().add_client(self) + + def setupStack(self): + stack = PropertyStack() + # stack layer 0 reserved for sdr properties + # stack.addLayer(0, self.sdr.getProps()) + stack.addLayer(1, Config.get()) + configProps = stack.filter(*OpenWebRxReceiverClient.sdr_config_keys) + + def sendConfig(changes=None): + if changes is None: + config = configProps.__dict__() + else: + # transform deletions into Nones + config = {k: v if v is not PropertyDeleted else None for k, v in changes.items()} + if ( + (changes is None or "start_freq" in changes or "center_freq" in changes) + and "start_freq" in configProps + and "center_freq" in configProps + ): + config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] + if (changes is None or "profile_id" in changes) and self.sdr is not None: + config["sdr_id"] = self.sdr.getId() + self.write_config(config) + + def sendBookmarks(*args): + cf = configProps["center_freq"] + srh = configProps["samp_rate"] / 2 + dial_frequencies = [] + bookmarks = [] + if "center_freq" in configProps and "samp_rate" in configProps: + frequencyRange = (cf - srh, cf + srh) + dial_frequencies = Bandplan.getSharedInstance().collectDialFrequencies(frequencyRange) + bookmarks = [b.__dict__() for b in Bookmarks.getSharedInstance().getBookmarks(frequencyRange)] + self.write_dial_frequencies(dial_frequencies) + self.write_bookmarks(bookmarks) + + def updateBookmarkSubscription(*args): + if self.bookmarkSub is not None: + self.bookmarkSub.cancel() + if "center_freq" in configProps and "samp_rate" in configProps: + cf = configProps["center_freq"] + srh = configProps["samp_rate"] / 2 + frequencyRange = (cf - srh, cf + srh) + self.bookmarkSub = Bookmarks.getSharedInstance().subscribe(frequencyRange, sendBookmarks) + sendBookmarks() + + self.configSubs.append(configProps.wire(sendConfig)) + self.configSubs.append(stack.filter("center_freq", "samp_rate").wire(updateBookmarkSubscription)) + + # send initial config + sendConfig() + return stack + + def setupGlobalConfig(self): + def writeConfig(changes): + # TODO it would be nicer to have all options available and switchable in the client + # this restores the existing functionality for now, but there is lots of potential + if "waterfall_scheme" in changes or "waterfall_colors" in changes: + scheme = WaterfallOptions(globalConfig["waterfall_scheme"]).instantiate() + changes["waterfall_colors"] = scheme.getColors() + self.write_config(changes) + + globalConfig = Config.get().filter(*OpenWebRxReceiverClient.global_config_keys) + self.configSubs.append(globalConfig.wire(writeConfig)) + writeConfig(globalConfig.__dict__()) + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: + self.handleSdrAvailable() + + def onFail(self): + logger.warning('SDR device "%s" has failed, selecting new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName())) + self.setSdr() + + def onDisable(self): + logger.warning('SDR device "%s" was disabled, selecting new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" was disabled, selecting new device'.format(self.sdr.getName())) + self.setSdr() + + def onShutdown(self): + logger.warning('SDR device "%s" is shutting down, selecting new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" is shutting down, selecting new device'.format(self.sdr.getName())) + self.setSdr() + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER + + def _onSdrDeviceChanges(self, changes): + # restart the client if an sdr has become available + if self.sdr is None and any(s is not PropertyDeleted for s in changes.values()): + self.setSdr() + + def _sendProfiles(self, *args): + profiles = [{"id": pid, "name": name} for pid, name in SdrService.getAvailableProfiles().items()] + self.write_profiles(profiles) + + def handleTextMessage(self, conn, message): + try: + message = json.loads(message) + if "type" in message: + if message["type"] == "dspcontrol": + dsp = self.getDsp() + if dsp is None: + logger.warning("DSP not available; discarding client dspcontrol message") + else: + if "action" in message and message["action"] == "start": + dsp.start() + + if "params" in message: + params = message["params"] + dsp.setProperties(params) + + elif message["type"] == "setsdr": + if "params" in message: + self.setSdr(message["params"]["sdr"]) + elif message["type"] == "selectprofile": + if "params" in message and "profile" in message["params"]: + profile = message["params"]["profile"].split("|") + self.setSdr(profile[0]) + self.sdr.activateProfile(profile[1]) + elif message["type"] == "connectionproperties": + if "params" in message: + self.connectionProperties = message["params"] + if self.dsp: + self.getDsp().setProperties(self.connectionProperties) + + else: + logger.warning("received message without type: {0}".format(message)) + + except json.JSONDecodeError: + logger.warning("message is not json: {0}".format(message)) + + def setSdr(self, id=None): + next = None + if id is not None: + next = SdrService.getSource(id) + if next is None: + next = SdrService.getFirstSource() + + # exit condition: no change + if next == self.sdr and next is not None: + return + + self.stopDsp() + self.stack.removeLayerByPriority(0) + + if self.sdr is not None: + self.sdr.removeClient(self) + + self.sdr = next + + if next is None: + # exit condition: no sdrs available + logger.warning("no more SDR devices available") + self.handleNoSdrsAvailable() + return + + self.sdr.addClient(self) + + def handleSdrAvailable(self): + self.getDsp().setProperties(self.connectionProperties) + self.stack.replaceLayer(0, self.sdr.getProps()) + + self.sdr.addSpectrumClient(self) + + def handleNoSdrsAvailable(self): + self.write_sdr_error("No SDR Devices available") + + def close(self): + if self.sdr is not None: + self.sdr.removeClient(self) + self.stopDsp() + CpuUsageThread.getSharedInstance().remove_client(self) + ClientRegistry.getSharedInstance().removeClient(self) + while self.configSubs: + self.configSubs.pop().cancel() + if self.bookmarkSub is not None: + self.bookmarkSub.cancel() + self.bookmarkSub = None + super().close() + + def stopDsp(self): + with self.dspLock: + if self.dsp is not None: + self.dsp.stop() + self.dsp = None + if self.sdr is not None: + self.sdr.removeSpectrumClient(self) + + def getDsp(self): + with self.dspLock: + if self.dsp is None and self.sdr is not None: + self.dsp = DspManager(self, self.sdr) + return self.dsp + + def write_spectrum_data(self, data): + self.mp_send(bytes([0x01]) + data) + + def write_dsp_data(self, data): + self.send(bytes([0x02]) + data) + + def write_hd_audio(self, data): + self.send(bytes([0x04]) + data) + + def write_s_meter_level(self, level): + try: + self.send({"type": "smeter", "value": level}) + except ValueError: + logger.warning("unable to send smeter value: %s", str(level)) + + def write_cpu_usage(self, usage): + self.mp_send({"type": "cpuusage", "value": usage}) + + def write_clients(self, clients): + self.mp_send({"type": "clients", "value": clients}) + + def write_secondary_fft(self, data): + self.send(bytes([0x03]) + data) + + def write_secondary_demod(self, data): + message = data.decode("ascii", "replace") + self.send({"type": "secondary_demod", "value": message}) + + def write_secondary_dsp_config(self, cfg): + self.send({"type": "secondary_config", "value": cfg}) + + def write_config(self, cfg): + self.send({"type": "config", "value": cfg}) + + def write_profiles(self, profiles): + self.send({"type": "profiles", "value": profiles}) + + def write_features(self, features): + self.send({"type": "features", "value": features}) + + def write_metadata(self, metadata): + self.send({"type": "metadata", "value": metadata}) + + def write_wsjt_message(self, message): + self.send({"type": "wsjt_message", "value": message}) + + def write_dial_frequencies(self, frequencies): + self.send({"type": "dial_frequencies", "value": frequencies}) + + def write_bookmarks(self, bookmarks): + self.send({"type": "bookmarks", "value": bookmarks}) + + def write_aprs_data(self, data): + self.send({"type": "aprs_data", "value": data}) + + def write_log_message(self, message): + self.send({"type": "log_message", "value": message}) + + def write_sdr_error(self, message): + self.send({"type": "sdr_error", "value": message}) + + def write_pocsag_data(self, data): + self.send({"type": "pocsag_data", "value": data}) + + def write_backoff_message(self, reason): + self.send({"type": "backoff", "reason": reason}) + + def write_js8_message(self, frame: Js8Frame, freq: int): + self.send( + { + "type": "js8_message", + "value": { + "msg": str(frame), + "timestamp": frame.timestamp, + "db": frame.db, + "dt": frame.dt, + "freq": freq + frame.freq, + "thread_type": frame.thread_type, + "mode": frame.mode, + }, + } + ) + + def write_modes(self, modes): + def to_json(m): + res = { + "modulation": m.modulation, + "name": m.name, + "type": "digimode" if isinstance(m, DigitalMode) else "analog", + "requirements": m.requirements, + "squelch": m.squelch, + } + if m.bandpass is not None: + res["bandpass"] = {"low_cut": m.bandpass.low_cut, "high_cut": m.bandpass.high_cut} + if isinstance(m, DigitalMode): + res["underlying"] = m.underlying + return res + + self.send({"type": "modes", "value": [to_json(m) for m in modes]}) + + +class MapConnection(OpenWebRxClient): + def __init__(self, conn): + super().__init__(conn) + + pm = Config.get() + filtered_config = pm.filter( + "google_maps_api_key", + "receiver_gps", + "map_position_retention_time", + "receiver_name", + ) + filtered_config.wire(self.write_config) + + self.write_config(filtered_config.__dict__()) + + Map.getSharedInstance().addClient(self) + + def handleTextMessage(self, conn, message): + pass + + def close(self): + Map.getSharedInstance().removeClient(self) + super().close() + + def write_config(self, cfg): + self.send({"type": "config", "value": cfg}) + + def write_update(self, update): + self.mp_send({"type": "update", "value": update}) + + +class HandshakeMessageHandler(Handler): + """ + This handler receives text messages, but will only respond to the second handshake string. + As soon as a valid handshake is received, the handler replaces itself with the corresponding handler type. + """ + def handleTextMessage(self, conn, message): + if message[:16] == "SERVER DE CLIENT": + meta = message[17:].split(" ") + handshake = {v[0]: "=".join(v[1:]) for v in map(lambda x: x.split("="), meta)} + + logger.debug("client connection initialized") + + client = None + if "type" in handshake: + if handshake["type"] == "receiver": + client = OpenWebRxReceiverClient(conn) + elif handshake["type"] == "map": + client = MapConnection(conn) + else: + logger.warning("invalid connection type: %s", handshake["type"]) + + if client is not None: + logger.debug("handshake complete, handing off to %s", type(client).__name__) + # hand off all further communication to the correspondig connection + conn.send("CLIENT DE SERVER server=openwebrx version={version}".format(version=openwebrx_version)) + conn.setMessageHandler(client) + else: + logger.warning('invalid handshake received') + else: + logger.warning("not answering client request since handshake is not complete") + + def handleBinaryMessage(self, conn, data): + pass + + def handleClose(self): + pass diff --git a/openwebrx/owrx/controllers/__init__.py b/openwebrx/owrx/controllers/__init__.py new file mode 100644 index 0000000..bb929ce --- /dev/null +++ b/openwebrx/owrx/controllers/__init__.py @@ -0,0 +1,60 @@ +from datetime import datetime, timezone + + +class BodySizeError(Exception): + pass + + +class Controller(object): + def __init__(self, handler, request, options): + self.handler = handler + self.request = request + self.options = options + self.responseCookies = None + + def send_response( + self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None + ): + self.handler.send_response(code) + if headers is None: + headers = {} + if content_type is not None: + headers["Content-Type"] = content_type + if last_modified is not None: + headers["Last-Modified"] = last_modified.astimezone(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT") + if max_age is not None: + headers["Cache-Control"] = "max-age={0}".format(max_age) + for key, value in headers.items(): + self.handler.send_header(key, value) + if self.responseCookies is not None: + self.handler.send_header("Set-Cookie", self.responseCookies.output(header="")) + self.handler.end_headers() + if type(content) == str: + content = content.encode() + while len(content): + w = self.handler.wfile.write(content) + content = content[w:] + + def send_redirect(self, location, code=303): + self.handler.send_response(code) + if self.responseCookies is not None: + self.handler.send_header("Set-Cookie", self.responseCookies.output(header="")) + self.handler.send_header("Location", location) + self.handler.end_headers() + + def set_response_cookies(self, cookies): + self.responseCookies = cookies + + def get_body(self, max_size=None): + if "Content-Length" not in self.handler.headers: + return None + length = int(self.handler.headers["Content-Length"]) + if max_size is not None and length > max_size: + raise BodySizeError("HTTP body exceeds maximum allowed size") + return self.handler.rfile.read(length) + + def handle_request(self): + action = "indexAction" + if "action" in self.options: + action = self.options["action"] + getattr(self, action)() diff --git a/openwebrx/owrx/controllers/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8144428d46e2fe75d6d011baba84fcd8037ee76f GIT binary patch literal 2426 zcmZt|O>Y}TbY{Q3wi7q~Ad!GtNG`Q#k|H4}RY9epG(xR~lt>j;VYS(rH0!TTX2--? z))x|v{RbQzi3@)Nap1;{!(6#g4*YBc+-YAfZvT~P+k0!=Tf z2Kg?SK5@sdzSf7!*CGOx|3p8pO6FG`Y+9;=@cg)3;j?X++dPb2-pO;SwC9;y!V=Aj%L;RZ4$-*fa)6q4#h^OLjr0dis7jk+^ z$0cwa@cq$P0h)u~9z#BX!~9lOK@0MMT-`p}=U^#AX4y(3yisqkdSj>F_`2TMyxn+o zyRlV&@Mt@$J_JLqKAIXbEBC|EYWS3Y^8L+mR^14rJ-#}1N#<{H2?%;xL2M{zkB5wl z=8|@|s-}Fvf>}f;ANWo)Q<&fhWe#Peyi|%^owv?zBKo0Jb}V^6Rb~CU;3yW5-bsdX ze<)QM*%lY6vLtAwOyHs%K8O+qNRJPceYhLq+ zqV0mZ0jLW>u60S3+2u#d{x0kdxuS>XB3HZtkC1hG4FEAK)T0(m{FY%Z&?WeM*!vXk zd^pLLr}l#sT03K+=j%|VBD4(-T<4+tB_KL1W}!k=khL8H!<3n?1d+)s z2##4u*%(qfEbP{+&J&k`dy|SQuqf|g5`~z}O{d6OR8c!XyoI~B5nKh(Gz+78z3Bt- zE?%8W?O718Snx=ql~Uagz#M3n*PEjtj<7D_z;#}u@O4q6W)+EQ>6*se$GY^K7vxgP zhmbfKNW*pNW&}F1!O>C@2kgH}ZTR<5wS0D1hQ@NL2+uYv%s=fp5sm@0c z=Pa#5fX4k{zaI8_$suR;6#CZ8Ro6inWya8Uu&gUv=p3*Fg!0r|Z4Uz+-zW#t1b*YZ zoxG=DC;Y{ms2-MV0=}|n4L*;asmC+Q2%9Sobkf{1C=8=x%Ct4Qj#x&0nXXR~bcY&d z9M1yMY_-erfbr315yqZ!LcM}hv0o?`ekznwOr0z~g2VHIFBEMFe5{+&Eg+e;5(`4j zr`gIY4n9lUw8aJFxCEfpf_5Tqzz(~eFR&R4%tL`aMbr?Wo;vl#I_};_@F9Ya5xmR* zy~Ca+FlrIg28HV2)~@)jXZoJLX@LVZPc)t?-3M3ehFZAMNp?DrJDT!!%$0Yi_>1x> M`XNQv*UXyrA9T(|uK)l5 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/admin.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/admin.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0dfb063062f6cc73e7a96d9b1f23052673dc2b43 GIT binary patch literal 2157 zcmZt{O>^5sbXVV2WH&8oA*Dddp@SKkq$fTm1BJqrk2D8ThN59cqi8p=Vp(!`*CcUe zhM9!vi4z<++DCp3_q}o|KY!ZPh?AHy5?hQi>x{%Uvq71oJ0FfzkC!UXQU%H~JcA2D zK>~n#0P+kFqcuGweP47K*(FFbx=Ie|#8TGOo{@=N+f%2uW_04z4kP_Qyn~X=svS7> zG1AI4k}e|?@8o*zf}FKVR$&KcJv>Xv*Q8u=i1PcP_?{Acz9FUch}0g~YwwJs>PQ^l zmQ8H5GlGzG1MO$Vf2rN?(qhB~x}h&0aJAb@1!td?Ql(|a;}BWRSz&`r+!>0hsLF#m zQwLx0lTU=GMAz4jmP2o0%2BO1Uv0BQji@H+5# zbbRe%^48~9>_A?FBq`GYPZAv_$)IAR0_iA89*@$Z`64cZ5@Y5$w2V9t&=P>eFb34@ zFH{zW4wu{nw5Y77WS^Sq3b4AE16Af2p3A(mAa)(LKnI^D@I` z!-a1b0CmVW0V#4yYLd_zs%>gvmB7wc&MBF?HLb1Mo|!g;o9Ry1GuFnP^g>-XzG7yy zD@Q{vFhF^kt0d_WD2_4o-lB`IjOp+!}CPXiS&`kh|u_BP1 z4jsSzU$52|S~7}qu<(sxy$2v)1q5!vJn6Tp8%Ry*j5O4t6+z3OOSUN~9p*COWx7{z z7OxCOQY9Ife87_-7 zeg&c?Q!*oBMNvqCH3O&0AC!GcCQtx1445s#Y<#a0=4_#~Q&Vhi=7Nvzq1X2WYbfS| zjMul!-e*2e#GfpZFBQI^IN0Z<6$ni94{Z=o_9KDw~unwi#v}V zY=5fM@XKZZjIAe={Ab7?)P$2&jE}=7lcGAv%MYGRv@Q7lGfZx`BW}P;Tmz&#O;IKD zn*|oOUXfe@M-HLBcru?{^Rnyll)+rkZOKdaTu6KSl^CSTjI@MR*=PVWI}uf-1on$= zjYPpqG)g&WsD)6k0O zvbAj8qT`$YQ#Kdf?E2yg92YMj!apB@?b3t=HR$%dr7~U=i?+gfxiHAI*i~Sx0hks8 z8{EJa$m?FxFX0?rKQ~9$=VRCm{x~$BaUe@G<+TUlDe}E0R#6Xf3WxCzQ#Kj#A&rp7 z1KonCYy%Br06owiet>D=ReOgJM>AWD5$Bo=4Hsy!4z`*}`##oz>;Q@&bnsY#723;J F{|5F)9wGn$ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/api.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/api.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4127567234db9d14e8ebcdf7583a9a6488d139d GIT binary patch literal 633 zcmY*WJ#Q2-5FM|*m(#JEq@batK-%W6p+qPl2)b+NR!EjvJJLqpwXuE5heDOK`436S zuc3EK6@CI0GkZiFBhAyy*nab7{I0GSfcE3pXH_wPpOieAz~BfyzDCfXnT3J1jDapR zx5dC)KE=X{R(u3q>e(&mnPE4)Ew%Ur&E6w&Lc{Xn#EmI9YfX5>3Y88%7?n1`yf?{o z$%S=C2TxXDaD*P8BN%9zhPKd57q`%I&5>K=`q=mXcspuEMo&rl33_~qkl>!B;s);d z13a*A>>AF*H6yaS0tWyj&Y~MpW7VN-GbWiXy=lrUsGdu`8GH=X zBk!MXr;x#T7gDaH88s%+yD^%k$RZkhnuS(LeTPuvo8PZi&L?y;o|`Y>V&%>esMB^q zaix5}^q2WrKWcMv+>xR^8iqOoJ7iZc{+oQcb8n4%nM*mU!AO}GQVvdUEX9?SUpLBb zPr?B*O=@;vpK2w-3_a2s=WNQGS%yG)u1M?h@66K%0x6qr?J&`pf#~x5%~rr$`khF` J6|2~s{{i>Lo3H=? literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/assets.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/assets.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..096d91ff175239041a7a94272aedc31c09494cb2 GIT binary patch literal 7371 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 D9*hMe literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/metrics.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/metrics.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8eea59940f6d80a9d9439f9495b76cad839dfc95 GIT binary patch literal 1671 zcmb7EOLH4V5T2QRXroBM7F0rbs=$}cVb=!=szA5`4j~-y#RWxeh1%NejO}&ym6?%a z%hCnA3OMAzKah_68t!xD6n;WZbni-5AP0)6nqJNH%s2h@*Xl3({Vsv=$6pT;89cTXIVosFDdCJ;IV-HlD(uKEoXBbR?A(pquL*a!dr7z}=!F$^xV=yO z&T|S7J~iFls?@T|b0MF@4)HD1->t@_7V=x6WtN)Wy-bQ!FIS!RtM*bny&4RD+=Z#$ z0V7C6If)pjoL!R0;ug2zYjX!4cFpDzVf}E5vEV=$5%Qk>RMc!Cex#0^o% z;Xx%=>+{*9fBsmU&4%YENj?@cLjyx&P{)E@<20MTXFL$~dpuyenaY+@OU#}`cBb1n zzml?&#s&N$(Z-gdmZk$XPg3Dq@+O{i+eqXsyo;i@W)poIwXDE>B*yA~gK-0P6&@ng zqupk9DPsdR8NI6U`f^(o!6>hw{?tye_O!5*oezgcTG#5+FkJhBtO|LRsu20faRG&& zm4_juuu>UTS==)2_3JU+mo2vCc^!V68=&<4JXtm?Wt>clLdib*?BgmcK}eLvbXp;md<|9&ffbp5 zYuUM~29G6KU;@EWKxTyFz$KSm;elkw-)*}ZPg}(}A zoo$KHNNf<;u$m^}QO|C6ps literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/profile.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/profile.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57003f61844555505d02f3053418c3c7720ffc4f GIT binary patch literal 1618 zcmah}&2J+$6u0Lqlds)vRISJg5FqvtBvLL&d#I{ix&nb9s#GDQ(V~$vcG_t@GpX$~ zZIqna0~dPXA4rb;HQasW1b;$Lyf?Gyh878r{QUee&%fXM@K^12gFyS^?>B7MBjit< zteX$YOX&JL5S(x-Nk$_|QFo-1xshAyuJkfL^0Occ%DE?NSr~I+e-D?X@Z9c4KWfr`|tsi{^rqt(Sqn#w6;MhX*u(f&`lX}pyLj1+v914yMJsl z*`w+)9ER{b0*>b*HX<8KW`ymmwfLN=6lp_?+ZQscoh?7ZC#=1*92FLxAa$J;c#zGPXl$s{c4<3 z?sefL_q*iOQ8(a);G|~4YW<4xfY*L?D49FU`U-M^hj5~C(gYj7R2mPvgx9+ycX(sr z<{ob@T)@+syQ{SqTZ5TDF{964+;MQ#kA+9V#KVGL+JK9s z;KKSTq=;4+JG%wDhsR6_{%x)e%M+n6i)|AuuFG8EYh->6Dd($Edv za&6FS*e<>Y{f+mazpgliv9?bi>@%LFxx!qtjdBzHQU##$ZEK>WOo#pQ9WBFF-nN0N Y1X)&XxLeWM!)(+TtQKw2C$#1M1JyIA9RL6T literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/receiverid.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/receiverid.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..452663853243cfaf1000eab9504f6951026a66d9 GIT binary patch literal 1280 zcmZuwJ8u**5VrSyms|4S2oDJRaaWpMIj{a zwnQfKk3zoXunTyqh0aTn;pO>oXNuYcMUaGYl2~VCn{vvn(;%_=l5n{9g&LdBgVQi^ zjo#s1IQPzoLlPf~=(YVtzC|4wLd1b9W&;X{dH_O`6LL-)+E|T!VPH0dS)4#D8b@~P zs_{t3Hef8Ta?M!VO~-oojYv75b6Q7QJsH9QOi>+*5AtYOAA-pK@TG=w##YV;qhskqg*cU|pRUl!!<*m@=G9$(gUeSP zEspeXSC>T$`9nyqSy}UZI~P2|a5!)cU>YWn_lE4jgYS3bM&rR6$n~6j^3V!P%)-b%Uc7?>W65E!W^=>7@l$`x>st03YyjsooQDlG-}IKzG!7pV6bI~b?M^u#ozL^2G!5|g*#tidx; z6pT$D04bl>Y90eW9ad<{3~0~oFG;k;k(aFCFBn|=1=50(WZ9S(AHt9}O^%+6M2k0X SgUxB-&+ySqt{za*vws5&4Kg?Y literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/robots.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/robots.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14397540317099a34149209039705d0f80bec126 GIT binary patch literal 640 zcmZ8eJx{|h5Vez(me5KSVr1xAB$~ZKsC-Cl9Y8D*l9k3L1i5ywUG+l@NOa|g@N3w! zGQm$^;x1LCs3+aC{cJzKJ70D>A%Xb%eMsIYA>X*zMgWr$r0oH4!l@)v8dFMc2=}w6c2TqYbEyF9VoY7JWlOnYZ4<;i>djudzOgVw8lzVFu``ibKpzPl2 ziFUT80H&(I5e;2I+CzX>@(h;H$cIP49^N0XihC=}X_5)$hP{*UI=6|GdOqw$QfIl^ zA@t1EnPQ$kB`Onj&I;#pmDxI%PvL=CA$7vTRp`XRMTJaq1;w{hC>|bXZdnLecdSsH z8DR^pEE0hg^6`_HCY12kk96T6g_?_rSwwmcfC`)T*~nDw7!=F$AXi*0E>f3k)xaL? z0T3@}dUSQX+qFUM@EF?KVoW7d!B`nGHr0G4ao%R^WtPazj@bvf%2f?o$oT-+d5a&| lqSbob_v0?Q9n{#!U+Eq}tC_+GVtB%WD`wp_pK)qC~6_r3aMy;~t&1{9H7iYCUQyQu4e!@j0z zoX9+nv-Ct~3icg;`1s(i5uLX-^f)cXNA=1M(lTZf76D>~=J3(t;2d}0*5X6gAt?L(}~$))6sfCB%+4TJ8kZ#O7f)9yeWvp;}asDb@h>J!-A5-L{A& ztnWx}PO(ggsX8@3aPcbzmB6#}Jxr}Wiq+@%=MPb=*kEky3s_=2#9|J8G?J=hn)V+GT7ZUbh_Z5M-?he2AJE zGT$7Kn{vv1uK~xIJ7>QFflj&hW+h@Xfk?Xa)z_j_)Dtee7I=-uh~=o)vXM9Q=L}=+ zMR^urywqZIuD;e4k~6D9j(f2c6W~2vyJ6GP#GBye5v65ZWBsB5iT@~CqTIqeqQz22 zRHi=mZL5EZ>TE~vVyz!TV@UB~LvvD^_qYt<6LJBO38lF|G{K#VAjZA$zd0{GVoOCy(W1ER z8mIHv@JqvA-GVhC`c3sn#68{12E(UEB$XWNv+n8cr z&RU4^MnD<8q4NAHQ(|1BH^8I3pgZF#d1e(GSMV0lI!@2?as6<3)$4cDbNTG6$J4R< z?5n9^?R$#MH32v{I?cqj@r9(Pm?_1;ZLSDZHjYg-&Lf!;eue5_Gz>6c$;t+#jc(%+ zEhs{vDJ%JMBy(*_sMNLDAB=Jx6&gfK#f^WKsX_;Bf-;9Jwn$qqVbE}#z$s_Z5s|T&A0FQr`T%=gz+YR z0pCHss`2;uKKIc(K-mO!QYL%M#}EG>*R3E$AfbD4ScE zh=(0KcF>m!4`1~&EdLh5s1v5bkb2$DYXpmPZg;vC>}5A<2bX6fGP|IfDb;Iu zhc9=VR@TqtPwFCtFLcq=uo+E+nn-nCXrbAebXhmkxAEk73SRg`$ezN|AA*=uLHAHHE1f+e z+#3fH^v1@mvr3vcmvtvqdZOeRALy)VQWfAPDoRPcJ{Y zo8|r;hfUTF_(0(heCk6GbGjf)($P7+rl;&@asns-q9N=%OR{j6&Ovu{i@Y z+;NV4;QhCt-h*8of&hwG zNFTDxNB^0~23TYV>9s7CpmbzGm*fgqx?7{L_$tVlT<5-vy`PoOJ^czhYu2Mz0vOJ^_Ze#(dN#Y|-Ic8#5DCQS;%(p0@feE7K+4 z-Mm9qI&5^$_J{FS&JOxG@Tm#=FHBrz7inG=&|i9k!v8F<4*;ixscm=|s(N;8zad3x z7sRKq`ocQ=2OmW(EqjCuUWv zRRABX>yxv#GCo%3?4Ht$-%9HtIOzu<2n`(^9e77DL>N}+$Nz@NIPn#FnkY@HM$BNw zrDm9>XS1wawUBLag)}If(6^)Rw#gNC1hQeLS4hJk9N4{Nhev(k6ha~OW{4AUe~q&R z%Y=3d5Vx?I*qVSZ{B2lVd1QAGrxil#5uOy3&2(DSQ!@aZzXe^YRW3fh00YCiFJ2QK Ut#UoanWuL^3~7IdEM$@M7vJRT0ssI2 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/__pycache__/template.cpython-37.pyc b/openwebrx/owrx/controllers/__pycache__/template.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85322ba84c9ed49f8db4542b37c58c90b1444a37 GIT binary patch literal 2722 zcmbtW&2QsG6rUM8j+-WJT9%Jh5h_bTgk_VxflyTyb|r)mIj~48Ss=^xOj;Kwc4sEt zZj_$799fAwhvvv%!+oxtmOr6fcyAmhG%4a^9nJW?$-Fo3{ocp;vqqyv;Q96UZ=*jd zg#3m~`M5yr!%#U)oN$_wJ`E^Enx(Ao1dgSh)a_S-ilyDu3%t(>uW;`f;hvxuPEZBD z%6;H{Q3c)yeudY7uL&Rc72sES9r(KCYutTAT8$TQDrr$udyoyZ%+gdy;MTtuu}DsZ zd?<93qzab&Z$v*&BQ3fyij)rnVjqUufk}{nauP63IeSI|hdbPbw_6w*RCpDR^v!0e zYJO&ndhh{w2tx8D4D}vNnp{#&$8^Fj8D|rE?r3N1T+nlO>>j&4GG@;yXmz@Qccgh1 zt5IdBd-pzWRZJuQ{xFn6WkVSY6|9$JsI*K5hsNU~&bW|xo>^B>PlRfCpmd^#T7bN( zMA|cMFG+=QPa~;F#yzMewz3y}@bado$ol=u*Gaq1S_-bU-YPXBqHoHvkhCIVCqnvHtFcjvO|j^ zH~aGzD8eM;4){bTt_buxZSI*WDuuAE;@zM_LObg=EICvEX#L68B}-+ zUrteJ?*nappE@Z&z=e6$*~YV3_RqP4+px`c4}$LPEW5WX0v$l!LhY~Ogdw*FOKZYi zWar1uiyiYB1`oj*OpClYr!0d87MFIbs--YkZObIjre>B ze_&j#UY|G!)p^7(y|ZXm!H2U7{i*&?;;WE3dOp}-gslu$Akg^Z>5 zgFGl&aSLG&R7-`gphAr!8HcKU1W{*9b0I?4-L1;nRC%t=m@=GNJl3a2Rd%gws+Bsc zFRPN$%lm1m>_vL5545%Ex#pPKGH#oSAZLrzE|Jj?qk>o{RN@t4sU&R<{?DK6&KM30 z50Iq0q;`c&4bO0ewHU!jyx=pw$#gf z%p4Ve)`rzhO{&X#eQF+t)jb({FactRl(zo*!Of$ayDXJ{*!CQ>sonBAzlxd-p#C4OTsMKA~elJOG@6x=fm_E(($YxBBb0Ha4%anA`$A?kdf#HOk pXW)ZBu%3r_!6*6L=C^r1eCNz`ror$E*V$-5Z8H0om-Uc7`3pBaoMQk0 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/admin.py b/openwebrx/owrx/controllers/admin.py new file mode 100644 index 0000000..803eeb8 --- /dev/null +++ b/openwebrx/owrx/controllers/admin.py @@ -0,0 +1,56 @@ +from owrx.controllers.session import SessionStorage +from owrx.users import UserList +from urllib import parse +from http.cookies import SimpleCookie + +import logging + +logger = logging.getLogger(__name__) + + +class Authentication(object): + def getUser(self, request): + if "owrx-session" not in request.cookies: + return None + session_id = request.cookies["owrx-session"].value + storage = SessionStorage.getSharedInstance() + session = storage.getSession(session_id) + if session is None: + return None + if "user" not in session: + return None + userList = UserList.getSharedInstance() + user = None + try: + user = userList[session["user"]] + storage.prolongSession(session_id) + except KeyError: + pass + return user + + +class AuthorizationMixin(object): + def __init__(self, handler, request, options): + self.authentication = Authentication() + self.user = self.authentication.getUser(request) + super().__init__(handler, request, options) + + def isAuthorized(self): + return self.user is not None and self.user.is_enabled() and not self.user.must_change_password + + def handle_request(self): + if self.isAuthorized(): + super().handle_request() + else: + cookie = SimpleCookie() + cookie["owrx-session"] = "" + cookie["owrx-session"]["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + self.set_response_cookies(cookie) + if ( + "x-requested-with" in self.request.headers + and self.request.headers["x-requested-with"] == "XMLHttpRequest" + ): + self.send_response("{}", code=403) + else: + target = "{}login?{}".format(self.get_document_root(), parse.urlencode({"ref": self.request.path[1:]})) + self.send_redirect(target) diff --git a/openwebrx/owrx/controllers/api.py b/openwebrx/owrx/controllers/api.py new file mode 100644 index 0000000..4e7a966 --- /dev/null +++ b/openwebrx/owrx/controllers/api.py @@ -0,0 +1,9 @@ +from . import Controller +from owrx.feature import FeatureDetector +import json + + +class ApiController(Controller): + def indexAction(self): + data = json.dumps(FeatureDetector().feature_report()) + self.send_response(data, content_type="application/json") diff --git a/openwebrx/owrx/controllers/assets.py b/openwebrx/owrx/controllers/assets.py new file mode 100644 index 0000000..ca3e7f9 --- /dev/null +++ b/openwebrx/owrx/controllers/assets.py @@ -0,0 +1,191 @@ +from . import Controller +from owrx.config.core import CoreConfig +from datetime import datetime, timezone +import mimetypes +import os +import pkg_resources +from abc import ABCMeta, abstractmethod +import gzip + +import logging + +logger = logging.getLogger(__name__) + + +class GzipMixin(object): + def send_response(self, content, code=200, headers=None, content_type="text/html", *args, **kwargs): + if self.zipable(content_type) and "accept-encoding" in self.request.headers: + accepted = [s.strip().lower() for s in self.request.headers["accept-encoding"].split(",")] + if "gzip" in accepted: + if type(content) == str: + content = content.encode() + content = self.gzip(content) + if headers is None: + headers = {} + headers["Content-Encoding"] = "gzip" + super().send_response(content, code, headers=headers, content_type=content_type, *args, **kwargs) + + def zipable(self, content_type): + types = ["application/javascript", "text/css", "text/html", "image/svg+xml"] + return content_type in types + + def gzip(self, content): + return gzip.compress(content) + + +class ModificationAwareController(Controller, metaclass=ABCMeta): + @abstractmethod + def getModified(self, file): + pass + + def wasModified(self, file): + try: + modified = self.getModified(file).replace(microsecond=0) + + if modified is not None and "If-Modified-Since" in self.handler.headers: + client_modified = datetime.strptime( + self.handler.headers["If-Modified-Since"], "%a, %d %b %Y %H:%M:%S %Z" + ).replace(tzinfo=timezone.utc) + if modified <= client_modified: + return False + except FileNotFoundError: + pass + + return True + + +class AssetsController(GzipMixin, ModificationAwareController, metaclass=ABCMeta): + def getModified(self, file): + return datetime.fromtimestamp(os.path.getmtime(self.getFilePath(file)), timezone.utc) + + def openFile(self, file): + return open(self.getFilePath(file), "rb") + + @abstractmethod + def getFilePath(self, file): + pass + + def serve_file(self, file, content_type=None): + try: + modified = self.getModified(file) + + if not self.wasModified(file): + self.send_response("", code=304) + return + + f = self.openFile(file) + data = f.read() + f.close() + + if content_type is None: + (content_type, encoding) = mimetypes.guess_type(self.getFilePath(file)) + self.send_response(data, content_type=content_type, last_modified=modified, max_age=3600) + except FileNotFoundError: + self.send_response("file not found", code=404) + + def indexAction(self): + filename = self.request.matches.group(1) + self.serve_file(filename) + + +class OwrxAssetsController(AssetsController): + def getFilePath(self, file): + mappedFiles = { + "gfx/openwebrx-avatar.png": "receiver_avatar", + "gfx/openwebrx-top-photo.jpg": "receiver_top_photo", + } + if file in mappedFiles and ("mapped" not in self.request.query or self.request.query["mapped"][0] != "false"): + config = CoreConfig() + for ext in ["png", "jpg", "webp"]: + user_file = "{}/{}.{}".format(config.get_data_directory(), mappedFiles[file], ext) + if os.path.exists(user_file) and os.path.isfile(user_file): + return user_file + return pkg_resources.resource_filename("htdocs", file) + + +class AprsSymbolsController(AssetsController): + def __init__(self, handler, request, options): + path = CoreConfig().get_aprs_symbols_path() + if not path.endswith("/"): + path += "/" + self.path = path + super().__init__(handler, request, options) + + def getFilePath(self, file): + return self.path + file + + +class CompiledAssetsController(GzipMixin, ModificationAwareController): + profiles = { + "receiver.js": [ + "lib/chroma.min.js", + "openwebrx.js", + "lib/jquery-3.2.1.min.js", + "lib/jquery.nanoscroller.min.js", + "lib/Header.js", + "lib/Demodulator.js", + "lib/DemodulatorPanel.js", + "lib/BookmarkLocalStorage.js", + "lib/BookmarkBar.js", + "lib/BookmarkDialog.js", + "lib/AudioEngine.js", + "lib/ProgressBar.js", + "lib/Measurement.js", + "lib/FrequencyDisplay.js", + "lib/MessagePanel.js", + "lib/Js8Threads.js", + "lib/Modes.js", + "lib/MetaPanel.js", + ], + "map.js": [ + "lib/jquery-3.2.1.min.js", + "lib/chroma.min.js", + "lib/Header.js", + "map.js", + ], + "settings.js": [ + "lib/jquery-3.2.1.min.js", + "lib/bootstrap.bundle.min.js", + "lib/location-picker.min.js", + "lib/Header.js", + "lib/settings/MapInput.js", + "lib/settings/ImageUpload.js", + "lib/BookmarkLocalStorage.js", + "lib/settings/BookmarkTable.js", + "lib/settings/WsjtDecodingDepthsInput.js", + "lib/settings/WaterfallDropdown.js", + "lib/settings/GainInput.js", + "lib/settings/OptionalSection.js", + "lib/settings/SchedulerInput.js", + "lib/settings/ExponentialInput.js", + "settings.js", + ], + } + + def indexAction(self): + profileName = self.request.matches.group(1) + if profileName not in CompiledAssetsController.profiles: + self.send_response("profile not found", code=404) + return + + files = CompiledAssetsController.profiles[profileName] + files = [pkg_resources.resource_filename("htdocs", f) for f in files] + + modified = self.getModified(files) + + if not self.wasModified(files): + self.send_response("", code=304) + return + + contents = [self.getContents(f) for f in files] + + (content_type, encoding) = mimetypes.guess_type(profileName) + self.send_response("\n".join(contents), content_type=content_type, last_modified=modified, max_age=3600) + + def getContents(self, file): + with open(file) as f: + return f.read() + + def getModified(self, files): + modified = [os.path.getmtime(f) for f in files] + return datetime.fromtimestamp(max(*modified), timezone.utc) diff --git a/openwebrx/owrx/controllers/feature.py b/openwebrx/owrx/controllers/feature.py new file mode 100644 index 0000000..06e262d --- /dev/null +++ b/openwebrx/owrx/controllers/feature.py @@ -0,0 +1,11 @@ +from owrx.controllers.template import WebpageController +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin +from owrx.controllers.settings import SettingsBreadcrumb + + +class FeatureController(BreadcrumbMixin, WebpageController): + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Feature report", "features")) + + def indexAction(self): + self.serve_template("features.html", **self.template_variables()) diff --git a/openwebrx/owrx/controllers/imageupload.py b/openwebrx/owrx/controllers/imageupload.py new file mode 100644 index 0000000..7686659 --- /dev/null +++ b/openwebrx/owrx/controllers/imageupload.py @@ -0,0 +1,79 @@ +from owrx.controllers import BodySizeError +from owrx.controllers.assets import AssetsController +from owrx.controllers.admin import AuthorizationMixin +from owrx.config.core import CoreConfig +from owrx.form.input.gfx import AvatarInput, TopPhotoInput +import uuid +import json + + +class ImageUploadController(AuthorizationMixin, AssetsController): + # max upload filesizes + max_sizes = { + # not the best idea to instantiate inputs, but i didn't want to duplicate the sizes here + "receiver_avatar": AvatarInput("id", "label").getMaxSize(), + "receiver_top_photo": TopPhotoInput("id", "label").getMaxSize(), + } + + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.file = request.query["file"][0] if "file" in request.query else None + + def getFilePath(self, file=None): + if self.file is None: + raise FileNotFoundError("missing filename") + return "{tmp}/{file}".format( + tmp=CoreConfig().get_temporary_directory(), + file=self.file + ) + + def indexAction(self): + self.serve_file(None) + + def _is_png(self, contents): + return contents[0:8] == bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + def _is_jpg(self, contents): + return contents[0:3] == bytes([0xFF, 0xD8, 0xFF]) + + def _is_webp(self, contents): + return contents[0:4] == bytes([0x52, 0x49, 0x46, 0x46]) and contents[8:12] == bytes([0x57, 0x45, 0x42, 0x50]) + + def processImage(self): + if "id" not in self.request.query: + self.send_json_response({"error": "missing id"}, code=400) + return + file_id = self.request.query["id"][0] + + if file_id not in ImageUploadController.max_sizes: + self.send_json_response({"error": "unexpected image id"}, code=400) + return + + try: + contents = self.get_body(ImageUploadController.max_sizes[file_id]) + except BodySizeError: + self.send_json_response({"error": "file size too large"}, code=400) + return + + filetype = None + if self._is_png(contents): + filetype = "png" + elif self._is_jpg(contents): + filetype = "jpg" + elif self._is_webp(contents): + filetype = "webp" + if filetype is None: + self.send_json_response({"error": "unsupported file type"}, code=400) + return + + self.file = "{id}-{uuid}.{ext}".format( + id=file_id, + uuid=uuid.uuid4().hex, + ext=filetype, + ) + with open(self.getFilePath(), "wb") as f: + f.write(contents) + self.send_json_response({"file": self.file}, code=200) + + def send_json_response(self, obj, code): + self.send_response(json.dumps(obj), code=code, content_type="application/json") diff --git a/openwebrx/owrx/controllers/metrics.py b/openwebrx/owrx/controllers/metrics.py new file mode 100644 index 0000000..4d41a2a --- /dev/null +++ b/openwebrx/owrx/controllers/metrics.py @@ -0,0 +1,30 @@ +from . import Controller +from owrx.metrics import CounterMetric, DirectMetric, Metrics +import json + + +class MetricsController(Controller): + def indexAction(self): + data = json.dumps(Metrics.getSharedInstance().getHierarchicalMetrics()) + self.send_response(data, content_type="application/json") + + def prometheusAction(self): + metrics = Metrics.getSharedInstance().getFlatMetrics() + + def prometheusFormat(key, metric): + value = metric.getValue() + if isinstance(metric, CounterMetric): + key += "_total" + value = value["count"] + elif isinstance(metric, DirectMetric): + pass + else: + raise ValueError("Unexpected metric type for metric {}".format(repr(metric))) + + return "{key} {value}".format(key=key.replace(".", "_"), value=value) + + data = ["# https://prometheus.io/docs/instrumenting/exposition_formats/"] + [ + prometheusFormat(k, v) for k, v in metrics.items() + ] + + self.send_response("\n".join(data), content_type="text/plain; version=0.0.4") diff --git a/openwebrx/owrx/controllers/profile.py b/openwebrx/owrx/controllers/profile.py new file mode 100644 index 0000000..6fd3aaf --- /dev/null +++ b/openwebrx/owrx/controllers/profile.py @@ -0,0 +1,24 @@ +from owrx.controllers.template import WebpageController +from owrx.controllers.admin import AuthorizationMixin +from owrx.users import UserList, DefaultPasswordClass +from urllib.parse import parse_qs + + +class ProfileController(AuthorizationMixin, WebpageController): + def isAuthorized(self): + return self.user is not None and self.user.is_enabled() and self.user.must_change_password + + def indexAction(self): + self.serve_template("pwchange.html", **self.template_variables()) + + def processPwChange(self): + data = parse_qs(self.get_body().decode("utf-8")) + data = {k: v[0] for k, v in data.items()} + userlist = UserList.getSharedInstance() + if "password" in data and "confirm" in data and data["password"] == data["confirm"]: + self.user.setPassword(DefaultPasswordClass(data["password"]), must_change_password=False) + userlist.store() + target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings" + else: + target = "/pwchange" + self.send_redirect(target) diff --git a/openwebrx/owrx/controllers/receiverid.py b/openwebrx/owrx/controllers/receiverid.py new file mode 100644 index 0000000..10c7361 --- /dev/null +++ b/openwebrx/owrx/controllers/receiverid.py @@ -0,0 +1,26 @@ +from owrx.controllers import Controller +from owrx.receiverid import ReceiverId +from datetime import datetime + + +class ReceiverIdController(Controller): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.authHeader = None + + def send_response( + self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None, headers=None + ): + if self.authHeader is not None: + if headers is None: + headers = {} + headers["Authorization"] = self.authHeader + super().send_response( + content, code=code, content_type=content_type, last_modified=last_modified, max_age=max_age, headers=headers + ) + pass + + def handle_request(self): + if "Authorization" in self.request.headers: + self.authHeader = ReceiverId.getResponseHeader(self.request.headers["Authorization"]) + super().handle_request() diff --git a/openwebrx/owrx/controllers/robots.py b/openwebrx/owrx/controllers/robots.py new file mode 100644 index 0000000..3d00a1e --- /dev/null +++ b/openwebrx/owrx/controllers/robots.py @@ -0,0 +1,16 @@ +from owrx.controllers import Controller + + +class RobotsController(Controller): + def indexAction(self): + # search engines should not be crawling internal / API routes + self.send_response( + """User-agent: * +Disallow: /login +Disallow: /logout +Disallow: /pwchange +Disallow: /settings +Disallow: /imageupload +""", + content_type="text/plain", + ) diff --git a/openwebrx/owrx/controllers/session.py b/openwebrx/owrx/controllers/session.py new file mode 100644 index 0000000..6807a91 --- /dev/null +++ b/openwebrx/owrx/controllers/session.py @@ -0,0 +1,79 @@ +from owrx.controllers.template import WebpageController +from urllib.parse import parse_qs, urlencode +from uuid import uuid4 +from http.cookies import SimpleCookie +from owrx.users import UserList +from datetime import datetime, timedelta + +import logging + +logger = logging.getLogger(__name__) + + +class SessionStorage(object): + sharedInstance = None + sessionLifetime = timedelta(hours=6) + + @staticmethod + def getSharedInstance(): + if SessionStorage.sharedInstance is None: + SessionStorage.sharedInstance = SessionStorage() + return SessionStorage.sharedInstance + + def __init__(self): + self.sessions = {} + + def generateKey(self): + return str(uuid4()) + + def startSession(self, data): + key = self.generateKey() + self.updateSession(key, data) + return key + + def getSession(self, key): + if key not in self.sessions: + return None + expires, data = self.sessions[key] + if expires < datetime.utcnow(): + del self.sessions[key] + return None + return data + + def updateSession(self, key, data): + expires = datetime.utcnow() + SessionStorage.sessionLifetime + self.sessions[key] = expires, data + + def prolongSession(self, key): + data = self.getSession(key) + if data is None: + raise KeyError("Invalid session key") + self.updateSession(key, data) + + +class SessionController(WebpageController): + def loginAction(self): + self.serve_template("login.html", **self.template_variables()) + + def processLoginAction(self): + data = parse_qs(self.get_body().decode("utf-8")) + data = {k: v[0] for k, v in data.items()} + userlist = UserList.getSharedInstance() + if "user" in data and "password" in data: + if data["user"] in userlist: + user = userlist[data["user"]] + if user.is_enabled() and user.password.is_valid(data["password"]): + key = SessionStorage.getSharedInstance().startSession({"user": user.name}) + cookie = SimpleCookie() + cookie["owrx-session"] = key + target = self.request.query["ref"][0] if "ref" in self.request.query else "/settings" + if user.must_change_password: + target = "/pwchange?{0}".format(urlencode({"ref": target})) + self.set_response_cookies(cookie) + self.send_redirect(target) + return + target = "?{}".format(urlencode({"ref": self.request.query["ref"][0]})) if "ref" in self.request.query else "" + self.send_redirect(self.request.path + target) + + def logoutAction(self): + self.send_redirect("logout happening here") diff --git a/openwebrx/owrx/controllers/settings/__init__.py b/openwebrx/owrx/controllers/settings/__init__.py new file mode 100644 index 0000000..8ad8a4e --- /dev/null +++ b/openwebrx/owrx/controllers/settings/__init__.py @@ -0,0 +1,147 @@ +from owrx.config import Config +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.template import WebpageController +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin +from abc import ABCMeta, abstractmethod +from urllib.parse import parse_qs + +import logging + +logger = logging.getLogger(__name__) + + +class SettingsController(AuthorizationMixin, WebpageController): + def indexAction(self): + self.serve_template("settings.html", **self.template_variables()) + + +class SettingsFormController(AuthorizationMixin, BreadcrumbMixin, WebpageController, metaclass=ABCMeta): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.errors = {} + self.globalError = None + + @abstractmethod + def getSections(self): + pass + + @abstractmethod + def getTitle(self): + pass + + def getData(self): + return Config.get() + + def getErrors(self): + return self.errors + + def render_sections(self): + sections = "".join(section.render(self.getData(), self.getErrors()) for section in self.getSections()) + buttons = self.render_buttons() + return """ +
    + {sections} +
    + {buttons} +
    +
    + """.format( + sections=sections, + buttons=buttons, + ) + + def render_buttons(self): + return """ + + """ + + def indexAction(self): + self.serve_template("settings/general.html", **self.template_variables()) + + def template_variables(self): + variables = super().template_variables() + variables["content"] = self.render_sections() + variables["title"] = self.getTitle() + variables["modal"] = self.buildModal() + variables["error"] = self.renderGlobalError() + return variables + + def parseFormData(self): + data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) + result = {} + errors = [] + for section in self.getSections(): + section_data, section_errors = section.parse(data) + result.update(section_data) + errors += section_errors + return result, errors + + def getSuccessfulRedirect(self): + return self.get_document_root() + self.request.path[1:] + + def _mergeErrors(self, errors): + result = {} + for e in errors: + if e.getKey() not in result: + result[e.getKey()] = [] + result[e.getKey()].append(e.getMessage()) + return result + + def processFormData(self): + data = None + errors = None + try: + data, errors = self.parseFormData() + except Exception as e: + logger.exception("Error while parsing form data") + self.globalError = str(e) + return self.indexAction() + + if errors: + self.errors = self._mergeErrors(errors) + return self.indexAction() + try: + self.processData(data) + self.store() + self.send_redirect(self.getSuccessfulRedirect()) + except Exception as e: + logger.exception("Error while processing form data") + self.globalError = str(e) + return self.indexAction() + + def processData(self, data): + config = self.getData() + for k, v in data.items(): + if v is None: + if k in config: + del config[k] + else: + config[k] = v + + def store(self): + Config.get().store() + + def buildModal(self): + return "" + + def renderGlobalError(self): + if self.globalError is None: + return "" + + return """ +
    +
    Error
    +
    +
    Your settings could not be saved due to an error:
    +
    {error}
    +
    +
    + """.format( + error=self.globalError + ) + + +class SettingsBreadcrumb(Breadcrumb): + def __init__(self): + super().__init__([]) + self.append(BreadcrumbItem("Settings", "settings")) diff --git a/openwebrx/owrx/controllers/settings/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e031fcdaadd4d99e6e1d8d77442e59803e64e58c GIT binary patch literal 6001 zcmbtYTW=f36`t7(xumG8Z6$V`WTUulSVU5grf6%>LhLvRg2qZ=8*#Etuvl_NGOguB zvr9)}sPw@u(ia!)KahYtw11^9?L(jDwNL3!$Wy;F%jHE9+XCIi%+Ai`%(Dk6DWcoKxG1t(G0wrftVg%MD!9cH%;-7!+Gx z;I&FY$@IB#xiuHewJJfy{1@W+)k;fc~SCQ8zl?o%hY zfS$6LL(d#~O45~cqVl{DTtxr8SU~@REIze^OQLw6RTsYprmV_!;k{0Kr@4#P%B^1d zsH2+wC~bDycbf;zHhPyI%6d22m6(>QP8>^BwRQQnl96brUaPJb#_f+%+0u*S{|ttu zi??pScUPv7UWn>Rs-i~PlGsWByw{CXBE$V;yFm!$kA=p&D9J@sk_B9_z!F?o&sbm! z8w5Cd<-SbQW_vdoqt@V4ik9Co%*<-8f36UddbGlSnnAdMp!Q?<6wb`3qA^SoFg?o1JcoBHLfdx;ohG ze1)npVq3DAZ?zeQ&2}>l!;S8to@=&+Jh;^$o-Yu#L^|XBSI$zmF)?);4DiCR9kpZ_ z>T(#iI-(a-yAp=`y(rE`)CzzZ^d>53FH>`<=26Uk9yk9}BCs(?Px5C}xd^^xJKTuD z3vB5KTR6|yQy#czyP{y)1yK|p#8ea|QO4I3&_jGnnHC0Ru_%`CKWAVqi0fz8A!6Vh!Jm-$L5KC5ZHO zy_z%bgN|xVs0OJ6J@j=^h`z)}m9nQ$ivb^41N#Ja+GGP3T7qg@Fu>%*Y&Yq3r6QhZ zveN~rR7WLxZa41KqxgOLt2$~C5OwiU)D|!mT~ubP+>$qBQa` z?~I{M>&wP)>eSXFCXFeA05&W1L z+p&cG%=^7zz#UlO2|wcavrgENC7j1LX|!;kTfnift=R}5F^Ta1DN3@2YQVm{as&f7 zW*M*y$cgnS+~__GAtTb(1tsCDl%h3?h=HH9Hr~?3B-5iQ29Q?&P^#_9wmj&ntsmpp zaNJ@2U(TRC10N2dB^$N4(}?0^YhwhZ|Btdi@zi!;;=VDan`_C?k+0V~;&9E+BFxRT zUw?A{!P?f;GJ^1RzRS^>p_*tu$${2;X$q_L$&E+Nwp8bUGT??2&w;iHaMSzOHVN-> zAK2*S=;-1Q5?qe+xYKN_SHU^GFyzf}P!|Y7lveG`wyT(u z8um+q|p;sIsF zds248dK|U)kPyZQ@n9EOm8SR>k_*xWAsZd|OJl&U$#Zp~*A+-7tHq2>4t^r?t4S|T zO%PG!D1*w-9fUL-0w9SGD&uN@jWS;3eC?aD07-@}}tAvi-*xWfCd&mehnn`D`Al7U|DYjFDy|E0XLlDldP$DD zY@ANj70k=dO&?N=Qae4@&0vy+;V6)Hl+-vnwneCh8mn$&UM~JxzEFKYEn}-=gIyc~ z%S|@GoP^>sGX`rR`(J`Cl=-UVz;Hc;=rZb+VeiaveG2Tz6vzxlrc7u;I8bqz!#ha& z;45JgZ$L?G@`!@%dFpL=Kx?m{Zjd?LWcw=vhgvnX9_EJ&gu6As(ry#(IjOo?CPI|! z0^$np6g1z{{K+Zm)yJ68MHy;6(dE%Q`{*&j`;pR<$01WUN>8lK4gzGbbXzhsRdXOv zuVGl-MRmRlGDC+L&K=Ix8P;dSK7|2cVK5-}iYy6I*U&aZ&)DuAj1N%q?OvO)-8j%r z?P+7F*^^wWNmNK;3$(ki9>X|s(P2L)T%2u#OH>1z(}~$chTDjg@Kbq^uEVxc>DPDH zMbw5}oRjHJX2!H`{Sj_~Xr{5g+SyFk$8%Fd6n9U>e?#04y zcT&GDO=c*3(UX4KL8|CWN>pyl*qDGCfM1(wM>D3K(|xK7a-_2o6yCUob!wk`s8*DN z{uU*nqba2}PTAr8H)aU*LBq>T5XKx$0RXt^TYGg#{R;Vnc%#&LcVL3RYnOulAPwN-Rb&T5W{9J3HeYt&9x zdMb{a^$n9GW*Kgg;wFNtrkjnAO$^OQ{fJi4y`s?rga4S+tVM)sua;d_Zjt*mx3k#^ S;35KC#qzAOz2d(3&A$O%B86Z8 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/settings/__pycache__/backgrounddecoding.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/backgrounddecoding.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c541e8301d31cf851b9325ae3d94b1765d87361 GIT binary patch literal 1344 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/settings/__pycache__/decoding.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/decoding.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1508ad6f55029b3fc2abc2f3c08046d70ade681e GIT binary patch literal 3759 zcmcgv%X8bt83#a$A}La)UewD;H?o_UX+-+qWHKH_6I-l0_Ed5jX`&9237#NRIt$+B16Wsr?gj>h~=GQV%DSLnq{5vER3^-}l@PPpj3k2ETv& z_bdBfHBI|B64@^goloHr-$6q)s=Jz}o4RM1hK{u1=DfU_SN)tj;}y(;H*3y%MYHIY z%#v3&%if$h=T*##nv-{{-n==l`ZMkwuV&V~1#>~s1$WU~GM7|;)?M~i%oVS0))ifJ zSG_fJ&09Cub?pxtEz$C&M$1ee=gbW{cce8c{{e(r!-(rgOiIV^i~WFmdx0-`;JS>* ziy8kZXEyEf(Cakxc=m{Or4#s#*|@qlVBND$aPiC^hBBTzgc*z{M77O>Aq_@;Lasg+ zAAiP!p5wCXixv+;S31{ug%?9SAub)W3#rz_xs{WicWj4W*lx%KpsxPuqYn=P$_{MF zor`$&M4ZYt>jo4gX|tgmh-~FTvYqXMZ0C$i#$ox~zL1YnL~#X)QA)vJc`9){KkYx0 z%s#1#W>BLG`70BZHdh zc&hiV4x8Z*-}vmoBhas?4r`6WXuZw6fQGJ(S|YYj2~7q6y9S!d3+DFt3M^2~kB?fx zP{PCf5$o`aRxpC5JF&B9iPZkBY@6oro4D9#^4O6stKjhlG@2gW`)SzCY(5|;a3(|U zUz218Hc*l*LPu(2?R#A+U5$+&P_Eq1$rUZ!!q~`hRDWEshaef{OF%SqUWeCIcD{;J z<^h`3nK=Vp8*~%;I=xHZ zQt!9vZ@}c`*Eu;$-=SNIcaPprc}4nL`Y!NF^ga53Ht748nk=8rwY4$CK5&0WKcL%+ zTbXdHv`Jflq1?}dDIOk1%M%w1ILUM$FNv2SbGrk}4rO3@Ht#!rRDS}*_E9oIJuf!s zQwYnV8ltb~j)na~gWGSyIa)LBw@%6m>OP5T_xtMgc!#TtpkmosGuG zW{`9&UdcG=8khNfIf(8b*cXl$dJwiqUt4+_bci}2UK+uD3?Y%8O7TXHHruRc!+lRq zj-DS+4@?GMJRt+8KVbaE02w*1OTd(1Q57cO*|3NBfOJBc4T=Cf(-)z~k~!UB7%#s8 z0!aMKr_Q-ULvZE{9(1r^h!;=A$2T$8p>o-S({D_UmvJsr9!;%bQrd`UC7Yd&02(x7 zy$1!xWVYi&4RUU~BC03;M#=us@goR>ls~y^@Ym6C;wkT`8EItp_}mHEo>&G zkGlT(L*SjjlS&w`z5M9I2}Y0~LPzCX5xtecgxZoEfaSocro)YSj#U^hV1riSSQ_y; zY_O)_SmN+`Y>L>Fuz~LbxT)-O+i@}ZJ_jjm#@fQ7@*ela={&eZk21wUkpcqIZ9p9~{DWK+&==xms3Swk&Y? z(8Yemg1lk7$p~Kq@Eqmfo7gsAT}0o6jlUVPi91$I7*NrE+a% zl?g}ZP3pk~H3@wmM%9;!vRZs+%6IT{C9&4jmQ#~q^eC&EOE=auq2f?RHDa2`^n&=A)Tcv_G=_@YoBnscfF9m_#R?{o` MeR%L)(`&i^0c8_ghX4Qo literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/settings/__pycache__/general.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/general.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7feab09ba674b785e132c01138a6cdb5e101cc3b GIT binary patch literal 6396 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 NomTp9A~$H5{||p~XsrMM literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/settings/__pycache__/reporting.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/reporting.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e023d56b9bb5a71a6350812514547f4ff32d24d GIT binary patch literal 2997 zcmb6b%W@k6!gzvu@z$w?Du457rIicOuG9 z1(B!tlL`SCU`9r4dS+~SmPxb~*>T0IXxxrg;;L8GcqLkm9nXntUQN?0Q9WMs*5ZcO zh?`zBZh0-uuSV-}+iS-iuVWft8?Xw_8v`6>PHk@kYJHT`RlZXG(?1NPfjd z{ER0`@F-#;+bH?_g82}LNj&VD*=nB!D&z^Goo~m4`4Mh<&4gkCwRQH!Lhds^;7R`? z9`Z9;2D{m^^3RGbgxm9p5 zHMMN>B^z@=S(zs!Fi{JA=%0Si@F%H|VB#k0FumR%bDc`pPrwzWieC&&Rzsn;?5iipB8D6B2mRMWRMMuRm4r@(7apIXRU zg9iSa(1aGO>optN&{35s?bv`#*itJ~1FpTTs485C8*meDY0f)uSCI2A+=llweFr6M z*alavPVMO$+J7JQ-i3SG&Ij-zd{o%}7{_m zn0EGFyzD=E-kni`VZp&y)%1fQTWU;zgvBzBb( zCdXXi3C^0@x4@4g8IF>)sU4Y-PmWGP>6T=!f5_a4M3>PqNimdOU*cQVS%_RRfs-a} zmHEl(Y;!?m>`RHBLwd6mDuQ&&l_iZsJ*v`Abt=hxkxt=`xeRH(k&~DBmA-lrF<&w_ zpvi?2zH%@4M9dXLJn*#w3?GloWb6i9(D=jL4jCqvxw?`#Wwe3Zl_l?_9---m&S<{w z=Dekb2zVT`M5XOTIVw&1g-$-9v&3VIwG zC;XCkCoCMDD6B?4+ueeGuuQxDkiTZGj|%(|_97F)4-CtoM5u3T3ItDI-URcaUuHUOf7gHU5>L-OVe4mdERZux*1fBeRS5RD7-j8p*DM<) zXEqkn&;L(;7L<1#L07P-5pW35%_yn_=#CR>1XdATaUZSWeN?<@BwZ--9D!k3X8Ela zc-3-Dr{I>n%1q9gVU{_^9jh_pm$Kzv%dFd_jdCsBzTyfjJv{8LX3k)c_%Ry{vie{U zbC^VgHwJ?rCVrH!2)flpgTOTcI|OErj~t`hShNsO>Leu$*t^Z>^udS}K pE-;m~5Sl+G@`&k4p@X>xRr|bzm;UuM~Z8q@RHgB43`)?uae;EJ( literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/controllers/settings/__pycache__/sdr.cpython-37.pyc b/openwebrx/owrx/controllers/settings/__pycache__/sdr.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb4ae1393536167b861417352b9803badfaef5ec GIT binary patch literal 19717 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 Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Background decoding", "settings/backgrounddecoding")) + + def getSections(self): + return [ + Section( + "Background decoding", + CheckboxInput( + "services_enabled", + "Enable background decoding services", + ), + ServicesCheckboxInput("services_decoders", "Enabled services"), + ), + ] diff --git a/openwebrx/owrx/controllers/settings/bookmarks.py b/openwebrx/owrx/controllers/settings/bookmarks.py new file mode 100644 index 0000000..2704bb6 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/bookmarks.py @@ -0,0 +1,148 @@ +from owrx.controllers.template import WebpageController +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.settings import SettingsBreadcrumb +from owrx.bookmarks import Bookmark, Bookmarks +from owrx.modes import Modes +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbMixin +import json +import math + +import logging + +logger = logging.getLogger(__name__) + + +class BookmarksController(AuthorizationMixin, BreadcrumbMixin, WebpageController): + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Bookmark editor", "settings/bookmarks")) + + def template_variables(self): + variables = super().template_variables() + variables["bookmarks"] = self.render_table() + return variables + + def render_table(self): + bookmarks = Bookmarks.getSharedInstance().getBookmarks() + emptyText = """ + + No bookmarks in storage. You can add new bookmarks using the buttons below. + + """ + + return """ + + + + + + + + {bookmarks} +
    NameFrequencyModulationActions
    + """.format( + bookmarks="".join(self.render_bookmark(b) for b in bookmarks) if bookmarks else emptyText, + modes=json.dumps({m.modulation: m.name for m in Modes.getAvailableModes()}), + ) + + def render_bookmark(self, bookmark: Bookmark): + def render_frequency(freq): + suffixes = { + 0: "", + 3: "k", + 6: "M", + 9: "G", + 12: "T", + } + exp = 0 + if freq > 0: + exp = int(math.log10(freq) / 3) * 3 + num = freq + suffix = "" + if exp in suffixes: + num = freq / 10 ** exp + suffix = suffixes[exp] + return "{num:g} {suffix}Hz".format(num=num, suffix=suffix) + + mode = Modes.findByModulation(bookmark.getModulation()) + return """ + + {name} + {rendered_frequency} + {modulation_name} + + + + + """.format( + id=id(bookmark), + name=bookmark.getName(), + # TODO render frequency in si units + frequency=bookmark.getFrequency(), + rendered_frequency=render_frequency(bookmark.getFrequency()), + modulation=bookmark.getModulation() if mode is None else mode.modulation, + modulation_name=bookmark.getModulation() if mode is None else mode.name, + ) + + def _findBookmark(self, bookmark_id): + bookmarks = Bookmarks.getSharedInstance() + try: + return next(b for b in bookmarks.getBookmarks() if id(b) == bookmark_id) + except StopIteration: + return None + + def update(self): + bookmark_id = int(self.request.matches.group(1)) + bookmark = self._findBookmark(bookmark_id) + if bookmark is None: + self.send_response("{}", content_type="application/json", code=404) + return + try: + data = json.loads(self.get_body().decode("utf-8")) + for key in ["name", "frequency", "modulation"]: + if key in data: + value = data[key] + if key == "frequency": + value = int(value) + setattr(bookmark, key, value) + Bookmarks.getSharedInstance().store() + # TODO this should not be called explicitly... bookmarks don't have any event capability right now, though + Bookmarks.getSharedInstance().notifySubscriptions(bookmark) + self.send_response("{}", content_type="application/json", code=200) + except json.JSONDecodeError: + self.send_response("{}", content_type="application/json", code=400) + + def new(self): + bookmarks = Bookmarks.getSharedInstance() + + def create(bookmark_data): + # sanitize + data = { + "name": bookmark_data["name"], + "frequency": int(bookmark_data["frequency"]), + "modulation": bookmark_data["modulation"], + } + bookmark = Bookmark(data) + bookmarks.addBookmark(bookmark) + return {"bookmark_id": id(bookmark)} + + try: + data = json.loads(self.get_body().decode("utf-8")) + result = [create(b) for b in data] + bookmarks.store() + self.send_response(json.dumps(result), content_type="application/json", code=200) + except json.JSONDecodeError: + self.send_response("{}", content_type="application/json", code=400) + + def delete(self): + bookmark_id = int(self.request.matches.group(1)) + bookmark = self._findBookmark(bookmark_id) + if bookmark is None: + self.send_response("{}", content_type="application/json", code=404) + return + bookmarks = Bookmarks.getSharedInstance() + bookmarks.removeBookmark(bookmark) + bookmarks.store() + self.send_response("{}", content_type="application/json", code=200) + + def indexAction(self): + self.serve_template("settings/bookmarks.html", **self.template_variables()) diff --git a/openwebrx/owrx/controllers/settings/decoding.py b/openwebrx/owrx/controllers/settings/decoding.py new file mode 100644 index 0000000..fb0e542 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/decoding.py @@ -0,0 +1,91 @@ +from owrx.controllers.settings import SettingsFormController, SettingsBreadcrumb +from owrx.form.section import Section +from owrx.form.input import CheckboxInput, NumberInput, DropdownInput, Js8ProfileCheckboxInput, MultiCheckboxInput, Option, TextInput +from owrx.form.input.wfm import WfmTauValues +from owrx.form.input.wsjt import Q65ModeMatrix, WsjtDecodingDepthsInput +from owrx.form.input.converter import OptionalConverter +from owrx.wsjt import Fst4Profile, Fst4wProfile +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem + + +class DecodingSettingsController(SettingsFormController): + def getTitle(self): + return "Demodulation and decoding" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Demodulation and decoding", "settings/decoding")) + + def getSections(self): + return [ + Section( + "Demodulator settings", + NumberInput( + "squelch_auto_margin", + "Auto-Squelch threshold", + infotext="Offset to be added to the current signal level when using the auto-squelch", + append="dB", + ), + DropdownInput( + "wfm_deemphasis_tau", + "Tau setting for WFM (broadcast FM) deemphasis", + WfmTauValues, + infotext='See
    this Wikipedia article for more information', + ), + ), + Section( + "Digital voice", + TextInput( + "digital_voice_codecserver", + "Codecserver address", + infotext="Address of a remote codecserver instance (name[:port]). Leave empty to use local" + + " codecserver", + converter=OptionalConverter(), + ), + CheckboxInput( + "digital_voice_dmr_id_lookup", + 'Enable lookup of DMR ids in the ' + + "radioid database to show callsigns and names", + ), + CheckboxInput( + "digital_voice_nxdn_id_lookup", + 'Enable lookup of NXDN ids in the ' + + "radioid database to show callsigns and names", + ), + ), + Section( + "Digimodes", + NumberInput("digimodes_fft_size", "Digimodes FFT size", append="bins"), + ), + Section( + "Decoding settings", + NumberInput("decoding_queue_workers", "Number of decoding workers"), + NumberInput("decoding_queue_length", "Maximum length of decoding job queue"), + NumberInput( + "wsjt_decoding_depth", + "Default WSJT decoding depth", + infotext="A higher decoding depth will allow more results, but will also consume more cpu", + ), + WsjtDecodingDepthsInput( + "wsjt_decoding_depths", + "Individual decoding depths", + ), + NumberInput( + "js8_decoding_depth", + "Js8Call decoding depth", + infotext="A higher decoding depth will allow more results, but will also consume more cpu", + ), + Js8ProfileCheckboxInput("js8_enabled_profiles", "Js8Call enabled modes"), + MultiCheckboxInput( + "fst4_enabled_intervals", + "Enabled FST4 intervals", + [Option(v, "{}s".format(v)) for v in Fst4Profile.availableIntervals], + ), + MultiCheckboxInput( + "fst4w_enabled_intervals", + "Enabled FST4W intervals", + [Option(v, "{}s".format(v)) for v in Fst4wProfile.availableIntervals], + ), + Q65ModeMatrix("q65_enabled_combinations", "Enabled Q65 Mode combinations"), + ), + ] diff --git a/openwebrx/owrx/controllers/settings/general.py b/openwebrx/owrx/controllers/settings/general.py new file mode 100644 index 0000000..e12c101 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/general.py @@ -0,0 +1,218 @@ +from owrx.controllers.settings import SettingsFormController +from owrx.form.section import Section +from owrx.config.core import CoreConfig +from owrx.form.input import ( + TextInput, + NumberInput, + FloatInput, + LocationInput, + TextAreaInput, + DropdownInput, + Option, +) +from owrx.form.input.converter import WaterfallColorsConverter, IntConverter +from owrx.form.input.receiverid import ReceiverKeysConverter +from owrx.form.input.gfx import AvatarInput, TopPhotoInput +from owrx.form.input.device import WaterfallLevelsInput, WaterfallAutoLevelsInput +from owrx.waterfall import WaterfallOptions +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem +from owrx.controllers.settings import SettingsBreadcrumb +import shutil +import os +import re +from glob import glob + +import logging + +logger = logging.getLogger(__name__) + + +class GeneralSettingsController(SettingsFormController): + def getTitle(self): + return "General Settings" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("General Settings", "settings/general")) + + def getSections(self): + return [ + Section( + "Receiver information", + TextInput("receiver_name", "Receiver name"), + TextInput("receiver_location", "Receiver location"), + NumberInput( + "receiver_asl", + "Receiver elevation", + append="meters above mean sea level", + ), + TextInput("receiver_admin", "Receiver admin"), + LocationInput("receiver_gps", "Receiver coordinates"), + TextInput("photo_title", "Photo title"), + TextAreaInput("photo_desc", "Photo description"), + ), + Section( + "Receiver images", + AvatarInput( + "receiver_avatar", + "Receiver Avatar", + infotext="For performance reasons, images are cached. " + + "It can take a few hours until they appear on the site.", + ), + TopPhotoInput( + "receiver_top_photo", + "Receiver Panorama", + infotext="For performance reasons, images are cached. " + + "It can take a few hours until they appear on the site.", + ), + ), + Section( + "Receiver limits", + NumberInput( + "max_clients", + "Maximum number of clients", + ), + ), + Section( + "Receiver listings", + TextAreaInput( + "receiver_keys", + "Receiver keys", + converter=ReceiverKeysConverter(), + infotext="Put the keys you receive on listing sites (e.g. " + + 'Receiverbook) here, one per line', + ), + ), + Section( + "Waterfall settings", + DropdownInput( + "waterfall_scheme", + "Waterfall color scheme", + options=WaterfallOptions, + ), + TextAreaInput( + "waterfall_colors", + "Custom waterfall colors", + infotext="Please provide 6-digit hexadecimal RGB colors in HTML notation (#RRGGBB)" + + " or HEX notation (0xRRGGBB), one per line", + converter=WaterfallColorsConverter(), + ), + NumberInput( + "fft_fps", + "FFT speed", + infotext="This setting specifies how many lines are being added to the waterfall per second. " + + "Higher values will give you a faster waterfall, but will also use more CPU.", + append="frames per second", + ), + NumberInput("fft_size", "FFT size", append="bins"), + FloatInput( + "fft_voverlap_factor", + "FFT vertical overlap factor", + infotext="If fft_voverlap_factor is above 0, multiple FFTs will be used for creating a line on the " + + "diagram.", + ), + WaterfallLevelsInput("waterfall_levels", "Waterfall levels"), + WaterfallAutoLevelsInput( + "waterfall_auto_levels", + "Automatic adjustment margins", + infotext="Specifies the upper and lower dynamic headroom that should be added when automatically " + + "adjusting waterfall colors", + ), + NumberInput( + "waterfall_auto_min_range", + "Automatic adjustment minimum range", + append="dB", + infotext="Minimum dynamic range the waterfall should cover after automatically adjusting " + + "waterfall colors", + ), + ), + Section( + "Compression", + DropdownInput( + "audio_compression", + "Audio compression", + options=[ + Option("adpcm", "ADPCM"), + Option("none", "None"), + ], + ), + DropdownInput( + "fft_compression", + "Waterfall compression", + options=[ + Option("adpcm", "ADPCM"), + Option("none", "None"), + ], + ), + ), + Section( + "Display settings", + DropdownInput( + "tuning_precision", + "Tuning precision", + options=[Option(str(i), "{} Hz".format(10 ** i)) for i in range(0, 6)], + converter=IntConverter(), + ), + ), + Section( + "Map settings", + TextInput( + "google_maps_api_key", + "Google Maps API key", + infotext="Google Maps requires an API key, check out " + + '' + + "their documentation on how to obtain one.", + ), + NumberInput( + "map_position_retention_time", + "Map retention time", + infotext="Specifies how log markers / grids will remain visible on the map", + append="s", + ), + ), + ] + + def remove_existing_image(self, image_id): + config = CoreConfig() + # remove all possible file extensions + for ext in ["png", "jpg", "webp"]: + try: + os.unlink("{}/{}.{}".format(config.get_data_directory(), image_id, ext)) + except FileNotFoundError: + pass + + def handle_image(self, data, image_id): + if image_id in data: + config = CoreConfig() + if data[image_id] == "restore": + self.remove_existing_image(image_id) + elif data[image_id]: + if not data[image_id].startswith(image_id): + logger.warning("invalid file name: %s", data[image_id]) + else: + # get file extension (at least 3 characters) + # should be all lowercase since they are set by the upload script + pattern = re.compile(".*\\.([a-z]{3,})$") + matches = pattern.match(data[image_id]) + if matches is None: + logger.warning("could not determine file extension for %s", image_id) + else: + self.remove_existing_image(image_id) + ext = matches.group(1) + data_file = "{}/{}.{}".format(config.get_data_directory(), image_id, ext) + temporary_file = "{}/{}".format(config.get_temporary_directory(), data[image_id]) + shutil.copy(temporary_file, data_file) + del data[image_id] + # remove any accumulated temporary files on save + for file in glob("{}/{}*".format(config.get_temporary_directory(), image_id)): + os.unlink(file) + + def processData(self, data): + # Image handling + for img in ["receiver_avatar", "receiver_top_photo"]: + self.handle_image(data, img) + # special handling for waterfall colors: custom colors only stay in config if custom color scheme is selected + if "waterfall_scheme" in data: + scheme = WaterfallOptions(data["waterfall_scheme"]) + if scheme is not WaterfallOptions.CUSTOM and "waterfall_colors" in data: + data["waterfall_colors"] = None + super().processData(data) diff --git a/openwebrx/owrx/controllers/settings/reporting.py b/openwebrx/owrx/controllers/settings/reporting.py new file mode 100644 index 0000000..09ee775 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/reporting.py @@ -0,0 +1,93 @@ +from owrx.controllers.settings import SettingsFormController, SettingsBreadcrumb +from owrx.form.section import Section +from owrx.form.input.converter import OptionalConverter +from owrx.form.input.aprs import AprsBeaconSymbols, AprsAntennaDirections +from owrx.form.input import TextInput, CheckboxInput, DropdownInput, NumberInput +from owrx.breadcrumb import Breadcrumb, BreadcrumbItem + + +class ReportingController(SettingsFormController): + def getTitle(self): + return "Spotting and reporting" + + def get_breadcrumb(self) -> Breadcrumb: + return SettingsBreadcrumb().append(BreadcrumbItem("Spotting and reporting", "settings/reporting")) + + def getSections(self): + return [ + Section( + "APRS-IS reporting", + CheckboxInput( + "aprs_igate_enabled", + "Send received APRS data to APRS-IS", + infotext="Due to limits of the APRS-IS network, reporting will only work for background decoders" + ), + TextInput( + "aprs_callsign", + "APRS callsign", + infotext="This callsign will be used to send data to the APRS-IS network", + ), + TextInput("aprs_igate_server", "APRS-IS server"), + TextInput("aprs_igate_password", "APRS-IS network password"), + CheckboxInput( + "aprs_igate_beacon", + "Send the receiver position to the APRS-IS network", + infotext="Please check that your receiver location is setup correctly before enabling the beacon", + ), + DropdownInput( + "aprs_igate_symbol", + "APRS beacon symbol", + AprsBeaconSymbols, + ), + TextInput( + "aprs_igate_comment", + "APRS beacon text", + infotext="This text will be sent as APRS comment along with your beacon", + converter=OptionalConverter(), + ), + NumberInput( + "aprs_igate_height", + "Antenna height", + infotext="Antenna height above average terrain (HAAT)", + append="m", + converter=OptionalConverter(), + ), + NumberInput( + "aprs_igate_gain", + "Antenna gain", + append="dBi", + converter=OptionalConverter(), + ), + DropdownInput("aprs_igate_dir", "Antenna direction", AprsAntennaDirections), + ), + Section( + "pskreporter settings", + CheckboxInput( + "pskreporter_enabled", + "Enable sending spots to pskreporter.info", + ), + TextInput( + "pskreporter_callsign", + "pskreporter callsign", + infotext="This callsign will be used to send spots to pskreporter.info", + ), + TextInput( + "pskreporter_antenna_information", + "Antenna information", + infotext="Antenna description to be sent along with spots to pskreporter", + converter=OptionalConverter(), + ), + ), + Section( + "WSPRnet settings", + CheckboxInput( + "wsprnet_enabled", + "Enable sending spots to wsprnet.org", + ), + TextInput( + "wsprnet_callsign", + "wsprnet callsign", + infotext="This callsign will be used to send spots to wsprnet.org", + ), + ), + ] diff --git a/openwebrx/owrx/controllers/settings/sdr.py b/openwebrx/owrx/controllers/settings/sdr.py new file mode 100644 index 0000000..40c6185 --- /dev/null +++ b/openwebrx/owrx/controllers/settings/sdr.py @@ -0,0 +1,433 @@ +from owrx.controllers.admin import AuthorizationMixin +from owrx.controllers.template import WebpageController +from owrx.controllers.settings import SettingsFormController +from owrx.source import SdrDeviceDescription, SdrDeviceDescriptionMissing, SdrClientClass +from owrx.config import Config +from owrx.connection import OpenWebRxReceiverClient +from owrx.controllers.settings import SettingsBreadcrumb +from owrx.form.section import Section +from urllib.parse import quote, unquote +from owrx.sdr import SdrService +from owrx.form.input import TextInput, DropdownInput, Option +from owrx.form.input.validator import RequiredValidator +from owrx.property import PropertyLayer +from owrx.breadcrumb import BreadcrumbMixin, Breadcrumb, BreadcrumbItem +from abc import ABCMeta, abstractmethod +from uuid import uuid4 + + +class SdrDeviceBreadcrumb(SettingsBreadcrumb): + def __init__(self): + super().__init__() + self.append(BreadcrumbItem("SDR device settings", "settings/sdr")) + + +class SdrDeviceListController(AuthorizationMixin, BreadcrumbMixin, WebpageController): + def template_variables(self): + variables = super().template_variables() + variables["content"] = self.render_devices() + variables["title"] = "SDR device settings" + variables["modal"] = "" + variables["error"] = "" + return variables + + def get_breadcrumb(self): + return SdrDeviceBreadcrumb() + + def render_devices(self): + def render_device(device_id, config): + sources = SdrService.getAllSources() + source = sources[device_id] if device_id in sources else None + + additional_info = "" + state_info = "Unknown" + + if source is not None: + profiles = source.getProfiles() + currentProfile = profiles[source.getProfileId()] + clients = {c: len(source.getClients(c)) for c in SdrClientClass} + clients = {c: v for c, v in clients.items() if v} + connections = len([c for c in source.getClients() if isinstance(c, OpenWebRxReceiverClient)]) + additional_info = """ +
    {num_profiles} profile(s)
    +
    Current profile: {current_profile}
    +
    Clients: {clients}
    +
    Connections: {connections}
    + """.format( + num_profiles=len(config["profiles"]), + current_profile=currentProfile["name"], + clients=", ".join("{cls}: {count}".format(cls=c.name, count=v) for c, v in clients.items()), + connections=connections, + ) + + state_info = ", ".join( + s + for s in [ + str(source.getState()), + None if source.isEnabled() else "Disabled", + "Failed" if source.isFailed() else None, + ] + if s is not None + ) + + return """ +
  • +
    +
    + +

    {device_name}

    +
    +
    State: {state}
    +
    +
    + {additional_info} +
    +
    +
  • + """.format( + device_name=config["name"] if config["name"] else "[Unnamed device]", + device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(device_id)), + state=state_info, + additional_info=additional_info, + ) + + return """ +
      + {devices} +
    + + """.format( + devices="".join(render_device(key, value) for key, value in Config.get()["sdrs"].items()) + ) + + def indexAction(self): + self.serve_template("settings/general.html", **self.template_variables()) + + +class SdrFormController(SettingsFormController, metaclass=ABCMeta): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.device_id, self.device = self._get_device() + + def getTitle(self): + return self.device["name"] + + def render_sections(self): + return """ + {tabs} +
    + {sections} +
    + """.format( + tabs=self.render_tabs(), + sections=super().render_sections(), + ) + + def render_tabs(self): + return """ + + """.format( + device_link="{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)), + device_name=self.device["name"] if self.device["name"] else "[Unnamed device]", + device_active="active" if self.isDeviceActive() else "", + new_profile_active="active" if self.isNewProfileActive() else "", + new_profile_link="{}settings/sdr/{}/newprofile".format(self.get_document_root(), quote(self.device_id)), + profile_tabs="".join( + """ + + """.format( + profile_link="{}settings/sdr/{}/profile/{}".format( + self.get_document_root(), quote(self.device_id), quote(profile_id) + ), + profile_name=profile["name"] if profile["name"] else "[Unnamed profile]", + profile_active="active" if self.isProfileActive(profile_id) else "", + ) + for profile_id, profile in self.device["profiles"].items() + ), + ) + + def isDeviceActive(self) -> bool: + return False + + def isProfileActive(self, profile_id) -> bool: + return False + + def isNewProfileActive(self) -> bool: + return False + + def store(self): + # need to overwrite the existing key in the config since the layering won't capture the changes otherwise + config = Config.get() + sdrs = config["sdrs"] + sdrs[self.device_id] = self.device + config["sdrs"] = sdrs + super().store() + + def _get_device(self): + config = Config.get() + device_id = unquote(self.request.matches.group(1)) + if device_id not in config["sdrs"]: + return None, None + return device_id, config["sdrs"][device_id] + + +class SdrFormControllerWithModal(SdrFormController, metaclass=ABCMeta): + def render_remove_button(self): + return "" + + def render_buttons(self): + return self.render_remove_button() + super().render_buttons() + + def buildModal(self): + return """ + + """.format( + object_type=self.getModalObjectType(), + confirm_url=self.getModalConfirmUrl(), + ) + + @abstractmethod + def getModalObjectType(self): + pass + + @abstractmethod + def getModalConfirmUrl(self): + pass + + +class SdrDeviceController(SdrFormControllerWithModal): + def get_breadcrumb(self) -> Breadcrumb: + return SdrDeviceBreadcrumb().append( + BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id)) + ) + + def getData(self): + return self.device + + def getSections(self): + try: + description = SdrDeviceDescription.getByType(self.device["type"]) + return [description.getDeviceSection()] + except SdrDeviceDescriptionMissing: + # TODO provide a generic interface that allows to switch the type + return [] + + def render_remove_button(self): + return """ + + """ + + def isDeviceActive(self) -> bool: + return True + + def indexAction(self): + if self.device is None: + self.send_response("device not found", code=404) + return + super().indexAction() + + def processFormData(self): + if self.device is None: + self.send_response("device not found", code=404) + return + return super().processFormData() + + def getModalObjectType(self): + return "SDR device" + + def getModalConfirmUrl(self): + return "{}settings/deletesdr/{}".format(self.get_document_root(), quote(self.device_id)) + + def deleteDevice(self): + if self.device_id is None: + return self.send_response("device not found", code=404) + config = Config.get() + sdrs = config["sdrs"] + del sdrs[self.device_id] + # need to overwrite the existing key in the config since the layering won't capture the changes otherwise + config["sdrs"] = sdrs + config.store() + return self.send_redirect("{}settings/sdr".format(self.get_document_root())) + + +class NewSdrDeviceController(SettingsFormController): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.data_layer = PropertyLayer(name="", type="", profiles=PropertyLayer()) + self.device_id = str(uuid4()) + + def get_breadcrumb(self) -> Breadcrumb: + return SdrDeviceBreadcrumb().append(BreadcrumbItem("New device", "settings/sdr/newsdr")) + + def getSections(self): + return [ + Section( + "New device settings", + TextInput("name", "Device name", validator=RequiredValidator()), + DropdownInput( + "type", + "Device type", + [Option(sdr_type, name) for sdr_type, name in SdrDeviceDescription.getTypes().items()], + infotext="Note: Switching the type will not be possible after creation since the set of available " + + "options is different for each type.
    Note: This dropdown only shows device types that have " + + "their requirements met. If a type is missing from the list, please check the feature report.", + ), + ) + ] + + def getTitle(self): + return "New device" + + def getData(self): + return self.data_layer + + def store(self): + # need to overwrite the existing key in the config since the layering won't capture the changes otherwise + config = Config.get() + sdrs = config["sdrs"] + # a uuid should be unique, so i'm not sure if there's a point in this check + if self.device_id in sdrs: + raise ValueError("device {} already exists!".format(self.device_id)) + sdrs[self.device_id] = self.data_layer + config["sdrs"] = sdrs + super().store() + + def getSuccessfulRedirect(self): + return "{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id)) + + +class SdrProfileController(SdrFormControllerWithModal): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.profile_id, self.profile = self._get_profile() + + def get_breadcrumb(self) -> Breadcrumb: + return ( + SdrDeviceBreadcrumb() + .append(BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id))) + .append( + BreadcrumbItem( + self.profile["name"], "settings/sdr/{}/profile/{}".format(self.device_id, self.profile_id) + ) + ) + ) + + def getData(self): + return self.profile + + def _get_profile(self): + if self.device is None: + return None + profile_id = unquote(self.request.matches.group(2)) + if profile_id not in self.device["profiles"]: + return None + return profile_id, self.device["profiles"][profile_id] + + def isProfileActive(self, profile_id) -> bool: + return profile_id == self.profile_id + + def getSections(self): + try: + description = SdrDeviceDescription.getByType(self.device["type"]) + return [description.getProfileSection()] + except SdrDeviceDescriptionMissing: + # TODO provide a generic interface that allows to switch the type + return [] + + def indexAction(self): + if self.profile is None: + self.send_response("profile not found", code=404) + return + super().indexAction() + + def processFormData(self): + if self.profile is None: + self.send_response("profile not found", code=404) + return + return super().processFormData() + + def render_remove_button(self): + return """ + + """ + + def getModalObjectType(self): + return "profile" + + def getModalConfirmUrl(self): + return "{}settings/sdr/{}/deleteprofile/{}".format( + self.get_document_root(), quote(self.device_id), quote(self.profile_id) + ) + + def deleteProfile(self): + if self.profile_id is None: + return self.send_response("profile not found", code=404) + config = Config.get() + del self.device["profiles"][self.profile_id] + config.store() + return self.send_redirect("{}settings/sdr/{}".format(self.get_document_root(), quote(self.device_id))) + + +class NewProfileController(SdrProfileController): + def __init__(self, handler, request, options): + self.data_layer = PropertyLayer(name="") + super().__init__(handler, request, options) + + def get_breadcrumb(self) -> Breadcrumb: + return ( + SdrDeviceBreadcrumb() + .append(BreadcrumbItem(self.device["name"], "settings/sdr/{}".format(self.device_id))) + .append(BreadcrumbItem("New profile", "settings/sdr/{}/newprofile".format(self.device_id))) + ) + + def _get_profile(self): + return str(uuid4()), self.data_layer + + def isNewProfileActive(self) -> bool: + return True + + def store(self): + # a uuid should be unique, so i'm not sure if there's a point in this check + if self.profile_id in self.device["profiles"]: + raise ValueError("Profile {} already exists!".format(self.profile_id)) + self.device["profiles"][self.profile_id] = self.data_layer + super().store() + + def getSuccessfulRedirect(self): + return "{}settings/sdr/{}/profile/{}".format( + self.get_document_root(), quote(self.device_id), quote(self.profile_id) + ) + + def render_remove_button(self): + # new profile doesn't have a remove button + return "" diff --git a/openwebrx/owrx/controllers/status.py b/openwebrx/owrx/controllers/status.py new file mode 100644 index 0000000..b45292a --- /dev/null +++ b/openwebrx/owrx/controllers/status.py @@ -0,0 +1,44 @@ +from .receiverid import ReceiverIdController +from owrx.version import openwebrx_version +from owrx.sdr import SdrService +from owrx.config import Config +from owrx.jsons import Encoder +import json + +import logging + +logger = logging.getLogger(__name__) + + +class StatusController(ReceiverIdController): + def getProfileStats(self, profile): + return { + "name": profile["name"], + "center_freq": profile["center_freq"], + "sample_rate": profile["samp_rate"], + } + + def getReceiverStats(self, receiver): + stats = { + "name": receiver.getName(), + # TODO would be better to have types from the config here + "type": type(receiver).__name__, + "profiles": [self.getProfileStats(p) for p in receiver.getProfiles().values()], + } + return stats + + def indexAction(self): + pm = Config.get() + status = { + "receiver": { + "name": pm["receiver_name"], + "admin": pm["receiver_admin"], + "gps": pm["receiver_gps"], + "asl": pm["receiver_asl"], + "location": pm["receiver_location"], + }, + "max_clients": pm["max_clients"], + "version": openwebrx_version, + "sdrs": [self.getReceiverStats(r) for r in SdrService.getActiveSources().values()], + } + self.send_response(json.dumps(status, cls=Encoder), content_type="application/json") diff --git a/openwebrx/owrx/controllers/template.py b/openwebrx/owrx/controllers/template.py new file mode 100644 index 0000000..f7e1a53 --- /dev/null +++ b/openwebrx/owrx/controllers/template.py @@ -0,0 +1,45 @@ +from owrx.controllers import Controller +from owrx.details import ReceiverDetails +from string import Template +import pkg_resources + + +class TemplateController(Controller): + def render_template(self, file, **vars): + file_content = pkg_resources.resource_string("htdocs", file).decode("utf-8") + template = Template(file_content) + + return template.safe_substitute(**vars) + + def serve_template(self, file, **vars): + self.send_response(self.render_template(file, **vars), content_type="text/html") + + def default_variables(self): + return {} + + +class WebpageController(TemplateController): + def get_document_root(self): + path_parts = [part for part in self.request.path[1:].split("/")] + levels = max(0, len(path_parts) - 1) + return "../" * levels + + def header_variables(self): + variables = {"document_root": self.get_document_root()} + variables.update(ReceiverDetails().__dict__()) + return variables + + def template_variables(self): + header = self.render_template("include/header.include.html", **self.header_variables()) + return {"header": header, "document_root": self.get_document_root()} + + +class IndexController(WebpageController): + def indexAction(self): + self.serve_template("index.html", **self.template_variables()) + + +class MapController(WebpageController): + def indexAction(self): + # TODO check if we have a google maps api key first? + self.serve_template("map.html", **self.template_variables()) diff --git a/openwebrx/owrx/controllers/websocket.py b/openwebrx/owrx/controllers/websocket.py new file mode 100644 index 0000000..3363abf --- /dev/null +++ b/openwebrx/owrx/controllers/websocket.py @@ -0,0 +1,10 @@ +from . import Controller +from owrx.websocket import WebSocketConnection +from owrx.connection import HandshakeMessageHandler + + +class WebSocketController(Controller): + def indexAction(self): + conn = WebSocketConnection(self.handler, HandshakeMessageHandler()) + # enter read loop + conn.handle() diff --git a/openwebrx/owrx/cpu.py b/openwebrx/owrx/cpu.py new file mode 100644 index 0000000..cad912f --- /dev/null +++ b/openwebrx/owrx/cpu.py @@ -0,0 +1,77 @@ +import threading + +import logging + +logger = logging.getLogger(__name__) + + +class CpuUsageThread(threading.Thread): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with CpuUsageThread.creationLock: + if CpuUsageThread.sharedInstance is None: + CpuUsageThread.sharedInstance = CpuUsageThread() + return CpuUsageThread.sharedInstance + + def __init__(self): + self.clients = [] + self.doRun = True + self.last_worktime = 0 + self.last_idletime = 0 + self.endEvent = threading.Event() + super().__init__() + + def run(self): + logger.debug("cpu usage thread starting up") + while self.doRun: + try: + cpu_usage = self.get_cpu_usage() + except: + cpu_usage = 0 + for c in self.clients: + c.write_cpu_usage(cpu_usage) + self.endEvent.wait(timeout=3) + logger.debug("cpu usage thread shut down") + + def get_cpu_usage(self): + try: + f = open("/proc/stat", "r") + except: + return 0 # Workaround, possibly we're on a Mac + line = "" + while not "cpu " in line: + line = f.readline() + f.close() + spl = line.split(" ") + worktime = int(spl[2]) + int(spl[3]) + int(spl[4]) + idletime = int(spl[5]) + dworktime = worktime - self.last_worktime + didletime = idletime - self.last_idletime + rate = float(dworktime) / (didletime + dworktime) + self.last_worktime = worktime + self.last_idletime = idletime + if self.last_worktime == 0: + return 0 + return rate + + def add_client(self, c): + self.clients.append(c) + if not self.is_alive(): + self.start() + + def remove_client(self, c): + try: + self.clients.remove(c) + except ValueError: + pass + if not self.clients: + self.shutdown() + + def shutdown(self): + with CpuUsageThread.creationLock: + CpuUsageThread.sharedInstance = None + self.doRun = False + self.endEvent.set() diff --git a/openwebrx/owrx/details.py b/openwebrx/owrx/details.py new file mode 100644 index 0000000..8bdfd3e --- /dev/null +++ b/openwebrx/owrx/details.py @@ -0,0 +1,24 @@ +from owrx.config import Config +from owrx.locator import Locator +from owrx.property import PropertyFilter +from owrx.property.filter import ByPropertyName + + +class ReceiverDetails(PropertyFilter): + def __init__(self): + super().__init__( + Config.get(), + ByPropertyName( + "receiver_name", + "receiver_location", + "receiver_asl", + "receiver_gps", + "photo_title", + "photo_desc", + ) + ) + + def __dict__(self): + receiver_info = super().__dict__() + receiver_info["locator"] = Locator.fromCoordinates(receiver_info["receiver_gps"]) + return receiver_info diff --git a/openwebrx/owrx/dsp.py b/openwebrx/owrx/dsp.py new file mode 100644 index 0000000..b6a569f --- /dev/null +++ b/openwebrx/owrx/dsp.py @@ -0,0 +1,221 @@ +from owrx.meta import MetaParser +from owrx.wsjt import WsjtParser +from owrx.js8 import Js8Parser +from owrx.aprs import AprsParser +from owrx.pocsag import PocsagParser +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass +from owrx.property import PropertyStack, PropertyLayer, PropertyValidator +from owrx.property.validators import OrValidator, RegexValidator, BoolValidator +from owrx.modes import Modes +from owrx.config.core import CoreConfig +from csdr.output import Output +from csdr import Dsp +import threading +import re + +import logging + +logger = logging.getLogger(__name__) + + +class ModulationValidator(OrValidator): + """ + This validator only allows alphanumeric characters and numbers, but no spaces or special characters + """ + + def __init__(self): + super().__init__(BoolValidator(), RegexValidator(re.compile("^[a-z0-9]+$"))) + + +class DspManager(Output, SdrSourceEventClient): + def __init__(self, handler, sdrSource): + self.handler = handler + self.sdrSource = sdrSource + self.parsers = { + "meta": MetaParser(self.handler), + "wsjt_demod": WsjtParser(self.handler), + "packet_demod": AprsParser(self.handler), + "pocsag_demod": PocsagParser(self.handler), + "js8_demod": Js8Parser(self.handler), + } + + self.props = PropertyStack() + + # local demodulator properties not forwarded to the sdr + # ensure strict validation since these can be set from the client + # and are used to build executable commands + validators = { + "output_rate": "int", + "hd_output_rate": "int", + "squelch_level": "num", + "secondary_mod": ModulationValidator(), + "low_cut": "num", + "high_cut": "num", + "offset_freq": "int", + "mod": ModulationValidator(), + "secondary_offset_freq": "int", + "dmr_filter": "int", + } + self.localProps = PropertyValidator(PropertyLayer().filter(*validators.keys()), validators) + + self.props.addLayer(0, self.localProps) + # properties that we inherit from the sdr + self.props.addLayer( + 1, + self.sdrSource.getProps().filter( + "audio_compression", + "fft_compression", + "digimodes_fft_size", + "samp_rate", + "center_freq", + "start_mod", + "start_freq", + "wfm_deemphasis_tau", + "digital_voice_codecserver", + ), + ) + + self.dsp = Dsp(self) + self.dsp.nc_port = self.sdrSource.getPort() + + def set_low_cut(cut): + bpf = self.dsp.get_bpf() + bpf[0] = cut + self.dsp.set_bpf(*bpf) + + def set_high_cut(cut): + bpf = self.dsp.get_bpf() + bpf[1] = cut + self.dsp.set_bpf(*bpf) + + def set_dial_freq(changes): + if ( + "center_freq" not in self.props + or self.props["center_freq"] is None + or "offset_freq" not in self.props + or self.props["offset_freq"] is None + ): + return + freq = self.props["center_freq"] + self.props["offset_freq"] + for parser in self.parsers.values(): + parser.setDialFrequency(freq) + + if "start_mod" in self.props: + self.dsp.set_demodulator(self.props["start_mod"]) + mode = Modes.findByModulation(self.props["start_mod"]) + + if mode and mode.bandpass: + self.dsp.set_bpf(mode.bandpass.low_cut, mode.bandpass.high_cut) + else: + self.dsp.set_bpf(-4000, 4000) + + if "start_freq" in self.props and "center_freq" in self.props: + self.dsp.set_offset_freq(self.props["start_freq"] - self.props["center_freq"]) + else: + self.dsp.set_offset_freq(0) + + self.subscriptions = [ + self.props.wireProperty("audio_compression", self.dsp.set_audio_compression), + self.props.wireProperty("fft_compression", self.dsp.set_fft_compression), + self.props.wireProperty("digimodes_fft_size", self.dsp.set_secondary_fft_size), + self.props.wireProperty("samp_rate", self.dsp.set_samp_rate), + self.props.wireProperty("output_rate", self.dsp.set_output_rate), + self.props.wireProperty("hd_output_rate", self.dsp.set_hd_output_rate), + self.props.wireProperty("offset_freq", self.dsp.set_offset_freq), + self.props.wireProperty("center_freq", self.dsp.set_center_freq), + self.props.wireProperty("squelch_level", self.dsp.set_squelch_level), + self.props.wireProperty("low_cut", set_low_cut), + self.props.wireProperty("high_cut", set_high_cut), + self.props.wireProperty("mod", self.dsp.set_demodulator), + self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter), + self.props.wireProperty("wfm_deemphasis_tau", self.dsp.set_wfm_deemphasis_tau), + self.props.wireProperty("digital_voice_codecserver", self.dsp.set_codecserver), + self.props.filter("center_freq", "offset_freq").wire(set_dial_freq), + ] + + self.dsp.set_temporary_directory(CoreConfig().get_temporary_directory()) + + def send_secondary_config(*args): + self.handler.write_secondary_dsp_config( + { + "secondary_fft_size": self.props["digimodes_fft_size"], + "if_samp_rate": self.dsp.if_samp_rate(), + "secondary_bw": self.dsp.secondary_bw(), + } + ) + + def set_secondary_mod(mod): + if mod == False: + mod = None + self.dsp.set_secondary_demodulator(mod) + if mod is not None: + send_secondary_config() + + self.subscriptions += [ + self.props.wireProperty("secondary_mod", set_secondary_mod), + self.props.wireProperty("digimodes_fft_size", send_secondary_config), + self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq), + ] + + self.startOnAvailable = False + + self.sdrSource.addClient(self) + + super().__init__() + + def start(self): + if self.sdrSource.isAvailable(): + self.dsp.start() + else: + self.startOnAvailable = True + + def receive_output(self, t, read_fn): + logger.debug("adding new output of type %s", t) + writers = { + "audio": self.handler.write_dsp_data, + "hd_audio": self.handler.write_hd_audio, + "smeter": self.handler.write_s_meter_level, + "secondary_fft": self.handler.write_secondary_fft, + "secondary_demod": self.handler.write_secondary_demod, + } + for demod, parser in self.parsers.items(): + writers[demod] = parser.parse + + write = writers[t] + + threading.Thread(target=self.pump(read_fn, write), name="dsp_pump_{}".format(t)).start() + + def stop(self): + self.dsp.stop() + self.startOnAvailable = False + self.sdrSource.removeClient(self) + for sub in self.subscriptions: + sub.cancel() + self.subscriptions = [] + + def setProperties(self, props): + for k, v in props.items(): + self.setProperty(k, v) + + def setProperty(self, prop, value): + self.localProps[prop] = value + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: + logger.debug("received STATE_RUNNING, attempting DspSource restart") + if self.startOnAvailable: + self.dsp.start() + self.startOnAvailable = False + elif state is SdrSourceState.STOPPING: + logger.debug("received STATE_STOPPING, shutting down DspSource") + self.dsp.stop() + + def onFail(self): + logger.debug("received onFail(), shutting down DspSource") + self.dsp.stop() + + def onShutdown(self): + self.dsp.stop() diff --git a/openwebrx/owrx/feature.py b/openwebrx/owrx/feature.py new file mode 100644 index 0000000..a1fff41 --- /dev/null +++ b/openwebrx/owrx/feature.py @@ -0,0 +1,568 @@ +import subprocess +from functools import reduce +from operator import and_ +import re +from distutils.version import LooseVersion +import inspect +from owrx.config.core import CoreConfig +from owrx.config import Config +import shlex +import os +from datetime import datetime, timedelta + +import logging + +logger = logging.getLogger(__name__) + + +class UnknownFeatureException(Exception): + pass + + +class FeatureCache(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if FeatureCache.sharedInstance is None: + FeatureCache.sharedInstance = FeatureCache() + return FeatureCache.sharedInstance + + def __init__(self): + self.cache = {} + self.cachetime = timedelta(hours=2) + + def has(self, feature): + if feature not in self.cache: + return False + now = datetime.now() + if self.cache[feature]["valid_to"] < now: + return False + return True + + def get(self, feature): + return self.cache[feature]["value"] + + def set(self, feature, value): + valid_to = datetime.now() + self.cachetime + self.cache[feature] = {"value": value, "valid_to": valid_to} + + +class FeatureDetector(object): + features = { + # core features; we won't start without these + "core": ["csdr", "nmux", "nc"], + # different types of sdrs and their requirements + "rtl_sdr": ["rtl_connector"], + "rtl_sdr_soapy": ["soapy_connector", "soapy_rtl_sdr"], + "rtl_tcp": ["rtl_tcp_connector"], + "sdrplay": ["soapy_connector", "soapy_sdrplay"], + "hackrf": ["soapy_connector", "soapy_hackrf"], + "perseussdr": ["perseustest"], + "airspy": ["soapy_connector", "soapy_airspy"], + "airspyhf": ["soapy_connector", "soapy_airspyhf"], + "lime_sdr": ["soapy_connector", "soapy_lime_sdr"], + "fifi_sdr": ["alsa", "rockprog"], + "pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"], + "soapy_remote": ["soapy_connector", "soapy_remote"], + "uhd": ["soapy_connector", "soapy_uhd"], + "radioberry": ["soapy_connector", "soapy_radioberry"], + "fcdpp": ["soapy_connector", "soapy_fcdpp"], + "sddc": ["sddc_connector"], + "hpsdr": ["hpsdr_connector"], + "runds": ["runds_connector"], + # optional features and their requirements + "digital_voice_digiham": ["digiham", "sox", "codecserver_ambe"], + "digital_voice_freedv": ["freedv_rx", "sox"], + "digital_voice_m17": ["m17_demod", "sox", "digiham"], + "wsjt-x": ["wsjtx", "sox"], + "wsjt-x-2-3": ["wsjtx_2_3", "sox"], + "wsjt-x-2-4": ["wsjtx_2_4", "sox"], + "packet": ["direwolf", "sox"], + "pocsag": ["digiham", "sox"], + "js8call": ["js8", "sox"], + "drm": ["dream", "sox"], + } + + def feature_availability(self): + return {name: self.is_available(name) for name in FeatureDetector.features} + + def feature_report(self): + def requirement_details(name): + available = self.has_requirement(name) + return { + "available": available, + # as of now, features are always enabled as soon as they are available. this may change in the future. + "enabled": available, + "description": self.get_requirement_description(name), + } + + def feature_details(name): + return { + "available": self.is_available(name), + "requirements": {name: requirement_details(name) for name in self.get_requirements(name)}, + } + + return {name: feature_details(name) for name in FeatureDetector.features} + + def is_available(self, feature): + return self.has_requirements(self.get_requirements(feature)) + + def get_requirements(self, feature): + try: + return FeatureDetector.features[feature] + except KeyError: + raise UnknownFeatureException('Feature "{0}" is not known.'.format(feature)) + + def has_requirements(self, requirements): + passed = True + for requirement in requirements: + passed = passed and self.has_requirement(requirement) + return passed + + def _get_requirement_method(self, requirement): + methodname = "has_" + requirement + if hasattr(self, methodname) and callable(getattr(self, methodname)): + return getattr(self, methodname) + return None + + def has_requirement(self, requirement): + cache = FeatureCache.getSharedInstance() + if cache.has(requirement): + return cache.get(requirement) + + method = self._get_requirement_method(requirement) + result = False + if method is not None: + result = method() + else: + logger.error("detection of requirement {0} not implement. please fix in code!".format(requirement)) + + cache.set(requirement, result) + return result + + def get_requirement_description(self, requirement): + return inspect.getdoc(self._get_requirement_method(requirement)) + + def command_is_runnable(self, command, expected_result=None): + tmp_dir = CoreConfig().get_temporary_directory() + cmd = shlex.split(command) + env = os.environ.copy() + # prevent X11 programs from opening windows if called from a GUI shell + env.pop("DISPLAY", None) + try: + process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=tmp_dir, + env=env, + ) + rc = process.wait() + if expected_result is None: + return rc != 32512 + else: + return rc == expected_result + except FileNotFoundError: + return False + + def has_csdr(self): + """ + OpenWebRX uses the demodulator and pipeline tools provided by the csdr project. Please check out [the project + page on github](https://github.com/jketterl/csdr) for further details and installation instructions. + """ + required_version = LooseVersion("0.17.0") + + csdr_version_regex = re.compile("^csdr version (.*)$") + + try: + process = subprocess.Popen(["csdr", "version"], stderr=subprocess.PIPE) + matches = csdr_version_regex.match(process.stderr.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(1)) + process.wait(1) + return version >= required_version + except FileNotFoundError: + return False + + def has_nmux(self): + """ + Nmux is another tool provided by the csdr project. It is used for internal multiplexing of the IQ data streams. + If you're missing nmux even though you have csdr installed, please update your csdr version. + """ + return self.command_is_runnable("nmux --help") + + def has_nc(self): + """ + Nc is the client used to connect to the nmux multiplexer. It is provided by either the BSD netcat (recommended + for better performance) or GNU netcat packages. Please check your distribution package manager for options. + """ + return self.command_is_runnable("nc --help") + + def has_perseustest(self): + """ + To use a Microtelecom Perseus HF receiver, compile and + install the libperseus-sdr: + ``` + sudo apt install libusb-1.0-0-dev + cd /tmp + wget https://github.com/Microtelecom/libperseus-sdr/releases/download/v0.8.2/libperseus_sdr-0.8.2.tar.gz + tar -zxvf libperseus_sdr-0.8.2.tar.gz + cd libperseus_sdr-0.8.2/ + ./configure + make + sudo make install + sudo ldconfig + perseustest + ``` + """ + return self.command_is_runnable("perseustest -h") + + def has_digiham(self): + """ + To use digital voice modes, the digiham package is required. You can find the package and installation + instructions [here](https://github.com/jketterl/digiham). + + Please note: there is close interaction between digiham and openwebrx, so older versions will probably not work. + If you have an older verison of digiham installed, please update it along with openwebrx. + As of now, we require version 0.3 of digiham. + """ + required_version = LooseVersion("0.5") + + digiham_version_regex = re.compile("^(.*) version (.*)$") + + def check_digiham_version(command): + try: + process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) + matches = digiham_version_regex.match(process.stdout.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(2)) + process.wait(1) + return matches.group(1) in [command, "digiham"] and version >= required_version + except FileNotFoundError: + return False + + return reduce( + and_, + map( + check_digiham_version, + [ + "rrc_filter", + "ysf_decoder", + "dmr_decoder", + "mbe_synthesizer", + "gfsk_demodulator", + "digitalvoice_filter", + "fsk_demodulator", + "pocsag_decoder", + "dstar_decoder", + "nxdn_decoder", + "dc_block", + ], + ), + True, + ) + + def _check_connector(self, command, required_version): + owrx_connector_version_regex = re.compile("^{} version (.*)$".format(re.escape(command))) + + try: + process = subprocess.Popen([command, "--version"], stdout=subprocess.PIPE) + matches = owrx_connector_version_regex.match(process.stdout.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(1)) + process.wait(1) + return version >= required_version + except FileNotFoundError: + return False + + def _check_owrx_connector(self, command): + return self._check_connector(command, LooseVersion("0.5")) + + def has_rtl_connector(self): + """ + The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker + frequency switching, uses less CPU and can even provide more stability in some cases. + + You can get it [here](https://github.com/jketterl/owrx_connector). + """ + return self._check_owrx_connector("rtl_connector") + + def has_rtl_tcp_connector(self): + """ + The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker + frequency switching, uses less CPU and can even provide more stability in some cases. + + You can get it [here](https://github.com/jketterl/owrx_connector). + """ + return self._check_owrx_connector("rtl_tcp_connector") + + def has_soapy_connector(self): + """ + The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker + frequency switching, uses less CPU and can even provide more stability in some cases. + + You can get it [here](https://github.com/jketterl/owrx_connector). + """ + return self._check_owrx_connector("soapy_connector") + + def _has_soapy_driver(self, driver): + try: + process = subprocess.Popen(["SoapySDRUtil", "--info"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + factory_regex = re.compile("^Available factories\\.\\.\\. ?(.*)$") + + drivers = [] + for line in process.stdout: + matches = factory_regex.match(line.decode()) + if matches: + drivers = [s.strip() for s in matches.group(1).split(", ")] + + return driver in drivers + except FileNotFoundError: + return False + + def has_soapy_rtl_sdr(self): + """ + The SoapySDR module for rtl-sdr devices can be used as an alternative to the rtl_connector. It provides + additional support for the direct sampling mod. + + You can get it [here](https://github.com/pothosware/SoapyRTLSDR/wiki). + """ + return self._has_soapy_driver("rtlsdr") + + def has_soapy_sdrplay(self): + """ + The SoapySDR module for sdrplay devices is required for interfacing with SDRPlay devices (RSP1*, RSP2*, RSPDuo) + + You can get it [here](https://github.com/SDRplay/SoapySDRPlay). + """ + return self._has_soapy_driver("sdrplay") + + def has_soapy_airspy(self): + """ + The SoapySDR module for airspy devices is required for interfacing with Airspy devices (Airspy R2, Airspy Mini). + + You can get it [here](https://github.com/pothosware/SoapyAirspy/wiki). + """ + return self._has_soapy_driver("airspy") + + def has_soapy_airspyhf(self): + """ + The SoapySDR module for airspyhf devices is required for interfacing with Airspy HF devices (Airspy HF+, + Airspy HF discovery). + + You can get it [here](https://github.com/pothosware/SoapyAirspyHF/wiki). + """ + return self._has_soapy_driver("airspyhf") + + def has_soapy_lime_sdr(self): + """ + The Lime Suite installs - amongst others - a Soapy driver for the LimeSDR device series. + + You can get it [here](https://github.com/myriadrf/LimeSuite). + """ + return self._has_soapy_driver("lime") + + def has_soapy_pluto_sdr(self): + """ + The SoapySDR module for PlutoSDR devices is required for interfacing with PlutoSDR devices. + + You can get it [here](https://github.com/pothosware/SoapyPlutoSDR). + """ + return self._has_soapy_driver("plutosdr") + + def has_soapy_remote(self): + """ + The SoapyRemote allows the usage of remote SDR devices using the SoapySDRServer. + + You can get the code and find additional information [here](https://github.com/pothosware/SoapyRemote/wiki). + """ + return self._has_soapy_driver("remote") + + def has_soapy_uhd(self): + """ + The SoapyUHD module allows using UHD / USRP devices with SoapySDR. + + You can get it [here](https://github.com/pothosware/SoapyUHD/wiki). + """ + return self._has_soapy_driver("uhd") + + def has_soapy_radioberry(self): + """ + The Radioberry is a SDR hat for the Raspberry Pi. + + You can find more information, along with its SoapySDR module [here](https://github.com/pa3gsb/Radioberry-2.x). + """ + return self._has_soapy_driver("radioberry") + + def has_soapy_hackrf(self): + """ + The SoapyHackRF allows HackRF to be used with SoapySDR. + + You can get it [here](https://github.com/pothosware/SoapyHackRF/wiki). + """ + return self._has_soapy_driver("hackrf") + + def has_soapy_fcdpp(self): + """ + The SoapyFCDPP module allows the use of the Funcube Dongle Pro+. + + You can get it [here](https://github.com/pothosware/SoapyFCDPP). + """ + return self._has_soapy_driver("fcdpp") + + def has_m17_demod(self): + """ + The `m17-demod` tool is used to demodulate M17 digital voice signals. + + You can find more information [here](https://github.com/mobilinkd/m17-cxx-demod) + """ + return self.command_is_runnable("m17-demod") + + def has_sox(self): + """ + The sox audio library is used to convert between the typical 8 kHz audio sampling rate used by digital modes and + the audio sampling rate requested by the client. + + It is available for most distributions through the respective package manager. + """ + return self.command_is_runnable("sox") + + def has_direwolf(self): + """ + OpenWebRX uses the [direwolf](https://github.com/wb2osz/direwolf) software modem to decode Packet Radio and + report data back to APRS-IS. Direwolf is available from the package manager on many distributions, or you can + compile it from source. + """ + return self.command_is_runnable("direwolf --help") + + def has_airspy_rx(self): + """ + In order to use an Airspy Receiver, you need to install the airspy_rx receiver software. + """ + return self.command_is_runnable("airspy_rx --help") + + def has_wsjtx(self): + """ + To decode FT8 and other digimodes, you need to install the WSJT-X software suite. Please check the + [WSJT-X homepage](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) for ready-made packages or instructions + on how to build from source. + """ + return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True) + + def _has_wsjtx_version(self, required_version): + wsjt_version_regex = re.compile("^WSJT-X (.*)$") + + try: + process = subprocess.Popen(["wsjtx_app_version", "--version"], stdout=subprocess.PIPE) + matches = wsjt_version_regex.match(process.stdout.readline().decode()) + if matches is None: + return False + version = LooseVersion(matches.group(1)) + process.wait(1) + return version >= required_version + except FileNotFoundError: + return False + + def has_wsjtx_2_3(self): + """ + Newer digital modes (e.g. FST4, FST4) require WSJT-X in at least version 2.3. + """ + return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.3")) + + def has_wsjtx_2_4(self): + """ + WSJT-X version 2.4 introduced the Q65 mode. + """ + return self.has_wsjtx() and self._has_wsjtx_version(LooseVersion("2.4")) + + def has_js8(self): + """ + To decode JS8, you will need to install [JS8Call](http://js8call.com/) + + Please note that the `js8` command line decoder is not made available on $PATH by some JS8Call package builds. + You will need to manually make it available by either linking it to `/usr/bin` or by adding its location to + $PATH. + """ + return self.command_is_runnable("js8") + + def has_alsa(self): + """ + Some SDR receivers are identifying themselves as a soundcard. In order to read their data, OpenWebRX relies + on the Alsa library. It is available as a package for most Linux distributions. + """ + return self.command_is_runnable("arecord --help") + + def has_rockprog(self): + """ + The "rockprog" executable is required to send commands to your FiFiSDR. It needs to be installed separately. + + You can find instructions and downloads [here](https://o28.sischa.net/fifisdr/trac/wiki/De%3Arockprog). + """ + return self.command_is_runnable("rockprog") + + def has_freedv_rx(self): + """ + The "freedv\_rx" executable is required to demodulate FreeDV digital transmissions. It comes together with the + codec2 library, but it's only a supplemental part and not installed by default or contained in its packages. + To install it, you will need to compile codec2 from source and manually install freedv\_rx. + + Detailed installation instructions are available on the + [OpenWebRX wiki](https://github.com/jketterl/openwebrx/wiki/FreeDV-demodulator-notes). + """ + return self.command_is_runnable("freedv_rx") + + def has_dream(self): + """ + In order to be able to decode DRM broadcasts, OpenWebRX needs the "dream" DRM decoder. + + The version supplied by most distributions will not work properly on the command line, so compiling from source + with a custom set of commands is recommended. Detailed installation instructions are available on the + [OpenWebRX wiki](https://github.com/jketterl/openwebrx/wiki/DRM-demodulator-notes). + """ + return self.command_is_runnable("dream --help", 0) + + def has_sddc_connector(self): + """ + The sddc_connector allows connectivity with SDR devices powered by libsddc, e.g. RX666, RX888, HF103. + + You can find more information [here](https://github.com/jketterl/sddc_connector). + """ + return self._check_connector("sddc_connector", LooseVersion("0.1")) + + def has_hpsdr_connector(self): + """ + In order to use the HPSDR connector, you will need to install [hpsdrconnector] + (https://github.com/jancona/hpsdrconnector). + """ + return self.command_is_runnable("hpsdrconnector -h") + + def has_runds_connector(self): + """ + To use radios supporting R&S radios via EB200 or Ammos, you need to install the runds_connector. + + You can find more information [here](https://github.com/jketterl/runds_connector). + """ + return self._check_connector("runds_connector", LooseVersion("0.2")) + + def has_codecserver_ambe(self): + tmp_dir = CoreConfig().get_temporary_directory() + cmd = ["mbe_synthesizer", "--test"] + config = Config.get() + if "digital_voice_codecserver" in config: + cmd += ["--server", config["digital_voice_codecserver"]] + try: + process = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + cwd=tmp_dir, + ) + return process.wait() == 0 + except FileNotFoundError: + return False diff --git a/openwebrx/owrx/fft.py b/openwebrx/owrx/fft.py new file mode 100644 index 0000000..0900b17 --- /dev/null +++ b/openwebrx/owrx/fft.py @@ -0,0 +1,90 @@ +from owrx.config.core import CoreConfig +from owrx.config import Config +import csdr +from csdr.output import Output +import threading +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass +from owrx.property import PropertyStack + +import logging + +logger = logging.getLogger(__name__) + + +class SpectrumThread(Output, SdrSourceEventClient): + def __init__(self, sdrSource): + self.sdrSource = sdrSource + super().__init__() + + stack = PropertyStack() + stack.addLayer(0, self.sdrSource.props) + stack.addLayer(1, Config.get()) + self.props = props = stack.filter( + "samp_rate", + "fft_size", + "fft_fps", + "fft_voverlap_factor", + "fft_compression", + ) + + self.dsp = dsp = csdr.Dsp(self) + dsp.nc_port = self.sdrSource.getPort() + dsp.set_demodulator("fft") + + def set_fft_averages(changes=None): + samp_rate = props["samp_rate"] + fft_size = props["fft_size"] + fft_fps = props["fft_fps"] + fft_voverlap_factor = props["fft_voverlap_factor"] + + dsp.set_fft_averages( + int(round(1.0 * samp_rate / fft_size / fft_fps / (1.0 - fft_voverlap_factor))) + if fft_voverlap_factor > 0 + else 0 + ) + + self.subscriptions = [ + props.wireProperty("samp_rate", dsp.set_samp_rate), + props.wireProperty("fft_size", dsp.set_fft_size), + props.wireProperty("fft_fps", dsp.set_fft_fps), + props.wireProperty("fft_compression", dsp.set_fft_compression), + props.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), + ] + + set_fft_averages() + + dsp.set_temporary_directory(CoreConfig().get_temporary_directory()) + logger.debug("Spectrum thread initialized successfully.") + + def start(self): + self.sdrSource.addClient(self) + if self.sdrSource.isAvailable(): + self.dsp.start() + + def supports_type(self, t): + return t == "audio" + + def receive_output(self, type, read_fn): + threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start() + + def stop(self): + self.dsp.stop() + self.sdrSource.removeClient(self) + for c in self.subscriptions: + c.cancel() + self.subscriptions = [] + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.USER + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.STOPPING: + self.dsp.stop() + elif state is SdrSourceState.RUNNING: + self.dsp.start() + + def onFail(self): + self.dsp.stop() + + def onShutdown(self): + self.dsp.stop() diff --git a/openwebrx/owrx/form/__init__.py b/openwebrx/owrx/form/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/owrx/form/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/form/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1696765faabbb95fe03147d4a490199887b73848 GIT binary patch literal 123 zcmZ?b<>g`kg7dFp5<&E15CH>>K!yVl7qb9~6oz01O-8?!3`HPe1o2BnKfj;^h*R^* tQTDjg$WN>V=cGM<>QzP{? z+9OZHeXrbj1WwGX<3vb3Fw*?(%zpE;`z8zrfb{eCr{t0W{353#7mZWo`WcD~kRaIx zS-}mD8TyXo=yS!;ccl9PA}__v9r3MIzd%Dl%p}ClElinY(%A$tmt49Vh+XMPAGLQ2 zF2p|K^i2P})blf~OPzKwq;O% zDxYc(8`0EJ#7tj^G*9YUh#&Ch>A0*KWK~?LOMN{quTT)wSkXi$)l$nZ{$2h}lL^^)ROUJ3$X?{F!RrsU?MKst0U!xG&QN z|F1|p=z~QDi1WT(4i)VR9sg5kn}BbN`z?~W!u<|}n3r;qlRXsT>mte9h~1BVgkXAz z!i3xPrYg^c*bj+Q2b4r*b+;qCpMVX$fZMYagFB+6Q{i_@% literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/__pycache__/section.cpython-37.pyc b/openwebrx/owrx/form/__pycache__/section.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5379fb21663dee8fc04de6c9d4c3232d41549c68 GIT binary patch literal 5985 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 RY_JN|*-;U4E!vCk{0AGt^5_5n literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/error.py b/openwebrx/owrx/form/error.py new file mode 100644 index 0000000..60eef10 --- /dev/null +++ b/openwebrx/owrx/form/error.py @@ -0,0 +1,15 @@ +class FormError(Exception): + def __init__(self, key, message): + super().__init__("Error processing form data for {}: {}".format(key, message)) + self.key = key + self.message = message + + def getKey(self): + return self.key + + def getMessage(self): + return self.message + + +class ValidationError(FormError): + pass diff --git a/openwebrx/owrx/form/input/__init__.py b/openwebrx/owrx/form/input/__init__.py new file mode 100644 index 0000000..f25279a --- /dev/null +++ b/openwebrx/owrx/form/input/__init__.py @@ -0,0 +1,411 @@ +from abc import ABC +from owrx.modes import Modes +from owrx.config import Config +from owrx.form.input.validator import Validator +from owrx.form.input.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter +from enum import Enum + + +class Input(ABC): + def __init__(self, id, label, infotext=None, converter: Converter = None, validator: Validator = None, disabled=False, removable=False): + self.id = id + self.label = label + self.infotext = infotext + self.converter = self.defaultConverter() if converter is None else converter + self.validator = validator + self.disabled = disabled + self.removable = removable + + def setDisabled(self, disabled=True): + self.disabled = disabled + + def setRemovable(self, removable=True): + self.removable = removable + + def defaultConverter(self): + return NullConverter() + + def bootstrap_decorate(self, input): + return """ +
    + +
    +
    + {input} + {infotext} +
    + {removebutton} +
    +
    + """.format( + id=self.id, + label=self.label, + input=input, + infotext="{text}".format(text=self.infotext) if self.infotext else "", + removable="removable" if self.removable else "", + removebutton='' + if self.removable + else "", + ) + + def input_classes(self, errors): + classes = ["form-control", "form-control-sm"] + if errors: + classes.append("is-invalid") + return " ".join(classes) + + def input_properties(self, value, errors): + props = { + "class": self.input_classes(errors), + "id": self.id, + "name": self.id, + "placeholder": self.label, + "value": value, + } + if self.disabled: + props["disabled"] = "disabled" + return props + + def render_input_properties(self, value, error): + return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value, error).items()) + + def render_errors(self, errors): + return "".join("""
    {msg}
    """.format(msg=e) for e in errors) + + def render_input_group(self, value, errors): + return """ + {input} + {errors} + """.format( + input=self.render_input(value, errors), + errors=self.render_errors(errors) + ) + + def render_input(self, value, errors): + return "".format(properties=self.render_input_properties(value, errors)) + + def render(self, config, errors): + value = config[self.id] if self.id in config else None + error = errors[self.id] if self.id in errors else [] + return self.bootstrap_decorate(self.render_input_group(self.converter.convert_to_form(value), error)) + + def parse(self, data): + if self.id in data: + value = self.converter.convert_from_form(data[self.id][0]) + if self.validator is not None: + self.validator.validate(self.id, value) + return {self.id: value} + return {} + + def getLabel(self): + return self.label + + +class TextInput(Input): + def input_properties(self, value, errors): + props = super().input_properties(value, errors) + props["type"] = "text" + return props + + +class NumberInput(Input): + def __init__(self, id, label, infotext=None, append="", converter: Converter = None, validator: Validator = None): + super().__init__(id, label, infotext, converter=converter, validator=validator) + self.step = None + self.append = append + + def defaultConverter(self): + return IntConverter() + + def input_properties(self, value, errors): + props = super().input_properties(value, errors) + props["type"] = "number" + if self.step: + props["step"] = self.step + return props + + def render_input_group(self, value, errors): + if self.append: + append = """ +
    + {append} +
    + """.format( + append=self.append + ) + else: + append = "" + + return """ +
    + {input} + {append} + {errors} +
    + """.format( + input=self.render_input(value, errors), + append=append, + errors=self.render_errors(errors) + ) + + +class FloatInput(NumberInput): + def __init__(self, id, label, infotext=None, converter: Converter = None): + super().__init__(id, label, infotext, converter=converter) + self.step = "any" + + def defaultConverter(self): + return FloatConverter() + + +class LocationInput(Input): + def render_input_group(self, value, errors): + return """ +
    + {inputs} +
    + {errors} +
    +
    +
    + """.format( + id=self.id, + rowclass="is-invalid" if errors else "", + inputs=self.render_input(value, errors), + errors=self.render_errors(errors), + key=Config.get()["google_maps_api_key"], + ) + + def render_input(self, value, errors): + return "".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"]) + + def render_sub_input(self, value, id, errors): + return """ +
    + +
    + """.format( + id="{0}-{1}".format(self.id, id), + label=self.label, + classes=self.input_classes(errors), + value=value[id], + disabled="disabled" if self.disabled else "", + ) + + def parse(self, data): + return {self.id: {k: float(data["{0}-{1}".format(self.id, k)][0]) for k in ["lat", "lon"]}} + + +class TextAreaInput(Input): + def render_input(self, value, errors): + return """ + + """.format( + id=self.id, + classes=self.input_classes(errors), + value=value, + disabled="disabled" if self.disabled else "", + ) + + +class CheckboxInput(Input): + def __init__(self, id, checkboxText, infotext=None, converter: Converter = None): + super().__init__(id, "", infotext=infotext, converter=converter) + self.checkboxText = checkboxText + + def render_input(self, value, errors): + return """ +
    + + + +
    + """.format( + id=self.id, + classes=self.input_classes(errors), + checked="checked" if value else "", + disabled="disabled" if self.disabled else "", + checkboxText=self.checkboxText, + ) + + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) + + def parse(self, data): + if self.id in data: + return {self.id: self.converter.convert_from_form("1" in data[self.id])} + return {} + + def getLabel(self): + return self.checkboxText + + +class Option(object): + # used for both MultiCheckboxInput and DropdownInput + def __init__(self, value, text): + self.value = value + self.text = text + + +class MultiCheckboxInput(Input): + def __init__(self, id, label, options, infotext=None): + super().__init__(id, label, infotext=infotext) + self.options = options + + def render_input(self, value, errors): + return "".join(self.render_checkbox(o, value, errors) for o in self.options) + + def checkbox_id(self, option): + return "{0}-{1}".format(self.id, option.value) + + def render_checkbox(self, option, value, errors): + return """ +
    + + +
    + """.format( + id=self.checkbox_id(option), + classes=self.input_classes(errors), + checked="checked" if option.value in value else "", + checkboxText=option.text, + disabled="disabled" if self.disabled else "", + ) + + def parse(self, data): + def in_response(option): + boxid = self.checkbox_id(option) + return boxid in data and data[boxid][0] == "on" + + return {self.id: [o.value for o in self.options if in_response(o)]} + + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) + + +class ServicesCheckboxInput(MultiCheckboxInput): + def __init__(self, id, label, infotext=None): + services = [Option(s.modulation, s.name) for s in Modes.getAvailableServices()] + super().__init__(id, label, services, infotext) + + +class Js8ProfileCheckboxInput(MultiCheckboxInput): + def __init__(self, id, label, infotext=None): + profiles = [ + Option("normal", "Normal (15s, 50Hz, ~16WPM)"), + Option("slow", "Slow (30s, 25Hz, ~8WPM"), + Option("fast", "Fast (10s, 80Hz, ~24WPM"), + Option("turbo", "Turbo (6s, 160Hz, ~40WPM"), + ] + super().__init__(id, label, profiles, infotext) + + +class DropdownInput(Input): + def __init__(self, id, label, options, infotext=None, converter: Converter = None): + try: + isEnum = issubclass(options, DropdownEnum) + except TypeError: + isEnum = False + if isEnum: + self.options = [o.toOption() for o in options] + if converter is None: + converter = EnumConverter(options) + else: + self.options = options + super().__init__(id, label, infotext=infotext, converter=converter) + + def render_input(self, value, errors): + return """ + + """.format( + classes=self.input_classes(errors), + id=self.id, + options=self.render_options(value), + disabled="disabled" if self.disabled else "", + ) + + def render_options(self, value): + options = [ + """ + + """.format( + text=o.text, + value=o.value, + selected="selected" if o.value == value else "", + ) + for o in self.options + ] + return "".join(options) + + +class DropdownEnum(Enum): + def toOption(self): + return Option(self.name, str(self)) + + +class ModesInput(DropdownInput): + def __init__(self, id, label): + options = [Option(m.modulation, m.name) for m in Modes.getAvailableModes()] + super().__init__(id, label, options) + + +class ExponentialInput(Input): + def __init__(self, id, label, unit, infotext=None): + super().__init__(id, label, infotext=infotext) + self.unit = unit + + def defaultConverter(self): + return IntConverter() + + def input_properties(self, value, errors): + props = super().input_properties(value, errors) + props["type"] = "number" + props["step"] = "any" + return props + + def render_input_group(self, value, errors): + append = """ +
    + +
    + """.format( + id=self.id, + disabled="disabled" if self.disabled else "", + unit=self.unit, + ) + + return """ +
    + {input} + {append} + {errors} +
    + """.format( + input=self.render_input(value, errors), + append=append, + errors=self.render_errors(errors) + ) + + def parse(self, data): + exponent_id = "{}-exponent".format(self.id) + if self.id in data and exponent_id in data: + value = int(float(data[self.id][0]) * 10 ** int(data[exponent_id][0])) + return {self.id: value} + return {} diff --git a/openwebrx/owrx/form/input/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89af80e10414ca8f796e74c1685921f2f360b5fa GIT binary patch literal 18172 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 P<-SmwDxEFmONIXhw9>7X literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/input/__pycache__/aprs.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/aprs.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8b3ccadfa025772040f2453f8222f7d2f57bbbe GIT binary patch literal 1635 zcmbtU&2HO95Z)yzO4g54Cr*(biV`g#M2A|5PdNldWSLQjCUhVPrLzb?(A;%Gha#CJ zWz~#yYOZ~U_SmQCo!6e)N9d_DlxTGs-9>Bf=G~ek5EK^aqtGAZxq^a;=nAu1`p>{yTV)9xd93GER8B zh|Fj{^HF&@6j(d~N*@6egi%hI!YNa^!Zfb38rPW4Ypl+7*5Gxv#T$IP*8z&GwEq3}{+%zQEK#KJ1V-)whxpA}CK6<4-g*@TV zfmEO)p!7>%OZr+_sVglbKhY)mnXYO}by;(jr3M^UkS1#Pv_g+h&V-*8b=Qr=!gXPB z?uB#V7A-DPUj|7Q#8FQ#R6k4$&697^LVvd?=UTZ_*5SHBg>!x(vIFZsyZt!HfJC$q zC-VD#ya09@%UM5&l6ltmpg4QUg{;A5bVy6Y(FSc&9lrdte^qa96W!H&@!g2VA-dlN z#_1VZk~4VgnZlJH6?mU&=l4ET9Kqq0!pj!fGeTSKb46Ns@wUMXC z?!zmiV~x#;dt5c{mo<=>z{)eRCKBH&5mK1uGQ|z5RX?RsFYW)gfuVV~pmEm?+~yQ2 z*DmfN1O`ziqR2ahp70Tcsa;T;DYjWqlPMFJCNMb#bwKG{!v@x+#0FLgFadw7cMZ9X zr=tXSC^owl&!T`|p6-P`x+%Qe0k29z%3NTQz5=?e`RD%`A|SP_d#@w!;30UwybFwN zA42|zQwxfG^uo4S>u=d+?TTPlO=i_Jxn`PNGdWc+r)sjDn+yCYM0O7-#W?>_U@W#G saR>e=ZebViVUz5YT`V!hB>>7Tm26$Xr)WH0qx~Cf*A#)&RzKPO3w_szoB#j- literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/input/__pycache__/converter.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/converter.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2974f181519ef9f743947de1e3d0ed232c840e6e GIT binary patch literal 5188 zcmbVQ&5zs073WZ-q}8t0TCb7X`LJ0yNUFxr8g0=6$ztPrYX`|e0qQgjga#~ELwRLW zq&%ejp~|Oq(e}_o?>*SJ_OI!k*Ph&aFFxh>8;V?Mt<`=Im?0l$_}-h}`tssAH4tL>AxiM%fiDgwD@1>q#;FM3`9o^%#JBYn?Y${bu9f{3ha*kg&{3zzc8fj zi-S_947w~UpeueEbOm%(u7Fu7Y0G^a|)TSqEL$bPe>nya4)wrdL5X}%9r&oWU%fpFf)5%5PA%wucyhFvtJ&HaV?4 z;&f?7-nGT@W-MW=HOyCacI)hx<9z{B8fa6zt0l!Zb9fgy;wDB8@dnnbz9p@H3Teyo z3*-h_k<}MQrz}@w4fl#P0qR=1@%1q2N1=CU2k$NdsOI4MMtNaOCyyP^iHCl-?*$HN zM~9G;L{2*j{cP^O??}Jrje^AK$4(GM4>{HGLdPErlV=XHKtH^H!`T}pPO^_FT@3B{ z&M5XJIe8LM$cHG|_mz`88~Slev;MM&59YXsQ3B?pP~OlZ$S!c>sZ!uP^x`Q~eix7S z+-dNDFLmmTR3;-|pGOEXVNjzFsj`NFCxNQqaC1YBeCF z*S|kfnw!?Vog(+7^*If?j5A)m(@}TP;;ZN;$axQ|YCSOyjL@8jiQ$TgiQb$hv1Vp} zbrF-(#uV+lu*bK|Xc*PmgQIGbU6JCHP9aDF{ji_7u7g4CQiixB#_zu60j+%ETu4%1 zt?RpZ$aS3<50GHdiYVcVj1nKrE3qt%7U9VHgh_NFt{|!Fx(g!pk|}bTcsj=I8S&Q^ zi=Xj}@{vS&n$-9nBz#6G+}#+Rh)=#->>gD7c=y2a%4z+pI0_fL1^sUqEoQBj0FQ}C zOey}((#dSYHA-E$MJcr&fJJJ{(O?)WezXiGy4V+FO3V#ZsdMs|#%&27Xnn{dFo)m&e z#V1(Gfvh5y;J?y*X=De-iTDi;k{T~N+o)4f>+-P3i^ibBF`EuZC>CPeEOUoP-Z>MgqfPHo2htwBr&{M*}m_G{?nn_`7JqTV#c2xx2U7$ zZ*2!r*9+pER>22Ctp`y*JjuttraT6TA*#X>H_!-G~YAaSL5`j{BSO7o#UM zsMU!8y!I;%?Q;`<8*oCOm;@R~RoVr-gqhxWkF0K?d-l-;&`Q`tT}@ExN))+&Hm|@| z2Z%2H#^RG@B<91K`c%LG{LyjG_%2J$cKeOE{+fJWvo2D6LMYba#8XN9q@V07nlY{E z;`okIkpgleK4PGBb?B+szo$N@y>blK4hgn1g|;W=?mI`1Xk;g2+_f|qO3`wd0VSCtMU=d z)z9$#AJL|o>SYdtev%(y=qYJgA2Kt09@*nd`ypkkzI< zt{*#&t@x(7o|e45uA))(zmv4aA4e^21=%K`91iiHi9WXL@?Dpbisg*nv=mdLW;X*7 imo+`HZD;>#cIezO&kYT+hAppAYg8MXXw}AQqxu6cP!pp7 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/input/__pycache__/device.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/device.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a3b6775282dddde2b9edf8a05b41a8b565947b70 GIT binary patch literal 17708 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 Jj+dsF{uh~)dOrXF literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/input/__pycache__/gfx.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/gfx.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aff09b4bd652646e730dbc99d6bf5449eaab281e GIT binary patch literal 3062 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 HujT#&D8v`F literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/input/__pycache__/wfm.cpython-37.pyc b/openwebrx/owrx/form/input/__pycache__/wfm.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27313935e68fc1913fb57efa6c6c3325caa90d6f GIT binary patch literal 833 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 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/form/input/aprs.py b/openwebrx/owrx/form/input/aprs.py new file mode 100644 index 0000000..a81eab7 --- /dev/null +++ b/openwebrx/owrx/form/input/aprs.py @@ -0,0 +1,36 @@ +from owrx.form.input import DropdownEnum + + +class AprsBeaconSymbols(DropdownEnum): + BEACON_RECEIVE_ONLY = ("R&", "Receive only IGate") + BEACON_HF_GATEWAY = ("/&", "HF Gateway") + BEACON_IGATE_GENERIC = ("I&", "Igate Generic (please use more specific overlay)") + BEACON_PSKMAIL = ("P&", "PSKmail node") + BEACON_TX_1 = ("T&", "TX IGate with path set to 1 hop") + BEACON_WIRES_X = ("W&", "Wires-X") + BEACON_TX_2 = ("2&", "TX IGate with path set to 2 hops") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return "{description} ({symbol})".format(description=self.description, symbol=self.value) + + +class AprsAntennaDirections(DropdownEnum): + DIRECTION_OMNI = None + DIRECTION_N = "N" + DIRECTION_NE = "NE" + DIRECTION_E = "E" + DIRECTION_SE = "SE" + DIRECTION_S = "S" + DIRECTION_SW = "SW" + DIRECTION_W = "W" + DIRECTION_NW = "NW" + + def __str__(self): + return "omnidirectional" if self.value is None else self.value diff --git a/openwebrx/owrx/form/input/converter.py b/openwebrx/owrx/form/input/converter.py new file mode 100644 index 0000000..5f9d15b --- /dev/null +++ b/openwebrx/owrx/form/input/converter.py @@ -0,0 +1,96 @@ +from abc import ABC, abstractmethod +from owrx.jsons import Encoder +import json + + +class Converter(ABC): + @abstractmethod + def convert_to_form(self, value): + pass + + @abstractmethod + def convert_from_form(self, value): + pass + + +class NullConverter(Converter): + def convert_to_form(self, value): + return value + + def convert_from_form(self, value): + return value + + +class OptionalConverter(Converter): + """ + Transforms a special form value to None + The default is look for an empty string, but this can be used to adopt to other types. + If the default is not found, the actual value is passed to the sub_converter for further transformation. + useful for optional fields since None is not stored in the configuration + """ + + def __init__(self, sub_converter: Converter = None, defaultFormValue=""): + self.sub_converter = NullConverter() if sub_converter is None else sub_converter + self.defaultFormValue = defaultFormValue + + def convert_to_form(self, value): + return self.defaultFormValue if value is None else self.sub_converter.convert_to_form(value) + + def convert_from_form(self, value): + return None if value == self.defaultFormValue else self.sub_converter.convert_from_form(value) + + +class IntConverter(Converter): + def convert_to_form(self, value): + return str(value) + + def convert_from_form(self, value): + return int(value) + + +class FloatConverter(Converter): + def convert_to_form(self, value): + return str(value) + + def convert_from_form(self, value): + return float(value) + + +class EnumConverter(Converter): + def __init__(self, enumCls): + self.enumCls = enumCls + + def convert_to_form(self, value): + return None if value is None else self.enumCls(value).name + + def convert_from_form(self, value): + return self.enumCls[value].value + + +class JsonConverter(Converter): + def convert_to_form(self, value): + return json.dumps(value, cls=Encoder) + + def convert_from_form(self, value): + return json.loads(value) + + +class WaterfallColorsConverter(Converter): + def convert_to_form(self, value): + if value is None: + return "" + return "\n".join("#{:06x}".format(v) for v in value) + + def convert_from_form(self, value): + def parseString(s): + try: + if s.startswith("#"): + return int(s[1:], 16) + # int() with base 0 can accept "0x" prefixed hex strings, or int numbers + return int(s, 0) + except ValueError: + return None + + # \r\n or \n? this should work with both. + values = [parseString(v.strip("\r ")) for v in value.split("\n")] + return [v for v in values if v is not None] diff --git a/openwebrx/owrx/form/input/device.py b/openwebrx/owrx/form/input/device.py new file mode 100644 index 0000000..2217b49 --- /dev/null +++ b/openwebrx/owrx/form/input/device.py @@ -0,0 +1,434 @@ +from owrx.form.input import Input, CheckboxInput, DropdownInput, DropdownEnum, TextInput +from owrx.form.input.converter import OptionalConverter +from owrx.form.input.validator import RequiredValidator +from owrx.soapy import SoapySettings + + +class GainInput(Input): + def __init__(self, id, label, has_agc, gain_stages=None): + super().__init__(id, label) + self.has_agc = has_agc + self.gain_stages = gain_stages + + def render_input(self, value, errors): + try: + display_value = float(value) + except (ValueError, TypeError): + display_value = "0.0" + + return """ + + + {stageoption} + """.format( + id=self.id, + classes=self.input_classes(errors), + value=display_value, + label=self.label, + options=self.render_options(value), + stageoption="" if self.gain_stages is None else self.render_stage_option(value, errors), + disabled="disabled" if self.disabled else "", + ) + + def render_input_group(self, value, errors): + return """ +
    + {input} + {errors} +
    + """.format( + id=self.id, input=self.render_input(value, errors), errors=self.render_errors(errors) + ) + + def render_options(self, value): + options = [] + if self.has_agc: + options.append(("auto", "Enable hardware AGC")) + options.append(("manual", "Specify manual gain")), + if self.gain_stages: + options.append(("stages", "Specify gain stages individually")) + + mode = self.getMode(value) + + return "".join( + """ + + """.format( + value=v[0], text=v[1], selected="selected" if mode == v[0] else "" + ) + for v in options + ) + + def getMode(self, value): + if value is None: + return "auto" if self.has_agc else "manual" + + if value == "auto": + return "auto" + + try: + float(value) + return "manual" + except (ValueError, TypeError): + pass + + return "stages" + + def render_stage_option(self, value, errors): + try: + value_dict = {k: v for item in SoapySettings.parse(value) for k, v in item.items()} + except (AttributeError, ValueError): + value_dict = {} + + return """ + + """.format( + inputs="".join( + """ +
    + + +
    + """.format( + id=self.id, + stage=stage, + value=value_dict[stage] if stage in value_dict else "", + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + ) + for stage in self.gain_stages + ) + ) + + def parse(self, data): + def getStageValue(stage): + input_id = "{id}-{stage}".format(id=self.id, stage=stage) + if input_id in data: + return data[input_id][0] + else: + return None + + select_id = "{id}-select".format(id=self.id) + if select_id in data: + if self.has_agc and data[select_id][0] == "auto": + return {self.id: "auto"} + if data[select_id][0] == "manual": + input_id = "{id}-manual".format(id=self.id) + value = 0.0 + if input_id in data: + try: + value = float(data[input_id][0]) + except ValueError: + pass + return {self.id: value} + if self.gain_stages is not None and data[select_id][0] == "stages": + settings_dict = [{s: getStageValue(s)} for s in self.gain_stages] + # filter out empty ones + settings_dict = [s for s in settings_dict if next(iter(s.values()))] + return {self.id: SoapySettings.encode(settings_dict)} + + return {} + + +class BiasTeeInput(CheckboxInput): + def __init__(self): + super().__init__("bias_tee", "Enable Bias-Tee power supply") + + +class DirectSamplingOptions(DropdownEnum): + DIRECT_SAMPLING_OFF = (0, "Off") + DIRECT_SAMPLING_I = (1, "Direct Sampling (I branch)") + DIRECT_SAMPLING_Q = (2, "Direct Sampling (Q branch)") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return self.description + + +class DirectSamplingInput(DropdownInput): + def __init__(self): + super().__init__( + "direct_sampling", + "Direct Sampling", + DirectSamplingOptions, + ) + + +class RemoteInput(TextInput): + def __init__(self): + super().__init__( + "remote", + "Remote IP and Port", + infotext="Remote hostname or IP and port to connect to. Format = IP:Port", + converter=OptionalConverter(), + validator=RequiredValidator(), + ) + + +class SchedulerInput(Input): + def __init__(self, id, label): + super().__init__(id, label) + self.profiles = {} + + def render(self, config, errors): + if "profiles" in config: + self.profiles = config["profiles"] + return super().render(config, errors) + + def render_profiles_select(self, value, errors, config_key, stage, extra_classes="", allow_empty=False): + stage_value = "" + if value and "schedule" in value and config_key in value["schedule"]: + stage_value = value["schedule"][config_key] + + options = "".join( + """ + + """.format( + id=p_id, + name=p["name"], + selected="selected" if stage_value == p_id else "", + ) + for p_id, p in self.profiles.items() + ) + + if allow_empty: + # prepend a special "off" option to allow a schedule slot to go unused (daylight scheduler) + options = """""".format( + selected="selected" if value is None else "" + ) + options + + return """ + + """.format( + id="{}-{}".format(self.id, stage), + classes=self.input_classes(errors), + extra_classes=extra_classes, + disabled="disabled" if self.disabled else "", + options=options, + ) + + def render_static_entires(self, value, errors): + def render_time_inputs(v): + values = ["{}:{}".format(x[0:2], x[2:4]) for x in [v[0:4], v[5:9]]] + return '
    -
    '.join( + """ + + """.format( + id="{}-{}-{}".format(self.id, "time", "start" if i == 0 else "end"), + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + value=v, + ) + for i, v in enumerate(values) + ) + + schedule = {"0000-0000": ""} + if value is not None and value and "schedule" in value and "type" in value and value["type"] == "static": + schedule = value["schedule"] + + rows = "".join( + """ +
    + {time_inputs} + {select} + +
    + """.format( + time_inputs=render_time_inputs(slot), + select=self.render_profiles_select(value, errors, slot, "profile"), + ) + for slot, entry in schedule.items() + ) + + return """ + {rows} + +
    + +
    + """.format( + rows=rows, + time_inputs=render_time_inputs("0000-0000"), + select=self.render_profiles_select("", errors, "0000-0000", "profile"), + ) + + def render_daylight_entries(self, value, errors): + return "".join( + """ +
    + + {select} +
    + """.format( + name=name, + select=self.render_profiles_select( + value, errors, stage, stage, extra_classes="col-9", allow_empty=True + ), + ) + for stage, name in [("day", "Day"), ("night", "Night"), ("greyline", "Greyline")] + ) + + def render_input(self, value, errors): + return """ +
    + + + +
    + """.format( + id=self.id, + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + options=self.render_options(value), + entries=self.render_static_entires(value, errors), + stages=self.render_daylight_entries(value, errors), + ) + + def _get_mode(self, value): + if value is not None and "type" in value: + return value["type"] + return "" + + def render_options(self, value): + options = [ + ("static", "Static scheduler"), + ("daylight", "Daylight scheduler"), + ] + + mode = self._get_mode(value) + + return "".join( + """ + + """.format( + value=value, name=name, selected="selected" if mode == value else "" + ) + for value, name in options + ) + + def parse(self, data): + def getStageValue(stage): + input_id = "{id}-{stage}".format(id=self.id, stage=stage) + if input_id in data: + # special treatment for the "off" option + if data[input_id][0] == "None": + return None + return data[input_id][0] + else: + return None + + select_id = "{id}-select".format(id=self.id) + if select_id in data: + if data[select_id][0] == "static": + keys = ["{}-{}".format(self.id, x) for x in ["time-start", "time-end", "profile"]] + lists = [data[key] for key in keys if key in data] + settings_dict = { + "{}{}-{}{}".format(start[0:2], start[3:5], end[0:2], end[3:5]): profile + for start, end, profile in zip(*lists) + } + # only apply scheduler if any slots are available + if settings_dict: + return {self.id: {"type": "static", "schedule": settings_dict}} + elif data[select_id][0] == "daylight": + settings_dict = {s: getStageValue(s) for s in ["day", "night", "greyline"]} + # filter out empty ones + settings_dict = {s: v for s, v in settings_dict.items() if v} + # only apply scheduler if any of the slots are in use + if settings_dict: + return {self.id: {"type": "daylight", "schedule": settings_dict}} + + return {} + + +class WaterfallLevelsInput(Input): + def __init__(self, id, label, infotext=None): + super().__init__(id, label, infotext=infotext) + + def render_input_group(self, value, errors): + return """ +
    + {input} +
    + {errors} + """.format( + rowclass="is-invalid" if errors else "", + id=self.id, + input=self.render_input(value, errors), + errors=self.render_errors(errors), + ) + + def getUnit(self): + return "dBFS" + + def getFields(self): + return {"min": "Minimum", "max": "Maximum"} + + def render_input(self, value, errors): + return "".join( + """ +
    + +
    + +
    + {unit} +
    +
    +
    + """.format( + id=self.id, + name=name, + label=label, + value=value[name] if value and name in value else "0", + classes=self.input_classes(errors), + disabled="disabled" if self.disabled else "", + unit=self.getUnit(), + ) + for name, label in self.getFields().items() + ) + + def parse(self, data): + def getValue(name): + key = "{}-{}".format(self.id, name) + if key in data: + return {name: float(data[key][0])} + raise KeyError("waterfall key not found") + + try: + return {self.id: {k: v for name in ["min", "max"] for k, v in getValue(name).items()}} + except KeyError: + return {} + + +class WaterfallAutoLevelsInput(WaterfallLevelsInput): + def getUnit(self): + return "dB" + + def getFields(self): + return {"min": "Lower", "max": "Upper"} diff --git a/openwebrx/owrx/form/input/gfx.py b/openwebrx/owrx/form/input/gfx.py new file mode 100644 index 0000000..24516b4 --- /dev/null +++ b/openwebrx/owrx/form/input/gfx.py @@ -0,0 +1,67 @@ +from abc import ABCMeta, abstractmethod +from owrx.form.input import Input +from datetime import datetime + + +class ImageInput(Input, metaclass=ABCMeta): + def render_input(self, value, errors): + # TODO display errors + return """ +
    + +
    + {label} +
    + + +
    + """.format( + id=self.id, + label=self.label, + url=self.cachebuster(self.getUrl()), + classes=" ".join(self.getImgClasses()), + maxsize=self.getMaxSize(), + ) + + def cachebuster(self, url: str): + return "{url}{separator}cb={cachebuster}".format( + url=url, + cachebuster=datetime.now().timestamp(), + separator="&" if "?" in url else "?", + ) + + @abstractmethod + def getUrl(self) -> str: + pass + + @abstractmethod + def getImgClasses(self) -> list: + pass + + @abstractmethod + def getMaxSize(self) -> int: + pass + + +class AvatarInput(ImageInput): + def getUrl(self) -> str: + return "../static/gfx/openwebrx-avatar.png" + + def getImgClasses(self) -> list: + return ["webrx-rx-avatar"] + + def getMaxSize(self) -> int: + # 256 kB + return 250 * 1024 + + +class TopPhotoInput(ImageInput): + def getUrl(self) -> str: + return "../static/gfx/openwebrx-top-photo.jpg" + + def getImgClasses(self) -> list: + return ["webrx-top-photo"] + + def getMaxSize(self) -> int: + # 2 MB + return 2 * 1024 * 1024 diff --git a/openwebrx/owrx/form/input/receiverid.py b/openwebrx/owrx/form/input/receiverid.py new file mode 100644 index 0000000..0812cc6 --- /dev/null +++ b/openwebrx/owrx/form/input/receiverid.py @@ -0,0 +1,10 @@ +from owrx.form.input.converter import Converter + + +class ReceiverKeysConverter(Converter): + def convert_to_form(self, value): + return "" if value is None else "\n".join(value) + + def convert_from_form(self, value): + # \r\n or \n? this should work with both. + return [v.strip("\r ") for v in value.split("\n")] diff --git a/openwebrx/owrx/form/input/validator.py b/openwebrx/owrx/form/input/validator.py new file mode 100644 index 0000000..b9b87d1 --- /dev/null +++ b/openwebrx/owrx/form/input/validator.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod +from owrx.form.error import ValidationError + + +class Validator(ABC): + @abstractmethod + def validate(self, key, value): + pass + + +class RequiredValidator(Validator): + def validate(self, key, value): + if value is None or value == "": + raise ValidationError(key, "Field is required") + +class RangeValidator(Validator): + def __init__(self, minValue, maxValue): + self.minValue = minValue + self.maxValue = maxValue + + def validate(self, key, value): + if value is None or value == "": + return # Ignore empty values + n = float(value) + if n < self.minValue or n > self.maxValue: + raise ValidationError(key, 'Value must be between %s and %s'%(self.minValue, self.maxValue)) diff --git a/openwebrx/owrx/form/input/wfm.py b/openwebrx/owrx/form/input/wfm.py new file mode 100644 index 0000000..544754b --- /dev/null +++ b/openwebrx/owrx/form/input/wfm.py @@ -0,0 +1,16 @@ +from owrx.form.input import DropdownEnum + + +class WfmTauValues(DropdownEnum): + TAU_50_MICRO = (50e-6, "most regions") + TAU_75_MICRO = (75e-6, "Americas and South Korea") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return "{}µs ({})".format(int(self.value * 1e6), self.description) diff --git a/openwebrx/owrx/form/input/wsjt.py b/openwebrx/owrx/form/input/wsjt.py new file mode 100644 index 0000000..1410599 --- /dev/null +++ b/openwebrx/owrx/form/input/wsjt.py @@ -0,0 +1,93 @@ +from owrx.form.input import Input +from owrx.form.input.converter import JsonConverter +from owrx.wsjt import Q65Mode, Q65Interval +from owrx.modes import Modes, WsjtMode +import html + + +class Q65ModeMatrix(Input): + def checkbox_id(self, mode, interval): + return "{0}-{1}-{2}".format(self.id, mode.value, interval.value) + + def render_checkbox(self, mode: Q65Mode, interval: Q65Interval, value, errors): + return """ +
    + + +
    + """.format( + classes=self.input_classes(errors), + id=self.checkbox_id(mode, interval), + checked="checked" if "{}{}".format(mode.name, interval.value) in value else "", + checkboxText="Mode {} interval {}s".format(mode.name, interval.value), + disabled="" if interval.is_available(mode) and not self.disabled else "disabled", + ) + + def render_input_group(self, value, errors): + return """ +
    + {checkboxes} + {errors} +
    + """.format( + checkboxes=self.render_input(value, errors), + errors=self.render_errors(errors), + ) + + def render_input(self, value, errors): + return "".join( + self.render_checkbox(mode, interval, value, errors) for interval in Q65Interval for mode in Q65Mode + ) + + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) + + def parse(self, data): + def in_response(mode, interval): + boxid = self.checkbox_id(mode, interval) + return boxid in data and data[boxid][0] == "on" + + return { + self.id: [ + "{}{}".format(mode.name, interval.value) + for interval in Q65Interval + for mode in Q65Mode + if in_response(mode, interval) + ], + } + + +class WsjtDecodingDepthsInput(Input): + def defaultConverter(self): + return JsonConverter() + + def render_input(self, value, errors): + def render_mode(m): + return """ + + """.format( + mode=m.modulation, + name=m.name, + ) + + return """ + + + """.format( + id=self.id, + classes=self.input_classes(errors), + value=html.escape(value), + options="".join(render_mode(m) for m in Modes.getAvailableModes() if isinstance(m, WsjtMode)), + disabled="disabled" if self.disabled else "" + ) + + def input_classes(self, error): + return super().input_classes(error) + " wsjt-decoding-depths" diff --git a/openwebrx/owrx/form/section.py b/openwebrx/owrx/form/section.py new file mode 100644 index 0000000..1eb9b9e --- /dev/null +++ b/openwebrx/owrx/form/section.py @@ -0,0 +1,124 @@ +from owrx.form.error import FormError +from owrx.form.input import Input +from typing import List + + +class Section(object): + def __init__(self, title, *inputs): + self.title = title + self.inputs = inputs + + def render_input(self, input, data, errors): + return input.render(data, errors) + + def render_inputs(self, data, errors): + return "".join([self.render_input(i, data, errors) for i in self.inputs]) + + def classes(self): + return ["col-12", "settings-section"] + + def render(self, data, errors): + return """ +
    +

    + {title} +

    + {inputs} +
    + """.format( + classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data, errors) + ) + + def parse(self, data): + parsed_data = {} + errors = [] + for i in self.inputs: + try: + parsed_data.update(i.parse(data)) + except FormError as e: + errors.append(e) + except Exception as e: + errors.append(FormError(i.id, "{}: {}".format(type(e).__name__, e))) + return parsed_data, errors + + +class OptionalSection(Section): + def __init__(self, title, inputs: List[Input], mandatory, optional): + super().__init__(title, *inputs) + self.mandatory = mandatory + self.optional = optional + self.optional_inputs = [] + + def classes(self): + classes = super().classes() + classes.append("optional-section") + return classes + + def _is_optional(self, input): + return input.id in self.optional + + def render_optional_select(self): + return """ +
    +
    + +
    +
    + +
    + +
    +
    + """.format( + options="".join( + """ + + """.format( + value=input.id, + name=input.getLabel(), + ) + for input in self.optional_inputs + ) + ) + + def render_optional_inputs(self, data, errors): + return """ + + """.format( + inputs="".join(self.render_input(input, data, errors) for input in self.optional_inputs) + ) + + def render_inputs(self, data, errors): + return ( + super().render_inputs(data, errors) + + self.render_optional_select() + + self.render_optional_inputs(data, errors) + ) + + def render(self, data, errors): + indexed_inputs = {input.id: input for input in self.inputs} + visible_keys = set(self.mandatory + [k for k in self.optional if k in data or k in errors]) + optional_keys = set(k for k in self.optional if k not in data and k not in errors) + self.inputs = [input for k, input in indexed_inputs.items() if k in visible_keys] + for input in self.inputs: + if self._is_optional(input): + input.setRemovable() + self.optional_inputs = [input for k, input in indexed_inputs.items() if k in optional_keys] + for input in self.optional_inputs: + input.setRemovable() + input.setDisabled() + return super().render(data, errors) + + def parse(self, data): + data, errors = super().parse(data) + # remove optional keys if they have been removed from the form by setting them to None + for k in self.optional: + if k not in data: + data[k] = None + return data, errors diff --git a/openwebrx/owrx/http.py b/openwebrx/owrx/http.py new file mode 100644 index 0000000..050e5ef --- /dev/null +++ b/openwebrx/owrx/http.py @@ -0,0 +1,196 @@ +from owrx.controllers.status import StatusController +from owrx.controllers.template import IndexController, MapController +from owrx.controllers.feature import FeatureController +from owrx.controllers.assets import OwrxAssetsController, AprsSymbolsController, CompiledAssetsController +from owrx.controllers.websocket import WebSocketController +from owrx.controllers.api import ApiController +from owrx.controllers.metrics import MetricsController +from owrx.controllers.settings import SettingsController +from owrx.controllers.settings.general import GeneralSettingsController +from owrx.controllers.settings.sdr import ( + SdrDeviceListController, + SdrDeviceController, + SdrProfileController, + NewSdrDeviceController, + NewProfileController, +) +from owrx.controllers.settings.reporting import ReportingController +from owrx.controllers.settings.backgrounddecoding import BackgroundDecodingController +from owrx.controllers.settings.decoding import DecodingSettingsController +from owrx.controllers.settings.bookmarks import BookmarksController +from owrx.controllers.session import SessionController +from owrx.controllers.profile import ProfileController +from owrx.controllers.imageupload import ImageUploadController +from owrx.controllers.robots import RobotsController +from http.server import BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +import re +from abc import ABC, abstractmethod +from http.cookies import SimpleCookie + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class Request(object): + def __init__(self, url, method, headers): + parsed_url = urlparse(url) + self.path = parsed_url.path + self.query = parse_qs(parsed_url.query) + self.matches = None + self.method = method + self.headers = headers + self.cookies = SimpleCookie() + if "Cookie" in headers: + self.cookies.load(headers["Cookie"]) + + def setMatches(self, matches): + self.matches = matches + + +class Route(ABC): + def __init__(self, controller, method="GET", options=None): + self.controller = controller + self.controllerOptions = options if options is not None else {} + self.method = method + + @abstractmethod + def matches(self, request): + pass + + +class StaticRoute(Route): + def __init__(self, route, controller, method="GET", options=None): + self.route = route + super().__init__(controller, method, options) + + def matches(self, request): + return request.path == self.route and self.method == request.method + + +class RegexRoute(Route): + def __init__(self, regex, controller, method="GET", options=None): + self.regex = re.compile(regex) + super().__init__(controller, method, options) + + def matches(self, request): + matches = self.regex.match(request.path) + # this is probably not the cleanest way to do it... + request.setMatches(matches) + return matches is not None and self.method == request.method + + +class Router(object): + def __init__(self): + self.routes = [ + StaticRoute("/", IndexController), + StaticRoute("/robots.txt", RobotsController), + StaticRoute("/status.json", StatusController), + RegexRoute("^/static/(.+)$", OwrxAssetsController), + RegexRoute("^/compiled/(.+)$", CompiledAssetsController), + RegexRoute("^/aprs-symbols/(.+)$", AprsSymbolsController), + StaticRoute("/ws/", WebSocketController), + RegexRoute("^(/favicon.ico)$", OwrxAssetsController), + StaticRoute("/map", MapController), + StaticRoute("/features", FeatureController), + StaticRoute("/api/features", ApiController), + StaticRoute("/metrics", MetricsController, options={"action": "prometheusAction"}), + StaticRoute("/metrics.json", MetricsController), + StaticRoute("/settings", SettingsController), + StaticRoute("/settings/general", GeneralSettingsController), + StaticRoute( + "/settings/general", GeneralSettingsController, method="POST", options={"action": "processFormData"} + ), + StaticRoute("/settings/sdr", SdrDeviceListController), + StaticRoute("/settings/newsdr", NewSdrDeviceController), + StaticRoute( + "/settings/newsdr", NewSdrDeviceController, method="POST", options={"action": "processFormData"} + ), + RegexRoute("^/settings/sdr/([^/]+)$", SdrDeviceController), + RegexRoute( + "^/settings/sdr/([^/]+)$", SdrDeviceController, method="POST", options={"action": "processFormData"} + ), + RegexRoute("^/settings/deletesdr/([^/]+)$", SdrDeviceController, options={"action": "deleteDevice"}), + RegexRoute("^/settings/sdr/([^/]+)/newprofile$", NewProfileController), + RegexRoute( + "^/settings/sdr/([^/]+)/newprofile$", + NewProfileController, + method="POST", + options={"action": "processFormData"}, + ), + RegexRoute("^/settings/sdr/([^/]+)/profile/([^/]+)$", SdrProfileController), + RegexRoute( + "^/settings/sdr/([^/]+)/profile/([^/]+)$", + SdrProfileController, + method="POST", + options={"action": "processFormData"}, + ), + RegexRoute( + "^/settings/sdr/([^/]+)/deleteprofile/([^/]+)$", + SdrProfileController, + options={"action": "deleteProfile"}, + ), + StaticRoute("/settings/bookmarks", BookmarksController), + StaticRoute("/settings/bookmarks", BookmarksController, method="POST", options={"action": "new"}), + RegexRoute("^/settings/bookmarks/(.+)$", BookmarksController, method="POST", options={"action": "update"}), + RegexRoute( + "^/settings/bookmarks/(.+)$", BookmarksController, method="DELETE", options={"action": "delete"} + ), + StaticRoute("/settings/reporting", ReportingController), + StaticRoute( + "/settings/reporting", ReportingController, method="POST", options={"action": "processFormData"} + ), + StaticRoute("/settings/backgrounddecoding", BackgroundDecodingController), + StaticRoute( + "/settings/backgrounddecoding", + BackgroundDecodingController, + method="POST", + options={"action": "processFormData"}, + ), + StaticRoute("/settings/decoding", DecodingSettingsController), + StaticRoute( + "/settings/decoding", DecodingSettingsController, method="POST", options={"action": "processFormData"} + ), + StaticRoute("/login", SessionController, options={"action": "loginAction"}), + StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), + StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), + StaticRoute("/pwchange", ProfileController), + StaticRoute("/pwchange", ProfileController, method="POST", options={"action": "processPwChange"}), + StaticRoute("/imageupload", ImageUploadController), + StaticRoute("/imageupload", ImageUploadController, method="POST", options={"action": "processImage"}), + ] + + def find_route(self, request): + for r in self.routes: + if r.matches(request): + return r + + def route(self, handler, request): + route = self.find_route(request) + if route is not None: + controller = route.controller + controller(handler, request, route.controllerOptions).handle_request() + else: + handler.send_error(404, "Not Found", "The page you requested could not be found.") + + +class RequestHandler(BaseHTTPRequestHandler): + timeout = 30 + router = Router() + + def log_message(self, format, *args): + logger.debug("%s - - [%s] %s", self.address_string(), self.log_date_time_string(), format % args) + + def do_GET(self): + self.router.route(self, self._build_request("GET")) + + def do_POST(self): + self.router.route(self, self._build_request("POST")) + + def do_DELETE(self): + self.router.route(self, self._build_request("DELETE")) + + def _build_request(self, method): + return Request(self.path, method, self.headers) diff --git a/openwebrx/owrx/js8.py b/openwebrx/owrx/js8.py new file mode 100644 index 0000000..97b1195 --- /dev/null +++ b/openwebrx/owrx/js8.py @@ -0,0 +1,135 @@ +from owrx.audio import AudioChopperProfile, ConfigWiredProfileSource +from owrx.parser import Parser +import re +from js8py import Js8 +from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound +from owrx.map import Map, LocatorLocation +from owrx.metrics import Metrics, CounterMetric +from owrx.config import Config +from abc import ABCMeta, abstractmethod +from owrx.reporting import ReportingEngine +from typing import List + +import logging + +logger = logging.getLogger(__name__) + + +class Js8Profile(AudioChopperProfile, metaclass=ABCMeta): + def decoding_depth(self): + pm = Config.get() + # return global default + if "js8_decoding_depth" in pm: + return pm["js8_decoding_depth"] + # default when no setting is provided + return 3 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M%S" + + def decoder_commandline(self, file): + return ["js8", "--js8", "-b", self.get_sub_mode(), "-d", str(self.decoding_depth()), file] + + @abstractmethod + def get_sub_mode(self): + pass + + +class Js8ProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["js8_enabled_profiles"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["js8_enabled_profiles"] if "js8_enabled_profiles" in config else [] + return [self._loadProfile(p) for p in profiles] + + def _loadProfile(self, profileName): + className = "Js8{0}Profile".format(profileName[0].upper() + profileName[1:].lower()) + return globals()[className]() + + +class Js8NormalProfile(Js8Profile): + def getInterval(self): + return 15 + + def get_sub_mode(self): + return "A" + + +class Js8SlowProfile(Js8Profile): + def getInterval(self): + return 30 + + def get_sub_mode(self): + return "E" + + +class Js8FastProfile(Js8Profile): + def getInterval(self): + return 10 + + def get_sub_mode(self): + return "B" + + +class Js8TurboProfile(Js8Profile): + def getInterval(self): + return 6 + + def get_sub_mode(self): + return "C" + + +class Js8Parser(Parser): + decoderRegex = re.compile(" ?") + + def parse(self, raw): + try: + profile, freq, raw_msg = raw + self.setDialFrequency(freq) + msg = raw_msg.decode().rstrip() + if Js8Parser.decoderRegex.match(msg): + return + if msg.startswith(" EOF on input file"): + return + + frame = Js8().parse_message(msg) + self.handler.write_js8_message(frame, self.dial_freq) + + self.pushDecode() + + if (isinstance(frame, Js8FrameHeartbeat) or isinstance(frame, Js8FrameCompound)) and frame.grid: + Map.getSharedInstance().updateLocation( + frame.callsign, LocatorLocation(frame.grid), "JS8", self.band + ) + ReportingEngine.getSharedInstance().spot( + { + "callsign": frame.callsign, + "mode": "JS8", + "locator": frame.grid, + "freq": self.dial_freq + frame.freq, + "db": frame.db, + "timestamp": frame.timestamp, + "msg": str(frame), + } + ) + + except Exception: + logger.exception("error while parsing js8 message") + + def pushDecode(self): + metrics = Metrics.getSharedInstance() + band = "unknown" + if self.band is not None: + band = self.band.getName() + if band is None: + band = "unknown" + + name = "js8call.decodes.{band}.JS8".format(band=band) + metric = metrics.getMetric(name) + if metric is None: + metric = CounterMetric() + metrics.addMetric(name, metric) + + metric.inc() diff --git a/openwebrx/owrx/jsons.py b/openwebrx/owrx/jsons.py new file mode 100644 index 0000000..4b2b977 --- /dev/null +++ b/openwebrx/owrx/jsons.py @@ -0,0 +1,9 @@ +from owrx.property import PropertyManager +import json + + +class Encoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, PropertyManager): + return o.__dict__() + return super().default(o) diff --git a/openwebrx/owrx/kiss.py b/openwebrx/owrx/kiss.py new file mode 100644 index 0000000..bd0d354 --- /dev/null +++ b/openwebrx/owrx/kiss.py @@ -0,0 +1,191 @@ +import socket +import time +import logging +import random +from owrx.config import Config +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + +FEND = 0xC0 +FESC = 0xDB +TFEND = 0xDC +TFESC = 0xDD + +FEET_PER_METER = 3.28084 + + +class DirewolfConfigSubscriber(ABC): + @abstractmethod + def onConfigChanged(self): + pass + + +class DirewolfConfig(object): + config_keys = [ + "aprs_callsign", + "aprs_igate_enabled", + "aprs_igate_server", + "aprs_igate_password", + "receiver_gps", + "aprs_igate_symbol", + "aprs_igate_beacon", + "aprs_igate_gain", + "aprs_igate_dir", + "aprs_igate_comment", + "aprs_igate_height", + ] + + def __init__(self): + self.subscribers = [] + self.configSub = None + self.port = None + + def wire(self, subscriber: DirewolfConfigSubscriber): + self.subscribers.append(subscriber) + if self.configSub is None: + pm = Config.get() + self.configSub = pm.filter(*DirewolfConfig.config_keys).wire(self._fireChanged) + + def unwire(self, subscriber: DirewolfConfigSubscriber): + self.subscribers.remove(subscriber) + if not self.subscribers and self.configSub is not None: + self.configSub.cancel() + + def _fireChanged(self, changes): + for sub in self.subscribers: + try: + sub.onConfigChanged() + except Exception: + logger.exception("Error while notifying Direwolf subscribers") + + def getPort(self): + # direwolf has some strange hardcoded port ranges + while self.port is None: + try: + port = random.randrange(1024, 49151) + # test if port is available for use + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("localhost", port)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.close() + self.port = port + except OSError: + pass + return self.port + + def getConfig(self, is_service): + pm = Config.get() + + config = """ +ACHANNELS 1 +ADEVICE stdin null + +CHANNEL 0 +MYCALL {callsign} +MODEM 1200 + +KISSPORT {port} +AGWPORT off + """.format( + port=self.getPort(), callsign=pm["aprs_callsign"] + ) + + if is_service and pm["aprs_igate_enabled"]: + pbeacon = "" + + if pm["aprs_igate_beacon"]: + # Format beacon lat/lon + lat = pm["receiver_gps"]["lat"] + lon = pm["receiver_gps"]["lon"] + direction_ns = "N" if lat > 0 else "S" + direction_we = "E" if lon > 0 else "W" + lat = abs(lat) + lon = abs(lon) + lat = "{0:02d}^{1:05.2f}{2}".format(int(lat), (lat - int(lat)) * 60, direction_ns) + lon = "{0:03d}^{1:05.2f}{2}".format(int(lon), (lon - int(lon)) * 60, direction_we) + + # Convert height from meters to feet if specified + height = "" + if "aprs_igate_height" in pm: + try: + height_m = float(pm["aprs_igate_height"]) + height_ft = round(height_m * FEET_PER_METER) + height = "HEIGHT=" + str(height_ft) + except: + logger.error( + "Cannot parse 'aprs_igate_height', expected float: " + str(pm["aprs_igate_height"]) + ) + + pbeacon = 'PBEACON sendto=IG delay=0:30 every=60:00 symbol={symbol} lat={lat} long={lon} {height} {gain} {adir} comment="{comment}"'.format( + symbol=pm["aprs_igate_symbol"], + lat=lat, + lon=lon, + height=height, + gain="GAIN=" + str(pm["aprs_igate_gain"]) if "aprs_igate_gain" in pm else "", + adir="DIR=" + str(pm["aprs_igate_dir"]) if "aprs_igate_dir" in pm else "", + comment=pm["aprs_igate_comment"], + ) + + logger.info("APRS PBEACON String: " + pbeacon) + + config += """ +IGSERVER {server} +IGLOGIN {callsign} {password} +{pbeacon} + """.format( + server=pm["aprs_igate_server"], + callsign=pm["aprs_callsign"], + password=pm["aprs_igate_password"], + pbeacon=pbeacon, + ) + + return config + + +class KissClient(object): + def __init__(self, port): + delay = 0.5 + retries = 0 + while True: + try: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect(("localhost", port)) + break + except ConnectionError: + if retries > 20: + logger.error("maximum number of connection attempts reached. did direwolf start up correctly?") + raise + retries += 1 + time.sleep(delay) + + def read(self): + return self.socket.recv(1) + + +class KissDeframer(object): + def __init__(self): + self.escaped = False + self.buf = bytearray() + + def parse(self, input): + frames = [] + for b in input: + if b == FESC: + self.escaped = True + elif self.escaped: + if b == TFEND: + self.buf.append(FEND) + elif b == TFESC: + self.buf.append(FESC) + else: + logger.warning("invalid escape char: %s", str(input[0])) + self.escaped = False + elif input[0] == FEND: + # data frames start with 0x00 + if len(self.buf) > 1 and self.buf[0] == 0x00: + frames += [self.buf[1:]] + self.buf = bytearray() + else: + self.buf.append(b) + return frames diff --git a/openwebrx/owrx/locator.py b/openwebrx/owrx/locator.py new file mode 100644 index 0000000..52c37e5 --- /dev/null +++ b/openwebrx/owrx/locator.py @@ -0,0 +1,25 @@ +class Locator(object): + @staticmethod + def fromCoordinates(coordinates, depth=3): + + lat = coordinates["lat"] + lon = coordinates["lon"] + + lon = lon + 180 + lat = lat + 90 + + res = "" + res += chr(65 + int(lon / 20)) + res += chr(65 + int(lat / 10)) + if depth >= 2: + lon = lon % 20 + lat = lat % 10 + res += str(int(lon / 2)) + res += str(int(lat)) + if depth >= 3: + lon = lon % 2 + lat = lat % 1 + res += chr(97 + int(lon * 12)) + res += chr(97 + int(lat * 24)) + + return res diff --git a/openwebrx/owrx/map.py b/openwebrx/owrx/map.py new file mode 100644 index 0000000..8c95ea5 --- /dev/null +++ b/openwebrx/owrx/map.py @@ -0,0 +1,141 @@ +from datetime import datetime, timedelta +from owrx.config import Config +from owrx.bands import Band +import threading +import time +import sys + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class Location(object): + def __dict__(self): + return {} + + +class Map(object): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with Map.creationLock: + if Map.sharedInstance is None: + Map.sharedInstance = Map() + return Map.sharedInstance + + def __init__(self): + self.clients = [] + self.positions = {} + self.positionsLock = threading.Lock() + + def removeLoop(): + loops = 0 + while True: + try: + self.removeOldPositions() + except Exception: + logger.exception("error while removing old map positions") + loops += 1 + # rebuild the positions dictionary every once in a while, it consumes lots of memory otherwise + if loops == 60: + try: + self.rebuildPositions() + except Exception: + logger.exception("error while rebuilding positions") + loops = 0 + time.sleep(60) + + threading.Thread(target=removeLoop, daemon=True, name="map_removeloop").start() + super().__init__() + + def broadcast(self, update): + for c in self.clients: + c.write_update(update) + + def addClient(self, client): + self.clients.append(client) + client.write_update( + [ + { + "callsign": callsign, + "location": record["location"].__dict__(), + "lastseen": record["updated"].timestamp() * 1000, + "mode": record["mode"], + "band": record["band"].getName() if record["band"] is not None else None, + } + for (callsign, record) in self.positions.items() + ] + ) + + def removeClient(self, client): + try: + self.clients.remove(client) + except ValueError: + pass + + def updateLocation(self, callsign, loc: Location, mode: str, band: Band = None): + ts = datetime.now() + with self.positionsLock: + self.positions[callsign] = {"location": loc, "updated": ts, "mode": mode, "band": band} + self.broadcast( + [ + { + "callsign": callsign, + "location": loc.__dict__(), + "lastseen": ts.timestamp() * 1000, + "mode": mode, + "band": band.getName() if band is not None else None, + } + ] + ) + + def touchLocation(self, callsign): + # not implemented on the client side yet, so do not use! + ts = datetime.now() + with self.positionsLock: + if callsign in self.positions: + self.positions[callsign]["updated"] = ts + self.broadcast([{"callsign": callsign, "lastseen": ts.timestamp() * 1000}]) + + def removeLocation(self, callsign): + with self.positionsLock: + del self.positions[callsign] + # TODO broadcast removal to clients + + def removeOldPositions(self): + pm = Config.get() + retention = timedelta(seconds=pm["map_position_retention_time"]) + cutoff = datetime.now() - retention + + to_be_removed = [callsign for (callsign, pos) in self.positions.items() if pos["updated"] < cutoff] + for callsign in to_be_removed: + self.removeLocation(callsign) + + def rebuildPositions(self): + logger.debug("rebuilding map storage; size before: %i", sys.getsizeof(self.positions)) + with self.positionsLock: + p = {key: value for key, value in self.positions.items()} + self.positions = p + logger.debug("rebuild complete; size after: %i", sys.getsizeof(self.positions)) + + +class LatLngLocation(Location): + def __init__(self, lat: float, lon: float): + self.lat = lat + self.lon = lon + + def __dict__(self): + res = {"type": "latlon", "lat": self.lat, "lon": self.lon} + return res + + +class LocatorLocation(Location): + def __init__(self, locator: str): + self.locator = locator + + def __dict__(self): + return {"type": "locator", "locator": self.locator} diff --git a/openwebrx/owrx/meta.py b/openwebrx/owrx/meta.py new file mode 100644 index 0000000..66994d4 --- /dev/null +++ b/openwebrx/owrx/meta.py @@ -0,0 +1,166 @@ +from owrx.config import Config +from urllib import request +import json +from datetime import datetime, timedelta +import logging +import threading +from owrx.map import Map, LatLngLocation +from owrx.parser import Parser +from owrx.aprs import AprsParser, AprsLocation +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + + +class Enricher(ABC): + def __init__(self, parser): + self.parser = parser + + @abstractmethod + def enrich(self, meta): + pass + + +class RadioIDCache(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if RadioIDCache.sharedInstance is None: + RadioIDCache.sharedInstance = RadioIDCache() + return RadioIDCache.sharedInstance + + def __init__(self): + self.cache = {} + self.cacheTimeout = timedelta(seconds=86400) + + def isValid(self, mode, radio_id): + key = self.__key(mode, radio_id) + if key not in self.cache: + return False + entry = self.cache[key] + return entry["timestamp"] + self.cacheTimeout > datetime.now() + + def __key(self, mode, radio_id): + return "{}-{}".format(mode, radio_id) + + def put(self, mode, radio_id, value): + self.cache[self.__key(mode, radio_id)] = {"timestamp": datetime.now(), "data": value} + + def get(self, mode, radio_id): + if not self.isValid(mode, radio_id): + return None + return self.cache[self.__key(mode, radio_id)]["data"] + + +class RadioIDEnricher(Enricher): + def __init__(self, mode, parser): + super().__init__(parser) + self.mode = mode + self.threads = {} + + def _fillCache(self, id): + RadioIDCache.getSharedInstance().put(self.mode, id, self._downloadRadioIdData(id)) + del self.threads[id] + + def _downloadRadioIdData(self, id): + try: + logger.debug("requesting radioid metadata for mode=%s and id=%s", self.mode, id) + res = request.urlopen("https://www.radioid.net/api/{0}/user/?id={1}".format(self.mode, id), timeout=30) + if res.status != 200: + logger.warning("radioid API returned error %i for mode=%s and id=%s", res.status, self.mode, id) + return None + data = json.loads(res.read().decode("utf-8")) + if "count" in data and data["count"] > 0 and "results" in data: + for item in data["results"]: + if "id" in item and item["id"] == id: + return item + except json.JSONDecodeError: + logger.warning("unable to parse radioid response JSON") + + return None + + def enrich(self, meta): + config_key = "digital_voice_{}_id_lookup".format(self.mode) + if not Config.get()[config_key]: + return meta + if "source" not in meta: + return meta + id = int(meta["source"]) + cache = RadioIDCache.getSharedInstance() + if not cache.isValid(self.mode, id): + if id not in self.threads: + self.threads[id] = threading.Thread(target=self._fillCache, args=[id], daemon=True) + self.threads[id].start() + return meta + data = cache.get(self.mode, id) + if data is not None: + meta["additional"] = data + return meta + + +class YsfMetaEnricher(Enricher): + def enrich(self, meta): + for key in ["source", "up", "down", "target"]: + if key in meta: + meta[key] = meta[key].strip() + for key in ["lat", "lon"]: + if key in meta: + meta[key] = float(meta[key]) + if "source" in meta and "lat" in meta and "lon" in meta: + loc = LatLngLocation(meta["lat"], meta["lon"]) + Map.getSharedInstance().updateLocation(meta["source"], loc, "YSF", self.parser.getBand()) + return meta + + +class DStarEnricher(Enricher): + def enrich(self, meta): + for key in ["lat", "lon"]: + if key in meta: + meta[key] = float(meta[key]) + if "ourcall" in meta and "lat" in meta and "lon" in meta: + loc = LatLngLocation(meta["lat"], meta["lon"]) + Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "D-Star", self.parser.getBand()) + if "dprs" in meta: + try: + # we can send the DPRS stuff through our APRS parser to extract the information + # TODO: only third-party parsing accepts this format right now + # TODO: we also need to pass a handler, which is not needed + parser = AprsParser(None) + dprsData = parser.parseThirdpartyAprsData(meta["dprs"]) + if "data" in dprsData: + data = dprsData["data"] + if "lat" in data and "lon" in data: + # TODO: we could actually get the symbols from the parsed APRS data and show that on the meta panel + meta["lat"] = data["lat"] + meta["lon"] = data["lon"] + + if "ourcall" in meta: + # send location info to map as well (it will show up with the correct symbol there!) + loc = AprsLocation(data) + Map.getSharedInstance().updateLocation(meta["ourcall"], loc, "DPRS", self.parser.getBand()) + except Exception: + logger.exception("Error while parsing DPRS data") + + return meta + + +class MetaParser(Parser): + def __init__(self, handler): + super().__init__(handler) + self.enrichers = { + "DMR": RadioIDEnricher("dmr", self), + "YSF": YsfMetaEnricher(self), + "DSTAR": DStarEnricher(self), + "NXDN": RadioIDEnricher("nxdn", self), + } + + def parse(self, meta): + fields = meta.split(";") + meta = {v[0]: ":".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""} + + if "protocol" in meta: + protocol = meta["protocol"] + if protocol in self.enrichers: + meta = self.enrichers[protocol].enrich(meta) + self.handler.write_metadata(meta) diff --git a/openwebrx/owrx/metrics.py b/openwebrx/owrx/metrics.py new file mode 100644 index 0000000..6600e85 --- /dev/null +++ b/openwebrx/owrx/metrics.py @@ -0,0 +1,70 @@ +import threading +from owrx.client import ClientRegistry + + +class Metric(object): + def getValue(self): + return 0 + + +class CounterMetric(Metric): + def __init__(self): + self.counter = 0 + + def inc(self, increment=1): + self.counter += increment + + def getValue(self): + return {"count": self.counter} + + +class DirectMetric(Metric): + def __init__(self, getter): + self.getter = getter + + def getValue(self): + return self.getter() + + +class Metrics(object): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with Metrics.creationLock: + if Metrics.sharedInstance is None: + Metrics.sharedInstance = Metrics() + return Metrics.sharedInstance + + def __init__(self): + self.metrics = {} + self.addMetric("openwebrx.users", DirectMetric(ClientRegistry.getSharedInstance().clientCount)) + + def addMetric(self, name, metric): + self.metrics[name] = metric + + def hasMetric(self, name): + return name in self.metrics + + def getMetric(self, name): + if not self.hasMetric(name): + return None + return self.metrics[name] + + def getFlatMetrics(self): + return self.metrics + + def getHierarchicalMetrics(self): + result = {} + + for (key, metric) in self.metrics.items(): + partial = result + keys = key.split(".") + for keypart in keys[0:-1]: + if not keypart in partial: + partial[keypart] = {} + partial = partial[keypart] + partial[keys[-1]] = metric.getValue() + + return result diff --git a/openwebrx/owrx/modes.py b/openwebrx/owrx/modes.py new file mode 100644 index 0000000..6bb292f --- /dev/null +++ b/openwebrx/owrx/modes.py @@ -0,0 +1,156 @@ +from owrx.feature import FeatureDetector +from owrx.audio import ProfileSource +from functools import reduce +from abc import ABCMeta, abstractmethod + + +class Bandpass(object): + def __init__(self, low_cut, high_cut): + self.low_cut = low_cut + self.high_cut = high_cut + + +class Mode(object): + def __init__(self, modulation, name, bandpass: Bandpass = None, requirements=None, service=False, squelch=True): + self.modulation = modulation + self.name = name + self.requirements = requirements if requirements is not None else [] + self.service = service + self.bandpass = bandpass + self.squelch = squelch + + def is_available(self): + fd = FeatureDetector() + return reduce(lambda a, b: a and b, [fd.is_available(r) for r in self.requirements], True) + + def is_service(self): + return self.service + + def get_bandpass(self): + return self.bandpass + + def get_modulation(self): + return self.modulation + + +class AnalogMode(Mode): + pass + + +class DigitalMode(Mode): + def __init__( + self, modulation, name, underlying, bandpass: Bandpass = None, requirements=None, service=False, squelch=True + ): + super().__init__(modulation, name, bandpass, requirements, service, squelch) + self.underlying = underlying + + def get_bandpass(self): + if self.bandpass is not None: + return self.bandpass + return Modes.findByModulation(self.underlying[0]).get_bandpass() + + def get_modulation(self): + return Modes.findByModulation(self.underlying[0]).get_modulation() + + +class AudioChopperMode(DigitalMode, metaclass=ABCMeta): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if bandpass is None: + bandpass = Bandpass(0, 3000) + super().__init__(modulation, name, ["usb"], bandpass=bandpass, requirements=requirements, service=True) + + @abstractmethod + def get_profile_source(self) -> ProfileSource: + pass + + +class WsjtMode(AudioChopperMode): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if requirements is None: + requirements = ["wsjt-x"] + super().__init__(modulation, name, bandpass=bandpass, requirements=requirements) + + def get_profile_source(self) -> ProfileSource: + # inline import due to circular dependencies + from owrx.wsjt import WsjtProfiles + return WsjtProfiles.getSource(self.modulation) + + +class Js8Mode(AudioChopperMode): + def __init__(self, modulation, name, bandpass=None, requirements=None): + if requirements is None: + requirements = ["js8call"] + super().__init__(modulation, name, bandpass, requirements) + + def get_profile_source(self) -> ProfileSource: + # inline import due to circular dependencies + from owrx.js8 import Js8ProfileSource + return Js8ProfileSource() + + +class Modes(object): + mappings = [ + AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)), + AnalogMode("wfm", "WFM", bandpass=Bandpass(-75000, 75000)), + AnalogMode("am", "AM", bandpass=Bandpass(-4000, 4000)), + AnalogMode("lsb", "LSB", bandpass=Bandpass(-3000, -300)), + AnalogMode("usb", "USB", bandpass=Bandpass(300, 3000)), + AnalogMode("cw", "CW", bandpass=Bandpass(700, 900)), + AnalogMode("dmr", "DMR", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode( + "dstar", "D-Star", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False + ), + AnalogMode("nxdn", "NXDN", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode("ysf", "YSF", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode("m17", "M17", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_m17"], squelch=False), + AnalogMode( + "freedv", "FreeDV", bandpass=Bandpass(300, 3000), requirements=["digital_voice_freedv"], squelch=False + ), + AnalogMode("drm", "DRM", bandpass=Bandpass(-5000, 5000), requirements=["drm"], squelch=False), + DigitalMode("bpsk31", "BPSK31", underlying=["usb"]), + DigitalMode("bpsk63", "BPSK63", underlying=["usb"]), + WsjtMode("ft8", "FT8"), + WsjtMode("ft4", "FT4"), + WsjtMode("jt65", "JT65"), + WsjtMode("jt9", "JT9"), + WsjtMode("wspr", "WSPR", bandpass=Bandpass(1350, 1650)), + WsjtMode("fst4", "FST4", requirements=["wsjt-x-2-3"]), + WsjtMode("fst4w", "FST4W", bandpass=Bandpass(1350, 1650), requirements=["wsjt-x-2-3"]), + WsjtMode("q65", "Q65", requirements=["wsjt-x-2-4"]), + Js8Mode("js8", "JS8Call"), + DigitalMode( + "packet", + "Packet", + underlying=["nfm", "usb", "lsb"], + bandpass=Bandpass(-6250, 6250), + requirements=["packet"], + service=True, + squelch=False, + ), + DigitalMode( + "pocsag", + "Pocsag", + underlying=["nfm"], + bandpass=Bandpass(-6000, 6000), + requirements=["pocsag"], + squelch=False, + ), + ] + + @staticmethod + def getModes(): + return Modes.mappings + + @staticmethod + def getAvailableModes(): + return [m for m in Modes.getModes() if m.is_available()] + + @staticmethod + def getAvailableServices(): + return [m for m in Modes.getAvailableModes() if m.is_service()] + + @staticmethod + def findByModulation(modulation): + modes = [m for m in Modes.getAvailableModes() if m.modulation == modulation] + if modes: + return modes[0] diff --git a/openwebrx/owrx/parser.py b/openwebrx/owrx/parser.py new file mode 100644 index 0000000..2bb75e2 --- /dev/null +++ b/openwebrx/owrx/parser.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from owrx.bands import Bandplan + + +class Parser(ABC): + def __init__(self, handler): + self.handler = handler + self.dial_freq = None + self.band = None + + @abstractmethod + def parse(self, raw): + pass + + def setDialFrequency(self, freq): + self.dial_freq = freq + self.band = Bandplan.getSharedInstance().findBand(freq) + + def getBand(self): + return self.band diff --git a/openwebrx/owrx/pocsag.py b/openwebrx/owrx/pocsag.py new file mode 100644 index 0000000..c265146 --- /dev/null +++ b/openwebrx/owrx/pocsag.py @@ -0,0 +1,17 @@ +from owrx.parser import Parser + +import logging + +logger = logging.getLogger(__name__) + + +class PocsagParser(Parser): + def parse(self, raw): + try: + fields = raw.decode("ascii", "replace").rstrip("\n").split(";") + meta = {v[0]: "".join(v[1:]) for v in map(lambda x: x.split(":"), fields) if v[0] != ""} + if "address" in meta: + meta["address"] = int(meta["address"]) + self.handler.write_pocsag_data(meta) + except Exception: + logger.exception("Exception while parsing Pocsag message") diff --git a/openwebrx/owrx/property/__init__.py b/openwebrx/owrx/property/__init__.py new file mode 100644 index 0000000..7cc84f6 --- /dev/null +++ b/openwebrx/owrx/property/__init__.py @@ -0,0 +1,421 @@ +from abc import ABC, abstractmethod +from owrx.property.validators import Validator +from owrx.property.filter import Filter, ByPropertyName +import logging + +logger = logging.getLogger(__name__) + + +class PropertyError(Exception): + pass + + +class PropertyDeletion(object): + def __bool__(self): + return False + + +# a special object that will be sent in events when a deletion occured +# it can also represent deletion of a key in internal storage, but should not be return from standard dict apis +PropertyDeleted = PropertyDeletion() + + +class Subscription(object): + def __init__(self, subscriptee, name, subscriber): + self.subscriptee = subscriptee + self.name = name + self.subscriber = subscriber + + def getName(self): + return self.name + + def call(self, *args, **kwargs): + self.subscriber(*args, **kwargs) + + def cancel(self): + self.subscriptee.unwire(self) + + +class PropertyManager(ABC): + def __init__(self): + self.subscribers = [] + + @abstractmethod + def __getitem__(self, item): + pass + + @abstractmethod + def __setitem__(self, key, value): + pass + + @abstractmethod + def __contains__(self, item): + pass + + @abstractmethod + def __dict__(self): + pass + + @abstractmethod + def __delitem__(self, key): + pass + + @abstractmethod + def keys(self): + pass + + @abstractmethod + def values(self): + pass + + @abstractmethod + def items(self): + pass + + def __len__(self): + return self.__dict__().__len__() + + def filter(self, *props): + return PropertyFilter(self, ByPropertyName(*props)) + + def readonly(self): + return PropertyReadOnly(self) + + def wire(self, callback): + sub = Subscription(self, None, callback) + self.subscribers.append(sub) + return sub + + def wireProperty(self, name, callback): + sub = Subscription(self, name, callback) + self.subscribers.append(sub) + if name in self: + sub.call(self[name]) + return sub + + def unwire(self, sub): + try: + self.subscribers.remove(sub) + except ValueError: + # happens when already removed before + pass + return self + + def _fireCallbacks(self, changes): + if not changes: + return + subscribers = self.subscribers.copy() + for c in subscribers: + try: + if c.getName() is None: + c.call(changes) + except Exception: + logger.exception("exception while firing changes") + for name in changes: + for c in subscribers: + try: + if c.getName() == name: + c.call(changes[name]) + except Exception: + logger.exception("exception while firing changes") + + +class PropertyLayer(PropertyManager): + def __init__(self, **kwargs): + super().__init__() + # copy, don't re-use + self.properties = {k: v for k, v in kwargs.items()} + + def __contains__(self, name): + return name in self.properties + + def __getitem__(self, name): + return self.properties[name] + + def __setitem__(self, name, value): + if name in self.properties and self.properties[name] == value: + return + self.properties[name] = value + self._fireCallbacks({name: value}) + + def __dict__(self): + return {k: v for k, v in self.properties.items()} + + def __delitem__(self, key): + self.properties.__delitem__(key) + self._fireCallbacks({key: PropertyDeleted}) + + def keys(self): + return self.properties.keys() + + def values(self): + return self.properties.values() + + def items(self): + return self.properties.items() + + +class PropertyFilter(PropertyManager): + def __init__(self, pm: PropertyManager, filter: Filter): + super().__init__() + self.pm = pm + self._filter = filter + self.pm.wire(self.receiveEvent) + + def receiveEvent(self, changes): + changesToForward = {name: value for name, value in changes.items() if self._filter.apply(name)} + self._fireCallbacks(changesToForward) + + def __getitem__(self, item): + if not self._filter.apply(item): + raise KeyError(item) + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + if not self._filter.apply(key): + raise KeyError(key) + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + if not self._filter.apply(item): + return False + return self.pm.__contains__(item) + + def __dict__(self): + return {k: v for k, v in self.pm.__dict__().items() if self._filter.apply(k)} + + def __delitem__(self, key): + if not self._filter.apply(key): + raise KeyError(key) + return self.pm.__delitem__(key) + + def keys(self): + return [k for k in self.pm.keys() if self._filter.apply(k)] + + def values(self): + return [v for k, v in self.pm.items() if self._filter.apply(k)] + + def items(self): + return self.__dict__().items() + + +class PropertyDelegator(PropertyManager): + def __init__(self, pm: PropertyManager): + self.pm = pm + self.subscription = self.pm.wire(self._fireCallbacks) + super().__init__() + + def __getitem__(self, item): + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + return self.pm.__contains__(item) + + def __dict__(self): + return self.pm.__dict__() + + def __delitem__(self, key): + return self.pm.__delitem__(key) + + def keys(self): + return self.pm.keys() + + def values(self): + return self.pm.values() + + def items(self): + return self.pm.items() + + +class PropertyValidationError(PropertyError): + def __init__(self, key, value): + super().__init__('Invalid value for property "{key}": "{value}"'.format(key=key, value=str(value))) + + +class PropertyValidator(PropertyDelegator): + def __init__(self, pm: PropertyManager, validators=None): + super().__init__(pm) + if validators is None: + self.validators = {} + else: + self.validators = {k: Validator.of(v) for k, v in validators.items()} + + def validate(self, key, value): + if key not in self.validators: + return + if not self.validators[key].isValid(value): + raise PropertyValidationError(key, value) + + def setValidator(self, key, validator): + self.validators[key] = Validator.of(validator) + + def __setitem__(self, key, value): + self.validate(key, value) + return self.pm.__setitem__(key, value) + + +class PropertyWriteError(PropertyError): + def __init__(self, key): + super().__init__('Key "{key}" is not writeable'.format(key=key)) + + +class PropertyReadOnly(PropertyDelegator): + def __setitem__(self, key, value): + raise PropertyWriteError(key) + + def __delitem__(self, key): + raise PropertyWriteError(key) + + +class PropertyStack(PropertyManager): + def __init__(self): + super().__init__() + self.layers = [] + + def addLayer(self, priority: int, pm: PropertyManager): + """ + highest priority = 0 + """ + self._fireCallbacks(self._addLayer(priority, pm)) + + def _addLayer(self, priority: int, pm: PropertyManager): + changes = {} + for key in pm.keys(): + if key not in self or self[key] != pm[key]: + changes[key] = pm[key] + + def eventClosure(changes): + self.receiveEvent(pm, changes) + + sub = pm.wire(eventClosure) + + self.layers.append({"priority": priority, "props": pm, "sub": sub}) + + return changes + + def removeLayerByPriority(self, priority): + for layer in self.layers: + if layer["priority"] == priority: + self.removeLayer(layer["props"]) + + def removeLayer(self, pm: PropertyManager): + for layer in self.layers: + if layer["props"] == pm: + self._fireCallbacks(self._removeLayer(layer)) + + def _removeLayer(self, layer): + layer["sub"].cancel() + self.layers.remove(layer) + changes = {} + pm = layer["props"] + for key in pm.keys(): + if key in self: + if self[key] != pm[key]: + changes[key] = self[key] + else: + changes[key] = PropertyDeleted + return changes + + def replaceLayer(self, priority: int, pm: PropertyManager): + layers = [x for x in self.layers if x["priority"] == priority] + + originalState = self.__dict__() + + changes = self._removeLayer(layers[0]) if layers else {} + changes = {**changes, **self._addLayer(priority, pm)} + changes = {k: v for k, v in changes.items() if k not in originalState or originalState[k] != v} + + self._fireCallbacks(changes) + + def receiveEvent(self, layer, changes): + changesToForward = {name: value for name, value in changes.items() if layer == self._getTopLayer(name)} + # deletions need to be handled separately: + # * send a deletion if the key was deleted in all layers + # * send lower value if the key is still present in a lower layer + deletionsToForward = { + name: PropertyDeleted if self._getTopLayer(name, False) is None else self[name] + for name, value in changes.items() + if value is PropertyDeleted + } + self._fireCallbacks({**changesToForward, **deletionsToForward}) + + def _getTopLayer(self, item, fallback=True): + layers = [la["props"] for la in sorted(self.layers, key=lambda l: l["priority"])] + for m in layers: + if item in m: + return m + # return top layer as fallback + if fallback and layers: + return layers[0] + + def __getitem__(self, item): + layer = self._getTopLayer(item) + return layer.__getitem__(item) + + def __setitem__(self, key, value): + layer = self._getTopLayer(key) + return layer.__setitem__(key, value) + + def __contains__(self, item): + layer = self._getTopLayer(item) + if layer: + return layer.__contains__(item) + return False + + def __dict__(self): + return {k: self.__getitem__(k) for k in self.keys()} + + def __delitem__(self, key): + for layer in self.layers: + if layer["props"].__contains__(key): + layer["props"].__delitem__(key) + + def keys(self): + return set([key for l in self.layers for key in l["props"].keys()]) + + def values(self): + return [self.__getitem__(k) for k in self.keys()] + + def items(self): + return self.__dict__().items() + + +class PropertyCarousel(PropertyDelegator): + def __init__(self): + # start with an empty dummy layer + self.emptyLayer = PropertyLayer().readonly() + super().__init__(self.emptyLayer) + self.layers = {} + + def _getDefaultLayer(self): + return self.emptyLayer + + def addLayer(self, key, value): + if key in self.layers and self.layers[key] is self.pm: + self.layers[key] = value + # switch after introducing the new value + self.switch(key) + else: + self.layers[key] = value + + def removeLayer(self, key): + if key in self.layers and self.layers[key] is self.pm: + self.switch() + del self.layers[key] + + def switch(self, key=None): + before = self.pm + self.subscription.cancel() + self.pm = self._getDefaultLayer() if key is None else self.layers[key] + self.subscription = self.pm.wire(self._fireCallbacks) + changes = {} + for key in set(list(before.keys()) + list(self.keys())): + if key not in self: + changes[key] = PropertyDeleted + else: + if key not in before or before[key] != self[key]: + changes[key] = self[key] + self._fireCallbacks(changes) diff --git a/openwebrx/owrx/property/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/property/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e3d76d4237c6958809a05fb21db68fa2c8d88f4 GIT binary patch literal 18732 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 WUaw!RpQ%4vf2v-p&*7-lZ~q_tf!(bD literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/property/__pycache__/filter.cpython-37.pyc b/openwebrx/owrx/property/__pycache__/filter.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ab99a4b78bc5dbfe44849009f0ce7758f538a1e GIT binary patch literal 1285 zcmbtT&2G~`5Z?9Lj$4wF0)iU{4oJQvFMv=<5xt-YiJOIFIo^#3*s)nVYE$*rJ_ZLk za^*$1`^pI(ffMtsv-zpi9$=*z@64>{oB3vU)a>T0efjOTKf)e!{TJrOgpZ^^%li zl4mUE5zn2-;p~J-M~W*Zh2obY5@fs5BimDgY?o|bcF67++mpdt7ViC`Oe}O7@nUq` z93+#f)=66Ds(xR{6fwKj*pJDhSi~iZ93_-1`DbpB-Af*M(xt3k3%SU!@5b?`MUu4&0j&JaO(*FzJ%a*C+ufnX&sFdt$BLS=oso+_sm|QQ{e0-6NWIKGGV&Kk=`kRE{G!N zqRT;-L6<}sblEL~u7IwHInZ;8o&#MKHPAJ84$rHg>!Ja=q39aurkDpkujo4H1+fTv zQPB<1OJW)Hvf|eSy&_gYuewd}n+Lrn)=VF z%vxHce|hyw++0M;V$*mdGalnZp9V3)*bLWl|$GDiVKR6rV*c(tPLI3UuE4B5gFoWYWW zKpX%aJ$8NvSxv5&4ZM;=fIe0TkdPrCGAqHM#U)|c(&Qpe5qBo^Q-~w(yZvrxS*P(l zMu893raZWjyp)qlHfK`VAtWDBj`4P0s$9a0Q*sqnXD39iPK!+8H>cp2YsBtYtO=Y` z#5l&fq{ndKPP&oewB^$68QbLw?^1^MGiI<3VQxmX?-03ZaO7|x^oeCacE;!<>DZxm zgwPzvBPxtRu^*YiK1zhLVeAx1+9DgumgRVM5Li}>m5Lm)^*r$=YZx!^!Aee%mw1LU z6GZ?K@gvGW>>Ewia5t73hsCiDx`-E(m{`us9lHn7m`{$(J5ge|>D_(^RVi;0jkAm_ntYg1xh0}e`-PZHLEgXmX=qjuHzy*B`cu>(FQqVaj6GSFDbNqr40E`aH031eVE z^lU8XNzzp(OGnaKCo28iH#4dS)p$Cn*uA9AS01IqUsg?$T2B>qb~V`|&Qt_dm0y}r zA_XYdbxgde(>GKVLyUQuS*Z5fb&PmIAL*!yMmwDl$pre4idwLO8xAwOil zup|Df_7H_s6(*=jO%_9xwsAS)k3eUQoSM_~%BI&1LdWmzUAajNiF<(!E>7w`?h_lE zo{s@BxPpu1n;R`%lEIQRk_wsWew4^my^yrIjw7H+jByS70v{~Skaj{>k|z}2cfs}x z5&D!jb;a$cl}e8ZHGEt z3-+L#bDJ7cNsGwr*`aekQH;!aQ89Y^E{?=|D^qH{eB?SQxM^DXmbjGqBz$tmi8Co1pm_ni=xKTvrbmFVx6{;X9- zN9FVfTmA5w1!7D2rb0`Sa+1chY)Hz+;;6J4|Ds=^=`NtJn!L)Z Wdedk&o26#CX^!_A&K0T@@%}HZ4-z~8 literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/property/filter.py b/openwebrx/owrx/property/filter.py new file mode 100644 index 0000000..7e870d0 --- /dev/null +++ b/openwebrx/owrx/property/filter.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + + +class Filter(ABC): + @abstractmethod + def apply(self, prop) -> bool: + pass + + +class ByPropertyName(Filter): + def __init__(self, *props): + self.props = props + + def apply(self, prop) -> bool: + return prop in self.props + + +class ByLambda(Filter): + def __init__(self, func): + self.func = func + + def apply(self, prop) -> bool: + return self.func(prop) diff --git a/openwebrx/owrx/property/validators.py b/openwebrx/owrx/property/validators.py new file mode 100644 index 0000000..f1c197e --- /dev/null +++ b/openwebrx/owrx/property/validators.py @@ -0,0 +1,97 @@ +from abc import ABC, abstractmethod +from functools import reduce +from operator import or_ + + +class ValidatorException(Exception): + pass + + +class Validator(ABC): + @staticmethod + def of(x): + if isinstance(x, Validator): + return x + if callable(x): + return LambdaValidator(x) + if x in validator_types: + return validator_types[x]() + raise ValidatorException("Cannot create validator") + + @abstractmethod + def isValid(self, value): + pass + + +class LambdaValidator(Validator): + def __init__(self, c): + self.callable = c + + def isValid(self, value): + return self.callable(value) + + +class TypeValidator(Validator): + def __init__(self, type): + self.type = type + super().__init__() + + def isValid(self, value): + return isinstance(value, self.type) + + +class IntegerValidator(TypeValidator): + def __init__(self): + super().__init__(int) + + +class FloatValidator(TypeValidator): + def __init__(self): + super().__init__(float) + + +class StringValidator(TypeValidator): + def __init__(self): + super().__init__(str) + + +class BoolValidator(TypeValidator): + def __init__(self): + super().__init__(bool) + + +class OrValidator(Validator): + def __init__(self, *validators): + self.validators = validators + super().__init__() + + def isValid(self, value): + return reduce( + or_, + [v.isValid(value) for v in self.validators], + False + ) + + +class NumberValidator(OrValidator): + def __init__(self): + super().__init__(IntegerValidator(), FloatValidator()) + + +class RegexValidator(StringValidator): + def __init__(self, regex): + self.regex = regex + super().__init__() + + def isValid(self, value): + return super().isValid(value) and self.regex.match(value) is not None + + +validator_types = { + "string": StringValidator, + "str": StringValidator, + "integer": IntegerValidator, + "int": IntegerValidator, + "number": NumberValidator, + "num": NumberValidator, +} diff --git a/openwebrx/owrx/receiverid.py b/openwebrx/owrx/receiverid.py new file mode 100644 index 0000000..e21760a --- /dev/null +++ b/openwebrx/owrx/receiverid.py @@ -0,0 +1,98 @@ +import re +import logging +import hashlib +import hmac +from datetime import datetime, timezone +from owrx.config import Config + +logger = logging.getLogger(__name__) + + +keyRegex = re.compile("^([a-zA-Z]+)-([0-9a-f]{32})-([0-9a-f]{64})$") +keyChallengeRegex = re.compile("^([a-zA-Z]+)-([0-9a-f]{32})-([0-9a-f]{32})$") +headerRegex = re.compile("^ReceiverId (.*)$") + + +class KeyException(Exception): + pass + + +class Key(object): + def __init__(self, keyString): + matches = keyRegex.match(keyString) + if not matches: + raise KeyException("invalid key format") + self.source = matches.group(1) + self.id = matches.group(2) + self.secret = matches.group(3) + + +class KeyChallenge(object): + def __init__(self, challengeString): + matches = keyChallengeRegex.match(challengeString) + if not matches: + raise KeyException("invalid key challenge format") + self.source = matches.group(1) + self.id = matches.group(2) + self.challenge = matches.group(3) + + +class KeyResponse(object): + def __init__(self, source, id, time, signature): + self.source = source + self.id = id + self.time = time + self.signature = signature + + def __str__(self): + return "{source}-{id}-{time}-{signature}".format( + source=self.source, + id=self.id, + time=self.time, + signature=self.signature, + ) + + +class ReceiverId(object): + @staticmethod + def getResponseHeader(requestHeader): + matches = headerRegex.match(requestHeader) + if not matches: + raise KeyException("invalid authorization header") + challenges = [KeyChallenge(i) for i in matches.group(1).split(",")] + + def signChallenge(challenge): + key = ReceiverId.findKey(challenge) + if key is None: + return + return ReceiverId.signChallenge(challenge, key) + + responses = [signChallenge(c) for c in challenges] + return ",".join(str(r) for r in responses if r is not None) + + @staticmethod + def findKey(challenge): + def parseKey(keyString): + try: + return Key(keyString) + except KeyException as e: + logger.error(e) + + config = Config.get() + if "receiver_keys" not in config or config["receiver_keys"] is None: + return None + keys = [parseKey(keyString) for keyString in config["receiver_keys"]] + keys = [key for key in keys if key is not None] + matching_keys = [key for key in keys if key.source == challenge.source and key.id == challenge.id] + if matching_keys: + return matching_keys[0] + return None + + @staticmethod + def signChallenge(challenge, key): + now = datetime.utcnow().replace(microsecond=0, tzinfo=timezone.utc) + now_bytes = int(now.timestamp()).to_bytes(4, byteorder="big") + m = hmac.new(bytes.fromhex(key.secret), digestmod=hashlib.sha256) + m.update(bytes.fromhex(challenge.challenge)) + m.update(now_bytes) + return KeyResponse(challenge.source, challenge.id, now_bytes.hex(), m.hexdigest()) diff --git a/openwebrx/owrx/reporting/__init__.py b/openwebrx/owrx/reporting/__init__.py new file mode 100644 index 0000000..f65feab --- /dev/null +++ b/openwebrx/owrx/reporting/__init__.py @@ -0,0 +1,57 @@ +import threading +from owrx.config import Config +from owrx.reporting.reporter import Reporter +from owrx.reporting.pskreporter import PskReporter +from owrx.reporting.wsprnet import WsprnetReporter +import logging + +logger = logging.getLogger(__name__) + + +class ReportingEngine(object): + creationLock = threading.Lock() + sharedInstance = None + + reporterClasses = { + "pskreporter_enabled": PskReporter, + "wsprnet_enabled": WsprnetReporter, + } + + @staticmethod + def getSharedInstance(): + with ReportingEngine.creationLock: + if ReportingEngine.sharedInstance is None: + ReportingEngine.sharedInstance = ReportingEngine() + return ReportingEngine.sharedInstance + + @staticmethod + def stopAll(): + with ReportingEngine.creationLock: + if ReportingEngine.sharedInstance is not None: + ReportingEngine.sharedInstance.stop() + + def __init__(self): + self.reporters = [] + self.configSub = Config.get().filter(*ReportingEngine.reporterClasses.keys()).wire(self.setupReporters) + self.setupReporters() + + def setupReporters(self, *args): + config = Config.get() + for configKey, reporterClass in ReportingEngine.reporterClasses.items(): + if configKey in config and config[configKey]: + if not any(isinstance(r, reporterClass) for r in self.reporters): + self.reporters += [reporterClass()] + else: + for reporter in [r for r in self.reporters if isinstance(r, reporterClass)]: + reporter.stop() + self.reporters.remove(reporter) + + def stop(self): + for r in self.reporters: + r.stop() + self.configSub.cancel() + + def spot(self, spot): + for r in self.reporters: + if spot["mode"] in r.getSupportedModes(): + r.spot(spot) diff --git a/openwebrx/owrx/reporting/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9f6cc69dd5a119618207ef4530991d99b5a0a56 GIT binary patch literal 2455 zcma)7&2Jk;6rY)0uV?)cCp2o4P%8BTH3+u_2`*7oND)Gm2pXx0Rt+o1GjTUr?>aM^ z=0lrPBJptm!I^`7aOO^oA2Ly^LuYTuh%OCzQ6u`+Rn;^ z{EdzA=YsM8ivAviAcDpuz?#Lh#{x!CcVegK2CmiJxYY9k&+4VP+~WbaI*t9nm;Rt4 zt3w*pz9hmEfx85Efu%UyMHH$4VXzy&MrhGCRM zSs1qZr)Hrmv(2fU77-)|K^UhM%IVqd83(jpS@Xu0`syZHIBh=cRKucL!y3Anq{WxjUqk z941@ja1mJmJTGlb;G#bba`LwB=?&n8tktH~6SSKUONs&`PgF^t#!qOuxCI_spp{@RDowlF<%_R$-| zoGfqpmZ53^55P!K2-K9KO!l;K+R3S!$KE;Y^_1+T&t0 z8t^S-)F_{fDdAq(qi(=Tz>g_128W|j*5NbJWsScNgQLKz(HcEld<80`oDT8@KLm#? zwRxlk%E_G(z+2}mhk#ioIYXEbOdK@mv!n;e7AV^=ZZ8!Q(mOgTu$lO%Zy!VP4Gci4 z(ET)naf|vrnDkocIPeLS3H15bLC2jI3?z}H-IHNxDq#qxAH}HG!tn5@9gjx*>=`^X z0$vK^;!|^#juqOgkm?16YVh`CQKu)fXQ@zi>~El0Mu9Z2ZdDff0_$7vT{`>ydJu|EPG()t~Q;o*Q(#!GkiWhW~l>NVCGzFIhV4PP9M(ZzvP J#m`yA`43CbL)-uW literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/reporting/__pycache__/pskreporter.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/pskreporter.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c42af9dd5d5ac03d33d49751a4dec38cc6c2fb2 GIT binary patch literal 7997 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 P#xv$WFW`rq`D_0TX3hUt literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/reporting/__pycache__/reporter.cpython-37.pyc b/openwebrx/owrx/reporting/__pycache__/reporter.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfa958e7d16fcb5381f2a3b9a2d83088f9489b18 GIT binary patch literal 705 zcmZ`%y-ve05cW^fhN?lZGP7i9c>sh;J226KZV{4|#-<=8O|V@RSj)@sH0)lPU}j?C z&Y@62IqA+{?#}n!oimK%4gq<7yUgCk1ONu0<`9e|k`|<-DJ@ybC^;jNN&ZMA zS9HZwE`te4!dJ*733L2(IC8t$)L5P6wp8}Ik~zlK!T}6G%@G(yQYuNx6qi)8N3x=6 z02pd|90Cqy%j1@815f0l3sqOzDxFg(fPyFL2-INfl0YUMKY?+9QN@)G!RyUO(ywX@ zs^&{I)pz}B2}b*pH}lySQo2{)!&O_=c+)U2ggwM->K;aN@5KP!;=}jW*0N_&2{&?oim@d+f9b&AOC*d zz1wE&pVXN>9)wR&%DBW=mi|CxyPS!eMI|IRwxM0;{rC@mG_ zm0~FKQ+cS)lJBJ|2Stv-V7thV27O53S4FR@3)OK<_(W8{Bksu%0x*r$Ok$} zprKdF^vEv4u11pd(r#HM$zR!jez{c)HA-#ft>RRjZ7J)4LEhgwEr%+X`qA)Q-NGlR zlmRNn10FczrAnVivrqLn&EX+^B9v{E@(|V8^9eg<>iPMv(ekNt$zJgZ*X~KAKCoJ{ zd~L#}-h^L5N4v(qWSkwamyXADaMD!YaP&FT;WQEst@;*o-=G{I&aF*$#vgum){{ex zK-oBbF-YacXqXmV0aZ2*_Mh(UO1)7I3*Bj`75r3XHn%UeX+A9mWsyG}q^W6EoA$BL zG<3H-Nd!W|H0EcUAT9cR*tj8QpX)ek4JX3V*ro2!;kLRk!ISRE$U5;Z4+$ z)8vbI$IaS|ST>J{Su|J+{tDYPVnXnBWb}P@!CyJ(x#)Q(u6j6egnts)Hnj&_dSYrV90>Tvf+M;+9t3NJY@@X6Ye0LW14ZZ;hJ3$B5+96dH3 z<;OVKJm1VV#n{{YV)Kb{#32gsMX14%R%Bb_9RYRvkl0^dKl-zYdfhZF2mRcHX@y|M z6^EHwuG1}<&$pE9$pNr0=?}}!qKS?WA{5@nBhWIQg)J9nsYf_nZJ~-i{ZjQ~U(+dQ z`f4E5Z!lrP8bo7XRddSD{Q`62doQ#ee!Ml$_(Ju!hDE8j)Xy;ueM9=a~>E5JGkQ>&P@g&$58}J*^;PbZ^9|dIvKPQOXZcA?w_G+;betI}2W`$U%S@ zUI(Sc*WB?+orl-?-)Rtz=G{ytiD@PY5@?i?+)9#{qi$NwC`tkgkZOY@Qo(-Sv}>&G z$uv!p>p$#pb(XY@L&ORo8CST%LkkKL z0a{;#B0?L82I9AAmJTW)pKK4YbrO5t`IXmuV6OJD!RY6Xk|(ws4q}Tds%Xt&Z0~H9^bp6|Nk-;F=B!14#+ zRuA0zxaL-W8VI-I)*Rew-RkY`5IGzIs!qn6Ga9wGs*M7Oa+DUQ4>!P&mRdJbfJP^@ zN~^E&2kHqb;|}tkYEbuG>V~IXm4lUQ+N{XtZJx1X-(qp)OJwQtPgww=G7o6-BU!;( zfJ{Mp_lXbPT}4P>{GI)0zdf+#{t46Dpd1bDHB=DOmEU1wNyjoie*dNiD1_fyy$k(6 z!&-XVLO9&MMG+>sm>@}&fh4p(-93IPF75651X7^iAuqysPi 0 + + def _sdrTypeAvailable(self, value): + featureDetector = FeatureDetector() + try: + if not featureDetector.is_available(value["type"]): + logger.error( + 'The SDR source type "{0}" is not available. please check the feature report for details.'.format( + value["type"] + ) + ) + return False + return True + except UnknownFeatureException: + logger.error( + 'The SDR source type "{0}" is invalid. Please check your configuration'.format(value["type"]) + ) + return False + + def buildNewSource(self, id, props): + sdrType = props["type"] + className = "".join(x for x in sdrType.title() if x.isalnum()) + "Source" + module = __import__("owrx.source.{0}".format(sdrType), fromlist=[className]) + cls = getattr(module, className) + return cls(id, props) + + def _removeSource(self, key, source): + source.shutdown() + for sub in self.subscriptions[key]: + sub.cancel() + del self.subscriptions[key] + + def __setitem__(self, key, value): + source = self[key] if key in self else None + if source is value: + return + super().__setitem__(key, value) + if source is not None: + self._removeSource(key, source) + + def __delitem__(self, key): + source = self[key] if key in self else None + super().__delitem__(key) + if source is not None: + self._removeSource(key, source) + + +class SourceStateHandler(SdrSourceEventClient): + def __init__(self, pm, key, source: SdrSource): + self.pm = pm + self.key = key + self.source = source + + def selfDestruct(self): + self.source.removeClient(self) + + def onFail(self): + del self.pm[self.key] + + def onDisable(self): + del self.pm[self.key] + + def onEnable(self): + self.pm[self.key] = self.source + + def onShutdown(self): + del self.pm[self.key] + + +class ActiveSdrSources(PropertyReadOnly): + def __init__(self, pm: PropertyManager): + self.handlers = {} + self._layer = PropertyLayer() + super().__init__(self._layer) + for key, value in pm.items(): + self._addSource(key, value) + pm.wire(self.handleSdrDeviceChange) + + def handleSdrDeviceChange(self, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + self._removeSource(key) + else: + self._addSource(key, value) + + def isAvailable(self, source: SdrSource): + return source.isEnabled() and not source.isFailed() + + def _addSource(self, key, source: SdrSource): + if self.isAvailable(source): + self._layer[key] = source + self.handlers[key] = SourceStateHandler(self._layer, key, source) + source.addClient(self.handlers[key]) + + def _removeSource(self, key): + self.handlers[key].selfDestruct() + del self.handlers[key] + if key in self._layer: + del self._layer[key] + + +class AvailableProfiles(PropertyReadOnly): + def __init__(self, pm: PropertyManager): + self.subscriptions = {} + self.profileSubscriptions = {} + self._layer = PropertyLayer() + super().__init__(self._layer) + for key, value in pm.items(): + self._addSource(key, value) + pm.wire(self.handleSdrDeviceChange) + + def handleSdrDeviceChange(self, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + self._removeSource(key) + else: + self._addSource(key, value) + + def handleSdrNameChange(self, s_id, source, name): + profiles = source.getProfiles() + for p_id in list(self._layer.keys()): + source_id, profile_id = p_id.split("|") + if source_id == s_id: + profile = profiles[profile_id] + self._layer[p_id] = "{} {}".format(name, profile["name"]) + + def handleProfileChange(self, source_id, source: SdrSource, changes): + for key, value in changes.items(): + if value is PropertyDeleted: + self._removeProfile(source_id, key) + else: + self._addProfile(source_id, source, key, value) + + def handleProfileNameChange(self, s_id, source: SdrSource, p_id, name): + for concat_p_id in list(self._layer.keys()): + source_id, profile_id = concat_p_id.split("|") + if source_id == s_id and profile_id == p_id: + self._layer[concat_p_id] = "{} {}".format(source.getName(), name) + + def _addSource(self, key, source: SdrSource): + for p_id, p in source.getProfiles().items(): + self._addProfile(key, source, p_id, p) + self.subscriptions[key] = [ + source.getProps().wireProperty("name", partial(self.handleSdrNameChange, key, source)), + source.getProfiles().wire(partial(self.handleProfileChange, key, source)), + ] + + def _addProfile(self, s_id, source: SdrSource, p_id, profile): + self._layer["{}|{}".format(s_id, p_id)] = "{} {}".format(source.getName(), profile["name"]) + if s_id not in self.profileSubscriptions: + self.profileSubscriptions[s_id] = {} + self.profileSubscriptions[s_id][p_id] = profile.wireProperty("name", partial(self.handleProfileNameChange, s_id, source, p_id)) + + def _removeSource(self, key): + for profile_id in list(self._layer.keys()): + if profile_id.startswith("{}|".format(key)): + del self._layer[profile_id] + if key in self.subscriptions: + while self.subscriptions[key]: + self.subscriptions[key].pop().cancel() + del self.subscriptions[key] + if key in self.profileSubscriptions: + for p_id in self.profileSubscriptions[key].keys(): + self.profileSubscriptions[key][p_id].cancel() + del self.profileSubscriptions[key] + + def _removeProfile(self, s_id, p_id): + for concat_p_id in list(self._layer.keys()): + source_id, profile_id = concat_p_id.split("|") + if source_id == s_id and profile_id == p_id: + del self._layer[concat_p_id] + if s_id in self.profileSubscriptions and p_id in self.profileSubscriptions[s_id]: + self.profileSubscriptions[s_id][p_id].cancel() + del self.profileSubscriptions[s_id][p_id] + + +class SdrService(object): + sources = None + activeSources = None + availableProfiles = None + + @staticmethod + def getFirstSource(): + sources = SdrService.getActiveSources() + if not sources: + return None + # TODO: configure default sdr in config? right now it will pick the first one off the list. + return sources[list(sources.keys())[0]] + + @staticmethod + def getSource(id): + sources = SdrService.getActiveSources() + if not sources: + return None + if id not in sources: + return None + return sources[id] + + @staticmethod + def getAllSources(): + if SdrService.sources is None: + SdrService.sources = MappedSdrSources(Config.get()["sdrs"]) + return SdrService.sources + + @staticmethod + def getActiveSources(): + if SdrService.activeSources is None: + SdrService.activeSources = ActiveSdrSources(SdrService.getAllSources()) + return SdrService.activeSources + + @staticmethod + def getAvailableProfiles(): + if SdrService.availableProfiles is None: + SdrService.availableProfiles = AvailableProfiles(SdrService.getActiveSources()) + return SdrService.availableProfiles diff --git a/openwebrx/owrx/service/__init__.py b/openwebrx/owrx/service/__init__.py new file mode 100644 index 0000000..2206924 --- /dev/null +++ b/openwebrx/owrx/service/__init__.py @@ -0,0 +1,374 @@ +import threading +from owrx.source import SdrSourceEventClient, SdrSourceState, SdrClientClass +from owrx.sdr import SdrService +from owrx.bands import Bandplan +from csdr.output import Output +from csdr import Dsp +from owrx.wsjt import WsjtParser +from owrx.aprs import AprsParser +from owrx.js8 import Js8Parser +from owrx.config.core import CoreConfig +from owrx.config import Config +from owrx.source.resampler import Resampler +from owrx.property import PropertyLayer, PropertyDeleted +from js8py import Js8Frame +from abc import ABCMeta, abstractmethod +from owrx.service.schedule import ServiceScheduler +from owrx.modes import Modes + +import logging + +logger = logging.getLogger(__name__) + + +class ServiceOutput(Output, metaclass=ABCMeta): + def __init__(self, frequency): + self.frequency = frequency + + @abstractmethod + def getParser(self): + # abstract method; implement in subclasses + pass + + def receive_output(self, t, read_fn): + parser = self.getParser() + parser.setDialFrequency(self.frequency) + target = self.pump(read_fn, parser.parse) + threading.Thread(target=target, name="service_output_receive").start() + + +class WsjtServiceOutput(ServiceOutput): + def getParser(self): + return WsjtParser(WsjtHandler()) + + def supports_type(self, t): + return t == "wsjt_demod" + + +class AprsServiceOutput(ServiceOutput): + def getParser(self): + return AprsParser(AprsHandler()) + + def supports_type(self, t): + return t == "packet_demod" + + +class Js8ServiceOutput(ServiceOutput): + def getParser(self): + return Js8Parser(Js8Handler()) + + def supports_type(self, t): + return t == "js8_demod" + + +class ServiceHandler(SdrSourceEventClient): + def __init__(self, source): + self.lock = threading.RLock() + self.services = [] + self.source = source + self.startupTimer = None + self.activitySub = None + self.running = False + props = self.source.getProps() + self.enabledSub = props.wireProperty("services", self._receiveEvent) + self.decodersSub = None + # need to call _start() manually if property is not set since the default is True, but the initial call is only + # made if the property is present + if "services" not in props: + self._start() + + def _receiveEvent(self, state): + # deletion means fall back to default, which is True + if state is PropertyDeleted: + state = True + if self.running == state: + return + if state: + self._start() + else: + self._stop() + + def _start(self): + self.running = True + self.source.addClient(self) + props = self.source.getProps() + self.activitySub = props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange) + self.decodersSub = Config.get().wireProperty("services_decoders", self.onFrequencyChange) + if self.source.isAvailable(): + self._scheduleServiceStartup() + + def _stop(self): + if self.activitySub is not None: + self.activitySub.cancel() + self.activitySub = None + if self.decodersSub is not None: + self.decodersSub.cancel() + self.decodersSub = None + self._cancelStartupTimer() + self.source.removeClient(self) + self.stopServices() + self.running = False + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.INACTIVE + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.RUNNING: + self._scheduleServiceStartup() + elif state is SdrSourceState.STOPPING: + logger.debug("sdr source becoming unavailable; stopping services.") + self.stopServices() + + def onFail(self): + logger.debug("sdr source failed; stopping services.") + self.stopServices() + + def onShutdown(self): + logger.debug("sdr source is shutting down; shutting down service handler, too.") + self.shutdown() + + def onEnable(self): + self._scheduleServiceStartup() + + def isSupported(self, mode): + configured = Config.get()["services_decoders"] + available = [m.modulation for m in Modes.getAvailableServices()] + return mode in configured and mode in available + + def shutdown(self): + self._stop() + if self.enabledSub is not None: + self.enabledSub.cancel() + self.enabledSub = None + + def stopServices(self): + with self.lock: + services = self.services + self.services = [] + + for service in services: + service.stop() + + def onFrequencyChange(self, changes): + self.stopServices() + if not self.source.isAvailable(): + return + self._scheduleServiceStartup() + + def _cancelStartupTimer(self): + if self.startupTimer: + self.startupTimer.cancel() + self.startupTimer = None + + def _scheduleServiceStartup(self): + self._cancelStartupTimer() + self.startupTimer = threading.Timer(10, self.updateServices) + self.startupTimer.start() + + def updateServices(self): + with self.lock: + logger.debug("re-scheduling services due to sdr changes") + self.stopServices() + if not self.source.isAvailable(): + logger.debug("sdr source is unavailable") + return + cf = self.source.getProps()["center_freq"] + sr = self.source.getProps()["samp_rate"] + srh = sr / 2 + frequency_range = (cf - srh, cf + srh) + + dials = [ + dial + for dial in Bandplan.getSharedInstance().collectDialFrequencies(frequency_range) + if self.isSupported(dial["mode"]) + ] + + if not dials: + logger.debug("no services available") + return + + groups = self.optimizeResampling(dials, sr) + if groups is None: + for dial in dials: + self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source)) + else: + for group in groups: + if len(group) > 1: + cf = self.get_center_frequency(group) + bw = self.get_bandwidth(group) + logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw)) + resampler_props = PropertyLayer() + resampler_props["center_freq"] = cf + resampler_props["samp_rate"] = bw + resampler = Resampler(resampler_props, self.source) + resampler.start() + + for dial in group: + self.services.append(self.setupService(dial["mode"], dial["frequency"], resampler)) + + # resampler goes in after the services since it must not be shutdown as long as the services are + # still running + self.services.append(resampler) + else: + dial = group[0] + self.services.append(self.setupService(dial["mode"], dial["frequency"], self.source)) + + def get_min_max(self, group): + frequencies = sorted(group, key=lambda f: f["frequency"]) + lowest = frequencies[0] + min = lowest["frequency"] + Modes.findByModulation(lowest["mode"]).get_bandpass().low_cut + highest = frequencies[-1] + max = highest["frequency"] + Modes.findByModulation(highest["mode"]).get_bandpass().high_cut + return min, max + + def get_center_frequency(self, group): + min, max = self.get_min_max(group) + return (min + max) / 2 + + def get_bandwidth(self, group): + minFreq, maxFreq = self.get_min_max(group) + # minimum bandwidth for a resampler: 25kHz + return max((maxFreq - minFreq) * 1.15, 25000) + + def optimizeResampling(self, freqs, bandwidth): + freqs = sorted(freqs, key=lambda f: f["frequency"]) + distances = [ + { + "frequency": freqs[i]["frequency"], + "distance": freqs[i + 1]["frequency"] - freqs[i]["frequency"], + } + for i in range(0, len(freqs) - 1) + ] + + distances = [d for d in distances if d["distance"] > 0] + + distances = sorted(distances, key=lambda f: f["distance"], reverse=True) + + def calculate_usage(num_splits): + splits = sorted([f["frequency"] for f in distances[0:num_splits]]) + previous = 0 + groups = [] + for split in splits: + groups.append([f for f in freqs if previous < f["frequency"] <= split]) + previous = split + groups.append([f for f in freqs if previous < f["frequency"]]) + + def get_total_bandwidth(group): + if len(group) > 1: + return bandwidth + len(group) * self.get_bandwidth(group) + else: + return bandwidth + + total_bandwidth = sum([get_total_bandwidth(group) for group in groups]) + return { + "num_splits": num_splits, + "total_bandwidth": total_bandwidth, + "groups": groups, + } + + usages = [calculate_usage(i) for i in range(0, len(freqs))] + # another possible outcome might be that it's best not to resample at all. this is a special case. + usages += [ + { + "num_splits": None, + "total_bandwidth": bandwidth * len(freqs), + "groups": [freqs], + } + ] + results = sorted(usages, key=lambda f: f["total_bandwidth"]) + + for r in results: + logger.debug("splits: {0}, total: {1}".format(r["num_splits"], r["total_bandwidth"])) + + best = results[0] + if best["num_splits"] is None: + return None + return best["groups"] + + def setupService(self, mode, frequency, source): + logger.debug("setting up service {0} on frequency {1}".format(mode, frequency)) + # TODO selecting outputs will need some more intelligence here + if mode == "packet": + output = AprsServiceOutput(frequency) + elif mode == "js8": + output = Js8ServiceOutput(frequency) + else: + output = WsjtServiceOutput(frequency) + d = Dsp(output) + d.nc_port = source.getPort() + center_freq = source.getProps()["center_freq"] + d.set_offset_freq(frequency - center_freq) + d.set_center_freq(center_freq) + modeObject = Modes.findByModulation(mode) + d.set_demodulator(modeObject.get_modulation()) + d.set_bandpass(modeObject.get_bandpass()) + d.set_secondary_demodulator(mode) + d.set_audio_compression("none") + d.set_samp_rate(source.getProps()["samp_rate"]) + d.set_temporary_directory(CoreConfig().get_temporary_directory()) + d.set_service() + d.start() + return d + + +class WsjtHandler(object): + def write_wsjt_message(self, msg): + pass + + +class AprsHandler(object): + def write_aprs_data(self, data): + pass + + +class Js8Handler(object): + def write_js8_message(self, frame: Js8Frame, freq: int): + pass + + +class Services(object): + handlers = {} + schedulers = {} + + @staticmethod + def start(): + config = Config.get() + config.wireProperty("services_enabled", Services._receiveEnabledEvent) + activeSources = SdrService.getActiveSources() + activeSources.wire(Services._receiveDeviceEvent) + for key, source in activeSources.items(): + Services.schedulers[key] = ServiceScheduler(source) + + @staticmethod + def _receiveEnabledEvent(state): + if state: + for key, source in SdrService.getActiveSources().__dict__().items(): + Services.handlers[key] = ServiceHandler(source) + else: + for handler in list(Services.handlers.values()): + handler.shutdown() + Services.handlers = {} + + @staticmethod + def _receiveDeviceEvent(changes): + for key, source in changes.items(): + if source is PropertyDeleted: + if key in Services.handlers: + Services.handlers[key].shutdown() + del Services.handlers[key] + if key in Services.schedulers: + Services.schedulers[key].shutdown() + del Services.schedulers[key] + else: + Services.schedulers[key] = ServiceScheduler(source) + if Config.get()["services_enabled"]: + Services.handlers[key] = ServiceHandler(source) + + @staticmethod + def stop(): + for handler in list(Services.handlers.values()): + handler.shutdown() + Services.handlers = {} + for scheduler in list(Services.schedulers.values()): + scheduler.shutdown() + Services.schedulers = {} diff --git a/openwebrx/owrx/service/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/service/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e9f7fcd255aabf04efbba54678fa160dd7fbec4 GIT binary patch literal 14474 zcmb_jTWlQHd7j(u&Mucrilj(Ul4V+vWYd;uJ9Zq`b{xr)Wjl!1j-*7v#_49YGo)79 zo6gKi)MlBBr9@H@H%`%}Dc}|)3KZ#EA9_jATl&znPi-IDc?!_HIOtP~qD4@o{l5Rq z40o4w96McN&YW}RT>kUl|8owXpPVch`2Ew5U#~s+xMBPo3&}5w%n3Zf8>V4+hG#a7 z3g1@KY*`hnWmoK$Q*lhz+09HVTggh^Y35pP#g%-fnQs*;1<7Zd6Rl#UDEVA-vQ?^- zB=0t-TGN$j$>*DUS~Hay$rqZlt+~paVe zNV(KJ*t(~3kL0JChg$bm?rkkqmZUu0ysve*a#-?vnjdN%sT`5K**sb~>L1-a=HI_% zRvviK@MgT(TZT957yNs@xi_84GV1nv^QfEmmv7pY2T@+|_MyB_$`7Hu=

    &zm$)o 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 now] + events.sort(key=lambda e: e["time"]) + + previousEvent = None + for event in events: + # night profile _until_ sunrise, day profile _until_ sunset + stype = "night" if event["type"] == "sunrise" else "day" + if stype in self.schedule and (previousEvent is not None or event["time"] - delta > now): + start = now if previousEvent is None else previousEvent + entries.append(DatetimeScheduleEntry(start, event["time"] - delta, self.schedule[stype])) + if useGreyline: + entries.append( + DatetimeScheduleEntry(event["time"] - delta, event["time"] + delta, self.schedule["greyline"]) + ) + previousEvent = event["time"] + delta + + logger.debug([str(e) for e in entries]) + return entries + + +class ServiceScheduler(SdrSourceEventClient): + def __init__(self, source): + self.source = source + self.selectionTimer = None + self.currentEntry = None + self.source.addClient(self) + self.schedule = None + props = self.source.getProps() + self.subscriptions = [] + self.subscriptions.append(props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange)) + self.subscriptions.append(props.wireProperty("scheduler", self.parseSchedule)) + # wireProperty calls parseSchedule with the initial value + # self.parseSchedule() + + def parseSchedule(self, *args): + props = self.source.getProps() + self.schedule = Schedule.parse(props) + self.scheduleSelection() + + def shutdown(self): + while self.subscriptions: + self.subscriptions.pop().cancel() + self.cancelTimer() + self.source.removeClient(self) + + def scheduleSelection(self, time=None): + if not self.source.isEnabled() or self.source.isFailed(): + return + seconds = 10 + if time is not None: + delta = time - datetime.utcnow() + seconds = delta.total_seconds() + self.cancelTimer() + self.selectionTimer = threading.Timer(seconds, self.selectProfile) + self.selectionTimer.start() + + def cancelTimer(self): + if self.selectionTimer: + self.selectionTimer.cancel() + + def getClientClass(self) -> SdrClientClass: + if self.currentEntry is None: + return SdrClientClass.INACTIVE + else: + return SdrClientClass.BACKGROUND + + def onStateChange(self, state: SdrSourceState): + if state is SdrSourceState.STOPPING: + self.scheduleSelection() + + def onFail(self): + self.shutdown() + + def onShutdown(self): + self.shutdown() + + def onDisable(self): + self.cancelTimer() + + def onEnable(self): + self.scheduleSelection() + + def onBusyStateChange(self, state: SdrBusyState): + if state is SdrBusyState.IDLE: + self.scheduleSelection() + + def onFrequencyChange(self, changes): + self.scheduleSelection() + + def _setCurrentEntry(self, entry): + self.currentEntry = entry + + if entry is not None: + logger.debug("selected profile %s until %s", entry.getProfile(), entry.getScheduledEnd()) + self.scheduleSelection(entry.getScheduledEnd()) + + try: + self.source.activateProfile(entry.getProfile()) + self.source.start() + except KeyError: + pass + + # tell the source to re-evaluate its current status + # this should make it shut down if there's no other activity + # TODO this is an improvised solution, should probably be integrated / improved in SdrSourceEventClient + self.source.checkStatus() + + def selectProfile(self): + if self.source.hasClients(SdrClientClass.USER): + logger.debug("source has active users; not touching") + return + + if self.schedule is None: + self._setCurrentEntry(None) + logger.debug("no active schedule, scheduler standing by for external events.") + return + + logger.debug("source seems to be idle, selecting profile for background services") + self._setCurrentEntry(self.schedule.getCurrentEntry()) + + if self.currentEntry is None: + logger.debug("schedule did not return a current profile. checking next (future) entry...") + nextEntry = self.schedule.getNextEntry() + if nextEntry is not None: + self.scheduleSelection(nextEntry.getNextActivation()) + else: + logger.debug("no next entry available, scheduler standing by for external events.") + return diff --git a/openwebrx/owrx/soapy.py b/openwebrx/owrx/soapy.py new file mode 100644 index 0000000..25b5f35 --- /dev/null +++ b/openwebrx/owrx/soapy.py @@ -0,0 +1,21 @@ +class SoapySettings(object): + @staticmethod + def parse(dstr): + def decodeComponent(c): + kv = c.split("=", 1) + if len(kv) < 2: + return c + else: + return {kv[0]: kv[1]} + + return [decodeComponent(c) for c in dstr.split(",")] + + @staticmethod + def encode(dobj): + def encodeComponent(c): + if isinstance(c, str): + return c + else: + return ",".join(["{0}={1}".format(key, value) for key, value in c.items()]) + + return ",".join([encodeComponent(c) for c in dobj]) diff --git a/openwebrx/owrx/socket.py b/openwebrx/owrx/socket.py new file mode 100644 index 0000000..069a538 --- /dev/null +++ b/openwebrx/owrx/socket.py @@ -0,0 +1,10 @@ +import socket + + +def getAvailablePort(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + s.close() + return port diff --git a/openwebrx/owrx/source/__init__.py b/openwebrx/owrx/source/__init__.py new file mode 100644 index 0000000..7f5e0a4 --- /dev/null +++ b/openwebrx/owrx/source/__init__.py @@ -0,0 +1,566 @@ +from owrx.config import Config +import threading +import subprocess +import os +import socket +import shlex +import time +import signal +import pkgutil +from abc import ABC, abstractmethod +from owrx.command import CommandMapper +from owrx.socket import getAvailablePort +from owrx.property import PropertyStack, PropertyLayer, PropertyFilter, PropertyCarousel, PropertyDeleted +from owrx.property.filter import ByLambda +from owrx.form.input import Input, TextInput, NumberInput, CheckboxInput, ModesInput, ExponentialInput +from owrx.form.input.converter import OptionalConverter +from owrx.form.input.device import GainInput, SchedulerInput, WaterfallLevelsInput +from owrx.form.input.validator import RequiredValidator +from owrx.form.section import OptionalSection +from owrx.feature import FeatureDetector +from typing import List +from enum import Enum + +import logging + +logger = logging.getLogger(__name__) + + +class SdrSourceState(Enum): + STOPPED = "Stopped" + STARTING = "Starting" + RUNNING = "Running" + STOPPING = "Stopping" + TUNING = "Tuning" + + def __str__(self): + return self.value + + +class SdrBusyState(Enum): + IDLE = 1 + BUSY = 2 + + +class SdrClientClass(Enum): + INACTIVE = 1 + BACKGROUND = 2 + USER = 3 + + +class SdrSourceEventClient(object): + def onStateChange(self, state: SdrSourceState): + pass + + def onBusyStateChange(self, state: SdrBusyState): + pass + + def onFail(self): + pass + + def onShutdown(self): + pass + + def onDisable(self): + pass + + def onEnable(self): + pass + + def getClientClass(self) -> SdrClientClass: + return SdrClientClass.INACTIVE + + +class SdrProfileCarousel(PropertyCarousel): + def __init__(self, props): + super().__init__() + if "profiles" not in props: + return + + for profile_id, profile in props["profiles"].items(): + self.addLayer(profile_id, profile) + # activate first available profile + self.switch() + + props["profiles"].wire(self.handleProfileUpdate) + + def addLayer(self, profile_id, profile): + profile_stack = PropertyStack() + profile_stack.addLayer(0, PropertyLayer(profile_id=profile_id).readonly()) + profile_stack.addLayer(1, profile) + super().addLayer(profile_id, profile_stack) + + def handleProfileUpdate(self, changes): + for profile_id, profile in changes.items(): + if profile is PropertyDeleted: + self.removeLayer(profile_id) + else: + self.addLayer(profile_id, profile) + + def _getDefaultLayer(self): + # return the first available profile, or the default empty layer if we don't have any + if self.layers: + return next(iter(self.layers.values())) + return super()._getDefaultLayer() + + +class SdrSource(ABC): + def __init__(self, id, props): + self.id = id + + self.commandMapper = None + + self.props = PropertyStack() + + # layer 0 reserved for profile properties + self.profileCarousel = SdrProfileCarousel(props) + # prevent profile names from overriding the device name + self.props.addLayer(0, PropertyFilter(self.profileCarousel, ByLambda(lambda x: x != "name"))) + + # props from our device config + self.props.addLayer(1, props) + + # the sdr_id is constant, so we put it in a separate layer + # this is used to detect device changes, that are then sent to the client + self.props.addLayer(2, PropertyLayer(sdr_id=id).readonly()) + + # finally, accept global config properties from the top-level config + self.props.addLayer(3, Config.get()) + + self.sdrProps = self.props.filter(*self.getEventNames()) + + self.wireEvents() + + self.port = getAvailablePort() + self.monitor = None + self.clients = [] + self.spectrumClients = [] + self.spectrumThread = None + self.spectrumLock = threading.Lock() + self.process = None + self.modificationLock = threading.Lock() + self.state = SdrSourceState.STOPPED + self.enabled = "enabled" not in props or props["enabled"] + props.filter("enabled").wire(self._handleEnableChanged) + self.failed = False + self.busyState = SdrBusyState.IDLE + + self.validateProfiles() + + if self.isAlwaysOn() and self.isEnabled(): + self.start() + + def isEnabled(self): + return self.enabled + + def _handleEnableChanged(self, changes): + if "enabled" in changes and changes["enabled"] is not PropertyDeleted: + self.enabled = changes["enabled"] + else: + self.enabled = True + if not self.enabled: + self.stop() + for c in self.clients.copy(): + if self.isEnabled(): + c.onEnable() + else: + c.onDisable() + + def isFailed(self): + return self.failed + + def fail(self): + self.failed = True + for c in self.clients.copy(): + c.onFail() + + def validateProfiles(self): + props = PropertyStack() + props.addLayer(1, self.props) + for id, p in self.props["profiles"].items(): + props.replaceLayer(0, p) + if "center_freq" not in props: + logger.warning('Profile "%s" does not specify a center_freq', id) + continue + if "samp_rate" not in props: + logger.warning('Profile "%s" does not specify a samp_rate', id) + continue + if "start_freq" in props: + start_freq = props["start_freq"] + srh = props["samp_rate"] / 2 + center_freq = props["center_freq"] + if start_freq < center_freq - srh or start_freq > center_freq + srh: + logger.warning('start_freq for profile "%s" is out of range', id) + + def isAlwaysOn(self): + return "always-on" in self.props and self.props["always-on"] + + def getEventNames(self): + return [ + "samp_rate", + "center_freq", + "ppm", + "rf_gain", + "lfo_offset", + ] + list(self.getCommandMapper().keys()) + + def getCommandMapper(self): + if self.commandMapper is None: + self.commandMapper = CommandMapper() + return self.commandMapper + + @abstractmethod + def onPropertyChange(self, changes): + pass + + def wireEvents(self): + self.sdrProps.wire(self.onPropertyChange) + + def getCommand(self): + return [self.getCommandMapper().map(self.getCommandValues())] + + def activateProfile(self, profile_id): + logger.debug("activating profile {0} for {1}".format(profile_id, self.getId())) + try: + self.profileCarousel.switch(profile_id) + except KeyError: + logger.warning("invalid profile %s for sdr %s. ignoring", profile_id, self.getId()) + + def getId(self): + return self.id + + def getProfileId(self): + return self.props["profile_id"] + + def getProfiles(self): + return self.props["profiles"] + + def getName(self): + return self.props["name"] + + def getProps(self): + return self.props + + def getPort(self): + return self.port + + def getCommandValues(self): + dict = self.sdrProps.__dict__() + if "lfo_offset" in dict and dict["lfo_offset"] is not None: + dict["tuner_freq"] = dict["center_freq"] + dict["lfo_offset"] + else: + dict["tuner_freq"] = dict["center_freq"] + return dict + + def start(self): + with self.modificationLock: + if self.monitor: + return + + if self.isFailed(): + return + + try: + self.preStart() + except Exception: + logger.exception("Exception during preStart()") + + cmd = self.getCommand() + cmd = [c for c in cmd if c is not None] + + # don't use shell mode for commands without piping + if len(cmd) > 1: + # multiple commands with pipes + cmd = "|".join(cmd) + self.process = subprocess.Popen(cmd, shell=True, start_new_session=True) + else: + # single command + cmd = cmd[0] + # start_new_session can go as soon as there's no piped commands left + # the os.killpg call must be replaced with something more reasonable at the same time + self.process = subprocess.Popen(shlex.split(cmd), start_new_session=True) + logger.info("Started sdr source: " + cmd) + + available = False + failed = False + + def wait_for_process_to_end(): + nonlocal failed + rc = self.process.wait() + logger.debug("shut down with RC={0}".format(rc)) + self.process = None + self.monitor = None + if self.getState() is SdrSourceState.RUNNING: + self.fail() + else: + failed = True + self.setState(SdrSourceState.STOPPED) + + self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor") + self.monitor.start() + + retries = 1000 + while retries > 0 and not failed: + retries -= 1 + if self.monitor is None: + break + testsock = socket.socket() + try: + testsock.connect(("127.0.0.1", self.getPort())) + testsock.close() + available = True + break + except: + time.sleep(0.1) + + if not available: + failed = True + + try: + self.postStart() + except Exception: + logger.exception("Exception during postStart()") + failed = True + + if failed: + self.fail() + else: + self.setState(SdrSourceState.RUNNING) + + def preStart(self): + """ + override this method in subclasses if there's anything to be done before starting up the actual SDR + """ + pass + + def postStart(self): + """ + override this method in subclasses if there's things to do after the actual SDR has started up + """ + pass + + def isAvailable(self): + return self.monitor is not None + + def stop(self): + with self.modificationLock: + if self.process is not None: + self.setState(SdrSourceState.STOPPING) + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + except ProcessLookupError: + # been killed by something else, ignore + pass + if self.monitor: + self.monitor.join() + + def shutdown(self): + self.stop() + for c in self.clients.copy(): + c.onShutdown() + + def getClients(self, *args): + if not args: + return self.clients + return [c for c in self.clients if c.getClientClass() in args] + + def hasClients(self, *args): + return len(self.getClients(*args)) > 0 + + def addClient(self, c: SdrSourceEventClient): + if c in self.clients: + return + self.clients.append(c) + c.onStateChange(self.getState()) + hasUsers = self.hasClients(SdrClientClass.USER) + hasBackgroundTasks = self.hasClients(SdrClientClass.BACKGROUND) + if hasUsers or hasBackgroundTasks: + self.start() + self.setBusyState(SdrBusyState.BUSY if hasUsers else SdrBusyState.IDLE) + + def removeClient(self, c: SdrSourceEventClient): + if c not in self.clients: + return + + self.clients.remove(c) + + self.checkStatus() + + def checkStatus(self): + hasUsers = self.hasClients(SdrClientClass.USER) + self.setBusyState(SdrBusyState.BUSY if hasUsers else SdrBusyState.IDLE) + + # no need to check for users if we are always-on + if self.isAlwaysOn(): + return + + hasBackgroundTasks = self.hasClients(SdrClientClass.BACKGROUND) + if not hasUsers and not hasBackgroundTasks: + self.stop() + + def addSpectrumClient(self, c): + if c in self.spectrumClients: + return + + # local import due to circular depencency + from owrx.fft import SpectrumThread + + self.spectrumClients.append(c) + with self.spectrumLock: + if self.spectrumThread is None: + self.spectrumThread = SpectrumThread(self) + self.spectrumThread.start() + + def removeSpectrumClient(self, c): + try: + self.spectrumClients.remove(c) + except ValueError: + pass + with self.spectrumLock: + if not self.spectrumClients and self.spectrumThread is not None: + self.spectrumThread.stop() + self.spectrumThread = None + + def writeSpectrumData(self, data): + for c in self.spectrumClients: + c.write_spectrum_data(data) + + def getState(self) -> SdrSourceState: + return self.state + + def setState(self, state: SdrSourceState): + if state == self.state: + return + self.state = state + for c in self.clients.copy(): + c.onStateChange(state) + + def setBusyState(self, state: SdrBusyState): + if state == self.busyState: + return + self.busyState = state + for c in self.clients.copy(): + c.onBusyStateChange(state) + + +class SdrDeviceDescriptionMissing(Exception): + pass + + +class SdrDeviceDescription(object): + @staticmethod + def getByType(sdr_type: str) -> "SdrDeviceDescription": + try: + className = "".join(x for x in sdr_type.title() if x.isalnum()) + "DeviceDescription" + module = __import__("owrx.source.{0}".format(sdr_type), fromlist=[className]) + cls = getattr(module, className) + return cls() + except (ModuleNotFoundError, AttributeError): + raise SdrDeviceDescriptionMissing("Device description for type {} not available".format(sdr_type)) + + @staticmethod + def getTypes(): + def get_description(module_name): + try: + description = SdrDeviceDescription.getByType(module_name) + return description.getName() + except SdrDeviceDescriptionMissing: + return None + + descriptions = { + module_name: get_description(module_name) for _, module_name, _ in pkgutil.walk_packages(__path__) + } + # filter out empty names and unavailable types + fd = FeatureDetector() + return {k: v for k, v in descriptions.items() if v is not None and fd.is_available(k)} + + def getName(self): + """ + must be overridden with a textual representation of the device, to be used for device type selection + + :return: str + """ + return None + + def getDeviceInputs(self) -> List[Input]: + keys = self.getDeviceMandatoryKeys() + self.getDeviceOptionalKeys() + return [TextInput("name", "Device name", validator=RequiredValidator())] + [ + i for i in self.getInputs() if i.id in keys + ] + + def getProfileInputs(self) -> List[Input]: + keys = self.getProfileMandatoryKeys() + self.getProfileOptionalKeys() + return [TextInput("name", "Profile name", validator=RequiredValidator())] + [ + i for i in self.getInputs() if i.id in keys + ] + + def getInputs(self) -> List[Input]: + return [ + CheckboxInput("enabled", "Enable this device", converter=OptionalConverter(defaultFormValue=True)), + GainInput("rf_gain", "Device gain", self.hasAgc()), + NumberInput( + "ppm", + "Frequency correction", + append="ppm", + ), + CheckboxInput( + "always-on", + "Keep device running at all times", + infotext="Prevents shutdown of the device when idle. Useful for devices with unreliable startup.", + ), + CheckboxInput( + "services", + "Run background services on this device", + ), + ExponentialInput( + "lfo_offset", + "Oscillator offset", + "Hz", + infotext="Use this when the actual receiving frequency differs from the frequency to be tuned on the" + + " device.
    Formula: Center frequency + oscillator offset = sdr tune frequency", + ), + WaterfallLevelsInput("waterfall_levels", "Waterfall levels"), + SchedulerInput("scheduler", "Scheduler"), + ExponentialInput("center_freq", "Center frequency", "Hz"), + ExponentialInput("samp_rate", "Sample rate", "S/s"), + ExponentialInput("start_freq", "Initial frequency", "Hz"), + ModesInput("start_mod", "Initial modulation"), + NumberInput("initial_squelch_level", "Initial squelch level", append="dBFS"), + ] + + def hasAgc(self): + # default is True since most devices have agc. override in subclasses if agc is not available + return True + + def getDeviceMandatoryKeys(self): + return ["name", "enabled"] + + def getDeviceOptionalKeys(self): + return [ + "ppm", + "always-on", + "services", + "rf_gain", + "lfo_offset", + "waterfall_levels", + "scheduler", + ] + + def getProfileMandatoryKeys(self): + return ["name", "center_freq", "samp_rate", "start_freq", "start_mod"] + + def getProfileOptionalKeys(self): + return ["initial_squelch_level", "rf_gain", "lfo_offset", "waterfall_levels"] + + def getDeviceSection(self): + return OptionalSection( + "Device settings", self.getDeviceInputs(), self.getDeviceMandatoryKeys(), self.getDeviceOptionalKeys() + ) + + def getProfileSection(self): + return OptionalSection( + "Profile settings", + self.getProfileInputs(), + self.getProfileMandatoryKeys(), + self.getProfileOptionalKeys(), + ) diff --git a/openwebrx/owrx/source/__pycache__/__init__.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54676dbf555e3fdced0e66ce8f0c924c0c88dc71 GIT binary patch literal 19625 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 FzX2nl&!_+Z literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/airspy.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/airspy.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d707324f7ffe521223f5d274f5bf590e742e5f37 GIT binary patch literal 2149 zcma)7&u<$=6rR~xuh-6oCRLlXNRUNpB`k;|$`v8<14R{yi7-WIskB<}Ox#uX7c=8J zw#q4ydg4Fe&>s0~xbKw{{0W?RZ`Q_fLjr5f?3)?So9}(|y|;U-t8D_$Z+||Gb{d5I zj)VDQgYgA4^%OczI88}L1DaWZMR9DUEVBbUYXl85W~q}kgQl_V)XiE!i;_da8{D}d z+!6Gg1#K8NxeH@gSTJ7Utv>0r|9}%n$Exo1i>RFJ7kMsXUC4ehlCh|^mS+#dizF5Y zLd7yEbyDQ8vGF)BN4i?we_5}&-SHvbk1JE`qZ#R*uoYe4QI`d$`8#pBBQN0hHCgU|PYY3jq017s8}$fsYQW8fsJuS=}27ZHVX# zt&@DHj-#@~cjb&q9%%u4Z6(sv%FSjgKtL-D<1|t#41XnmJ=iHq4Nc@@F_33F#TYte zNZyG|NZoQGJvi3I7>qP%o7$E|+id#qa@5`H(ELLO(t>RtU7?mZ25t2!r#?!q!(4~T zci?60y1Na-ss-K;Wb#5t94cg%(CNn80P4SVoXQQuJjz5ER_!p%3O-8F_QLSTQIysz zCMx%ldbxt&^)z9sp+0fb~m_R(us@Wh#`PCzRM$T$#zHFp zNq^5DL^&TPTtD;UB3Gl#6o!xC|FIO2f}Q&(PyDoqI6!xk{It;GOm}R#0+5Z|OodR= zfw9Cao+)##2^4EBjQ7BL7n;I6A=ITdwajn&!40g{?w8m?rVLxC@p-L4o@NZOp2Q5S z%YaoQy^D!q>fhHU!YKVpOw_elu?jteHow>H8xUS~yMz{%VbEfVEYJ?AxlOCa+L^)J z0GKDTI89RVzi@7W&krx*?B0O$U;HisMUeaG3(2cxH)M6WWknK49M+?%tFX zs}R6pl;nLK4TbvX60}(rypoof8jBsB6?qpn$qnof2E`99o zVP}LkD~*e4bRU+`mkG+HF56h~n8zf}_c~s%j-|X?mvVT)M^xg4NhG7XWfo5hnROHV zOx6fccnWzwK>^aMXJHEOJf=C}pI+A;;({j9FKpJT;0AlK*`^{B_zZv72# C)b{%T literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/airspyhf.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/airspyhf.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba4fef459036791b983f8e65cbfb49780d10c3b8 GIT binary patch literal 822 zcmb7C%}T>S5Z>J+HdQMMf_k!EgdnsRFM^0aMLqS>TM$Cbt{P~wanc{#t9==t#`|79 z=_7b@CNY(wA`Z;#Om-&o%{O~mt1U8==eMiyfHU?&K`Aym$Eevp8p$N@vL5$&Pxykf z3nqoMrc7FjKU%(pu`L~p9VIZfe{HE@ z9I2MdB8by|tdoeUOA+WCqh{-96!W=czK~psDf2C9VK>{XoWzi&KY!JdW0s+u{X=YMmjdtTh(tt zJXGLN147F9WOE)&W0urLq;3!-VNV5tSqy@nmV++&%Rz8I2)o4%R7)UVkieyHGy!Se z$>n+cZ?3jVc;9LNi?+!|QCq(}->Yk=x8f|)IQ=*W>T#&dBPY;hjRCqZpHM}6sN?IsxdyZM7y)}xx;eihkXhmR=As&G;fBL0y Q4~yL#5heM{S+!Q34@K*>)&Kwi literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/connector.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/connector.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d4e6f954fef307e42e712bc645c22f32b008590 GIT binary patch literal 4018 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 LT9|Lrw%X1I!YQl} literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/fifi_sdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/fifi_sdr.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eeea0dab2b85fe94bcbd9f2a4e656197fd69ecdd GIT binary patch literal 2201 zcmZ`)PjA~c6elTJlK*vC+NE8K4l1^`@CGN?fbFuS=#n(sp>vBG*g+U3s3PqNktLUu zKie0-#y9)JAty~n*~vqs?g>))TE-3lRp z<6!aFpge}Aeg?q_rzsiGfKt?1$_7?o4eY=+V=Ju;oWL=kXDy~12ZD-` z%4-)SaCx0Kp!Z%An*>$B+SJX%q?dHLEV0FOz6|z2BEe z**OJiol|cA!CDjColf9Ju-{zr32PrflfZ$)%}#YyMT21|qe4u!+J!HpvET0b z-Os-YpFZ*1@fUu(<>-nU4TaPjeNpV^gF%$>AEF`7xJndzkrKKN3eF{2Ur7c4!Lca1 zMN}j)aBnM-_MoqZVVp)vh2fv%zXv<{uz)7Av3Mze-O0xwlsS~09?%dfF1Lp#662_j z8}~pER$~smK_?%rFtxo7ZG}1nWT@K!WkDUMeR`7sL98QPm~Syzg-$oY)sth96`g1x zREx@M_>s!@pp)o8zK>!P#0_W)ofA5_a|O?(lPh5M@kiVahD)CT2&*&MjA6rmoM*>E z7NLS8=uNJfxqg&nVXxFa>|FqeY}&Iny72=<$}&K%&qD*)H6V`B-w zV16Px19K0Q!T{wE&r^CzdF2k7(Y}37&)6xOvKgTSdTU0`tW%3SN0xk4*bsp3sWoGm zxv)X6qdl3&M?;zSCzW>lIL;vy$CGNi-IL2+@{p z4pmo|zO)OtVxg2~GG1r)10^#40^?1zixZ^NR>)Hr&Zdgh&cyvp%g|62wFvW-j| zmeJcOnG)cTLXRxd4}s-5d#s~t#V8XpM97f8Z{*^SQ92Sz-USa3!x*jFIWFf+bX^;cx{g2Y5%0+(N)tf!1S4o&{36g3FD6iLyR`T?4j} zXTU8~l;ULHSlnL$xR&ed#;uz6!Z3q$4MSZE!$HnRDe8?dd^L*F@{6e>CT-<)@NRHP zBtm`yLO187DHdrOhL->rnQxNb0ZTk`)d1nqMzvvimgjo5#4jhNiGcEN%#M zJCMNQ7G#92Z_SCna_cN(1Tz7fi4i<7jwPLSCf;FknE2g;7Y6A&N`ea|0jt<&@Z+Kk z`0rX1ODGz5AUlwL`2fX36nNuYGE!=Wj~0BukH)!2C63`1YhWqg#yo_p!dwTzrkN_M z`!+ZCue8uDZc5kG=;b^C%Vc&jVE`Qn4viDzm!DsxqM3 zxmVWn1{D8xmZ;x^=xqBzrkWJpcLPE+LtTeOkce^;S#Qada>}heFJhc=dyho+TVj*Q z0eFt~?UmmrkM;rgE@fN{MevDh9Wc0mw2pr`2TpAW}3J_*U!z%cB^j55bsr%r4Q)VxO9D=DZhlIW!eG8zyC2+MD$7NE8 zIM#zWE-F6HQTOBc`8>(n6BCj=i{6Kee54}J5I-)Oas2$(5p+%kl^WERG>J`s&EtSi59K z*9b&H!+^;?oSCYcPfke(>_zRntRo%T)L#PaJj@Q*wI#q>f8Pg$b?g9fx`1Pa5{VsH zEvECxG}9{{nKqH+_r;Dn5!#RN=!HYjZY+Uz-0lB|IRcoEWi`!m@t<&Fqd}})3dc)u ze#h?-pjce<5o1$8dUG~oP*^+E>_NDp*~Y`in*F}Dv5as-2w*G8%1M*Vgt~kP+M=x- z<2lmq;E7;G{s0fjb10A;63Za5JxTlyC6Y#B$P#0hS5b^m7+x)U&B5or21jRLD%0tx z$41PDOX7YQtmre?7sqX1gav*$5{WJ`8Tzz2oK~_JXZTAo2=wTi-7z=3GF;bgvxAMg f1&n>gyy*a3tC{BK-E2G9E$p5K-zxR-VylBMOkK3+ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/hpsdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/hpsdr.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a89cbc99f8456409a85f2b698d58c90563b3d938 GIT binary patch literal 2745 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 E51bdaMF0Q* literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/resampler.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/resampler.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..815bfab94d8565afec2202c3f375628a4b7d6e67 GIT binary patch literal 1772 zcmZ`)UvC>l5Z~Rq^PL?#1PBy2LL5-3vJgrCJb+Lse}Ytys8!Lo6VmE(yLK-+-?_W% zq;+fwDUtfr?_fXjIrsp)?<-H`Bk;t``d<>nopwAsJ99Jho0;|ZtyY7;^XK2crbhuG zf8l2J_`rM%OWy+`h@b@-(1cPVnD7dgFv(_~^yf4Q9ueV-;DU%i(sM7V3GXTC)ZS1q z>`>eMAy+apPpgs29naQ^s^6E&hNr12^RjPi zy-E#I(_z-vvgp}xY4AHyi1$CY52~SoCCgKJq+T3Ur$DsB=RmE(-ygoTJ5~8)>GaU~ ztYZo;FFL-tj&}dLTDHOz)5e8+{Tq-8n~^!0dJ|93HkkknZSnzu9cz<4G9jEAX7CjD z=M>JJ95}-Lg>*uW{XH`Eg*WjxntNmyt%He=HV?oL>}n=FC$mU^pE+B??04-}CQBm~ z?N!+9rA5u|PdETC5JAs>O_!a+0d@pTN)u653{BGsC6S1h#! z>gooNCM>-JgwTlIgm;_1Pa{^N<99ZdSozGb`SHQu8Z6g*UjifOF(7pe?Kg)}dJWy} z_R!w6P2tIf#`Y|yYfZkJj7`AK~D-WU6K;Jq84l^Gva%1j~faLJE9 z%pkeg;l@)TdZ{)%%i=R90ZoNTjK6kTyvht*&J!v!&6g%*J0h&a@31GJVJ zSOD!VE4D+M)Ti5Qyt{#Yqu9MN_Sp9h`E6hpV}E(zKU;qx7M0X-S(zA`2N8|6GzNwl zr@ogLQbWc#NeYVzyz8jO@{s4^dhBndnaNL}t)a`%J_a+5cPgO`I&QBW?_blq(m2>W zE_^g}w-oI@*gu$s*S9{sr46ypR9@ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/rtl_sdr.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/rtl_sdr.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a61dfc88e999d7225d0c4bb54104fc1afc7903d2 GIT binary patch literal 1917 zcma)7&2Aev5GJ`lT1hKQX==Mkn^q~3gASHopeO>{NKrIZ3(KcPfyH`B2@C9xN>Yuj zs|@6nH^{+0_TKmCo!6e)N9d_DT*mZ%dU>ZONA znFK%Mq?qMdIkl51f>=M!N>N{cB1l9ziP#%5p`3Dd5k$@#;*!V(@Qw+Obw1`R$SGZY ziEj@Eo}`|E&}2z2=@q$ToL8+ zr$8arLYOYM@ME>YKk$vG<})EpZz}X*RTN3de@tdL(@;VmBubbzC^!dvmBfTggg1b& zDUna$-HhWjPn3$|-^stv_p6zPA@Hg(wwy9>;PUTv#!n@>A4z zP_#h=Fcd?DT6-gx$Q2*;62v-}HjgB7ge$y9ggfu3&92M6i!k!7j)faU4Xb0(22o(M z-h@20%x=x{PF-#k02aX^x<(8f6U;`7t>cqdd*h?yKDRZ!X7muY+)%XKOar1ff>7a_ zCdk&}*PyIxQ9~_q#vSgyb+of2+Fg>VvrFnq^lN~N z2Q|rl#zm>KNhajt$!baonI(C@oEN7;_AA-XN-oX-LXee{O2Y*lc(Mx~O%veRd!{62 zOJdbX>`0JIq_@tgO|ngFA7)WeTObGxD6>DGxpeXLo*>j-HppZ6EqN@PHTjzQimp47 zU&5N~pg@NMR^#3V+%?&{&m{Q|;#_?c^i%j0$2wbk_W-?n>;`BAY(akl%5Bh7W;5iB z?xNiRv_H#glI7xm!Np>Db_?$C9=IPmw%Bv7WkM?K?qv!Nm F{{iSI#>oHx literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/rtl_sdr_soapy.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/rtl_sdr_soapy.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04d4b1f4f2dd41131fd7485a365ea132b3d568a9 GIT binary patch literal 1924 zcmbtVOK%iM5bmDW&Msrw=3yX&-~&Poh_wV)gd$iaib6J#m(vKTHQsJpBhBm#)x8E= zaIz)-L=JGwugSfyIVHy&b4pduPHe{pM@mcGUEMuf^;OkZwNICp1`Nlqf4t29^cnk& z4&4Y)_!_5viHl^Cm#pF$ue{9TboNTW3bMe-ei>F#7FE5h=gvVHSN*Kd*|$uFGCE{3 zQvATr26*nt7|*ft@O(k`CoCQOjuu$z*~^nUZ}y(mvzeNj8YcA|rpn$rc{@_O#Z--y zoqMoJPhTqMaxj_xd(<0X|lyYZ#X;c7en&eee7PFo9BW{D|g*H>N*$wpF4SgFo z#WF5g=DlazTyp6h#+fgD862`Kc+Ucsg@`${tDB~rNCb(HTclIIP~+hq;v}{DD_jga zU?2EL_Kv^hA3Rbj{hz&$oU^wal?7vrgo7$HVzs?3^uj2G4lN(MCm(D~Z-O3u8|rzZ zz+T%?riG!5!Ef{rdDGBtqj@89LwW+O%5580ix-SpUx;a$Yc0et?63P9bz^X<*=w~0 zZ#L@JxM&yu4KSt95`-pChs_==p+_;f7U9D9fUofVd#9o?{1mEx!bIVs`3$Efx=9L# zH?|R)M2m0{?@|uSxH-99F5%JkQTqsrUFFbdia6u@H_k-SewRiz7GjoHN(eg;qN?S* zBsme{)jTiT7pD%ckcp*5fTpS}g*XC$h*t3(2nSFcdN>{am?xpTIYBP+jE4ROmyTSH zSS<5Zpu+16*}xs$0vR4AS>)tM_K=CQx#CqB^aI8E#-wXvq`I$r0&n mWcH}Zws6U;Gk1iqm0YumAGgis33WS7Oj+RxcFn|J8T<`jX~>BH literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/rtl_tcp.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/rtl_tcp.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d940ea0070e28410f02b72bbbfaa7cc7a6c1a3e7 GIT binary patch literal 1609 zcmaJ>&2Aev5a!SBY9-5&@@F?`+PH@T*+b-e5Cm;(v?!XWfSuE#0I^=u#sd2nl1gG< zWgst+gMI94^aXO~HK+Cwdg=^Uo7Aoo3Y;C1;>BpW|Uh41^3{d(O6|2qCm`$p}?r} z5WYhp@-j0OjBQZeD{hz9!o%#IyETPr<|xrtz#|BNp+9M)c&H z|0Zg78Cep#Scp2Sln`ztMAgVeiTYTGmy4|IPP}oqzCx9&vJ^t!M&KwCs&Apbfdak9 zFbzY6`t^k(W6(IvBM^O&eSt~JRiMHYcA@JF!0@)G=?zeNnp(CbM|6__Ogc}vp1_$Kf+62DMq_3n z-3YDEx-WxI{kO1-oznyR?hC*=dshQOCiQ?MUBO{`4$}r;>JtAPSf0Tvar~<~ zHlM|IAMWDpvCY1K?JB!Ny$QGVS12&~8UxkHcRfIXv1@do@1nSxn_t5bS}@pdF^%~K z*SI%NChj)2)vRl)*~bS!W0f?P$&LLV@~*8fwXU1^!^1{bvjSf`56W#^+MRWD^Hp?_ mJ;qm{ivhdFL-~Zf)3C5Ee%!U?6C|@4gP77=bc{bfo&F0BN_`vv literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/runds.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/runds.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..63790267d20984848e1a9997767e79246fc20fcc GIT binary patch literal 2529 zcmai0OOM+&5GF-QmS1ZpX*Q20h0zpk)MlL^MQ=rsWb;7LtQ}7Cb}+Mm!ae?5y{yM+9K zO7+;#xdTo83W5_(6Oz(^rdD84)UAZ2c3`JY;G}NgnlYBtQZMk*dQeXrK_hJjO*3vM zt+XAqP2Wk@(oWE!NY_b>Ic;EwM>p!-*Ien`T^<$B60TLwrL>2pUq?K@?<8-qSbLh9~F6q|XPMj79VCOA@4qPQ0+p_|abv2iw1(g92oNg@#@_{3Hsc@FP-7{*DY zR2cq7{=RlIpA^tUwkvkz>zny52xTI6(}3(x_T)J*P=}@r8?A=rGMCzPcIB93`X^!e zj`qSZi&7DWx)FwH&Zi0XTVeRiG)l@DV_UZukYbS}VJI&mY7{<|7qEW`MH_?%O|7$SsELG06K)*JT5E`S&Na$h&8-o;fShm!@(gSn zconH+2h`cspOol*eS2ePT=Z0wP7={m@yK@_kpHg;lx^XiS|}vI)Ax(3y}) ztTqdlRfXUv)Hh)AD`+Ov1c+?|wP(E#pSav(<)lbYhBmq&s*`d$qUKcAK;pP>l{nV{ zXBpfrD7_I}YiJjSoXR#q0`B#AtA$!;WxrYh)Rutn|O}dIk6V z5^h=9qU1l(_QCQu$I#yRKeQ`W8z<6OehH>z3q=hD21}wtB<>@xqA;OEQN`9|unz-Y zKwD(6$JT2tCUJD&pVQ~@dg_Y=O4>YOfdie literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/sddc.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/sddc.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03d3318bb253f77378119b8b37656534f75b4acf GIT binary patch literal 1020 zcmaJ<&2AGh5VqI5%{DY`0QCYzia2#INf9W@0ilvq*t?iab`xER9gl@-*CiL%V6fk&8J*2o;#=L}}YRRpp?_vLu&plL@}m zj4JzyQ4zJiQRxYs>zs=;F^2PR*0J`UdA8r%77vp^D?Dcvr>4u@Tn~sl>t!R#a9h`3qa+TN`a5V&Cu(mmsPgVj#R?+g# zRICDa_`iH>9}@HWR5X^-?W|5$84jaBqp{vZv4!FRiiao`&8alkn)~p9g*vK28ZwP9 o5XNrf7`3Z0YA@zgW2-byw(Fv^UKQAeFRQV6g*ChAP1X$l09GmC_W%F@ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/sdrplay.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/sdrplay.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..376e514284c2996c028a71c9067cfb8039cc5671 GIT binary patch literal 2711 zcmb_eOK%%D5GMC6X%)Lh;y8UEYTBe#kjjaX7Ci)Qn#fL#)=FUYL=;%8mz1MsA1Zg{ z*g|@;f&7Ual4E~O?|bd3{RusFhFnEf{OF}CEIC7RhaczhQ4gBUI)Uepzkd!RmykbE znLZW>_uilR|>0K@Q1l`iA^QU(nYE8bo90y;t@dO34cf=G-2u?;y$` zDrC%jIY_f2daAW14|is2=?(`$l5xQrV!x1LpcVlj4-Eu`5Tz#vf%>4V!KYf34FJTJ z;1eO!Fb?2DAw?RAwprTwcqC-GvMUM|g1#t(nv~s(*EJ za%0Z3owHEAbve2o--qz#nB^Q@YV=CC+EQME!8Vmk&?#9r%b=DuFyEEKJ)y>O3?HGB z%O@b{HEpN#f*=hO5d>vDz&MO!l>H$1bsWZeMx9Ttp!sIyok+xS5X?3QG#6oBA_j>y z3)QN@d(>~Lk+}>-T=#Lw@eJh@GSgrdH<`^X=ID}SE_dPlUfJ9nY(btpR>hM~Tqb1p z#pVWc?tXdu+4mFX-uY@x3v0LUehnk1d(?q#Uyxr2*rYuOxV;y~V{wK%-5|&d83b1m zYvs&Kb2vKHieB53S74QVADa)LVHY+xwjOua1Gc{XH~^saN(a2GQOSt;t%A5EZZ&i4 zEVS|?n7|!!^sh%P{WH|~oO3Z}@X347Ov_)DKADxiu!VC4Snm~8#NcrE$Y-vSJzfKn z@RVHV4UlV!DC*!czg($u=>(sh?(Sm=Jh|ELZa)EzX>rAct>6-3v&XFMjYr$e+JyEz1Whpd zC>*B!BHR`EXUA|%i~WRm5XbBj$U8@hVl2}+TyL577MPqeZDAae!bFsZ2K2zT&@09* z>_c}9caaC-lg^!e^s~@9s6W$yNgHTLVq(V)JQ(~N4J1<^(*E!GN z<|bN2hM9V1dizv9uzS*nS>*_IV0B5f4Sk$T5C9z&%^+d?gE+{~JjQjfbPqSIs=jj` z;FEU$XYk7a{)x;Wm=*v12l^X;{M%#bSIJb&GoU^iB8;-orU2)goP? LKK0GTrN#Qc!w78? literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/soapy.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/soapy.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..87f455f2b40fca9ac139a870b067ff86ed3495dd GIT binary patch literal 4884 zcmb7I&2!tv702R>AEG2$m6g~{9n^{Aa3-ehG>M&#tkjlT=c7?QRhrIll3`))ib4zm z)GlaA4B3l3lU{u2JqPiazovIwdusoK`qbZB5R@oUwM)$|-YyoqZ{Pd9FFlx_FB|y% zTl?{!C7c@Sc7%UhRJAbhE~UJ*qXLNr{gx>K% zN{y1Hov_@QYs_^jjfx(-;e2PIv7qTfxY(&Ss?2z3@FFigGk8h3g7NZmyKw ztt6IPaZk2HdU-OjA)W>;u_07T2HA3!+P@D}g3p}KqHZrqOJ9loM3dOG^w1BYd@#Qi z``v>rkt9LXRxM&F|5;-~g&>5{VBBb!KN#DLGj2XBHY{Oti`&nPL)LKgyu)40yS%`Q z&x}Tamv|Y^Vs`mPiO=%|jLUlMBClfYoTe}ECD4^04BKdsXnvM1Yqlm=%}aZFGroXdJPTab$8fVn6u7yE>ccqN3Y2Te&Rz<_#Z>BvtUE zL_`s^kgZW;k<^^jQN6B^X|*kq^|;gVBmSk|rI`X2J@A!C=RnXL$xzy02x7LC2)9Yx z&1Ng~m1;JBGG4rOGwvp6BH9y=<^Iih51rB)#RI(6y9e?DHkhNU710@H*>o+J^{>qm zN&PfWQ!*kstNCI|Ojk*DUDO0j7s+z_^8L z7$YNKZFa|?83@$w|EKJYf5}duyv%kzKiu;Vl;Q`KPyFDQf-t)KsN;nYZ#gVtS<0sw(w%(qvyq*%gK7Dpq3X&vF%lA8m zy0GhtBL2>Onz=Wq-BJyPrE1V^ypfyrlS6&o*3Yxi*9CX1Wm+JV;iRK9? zB)Tm$^*Xw?F)+TZ4&gBd>`6h|1NL`w*Cza>Zg%?&hGuHkZ>8+%DImTmuM!Meai_ah zqdn;W*XXa$ROjPf5OVz{^MsFl35F8^R#(Hg<%enwM82PfHjqPX)+}uXsS8a&nyLHL z4|_tL;wHU({t1K=Me)UO7T6N&-~2y0oQRN2b;?|Wmzt~mIlj4pmZSZ~%)`$!rIt2t z;(XjR@IS>MFox#-S_XFVsvE|)^>56fHM9p7^y$i_0UKDC;8C8GK*Q^1;}IK}1AEoj zUHY>zFtxXVgo(@4319~*y#xruyG+~m*Ccef1MQKn(&}RFd{g#BzKNOB8d*BlNbVtS zVVTl70JGT&TSn_&pDB>D-%dn9A|XGZMUlC%0ZZ7SDOUzeE^$~q$HlbeTbrq!$eu`@ zZ9q&Qgp>nVmrDEy;n{v1$6?LP@a1S+ow|g)hJ)@9*BU>p%qpy3pULsLQk%esbV%C} z^XjkYa^DLZgrkDa>TqCeth0Jz9zl~d99MAO73`=;s5k^Qw3E`nK4h{#fR%G+H4~1z2}9SuTxvh@1_EXo&TJADFo+(h+#5j^` z@XdAN55z+;b+4sRF0wj&Z%**A33-vdzYGm8 zN~>)ViT$ozdqjtjdD}++?o3R?(Ibh(OeP2GJATv_CxS=5Nv5lUc4=Ch>buOimon2Q0`Wn3@XiDNMogb&R zEfI#z=COavuR%$rte_*mtd`IfS!JnWSFB>GXcY@Zr!ueK!@x@j*}hJcTUa;wQCdMi z^*eNV5h74}z?HEdnJym)fR%9xVsnQ|5Iv7V1T=~fs>d2dU0##`q;mS^Gy-N-P_7h7 zPev28QgEhxT-y)bKKHN*&`CDH?dMivanwNMNk?Q~&dA_}=QdV=g+sG#>D;%xDeL&Y ze<3^83pjE%@&&U0iRNo-c;C1H&zaPB2Yh_o!Q=u0S1`pd?61Qi-H{>VxDb zL}}EG3tU|qY6RZLtFO>>JzZog)rwWIB+YK(Uk#T3xFty$+V`auD#`08J*dvtwS0O} zPi0j)-i-ebBPn)C3ahD$;s~~>IZPMzYD!n?0sZqOW0}k+T{-ot&UK2EnN@jw3aUVz iTxiFrxjepBM$x6x;%er`*GMfILsi6uyj85_&;JWVKDJ8$ literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/soapy_remote.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/soapy_remote.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58474753925ab27bac35cee25487ee6cc01338cc GIT binary patch literal 2512 zcmb7G&2Jnv6u0MVXTQ=-X;ab`x`Oht71~yCsDz@Zq5*_}EowMaqoUDd?4+yA$JU-r zlhtxcq$URr+&R!Ae+_qEIl-UM6YtqG+1;kJbXK;XJ>%#1@q0h-7wvX~!1L!n52LO} z$X__A9tVUw(BvcN7-2LaDGg|91s27*mDs5hIH?=BdTu9PS_^7gb`n3W2X!sGNh56r zO-jBd%wx49!fKoz+CdBEKC8pL&Mla?S!0v*nt#J-q-UuMn|U-kxSMAgkBeMv=3^0a zb!~BVgYOMvzQJWIhNEJbXRy)zCL4_lRsWIi7h3F5)x6Ksyx`Sz`JVnbNY-Tg(hy;#abD0Ng?o;BBzynWeYFQg= zjyCqkv{Z>Bs>deJLsyVP@;iM7VP@8jb%}rid!Rg_?N)6whD;2>UUX89M_j1(4llmk z<5@9?QZCU6$&+mu>tPrtk(6Qh2YG(uW`S(6^!eYQeNR z_sW^r;ko9!xJnF;c$ALfLl8_9r*vvPvrfn?RmWt59MThVOqe~jn7vAdbcfz1Cxkgu zYu6JGVAWX#Snt-w1Nh1{^XjW(%!fBPbryqz2fqbPz75?DnUbG6KLbLJ$!<+JQ~Im* z*nv%S&VNf;{acFeL4LeZB%)N#!tlJYmLUG}bhM6P(Q)v|kHr)5`|_KcFfO zAeQ~LB#)y+u7gl1Gv_x!<)lQ>=M{oOBld4p zBgkyJF>OM&SYE_OBON)jp|JgYzPKD@jNO(I7T>QjeW*8dNT8IyZSjPu}SjC3{>u7E-d9i01b61et^j-FSl(7izyMu4}0TvosbJp1Fu=)|S zDv_VP&cK%_e=}<_ca^mlPpz%)(N`M#0c^c7XY8l{&)5as4RIMXh%4A(mc%ve5Q73s zR%cIOR0N(T-oXw7Usc462Hu5LWNe9>ed^mQ&6X{2bHVgDKL}j zoPN2Ti?lz){}|?IgqB));Z>jMY-;eS)fZo$!c{|3dBwqKnC%#Mkdm}HGy7*KRsNb` S#C0SJ&9xocp)LH`ozB0|8Crk< literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/__pycache__/uhd.cpython-37.pyc b/openwebrx/owrx/source/__pycache__/uhd.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04ae29b6731c2ff31014d3f337c31aa5eda6ea07 GIT binary patch literal 795 zcmb7C%}T>S5Z<4pKiW#go3u5Ud2q9)y8%UG7Nvid#FXPjA->WBm z1W(RvNTn!<3o|>jGrRN6H+#}dhiQtkxagT#<2^)M{GVm=?8xg1e4mXLzba#{nv&CteBr?oXHA)K=O1-;!I+XJ$ zltY3*Eex`A|i3scd`} zp%MDHwXEJ>#$75WI17d>I7WMDc})mL#NWL<~uU1%78R>jTEZwQbk zOazh?CaZN+EmRw*mNO|W!$%teNLIxM`mST^O&n9d920Et+Z2vPaa2g4!pz^E)Q!V( OC`U+$`6C_PvA+P{VyXiG literal 0 HcmV?d00001 diff --git a/openwebrx/owrx/source/airspy.py b/openwebrx/owrx/source/airspy.py new file mode 100644 index 0000000..53e7f13 --- /dev/null +++ b/openwebrx/owrx/source/airspy.py @@ -0,0 +1,44 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input, CheckboxInput +from owrx.form.input.device import BiasTeeInput +from typing import List + + +class AirspySource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update( + { + "bias_tee": "biastee", + "bitpack": "bitpack", + } + ) + return mappings + + def getDriver(self): + return "airspy" + + +class AirspyDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Airspy R2 or Mini" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + BiasTeeInput(), + CheckboxInput( + "bitpack", + "Enable bit-packing", + infotext="Packs two 12-bit samples into 3 bytes." + + " Lowers USB bandwidth consumption, increases CPU load", + ), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "bitpack"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee"] + + def getGainStages(self): + return ["LNA", "MIX", "VGA"] diff --git a/openwebrx/owrx/source/airspyhf.py b/openwebrx/owrx/source/airspyhf.py new file mode 100644 index 0000000..82cc03b --- /dev/null +++ b/openwebrx/owrx/source/airspyhf.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class AirspyhfSource(SoapyConnectorSource): + def getDriver(self): + return "airspyhf" + + +class AirspyhfDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Airspy HF+ or Discovery" diff --git a/openwebrx/owrx/source/connector.py b/openwebrx/owrx/source/connector.py new file mode 100644 index 0000000..58c535d --- /dev/null +++ b/openwebrx/owrx/source/connector.py @@ -0,0 +1,100 @@ +from owrx.source import SdrSource, SdrDeviceDescription +from owrx.socket import getAvailablePort +from owrx.property import PropertyDeleted +import socket +from owrx.command import Flag, Option +from typing import List +from owrx.form.input import Input, NumberInput, CheckboxInput + +import logging + +logger = logging.getLogger(__name__) + + +class ConnectorSource(SdrSource): + def __init__(self, id, props): + self.controlSocket = None + self.controlPort = getAvailablePort() + super().__init__(id, props) + + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setMappings( + { + "samp_rate": Option("-s"), + "tuner_freq": Option("-f"), + "port": Option("-p"), + "controlPort": Option("-c"), + "device": Option("-d"), + "iqswap": Flag("-i"), + "rtltcp_compat": Option("-r"), + "ppm": Option("-P"), + "rf_gain": Option("-g"), + } + ) + ) + + def sendControlMessage(self, changes): + for prop, value in changes.items(): + if value is PropertyDeleted: + value = None + logger.debug("sending property change over control socket: {0} changed to {1}".format(prop, value)) + self.controlSocket.sendall("{prop}:{value}\n".format(prop=prop, value=value).encode()) + + def onPropertyChange(self, changes): + if self.monitor is None: + return + if ( + ("center_freq" in changes or "lfo_offset" in changes) + and "lfo_offset" in self.sdrProps + and self.sdrProps["lfo_offset"] is not None + ): + changes["center_freq"] = self.sdrProps["center_freq"] + self.sdrProps["lfo_offset"] + changes.pop("lfo_offset", None) + self.sendControlMessage(changes) + + def postStart(self): + logger.debug("opening control socket...") + self.controlSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.controlSocket.connect(("localhost", self.controlPort)) + + def stop(self): + super().stop() + if self.controlSocket: + self.controlSocket.close() + self.controlSocket = None + + def getControlPort(self): + return self.controlPort + + def getCommandValues(self): + values = super().getCommandValues() + values["port"] = self.getPort() + values["controlPort"] = self.getControlPort() + return values + + +class ConnectorDeviceDescription(SdrDeviceDescription): + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + NumberInput( + "rtltcp_compat", + "Port for rtl_tcp compatible data", + infotext="Activate an rtl_tcp compatible interface on the port number specified.
    " + + "Note: Port is only available on the local machine, not on the network.
    " + + "Note: IQ data may be degraded by the downsampling process to 8 bits.", + ), + CheckboxInput( + "iqswap", + "Swap I and Q channels", + infotext="Swapping inverts the spectrum, so this is useful in combination with an inverting mixer", + ), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["rtltcp_compat", "iqswap"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["iqswap"] diff --git a/openwebrx/owrx/source/direct.py b/openwebrx/owrx/source/direct.py new file mode 100644 index 0000000..d11d83b --- /dev/null +++ b/openwebrx/owrx/source/direct.py @@ -0,0 +1,53 @@ +from abc import ABCMeta +from owrx.source import SdrSource, SdrDeviceDescription + +import logging + +logger = logging.getLogger(__name__) + + +class DirectSource(SdrSource, metaclass=ABCMeta): + def onPropertyChange(self, changes): + logger.debug("restarting sdr source due to property changes: {0}".format(changes)) + self.stop() + self.sleepOnRestart() + self.start() + + def nmux_memory(self): + # in megabytes. This sets the approximate size of the circular buffer used by nmux. + return 50 + + def getNmuxCommand(self): + props = self.sdrProps + + nmux_bufcnt = nmux_bufsize = 0 + while nmux_bufsize < props["samp_rate"] / 4: + nmux_bufsize += 4096 + while nmux_bufsize * nmux_bufcnt < self.nmux_memory() * 1e6: + nmux_bufcnt += 1 + if nmux_bufcnt == 0 or nmux_bufsize == 0: + raise ValueError("Error: unable to calculate nmux buffer parameters.") + + return [ + "nmux --bufsize %d --bufcnt %d --port %d --address 127.0.0.1" + % ( + nmux_bufsize, + nmux_bufcnt, + self.port, + ) + ] + + def getCommand(self): + return super().getCommand() + self.getFormatConversion() + self.getNmuxCommand() + + # override this in subclasses, if necessary + def getFormatConversion(self): + return [] + + # override this in subclasses, if necessary + def sleepOnRestart(self): + pass + + +class DirectSourceDeviceDescription(SdrDeviceDescription): + pass diff --git a/openwebrx/owrx/source/fcdpp.py b/openwebrx/owrx/source/fcdpp.py new file mode 100644 index 0000000..8f9b7af --- /dev/null +++ b/openwebrx/owrx/source/fcdpp.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class FcdppSource(SoapyConnectorSource): + def getDriver(self): + return "fcdpp" + + +class FcdppDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "FunCube Dongle Pro+" diff --git a/openwebrx/owrx/source/fifi_sdr.py b/openwebrx/owrx/source/fifi_sdr.py new file mode 100644 index 0000000..cf6e3e7 --- /dev/null +++ b/openwebrx/owrx/source/fifi_sdr.py @@ -0,0 +1,44 @@ +from owrx.command import Option +from owrx.source.direct import DirectSource, DirectSourceDeviceDescription +from subprocess import Popen + +import logging + +logger = logging.getLogger(__name__) + + +class FifiSdrSource(DirectSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("arecord") + .setMappings({"device": Option("-D"), "samp_rate": Option("-r")}) + .setStatic("-t raw -f S16_LE -c2 -") + ) + + def getEventNames(self): + return super().getEventNames() + ["device"] + + def getFormatConversion(self): + return ["csdr convert_s16_f", "csdr gain_ff 5"] + + def sendRockProgFrequency(self, frequency): + process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1e6)]) + process.communicate() + rc = process.wait() + if rc != 0: + logger.warning("rockprog failed to set frequency; rc=%i", rc) + + def preStart(self): + values = self.getCommandValues() + self.sendRockProgFrequency(values["tuner_freq"]) + + def onPropertyChange(self, changes): + if "center_freq" in changes: + self.sendRockProgFrequency(changes["center_freq"]) + + +class FifiSdrDeviceDescription(DirectSourceDeviceDescription): + def getName(self): + return "FiFi SDR" diff --git a/openwebrx/owrx/source/hackrf.py b/openwebrx/owrx/source/hackrf.py new file mode 100644 index 0000000..bd16a3d --- /dev/null +++ b/openwebrx/owrx/source/hackrf.py @@ -0,0 +1,31 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input +from owrx.form.input.device import BiasTeeInput +from typing import List + + +class HackrfSource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update({"bias_tee": "bias_tx"}) + return mappings + + def getDriver(self): + return "hackrf" + + +class HackrfDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "HackRF" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [BiasTeeInput()] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee"] + + def getGainStages(self): + return ["LNA", "AMP", "VGA"] diff --git a/openwebrx/owrx/source/hpsdr.py b/openwebrx/owrx/source/hpsdr.py new file mode 100644 index 0000000..71cb691 --- /dev/null +++ b/openwebrx/owrx/source/hpsdr.py @@ -0,0 +1,62 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Option +from owrx.form.error import ValidationError +from owrx.form.input import Input, NumberInput, TextInput +from owrx.form.input.validator import RangeValidator +from typing import List + +# In order to use an HPSDR radio, you must install hpsdrconnector from https://github.com/jancona/hpsdrconnector +# These are the command line options available: +# --frequency uint +# Tune to specified frequency in Hz (default 7100000) +# --gain uint +# LNA gain between 0 (-12dB) and 60 (48dB) (default 20) +# --radio string +# IP address of radio (default use first radio discovered) +# --samplerate uint +# Use the specified samplerate: one of 48000, 96000, 192000, 384000 (default 96000) +# --debug +# Emit debug log messages on stdout +# +# If you omit `remote` from config_webrx.py, hpsdrconnector will use the HPSDR discovery protocol +# to find radios on your local network and will connect to the first radio it discovered. + + +class HpsdrSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("hpsdrconnector") + .setMappings( + { + "tuner_freq": Option("--frequency"), + "samp_rate": Option("--samplerate"), + "remote": Option("--radio"), + "rf_gain": Option("--gain"), + } + ) + ) + +class RemoteInput(TextInput): + def __init__(self): + super().__init__( + "remote", "Remote IP", infotext="Remote IP address to connect to." + ) + +class HpsdrDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "HPSDR devices (Hermes / Hermes Lite 2 / Red Pitaya)" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + RemoteInput(), + NumberInput("rf_gain", "LNA Gain", "LNA gain between 0 (-12dB) and 60 (48dB)", validator=RangeValidator(0, 60)), + ] + + def getDeviceOptionalKeys(self): + return list(filter(lambda x : x not in ["rtltcp_compat", "iqswap"], super().getDeviceOptionalKeys())) + ["remote"] + + def getProfileOptionalKeys(self): + return list(filter(lambda x : x != "iqswap", super().getProfileOptionalKeys())) + diff --git a/openwebrx/owrx/source/lime_sdr.py b/openwebrx/owrx/source/lime_sdr.py new file mode 100644 index 0000000..f7e6ba4 --- /dev/null +++ b/openwebrx/owrx/source/lime_sdr.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class LimeSdrSource(SoapyConnectorSource): + def getDriver(self): + return "lime" + + +class LimeSdrDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "LimeSDR device" diff --git a/openwebrx/owrx/source/perseussdr.py b/openwebrx/owrx/source/perseussdr.py new file mode 100644 index 0000000..ff5b0c5 --- /dev/null +++ b/openwebrx/owrx/source/perseussdr.py @@ -0,0 +1,79 @@ +from owrx.source.direct import DirectSource, DirectSourceDeviceDescription +from owrx.command import Option, Flag +from owrx.form.input import Input, DropdownEnum, DropdownInput, CheckboxInput +from typing import List + + +# +# In order to interface Perseus hardware, we resolve to use the +# perseustest utility that comes with libperseus-sdr support package. +# Below the base options used are shown: +# +# -p output I/Q samples as 32 bits floating point +# -d -1 suppress debug messages +# -a don't test attenuators on startup +# -t 0 runs indefinitely +# -o - output samples on stdout +# +# As we are already returning I/Q samples as pairs of 32 bits +# floating points (option -p),no need for further conversions, +# so the method getFormatConversion(self) is not implemented at all. + + +class PerseussdrSource(DirectSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("perseustest -p -d -1 -a -t 0 -o - ") + .setMappings( + { + "samp_rate": Option("-s"), + "tuner_freq": Option("-f"), + "attenuator": Option("-u"), + "adc_preamp": Flag("-m"), + "adc_dither": Flag("-x"), + "wideband": Flag("-w"), + } + ) + ) + + +class AttenuatorOptions(DropdownEnum): + ATTENUATOR_0 = 0 + ATTENUATOR_10 = -10 + ATTENUATOR_20 = -20 + ATTENUATOR_30 = -30 + + def __str__(self): + return "{value} dB".format(value=self.value) + + +class PerseussdrDeviceDescription(DirectSourceDeviceDescription): + def getName(self): + return "Perseus SDR" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + DropdownInput("attenuator", "Attenuator", options=AttenuatorOptions), + CheckboxInput("adc_preamp", "Activate ADC preamp"), + CheckboxInput("adc_dither", "Enable ADC dithering"), + CheckboxInput("wideband", "Disable analog filters"), + ] + + def getDeviceOptionalKeys(self): + # no rf_gain + return [key for key in super().getDeviceOptionalKeys() if key != "rf_gain"] + [ + "attenuator", + "adc_preamp", + "adc_dither", + "wideband", + ] + + def getProfileOptionalKeys(self): + return [key for key in super().getProfileOptionalKeys() if key != "rf_gain"] + [ + "attenuator", + "adc_preamp", + "adc_dither", + "wideband", + ] diff --git a/openwebrx/owrx/source/pluto_sdr.py b/openwebrx/owrx/source/pluto_sdr.py new file mode 100644 index 0000000..593b5c7 --- /dev/null +++ b/openwebrx/owrx/source/pluto_sdr.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class PlutoSdrSource(SoapyConnectorSource): + def getDriver(self): + return "plutosdr" + + +class PlutoSdrDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "PlutoSDR" diff --git a/openwebrx/owrx/source/radioberry.py b/openwebrx/owrx/source/radioberry.py new file mode 100644 index 0000000..932a5e4 --- /dev/null +++ b/openwebrx/owrx/source/radioberry.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class RadioberrySource(SoapyConnectorSource): + def getDriver(self): + return "radioberry" + + +class RadioberryDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "RadioBerry" diff --git a/openwebrx/owrx/source/resampler.py b/openwebrx/owrx/source/resampler.py new file mode 100644 index 0000000..03c2097 --- /dev/null +++ b/openwebrx/owrx/source/resampler.py @@ -0,0 +1,38 @@ +from .direct import DirectSource + +import logging + +logger = logging.getLogger(__name__) + + +class Resampler(DirectSource): + def onPropertyChange(self, changes): + logger.warning("Resampler is unable to handle property changes: {0}".format(changes)) + + def __init__(self, props, sdr): + sdrProps = sdr.getProps() + self.shift = (sdrProps["center_freq"] - props["center_freq"]) / sdrProps["samp_rate"] + self.decimation = int(float(sdrProps["samp_rate"]) / props["samp_rate"]) + if_samp_rate = sdrProps["samp_rate"] / self.decimation + self.transition_bw = 0.15 * (if_samp_rate / float(sdrProps["samp_rate"])) + props["samp_rate"] = if_samp_rate + + self.sdr = sdr + super().__init__(None, props) + + def getCommand(self): + return [ + "nc -v 127.0.0.1 {nc_port}".format(nc_port=self.sdr.getPort()), + "csdr shift_addfast_cc {shift}".format(shift=self.shift), + "csdr fir_decimate_cc {decimation} {ddc_transition_bw} HAMMING".format( + decimation=self.decimation, ddc_transition_bw=self.transition_bw + ), + ] + self.getNmuxCommand() + + def activateProfile(self, profile_id=None): + logger.warning("Resampler does not support setting profiles") + pass + + def validateProfiles(self): + # resampler does not support profiles + pass diff --git a/openwebrx/owrx/source/rtl_sdr.py b/openwebrx/owrx/source/rtl_sdr.py new file mode 100644 index 0000000..cb9e954 --- /dev/null +++ b/openwebrx/owrx/source/rtl_sdr.py @@ -0,0 +1,37 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Flag, Option +from typing import List +from owrx.form.input import Input, TextInput +from owrx.form.input.device import BiasTeeInput, DirectSamplingInput + + +class RtlSdrSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("rtl_connector") + .setMappings({"bias_tee": Flag("-b"), "direct_sampling": Option("-e")}) + ) + + +class RtlSdrDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "RTL-SDR device" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + TextInput( + "device", + "Device identifier", + infotext="Device serial number or index", + ), + BiasTeeInput(), + DirectSamplingInput(), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["device", "bias_tee", "direct_sampling"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/openwebrx/owrx/source/rtl_sdr_soapy.py b/openwebrx/owrx/source/rtl_sdr_soapy.py new file mode 100644 index 0000000..a308c7d --- /dev/null +++ b/openwebrx/owrx/source/rtl_sdr_soapy.py @@ -0,0 +1,28 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input +from owrx.form.input.device import BiasTeeInput, DirectSamplingInput +from typing import List + + +class RtlSdrSoapySource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update({"direct_sampling": "direct_samp", "bias_tee": "biastee"}) + return mappings + + def getDriver(self): + return "rtlsdr" + + +class RtlSdrSoapyDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "RTL-SDR device (via SoapySDR)" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [BiasTeeInput(), DirectSamplingInput()] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "direct_sampling"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee", "direct_sampling"] diff --git a/openwebrx/owrx/source/rtl_tcp.py b/openwebrx/owrx/source/rtl_tcp.py new file mode 100644 index 0000000..6c3f7d2 --- /dev/null +++ b/openwebrx/owrx/source/rtl_tcp.py @@ -0,0 +1,32 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Flag, Option, Argument +from owrx.form.input import Input +from owrx.form.input.device import RemoteInput +from typing import List + + +class RtlTcpSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("rtl_tcp_connector") + .setMappings( + { + "bias_tee": Flag("-b"), + "direct_sampling": Option("-e"), + "remote": Argument(), + } + ) + ) + + +class RtlTcpDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "RTL-SDR device (via rtl_tcp)" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [RemoteInput()] + + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["remote"] diff --git a/openwebrx/owrx/source/runds.py b/openwebrx/owrx/source/runds.py new file mode 100644 index 0000000..9d4e9b9 --- /dev/null +++ b/openwebrx/owrx/source/runds.py @@ -0,0 +1,54 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from owrx.command import Argument, Flag, Option +from owrx.form.input import Input, DropdownInput, DropdownEnum, CheckboxInput +from owrx.form.input.device import RemoteInput +from typing import List + + +class RundsSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("runds_connector") + .setMappings( + { + "long": Flag("-l"), + "remote": Argument(), + "protocol": Option("-m"), + } + ) + ) + + +class ProtocolOptions(DropdownEnum): + PROTOCOL_EB200 = ("eb200", "EB200 protocol") + PROTOCOL_AMMOS = ("ammos", "Ammos protocol") + + def __new__(cls, *args, **kwargs): + value, description = args + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + + def __str__(self): + return self.description + + +class RundsDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "R&S device using EB200 or Ammos protocol" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + RemoteInput(), + DropdownInput("protocol", "Protocol", ProtocolOptions), + CheckboxInput("long", "Use 32-bit sample size (LONG)"), + ] + + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["remote"] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["protocol", "long"] diff --git a/openwebrx/owrx/source/sddc.py b/openwebrx/owrx/source/sddc.py new file mode 100644 index 0000000..5c3255f --- /dev/null +++ b/openwebrx/owrx/source/sddc.py @@ -0,0 +1,14 @@ +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription + + +class SddcSource(ConnectorSource): + def getCommandMapper(self): + return super().getCommandMapper().setBase("sddc_connector") + + +class SddcDeviceDescription(ConnectorDeviceDescription): + def getName(self): + return "BBRF103 / RX666 / RX888 device (libsddc)" + + def hasAgc(self): + return False diff --git a/openwebrx/owrx/source/sdrplay.py b/openwebrx/owrx/source/sdrplay.py new file mode 100644 index 0000000..454e472 --- /dev/null +++ b/openwebrx/owrx/source/sdrplay.py @@ -0,0 +1,64 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input, CheckboxInput, DropdownInput, DropdownEnum +from owrx.form.input.device import BiasTeeInput +from typing import List + + +class SdrplaySource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update( + { + "bias_tee": "biasT_ctrl", + "rf_notch": "rfnotch_ctrl", + "dab_notch": "dabnotch_ctrl", + "if_mode": "if_mode", + "external_reference": "extref_ctrl", + } + ) + return mappings + + def getDriver(self): + return "sdrplay" + + +class IfModeOptions(DropdownEnum): + IFMODE_ZERO_IF = "Zero-IF" + IFMODE_450 = "450kHz" + IFMODE_1620 = "1620kHz" + IFMODE_2048 = "2048kHz" + + def __str__(self): + return self.value + + +class SdrplayDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "SDRPlay device (RSP1, RSP2, RSPDuo, RSPDx)" + + def getGainStages(self): + return ["RFGR", "IFGR"] + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + BiasTeeInput(), + CheckboxInput( + "rf_notch", + "Enable RF notch filter", + ), + CheckboxInput( + "dab_notch", + "Enable DAB notch filter", + ), + DropdownInput( + "if_mode", + "IF Mode", + IfModeOptions, + ), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["bias_tee", "rf_notch", "dab_notch", "if_mode"] diff --git a/openwebrx/owrx/source/soapy.py b/openwebrx/owrx/source/soapy.py new file mode 100644 index 0000000..745343c --- /dev/null +++ b/openwebrx/owrx/source/soapy.py @@ -0,0 +1,108 @@ +from abc import ABCMeta, abstractmethod +from owrx.command import Option +from owrx.source.connector import ConnectorSource, ConnectorDeviceDescription +from typing import List +from owrx.form.input import Input, TextInput +from owrx.form.input.device import GainInput +from owrx.soapy import SoapySettings + + +class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("soapy_connector") + .setMappings( + { + "antenna": Option("-a"), + "soapy_settings": Option("-t"), + } + ) + ) + + """ + must be implemented by child classes to be able to build a driver-based device selector by default. + return value must be the corresponding soapy driver identifier. + """ + + @abstractmethod + def getDriver(self): + pass + + def getEventNames(self): + return super().getEventNames() + list(self.getSoapySettingsMappings().keys()) + + def buildSoapyDeviceParameters(self, parsed, values): + """ + this method always attempts to inject a driver= part into the soapysdr query, depending on what connector was used. + this prevents the soapy_connector from using the wrong device in scenarios where there's no same-type sdrs. + """ + parsed = [v for v in parsed if "driver" not in v] + parsed += [{"driver": self.getDriver()}] + return parsed + + def getSoapySettingsMappings(self): + return {} + + def buildSoapySettings(self, values): + settings = {} + for k, v in self.getSoapySettingsMappings().items(): + if k in values and values[k] is not None: + settings[v] = self.convertSoapySettingsValue(values[k]) + return settings + + def convertSoapySettingsValue(self, value): + if isinstance(value, bool): + return "true" if value else "false" + return value + + def getCommandValues(self): + values = super().getCommandValues() + if "device" in values and values["device"] is not None: + parsed = SoapySettings.parse(values["device"]) + else: + parsed = [] + modified = self.buildSoapyDeviceParameters(parsed, values) + values["device"] = SoapySettings.encode(modified) + settings = ",".join(["{0}={1}".format(k, v) for k, v in self.buildSoapySettings(values).items()]) + if len(settings): + values["soapy_settings"] = settings + return values + + def onPropertyChange(self, changes): + mappings = self.getSoapySettingsMappings() + settings = {} + for prop, value in changes.items(): + if prop in mappings.keys(): + settings[mappings[prop]] = self.convertSoapySettingsValue(value) + if settings: + changes["settings"] = ",".join("{0}={1}".format(k, v) for k, v in settings.items()) + super().onPropertyChange(changes) + + +class SoapyConnectorDeviceDescription(ConnectorDeviceDescription): + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + TextInput( + "device", + "Device identifier", + infotext='SoapySDR device identifier string (example: "serial=123456789")', + ), + GainInput( + "rf_gain", + "Device Gain", + gain_stages=self.getGainStages(), + has_agc=self.hasAgc(), + ), + TextInput("antenna", "Antenna"), + ] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["device", "rf_gain", "antenna"] + + def getProfileOptionalKeys(self): + return super().getProfileOptionalKeys() + ["antenna"] + + def getGainStages(self): + return None diff --git a/openwebrx/owrx/source/soapy_remote.py b/openwebrx/owrx/source/soapy_remote.py new file mode 100644 index 0000000..efbe5c6 --- /dev/null +++ b/openwebrx/owrx/source/soapy_remote.py @@ -0,0 +1,43 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription +from owrx.form.input import Input, TextInput +from owrx.form.input.device import RemoteInput +from owrx.form.input.converter import OptionalConverter +from typing import List + + +class SoapyRemoteSource(SoapyConnectorSource): + def getEventNames(self): + return super().getEventNames() + ["remote", "remote_driver"] + + def getDriver(self): + return "remote" + + def buildSoapyDeviceParameters(self, parsed, values): + params = super().buildSoapyDeviceParameters(parsed, values) + params = [v for v in params if not "remote" in params] + params += [{"remote": values["remote"]}] + if "remote_driver" in values and values["remote_driver"] is not None: + params += [{"remote:driver": values["remote_driver"]}] + return params + + +class SoapyRemoteDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Device connected to a SoapyRemote server" + + def getInputs(self) -> List[Input]: + return super().getInputs() + [ + RemoteInput(), + TextInput( + "remote_driver", + "Remote driver", + infotext="SoapySDR driver to be used on the remote SoapySDRServer", + converter=OptionalConverter(), + ), + ] + + def getDeviceMandatoryKeys(self): + return super().getDeviceMandatoryKeys() + ["remote"] + + def getDeviceOptionalKeys(self): + return super().getDeviceOptionalKeys() + ["remote_driver"] diff --git a/openwebrx/owrx/source/uhd.py b/openwebrx/owrx/source/uhd.py new file mode 100644 index 0000000..56f66b6 --- /dev/null +++ b/openwebrx/owrx/source/uhd.py @@ -0,0 +1,11 @@ +from owrx.source.soapy import SoapyConnectorSource, SoapyConnectorDeviceDescription + + +class UhdSource(SoapyConnectorSource): + def getDriver(self): + return "uhd" + + +class UhdDeviceDescription(SoapyConnectorDeviceDescription): + def getName(self): + return "Ettus Research USRP device" diff --git a/openwebrx/owrx/users.py b/openwebrx/owrx/users.py new file mode 100644 index 0000000..be103b1 --- /dev/null +++ b/openwebrx/owrx/users.py @@ -0,0 +1,237 @@ +from abc import ABC, abstractmethod +from owrx.config.core import CoreConfig +from datetime import datetime, timezone +import json +import hashlib +import os +import stat + +import logging + +logger = logging.getLogger(__name__) + + +class PasswordException(Exception): + pass + + +class Password(ABC): + @staticmethod + def from_dict(d: dict): + if "encoding" not in d: + raise PasswordException("password encoding not set") + if d["encoding"] == "string": + return CleartextPassword(d) + elif d["encoding"] == "hash": + return HashedPassword(d) + raise PasswordException("invalid passord encoding: {0}".format(d["type"])) + + @abstractmethod + def is_valid(self, inp: str) -> bool: + pass + + @abstractmethod + def toJson(self) -> dict: + pass + + +class CleartextPassword(Password): + def __init__(self, pwinfo): + if isinstance(pwinfo, str): + self._value = pwinfo + elif isinstance(pwinfo, dict): + self._value = pwinfo["value"] + else: + raise ValueError("invalid argument to ClearTextPassword()") + + def is_valid(self, inp: str) -> bool: + return self._value == inp + + def toJson(self) -> dict: + return { + "encoding": "string", + "value": self._value + } + + +class HashedPassword(Password): + def __init__(self, pwinfo, algorithm="sha256"): + self.iterations = 100000 + if isinstance(pwinfo, str): + self._createFromString(pwinfo, algorithm) + else: + self._loadFromDict(pwinfo) + + def _createFromString(self, pw: str, algorithm: str): + self._algorithm = algorithm + self._salt = os.urandom(32) + dk = hashlib.pbkdf2_hmac(self._algorithm, pw.encode(), self._salt, self.iterations) + self._hash = dk.hex() + pass + + def _loadFromDict(self, d: dict): + self._hash = d["value"] + self._algorithm = d["algorithm"] + self._salt = bytes.fromhex(d["salt"]) + pass + + def is_valid(self, inp: str) -> bool: + dk = hashlib.pbkdf2_hmac(self._algorithm, inp.encode(), self._salt, self.iterations) + return dk.hex() == self._hash + + def toJson(self) -> dict: + return { + "encoding": "hash", + "value": self._hash, + "algorithm": self._algorithm, + "salt": self._salt.hex(), + } + + +DefaultPasswordClass = HashedPassword + + +class User(object): + def __init__(self, name: str, enabled: bool, password: Password, must_change_password: bool = False): + self.name = name + self.enabled = enabled + self.password = password + self.must_change_password = must_change_password + + def toJson(self): + return { + "user": self.name, + "enabled": self.enabled, + "must_change_password": self.must_change_password, + "password": self.password.toJson() + } + + @staticmethod + def fromJson(d): + if "user" in d and "password" in d and "enabled" in d: + mcp = d["must_change_password"] if "must_change_password" in d else False + return User(d["user"], d["enabled"], Password.from_dict(d["password"]), mcp) + + def setPassword(self, password: Password, must_change_password: bool = None): + self.password = password + if must_change_password is not None: + self.must_change_password = must_change_password + + def is_enabled(self): + return self.enabled + + def enable(self): + self.enabled = True + + def disable(self): + self.enabled = False + + +class UserList(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if UserList.sharedInstance is None: + UserList.sharedInstance = UserList() + return UserList.sharedInstance + + def __init__(self): + self.file_modified = None + self.users = {} + + def refresh(self): + if self.file_modified is None or self._getUsersFileModifiedTimestamp() > self.file_modified: + logger.debug("reloading users from disk due to file modification") + self.users = self._loadUsers() + + def _getUsersFile(self): + config = CoreConfig() + return "{data_directory}/users.json".format(data_directory=config.get_data_directory()) + + def _getUsersFileModifiedTimestamp(self): + timestamp = 0 + try: + timestamp = os.path.getmtime(self._getUsersFile()) + except FileNotFoundError: + pass + return datetime.fromtimestamp(timestamp, timezone.utc) + + def _loadUsers(self): + usersFile = self._getUsersFile() + # to avoid concurrency issues and problems when parsing errors occur: + # get early, store late + modified = self._getUsersFileModifiedTimestamp() + try: + with open(usersFile, "r") as f: + users_json = json.load(f) + + users = {u.name: u for u in [User.fromJson(d) for d in users_json]} + self.file_modified = modified + return users + except FileNotFoundError: + self.file_modified = modified + return {} + except json.JSONDecodeError: + logger.exception("error while parsing users file %s", usersFile) + return {} + except Exception: + logger.exception("error while processing users from %s", usersFile) + return {} + + def _userToJson(self, u): + return u.toJson() + + def store(self): + usersFile = self._getUsersFile() + users = [u.toJson() for u in self.values()] + try: + # don't write directly to file to avoid corruption on exceptions + jsonContent = json.dumps(users, indent=4) + with open(usersFile, "w") as f: + f.write(jsonContent) + # file should be readable by us only + os.chmod(usersFile, stat.S_IWUSR + stat.S_IRUSR) + except Exception: + logger.exception("error while writing users file %s", usersFile) + self.refresh() + + def _getUsername(self, user): + if isinstance(user, User): + return user.name + elif isinstance(user, str): + return user + else: + raise ValueError("invalid user type") + + def addUser(self, user: User): + self[user.name] = user + + def deleteUser(self, user): + del self[self._getUsername(user)] + + def __delitem__(self, key): + self.refresh() + if key not in self.users: + raise KeyError("User {user} doesn't exist".format(user=key)) + del self.users[key] + self.store() + + def __getitem__(self, item): + self.refresh() + return self.users[item] + + def __contains__(self, item): + self.refresh() + return item in self.users + + def __setitem__(self, key, value): + self.refresh() + if key in self.users: + raise KeyError("User {user} already exists".format(user=key)) + self.users[key] = value + self.store() + + def values(self): + self.refresh() + return self.users.values() diff --git a/openwebrx/owrx/version.py b/openwebrx/owrx/version.py new file mode 100644 index 0000000..13c9183 --- /dev/null +++ b/openwebrx/owrx/version.py @@ -0,0 +1,5 @@ +from distutils.version import LooseVersion + +_versionstring = "1.1.0" +looseversion = LooseVersion(_versionstring) +openwebrx_version = "v{0}".format(looseversion) diff --git a/openwebrx/owrx/waterfall.py b/openwebrx/owrx/waterfall.py new file mode 100644 index 0000000..c88231a --- /dev/null +++ b/openwebrx/owrx/waterfall.py @@ -0,0 +1,326 @@ +from owrx.form.input import DropdownEnum +from owrx.config import Config + + +class Waterfall(object): + def __init__(self, colors): + self.colors = colors + + def getColors(self): + return self.colors + + +class GoogleTurboWaterfall(Waterfall): + def __init__(self): + super().__init__( + [ + 0x30123B, + 0x311542, + 0x33184A, + 0x341B51, + 0x351E58, + 0x36215F, + 0x372466, + 0x38266C, + 0x392973, + 0x3A2C79, + 0x3B2F80, + 0x3C3286, + 0x3D358B, + 0x3E3891, + 0x3E3A97, + 0x3F3D9C, + 0x4040A2, + 0x4043A7, + 0x4146AC, + 0x4248B1, + 0x424BB6, + 0x434EBA, + 0x4351BF, + 0x4453C3, + 0x4456C7, + 0x4559CB, + 0x455BCF, + 0x455ED3, + 0x4561D7, + 0x4663DA, + 0x4666DD, + 0x4669E1, + 0x466BE4, + 0x466EE7, + 0x4671E9, + 0x4673EC, + 0x4676EE, + 0x4678F1, + 0x467BF3, + 0x467DF5, + 0x4680F7, + 0x4682F9, + 0x4685FA, + 0x4587FC, + 0x458AFD, + 0x448CFE, + 0x448FFE, + 0x4391FF, + 0x4294FF, + 0x4196FF, + 0x3F99FF, + 0x3E9BFF, + 0x3D9EFE, + 0x3BA1FD, + 0x3AA3FD, + 0x38A6FB, + 0x36A8FA, + 0x35ABF9, + 0x33ADF7, + 0x31B0F6, + 0x2FB2F4, + 0x2DB5F2, + 0x2CB7F0, + 0x2AB9EE, + 0x28BCEC, + 0x26BEEA, + 0x25C0E7, + 0x23C3E5, + 0x21C5E2, + 0x20C7E0, + 0x1FC9DD, + 0x1DCCDB, + 0x1CCED8, + 0x1BD0D5, + 0x1AD2D3, + 0x19D4D0, + 0x18D6CD, + 0x18D8CB, + 0x18DAC8, + 0x17DBC5, + 0x17DDC3, + 0x17DFC0, + 0x18E0BE, + 0x18E2BB, + 0x19E3B9, + 0x1AE5B7, + 0x1BE6B4, + 0x1DE8B2, + 0x1EE9AF, + 0x20EAAD, + 0x22ECAA, + 0x24EDA7, + 0x27EEA4, + 0x29EFA1, + 0x2CF09E, + 0x2FF19B, + 0x32F298, + 0x35F394, + 0x38F491, + 0x3CF58E, + 0x3FF68B, + 0x43F787, + 0x46F884, + 0x4AF980, + 0x4EFA7D, + 0x51FA79, + 0x55FB76, + 0x59FC73, + 0x5DFC6F, + 0x61FD6C, + 0x65FD69, + 0x69FE65, + 0x6DFE62, + 0x71FE5F, + 0x75FF5C, + 0x79FF59, + 0x7DFF56, + 0x80FF53, + 0x84FF50, + 0x88FF4E, + 0x8BFF4B, + 0x8FFF49, + 0x92FF46, + 0x96FF44, + 0x99FF42, + 0x9CFE40, + 0x9FFE3E, + 0xA2FD3D, + 0xA4FD3B, + 0xA7FC3A, + 0xAAFC39, + 0xACFB38, + 0xAFFA37, + 0xB1F936, + 0xB4F835, + 0xB7F835, + 0xB9F634, + 0xBCF534, + 0xBFF434, + 0xC1F334, + 0xC4F233, + 0xC6F033, + 0xC9EF34, + 0xCBEE34, + 0xCEEC34, + 0xD0EB34, + 0xD2E934, + 0xD5E835, + 0xD7E635, + 0xD9E435, + 0xDBE236, + 0xDDE136, + 0xE0DF37, + 0xE2DD37, + 0xE4DB38, + 0xE6D938, + 0xE7D738, + 0xE9D539, + 0xEBD339, + 0xEDD139, + 0xEECF3A, + 0xF0CD3A, + 0xF1CB3A, + 0xF3C93A, + 0xF4C73A, + 0xF5C53A, + 0xF7C33A, + 0xF8C13A, + 0xF9BF39, + 0xFABD39, + 0xFABA38, + 0xFBB838, + 0xFCB637, + 0xFCB436, + 0xFDB135, + 0xFDAF35, + 0xFEAC34, + 0xFEA933, + 0xFEA732, + 0xFEA431, + 0xFFA12F, + 0xFF9E2E, + 0xFF9C2D, + 0xFF992C, + 0xFE962B, + 0xFE932A, + 0xFE9028, + 0xFE8D27, + 0xFD8A26, + 0xFD8724, + 0xFC8423, + 0xFC8122, + 0xFB7E20, + 0xFB7B1F, + 0xFA781E, + 0xF9751C, + 0xF8721B, + 0xF86F1A, + 0xF76C19, + 0xF66917, + 0xF56616, + 0xF46315, + 0xF36014, + 0xF25D13, + 0xF05B11, + 0xEF5810, + 0xEE550F, + 0xED530E, + 0xEB500E, + 0xEA4E0D, + 0xE94B0C, + 0xE7490B, + 0xE6470A, + 0xE4450A, + 0xE34209, + 0xE14009, + 0xDF3E08, + 0xDE3C07, + 0xDC3A07, + 0xDA3806, + 0xD83606, + 0xD63405, + 0xD43205, + 0xD23105, + 0xD02F04, + 0xCE2D04, + 0xCC2B03, + 0xCA2903, + 0xC82803, + 0xC62602, + 0xC32402, + 0xC12302, + 0xBF2102, + 0xBC1F01, + 0xBA1E01, + 0xB71C01, + 0xB41B01, + 0xB21901, + 0xAF1801, + 0xAC1601, + 0xAA1501, + 0xA71401, + 0xA41201, + 0xA11101, + 0x9E1001, + 0x9B0F01, + 0x980D01, + 0x950C01, + 0x920B01, + 0x8E0A01, + 0x8B0901, + 0x880801, + 0x850701, + 0x810602, + 0x7E0502, + 0x7A0402, + ] + ) + + +class TeejeezWaterfall(Waterfall): + def __init__(self): + super().__init__([0x000000, 0x0000FF, 0x00FFFF, 0x00FF00, 0xFFFF00, 0xFF0000, 0xFF00FF, 0xFFFFFF]) + + +class Ha7ilmWaterfall(Waterfall): + def __init__(self): + super().__init__([0x000000, 0x2E6893, 0x69A5D0, 0x214B69, 0x9DC4E0, 0xFFF775, 0xFF8A8A, 0xB20000]) + + +class CustomWaterfall(Waterfall): + def __init__(self): + config = Config.get() + if "waterfall_colors" in config and config["waterfall_colors"]: + colors = config["waterfall_colors"] + else: + # fallback: black and white + colors = [0x000000, 0xffffff] + super().__init__(colors) + + +class WaterfallOptions(DropdownEnum): + DEFAULT = ("Google Turbo (OpenWebRX default)", GoogleTurboWaterfall) + TEEJEEZ = ("Original colorscheme by teejeez (default in OpenWebRX < 0.20)", TeejeezWaterfall) + HA7ILM = ("Old theme by HA7ILM", Ha7ilmWaterfall) + CUSTOM = ("Custom", CustomWaterfall) + + def __new__(cls, *args, **kwargs): + description, waterfallClass = args + obj = object.__new__(cls) + obj._value_ = waterfallClass.__name__ + obj.waterfallClass = waterfallClass + obj.description = description + return obj + + def __str__(self): + return self.description + + def instantiate(self): + return self.waterfallClass() + + @staticmethod + def findByColors(colors): + for o in WaterfallOptions: + if o is WaterfallOptions.CUSTOM: + continue + waterfall = o.instantiate() + if waterfall.getColors() == colors: + return o + return WaterfallOptions.CUSTOM diff --git a/openwebrx/owrx/websocket.py b/openwebrx/owrx/websocket.py new file mode 100644 index 0000000..7aa3b86 --- /dev/null +++ b/openwebrx/owrx/websocket.py @@ -0,0 +1,290 @@ +from owrx.jsons import Encoder +import base64 +import hashlib +import json +from multiprocessing import Pipe +import select +import threading +from abc import ABC, abstractmethod + +import logging + +logger = logging.getLogger(__name__) + +OPCODE_TEXT_MESSAGE = 0x01 +OPCODE_BINARY_MESSAGE = 0x02 +OPCODE_CLOSE = 0x08 +OPCODE_PING = 0x09 +OPCODE_PONG = 0x0A + + +class WebSocketException(IOError): + pass + + +class IncompleteRead(WebSocketException): + pass + + +class Drained(WebSocketException): + pass + + +class WebSocketClosed(WebSocketException): + pass + + +class Handler(ABC): + @abstractmethod + def handleTextMessage(self, connection, message: str): + pass + + @abstractmethod + def handleBinaryMessage(self, connection, data: bytes): + pass + + @abstractmethod + def handleClose(self): + pass + + +class WebSocketConnection(object): + connections = [] + + @staticmethod + def closeAll(): + for c in WebSocketConnection.connections: + try: + c.close() + except: + logger.exception("exception while shutting down websocket connections") + + def __init__(self, handler, messageHandler: Handler): + self.handler = handler + self.handler.connection.setblocking(0) + self.messageHandler = None + self.setMessageHandler(messageHandler) + (self.interruptPipeRecv, self.interruptPipeSend) = Pipe(duplex=False) + self.open = True + self.sendLock = threading.Lock() + + headers = {key.lower(): value for key, value in self.handler.headers.items()} + if "upgrade" not in headers: + raise WebSocketException("Upgrade header not found") + if headers["upgrade"].lower() != "websocket": + raise WebSocketException("Upgrade header does not contain expected value") + if "sec-websocket-key" not in headers: + raise WebSocketException("Websocket key not provided") + + ws_key = headers["sec-websocket-key"] + shakey = hashlib.sha1() + shakey.update("{ws_key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".format(ws_key=ws_key).encode()) + ws_key_toreturn = base64.b64encode(shakey.digest()) + self.handler.wfile.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\nCQ-CQ-de: HA5KFU\r\n\r\n".format( + ws_key_toreturn.decode() + ).encode() + ) + self.pingTimer = None + self.resetPing() + + def setMessageHandler(self, messageHandler: Handler): + self.messageHandler = messageHandler + + def get_header(self, size, opcode): + ws_first_byte = 0b10000000 | (opcode & 0x0F) + if size > 2 ** 16 - 1: + # frame size can be increased up to 2^64 by setting the size to 127 + # anything beyond that would need to be segmented into frames. i don't really think we'll need more. + return bytes( + [ + ws_first_byte, + 127, + (size >> 56) & 0xFF, + (size >> 48) & 0xFF, + (size >> 40) & 0xFF, + (size >> 32) & 0xFF, + (size >> 24) & 0xFF, + (size >> 16) & 0xFF, + (size >> 8) & 0xFF, + size & 0xFF, + ] + ) + elif size > 125: + # up to 2^16 can be sent using the extended payload size field by putting the size to 126 + return bytes([ws_first_byte, 126, (size >> 8) & 0xFF, size & 0xFF]) + else: + # 125 bytes binary message in a single unmasked frame + return bytes([ws_first_byte, size]) + + def send(self, data): + if not self.open: + raise WebSocketClosed() + # convenience + if type(data) == dict: + # allow_nan = False disallows NaN and Infinty to be encoded. Browser JSON will not parse them anyway. + data = json.dumps(data, allow_nan=False, cls=Encoder) + + # string-type messages are sent as text frames + if type(data) == str: + header = self.get_header(len(data), OPCODE_TEXT_MESSAGE) + data_to_send = header + data.encode("utf-8") + # anything else as binary + else: + header = self.get_header(len(data), OPCODE_BINARY_MESSAGE) + data_to_send = header + data + + self._sendBytes(data_to_send) + + def _sendBytes(self, data_to_send): + def chunks(input, n): + """Yield successive n-sized chunks from input.""" + for i in range(0, len(input), n): + yield input[i: i + n] + + try: + with self.sendLock: + for chunk in chunks(data_to_send, 1024): + (_, write, _) = select.select([], [self.handler.wfile], [], 10) + if self.handler.wfile in write: + written = self.handler.wfile.write(chunk) + if written != len(chunk): + logger.error("incomplete write! closing socket!") + self.close() + break + else: + logger.debug("socket not returned from select; closing") + self.close() + break + # these exception happen when the socket is closed + except OSError: + logger.exception("OSError while writing data") + self.close() + except ValueError: + logger.exception("ValueError while writing data") + self.close() + + def interrupt(self): + if self.interruptPipeSend is None: + logger.debug("interrupt with closed pipe") + return + self.interruptPipeSend.send(bytes(0x00)) + + def handle(self): + WebSocketConnection.connections.append(self) + try: + self.read_loop() + finally: + logger.debug("websocket loop ended; shutting down") + + self.messageHandler.handleClose() + self.cancelPing() + + logger.debug("websocket loop ended; sending close frame") + + header = self.get_header(0, OPCODE_CLOSE) + self._sendBytes(header) + + try: + WebSocketConnection.connections.remove(self) + except ValueError: + pass + + def read_loop(self): + def protected_read(num): + data = self.handler.rfile.read(num) + if data is None: + raise Drained() + if len(data) != num: + raise IncompleteRead() + return data + + self.open = True + while self.open: + (read, _, _) = select.select([self.interruptPipeRecv, self.handler.rfile], [], [], 15) + if self.handler.rfile in read: + available = True + self.resetPing() + while self.open and available: + try: + header = protected_read(2) + opcode = header[0] & 0x0F + length = header[1] & 0x7F + mask = (header[1] & 0x80) >> 7 + if length == 126: + header = protected_read(2) + length = (header[0] << 8) + header[1] + if mask: + masking_key = protected_read(4) + data = protected_read(length) + data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)]) + else: + data = protected_read(length) + if opcode == OPCODE_TEXT_MESSAGE: + message = data.decode("utf-8") + try: + self.messageHandler.handleTextMessage(self, message) + except Exception: + logger.exception("Exception in websocket handler handleTextMessage()") + elif opcode == OPCODE_BINARY_MESSAGE: + try: + self.messageHandler.handleBinaryMessage(self, data) + except Exception: + logger.exception("Exception in websocket handler handleBinaryMessage()") + elif opcode == OPCODE_PING: + self.sendPong() + elif opcode == OPCODE_PONG: + # since every read resets the ping timer, there's nothing to do here. + pass + elif opcode == OPCODE_CLOSE: + logger.debug("websocket close frame received; closing connection") + self.open = False + else: + logger.warning("unsupported opcode: {0}".format(opcode)) + except Drained: + available = False + except IncompleteRead: + logger.warning("incomplete read on websocket; closing connection") + self.open = False + except OSError: + logger.exception("OSError while reading data; closing connection") + self.open = False + + self.interruptPipeSend.close() + self.interruptPipeSend = None + # drain messages left in the queue so that the queue can be successfully closed + # this is necessary since python keeps the file descriptors open otherwise + try: + while True: + self.interruptPipeRecv.recv() + except EOFError: + pass + self.interruptPipeRecv.close() + self.interruptPipeRecv = None + + def close(self): + if not self.open: + return + self.open = False + self.interrupt() + + def cancelPing(self): + if self.pingTimer: + self.pingTimer.cancel() + + def resetPing(self): + self.cancelPing() + if not self.open: + logger.debug("resetPing() while closed. passing...") + return + self.pingTimer = threading.Timer(30, self.sendPing) + self.pingTimer.start() + + def sendPing(self): + header = self.get_header(0, OPCODE_PING) + self._sendBytes(header) + self.resetPing() + + def sendPong(self): + header = self.get_header(0, OPCODE_PONG) + self._sendBytes(header) diff --git a/openwebrx/owrx/wsjt.py b/openwebrx/owrx/wsjt.py new file mode 100644 index 0000000..0693046 --- /dev/null +++ b/openwebrx/owrx/wsjt.py @@ -0,0 +1,399 @@ +from datetime import datetime, timezone +from typing import List + +from owrx.map import Map, LocatorLocation +import re +from owrx.metrics import Metrics, CounterMetric +from owrx.reporting import ReportingEngine +from owrx.parser import Parser +from owrx.audio import AudioChopperProfile, StaticProfileSource, ConfigWiredProfileSource +from abc import ABC, ABCMeta, abstractmethod +from owrx.config import Config +from enum import Enum + +import logging + +logger = logging.getLogger(__name__) + + +class WsjtProfile(AudioChopperProfile, metaclass=ABCMeta): + def decoding_depth(self): + pm = Config.get() + mode = self.getMode().lower() + # mode-specific setting? + if "wsjt_decoding_depths" in pm and mode in pm["wsjt_decoding_depths"]: + return pm["wsjt_decoding_depths"][mode] + # return global default + if "wsjt_decoding_depth" in pm: + return pm["wsjt_decoding_depth"] + # default when no setting is provided + return 3 + + def getTimestampFormat(self): + if self.getInterval() < 60: + return "%H%M%S" + return "%H%M" + + def getFileTimestampFormat(self): + return "%y%m%d_" + self.getTimestampFormat() + + @abstractmethod + def getMode(self): + pass + + +class Fst4ProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["fst4_enabled_intervals"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["fst4_enabled_intervals"] if "fst4_enabled_intervals" in config else [] + return [Fst4Profile(i) for i in profiles if i in Fst4Profile.availableIntervals] + + +class Fst4wProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["fst4w_enabled_intervals"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["fst4w_enabled_intervals"] if "fst4w_enabled_intervals" in config else [] + return [Fst4wProfile(i) for i in profiles if i in Fst4wProfile.availableIntervals] + + +class Q65ProfileSource(ConfigWiredProfileSource): + def getPropertiesToWire(self) -> List[str]: + return ["q65_enabled_combinations"] + + def getProfiles(self) -> List[AudioChopperProfile]: + config = Config.get() + profiles = config["q65_enabled_combinations"] if "q65_enabled_combinations" in config else [] + + def buildProfile(modestring): + try: + mode = Q65Mode[modestring[0]] + interval = Q65Interval(int(modestring[1:])) + if interval.is_available(mode): + return Q65Profile(interval, mode) + except (ValueError, KeyError): + pass + logger.warning('"%s" is not a valid Q65 mode, or an invalid mode string, ignoring', modestring) + return None + + mapped = [buildProfile(m) for m in profiles] + return [p for p in mapped if p is not None] + + +class WsjtProfiles(object): + @staticmethod + def getSource(mode: str): + if mode == "ft8": + return StaticProfileSource([Ft8Profile()]) + elif mode == "wspr": + return StaticProfileSource([WsprProfile()]) + elif mode == "jt65": + return StaticProfileSource([Jt65Profile()]) + elif mode == "jt9": + return StaticProfileSource([Jt9Profile()]) + elif mode == "ft4": + return StaticProfileSource([Ft4Profile()]) + elif mode == "fst4": + return Fst4ProfileSource() + elif mode == "fst4w": + return Fst4wProfileSource() + elif mode == "q65": + return Q65ProfileSource() + + +class Ft8Profile(WsjtProfile): + def getInterval(self): + return 15 + + def decoder_commandline(self, file): + return ["jt9", "--ft8", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FT8" + + +class WsprProfile(WsjtProfile): + def getInterval(self): + return 120 + + def decoder_commandline(self, file): + cmd = ["wsprd"] + if self.decoding_depth() > 1: + cmd += ["-d"] + cmd += [file] + return cmd + + def getMode(self): + return "WSPR" + + +class Jt65Profile(WsjtProfile): + def getInterval(self): + return 60 + + def decoder_commandline(self, file): + return ["jt9", "--jt65", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "JT65" + + +class Jt9Profile(WsjtProfile): + def getInterval(self): + return 60 + + def decoder_commandline(self, file): + return ["jt9", "--jt9", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "JT9" + + +class Ft4Profile(WsjtProfile): + def getInterval(self): + return 7.5 + + def decoder_commandline(self, file): + return ["jt9", "--ft4", "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FT4" + + +class Fst4Profile(WsjtProfile): + availableIntervals = [15, 30, 60, 120, 300, 900, 1800] + + def __init__(self, interval): + self.interval = interval + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--fst4", "-p", str(self.interval), "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FST4" + + +class Fst4wProfile(WsjtProfile): + availableIntervals = [120, 300, 900, 1800] + + def __init__(self, interval): + self.interval = interval + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--fst4w", "-p", str(self.interval), "-d", str(self.decoding_depth()), file] + + def getMode(self): + return "FST4W" + + +class Q65Mode(Enum): + # value is the bandwidth multiplier according to https://physics.princeton.edu/pulsar/k1jt/Q65_Quick_Start.pdf + A = 1 + B = 2 + C = 4 + D = 8 + E = 16 + + def is_available(self, interval: "Q65Interval"): + return interval.is_available(self) + + +class Q65Interval(Enum): + # (interval, occupied bandwidth in mode "A") + # according to https://physics.princeton.edu/pulsar/k1jt/Q65_Quick_Start.pdf + INTERVAL_15 = (15, 433) + INTERVAL_30 = (30, 217) + INTERVAL_60 = (60, 108) + INTERVAL_120 = (120, 49) + INTERVAL_300 = (300, 19) + + def __new__(cls, *args, **kwargs): + interval, occupied_bandwidth = args + obj = object.__new__(cls) + obj._value_ = interval + obj.occupied_bandwidth = occupied_bandwidth + return obj + + def is_available(self, mode: Q65Mode): + # total bandwidth must not exceed the typical SSB bandwidth + return self.occupied_bandwidth * mode.value < 2700 + + +class Q65Profile(WsjtProfile): + def __init__(self, interval: Q65Interval, mode: Q65Mode): + self.interval = interval.value + self.mode = mode + + def getMode(self): + return "Q65" + + def getInterval(self): + return self.interval + + def decoder_commandline(self, file): + return ["jt9", "--q65", "-p", str(self.interval), "-b", self.mode.name, "-d", str(self.decoding_depth()), file] + + +class WsjtParser(Parser): + def parse(self, data): + try: + profile, freq, raw_msg = data + self.setDialFrequency(freq) + msg = raw_msg.decode().rstrip() + # known debug messages we know to skip + if msg.startswith(""): + return + if msg.startswith(" EOF on input file"): + return + + mode = profile.getMode() + if mode in ["WSPR", "FST4W"]: + messageParser = BeaconMessageParser() + else: + messageParser = QsoMessageParser() + if mode == "WSPR": + decoder = WsprDecoder(profile, messageParser) + else: + decoder = Jt9Decoder(profile, messageParser) + out = decoder.parse(msg, freq) + if isinstance(profile, Q65Profile) and not out["msg"]: + # all efforts in vain, it's just a potential signal indicator + return + out["mode"] = mode + out["interval"] = profile.getInterval() + + self.pushDecode(mode) + if "callsign" in out and "locator" in out: + Map.getSharedInstance().updateLocation( + out["callsign"], LocatorLocation(out["locator"]), mode, self.band + ) + ReportingEngine.getSharedInstance().spot(out) + + self.handler.write_wsjt_message(out) + except Exception: + logger.exception("Exception while parsing wsjt message") + + def pushDecode(self, mode): + metrics = Metrics.getSharedInstance() + band = "unknown" + if self.band is not None: + band = self.band.getName() + if band is None: + band = "unknown" + + if mode is None: + mode = "unknown" + + name = "wsjt.decodes.{band}.{mode}".format(band=band, mode=mode) + metric = metrics.getMetric(name) + if metric is None: + metric = CounterMetric() + metrics.addMetric(name, metric) + + metric.inc() + + +class Decoder(ABC): + def __init__(self, profile, messageParser): + self.profile = profile + self.messageParser = messageParser + + def parse_timestamp(self, instring): + dateformat = self.profile.getTimestampFormat() + remain = instring[len(dateformat) + 1 :] + try: + ts = datetime.strptime(instring[0 : len(dateformat)], dateformat) + return remain, int( + datetime.combine(datetime.utcnow().date(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000 + ) + except ValueError: + return remain, None + + @abstractmethod + def parse(self, msg, dial_freq): + pass + + +class MessageParser(ABC): + @abstractmethod + def parse(self, msg): + pass + + +# Used in QSO-style modes (FT8, FT4, FST4) +class QsoMessageParser(MessageParser): + locator_pattern = re.compile(".*\\s([A-Z0-9/]{2,})(\\sR)?\\s([A-R]{2}[0-9]{2})$") + + def parse(self, msg): + m = QsoMessageParser.locator_pattern.match(msg) + if m is None: + return {} + # this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very + # likely this just means roger roger goodbye. + if m.group(3) == "RR73": + return {"callsign": m.group(1)} + return {"callsign": m.group(1), "locator": m.group(3)} + + +# Used in propagation reporting / beacon modes (WSPR / FST4W) +class BeaconMessageParser(MessageParser): + wspr_splitter_pattern = re.compile("([A-Z0-9/]*)\\s([A-R]{2}[0-9]{2})\\s([0-9]+)") + + def parse(self, msg): + m = BeaconMessageParser.wspr_splitter_pattern.match(msg) + if m is None: + return {} + return {"callsign": m.group(1), "locator": m.group(2), "dbm": m.group(3)} + + +class Jt9Decoder(Decoder): + def parse(self, msg, dial_freq): + # ft8 sample + # '222100 -15 -0.0 508 ~ CQ EA7MJ IM66' + # jt65 sample + # '2352 -7 0.4 1801 # R0WAS R2ABM KO85' + # '0003 -4 0.4 1762 # CQ R2ABM KO85' + # fst4 sample + # '**** -23 0.6 3023 ` <...> <...> R 591631 BI53PV' + msg, timestamp = self.parse_timestamp(msg) + wsjt_msg = msg[17:53].strip() + + result = { + "timestamp": timestamp, + "db": float(msg[0:3]), + "dt": float(msg[4:8]), + "freq": dial_freq + int(msg[9:13]), + "msg": wsjt_msg, + } + result.update(self.messageParser.parse(wsjt_msg)) + return result + + +class WsprDecoder(Decoder): + def parse(self, msg, dial_freq): + # wspr sample + # '2600 -24 0.4 0.001492 -1 G8AXA JO01 33' + # '0052 -29 2.6 0.001486 0 G02CWT IO92 23' + msg, timestamp = self.parse_timestamp(msg) + wsjt_msg = msg[24:].strip() + result = { + "timestamp": timestamp, + "db": float(msg[0:3]), + "dt": float(msg[4:8]), + "freq": dial_freq + int(float(msg[10:20]) * 1e6), + "drift": int(msg[20:23]), + "msg": wsjt_msg, + } + result.update(self.messageParser.parse(wsjt_msg)) + return result diff --git a/openwebrx/setup.py b/openwebrx/setup.py new file mode 100644 index 0000000..9c11425 --- /dev/null +++ b/openwebrx/setup.py @@ -0,0 +1,42 @@ +from glob import glob +from setuptools import setup +from owrx.version import looseversion + +try: + from setuptools import find_namespace_packages +except ImportError: + from setuptools import PEP420PackageFinder + + find_namespace_packages = PEP420PackageFinder.find + +setup( + name="OpenWebRX", + version=str(looseversion), + packages=find_namespace_packages( + include=[ + "owrx", + "owrx.source", + "owrx.service", + "owrx.controllers", + "owrx.controllers.settings", + "owrx.property", + "owrx.form", + "owrx.form.input", + "owrx.config", + "owrx.reporting", + "owrx.audio", + "owrx.admin", + "csdr", + "htdocs", + ] + ), + package_data={"htdocs": [f[len("htdocs/") :] for f in glob("htdocs/**/*", recursive=True)]}, + entry_points={"console_scripts": ["openwebrx=owrx.__main__:main"]}, + url="https://www.openwebrx.de/", + author="Jakob Ketterl", + author_email="jakob.ketterl@gmx.de", + maintainer="Jakob Ketterl", + maintainer_email="jakob.ketterl@gmx.de", + license="GAGPL", + python_requires=">=3.5", +) diff --git a/openwebrx/systemd/openwebrx.service b/openwebrx/systemd/openwebrx.service new file mode 100644 index 0000000..8b87833 --- /dev/null +++ b/openwebrx/systemd/openwebrx.service @@ -0,0 +1,12 @@ +[Unit] +Description=OpenWebRX WebSDR receiver + +[Service] +Type=simple +User=openwebrx +Group=openwebrx +ExecStart=/usr/bin/openwebrx +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/openwebrx/test/__init__.py b/openwebrx/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/__init__.py b/openwebrx/test/property/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/filter/__init__.py b/openwebrx/test/property/filter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/filter/test_by_lambda.py b/openwebrx/test/property/filter/test_by_lambda.py new file mode 100644 index 0000000..14c6daf --- /dev/null +++ b/openwebrx/test/property/filter/test_by_lambda.py @@ -0,0 +1,17 @@ +from owrx.property.filter import ByLambda +from unittest import TestCase +from unittest.mock import Mock + + +class TestByLambda(TestCase): + def testPositive(self): + mock = Mock(return_value=True) + filter = ByLambda(mock) + self.assertTrue(filter.apply("test_key")) + mock.assert_called_with("test_key") + + def testNegateive(self): + mock = Mock(return_value=False) + filter = ByLambda(mock) + self.assertFalse(filter.apply("test_key")) + mock.assert_called_with("test_key") diff --git a/openwebrx/test/property/filter/test_by_property_name.py b/openwebrx/test/property/filter/test_by_property_name.py new file mode 100644 index 0000000..098f69b --- /dev/null +++ b/openwebrx/test/property/filter/test_by_property_name.py @@ -0,0 +1,12 @@ +from owrx.property.filter import ByPropertyName +from unittest import TestCase + + +class ByPropertyNameTest(TestCase): + def testNameIsInList(self): + filter = ByPropertyName("test_key") + self.assertTrue(filter.apply("test_key")) + + def testNameNotInList(self): + filter = ByPropertyName("test_key") + self.assertFalse(filter.apply("other_key")) diff --git a/openwebrx/test/property/test_property_carousel.py b/openwebrx/test/property/test_property_carousel.py new file mode 100644 index 0000000..bc0c837 --- /dev/null +++ b/openwebrx/test/property/test_property_carousel.py @@ -0,0 +1,125 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyCarousel, PropertyLayer, PropertyDeleted, PropertyWriteError + + +class PropertyCarouselTest(TestCase): + def testInitiallyEmpty(self): + pc = PropertyCarousel() + with self.assertRaises(KeyError): + x = pc["testkey"] + + def testPropertyAccess(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("test", pl) + pc.switch("test") + self.assertEqual(pc["testkey"], "testvalue") + + def testWriteAccess(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="old_value") + pc.addLayer("test", pl) + pc.switch("test") + pc["testkey"] = "new_value" + self.assertEqual(pc["testkey"], "new_value") + self.assertEqual(pl["testkey"], "new_value") + + def testForwardsEvents(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="old_value") + pc.addLayer("test", pl) + pc.switch("test") + mock = Mock() + pc.wire(mock.method) + pc["testkey"] = "new_value" + mock.method.assert_called_once_with({"testkey": "new_value"}) + + def testStopsForwardingAfterSwitch(self): + pc = PropertyCarousel() + pl_x = PropertyLayer(testkey="old_value") + pc.addLayer("x", pl_x) + pl_y = PropertyLayer(testkey="new_value") + pc.addLayer("y", pl_y) + pc.switch("x") + pc.switch("y") + mock = Mock() + pc.wire(mock.method) + pl_x["testkey"] = "new_value" + mock.method.assert_not_called() + + def testEventsOnSwitch(self): + pc = PropertyCarousel() + pl_x = PropertyLayer(old_key="old_value") + pc.addLayer("x", pl_x) + pl_y = PropertyLayer(new_key="new_value") + pc.addLayer("y", pl_y) + pc.switch("x") + mock = Mock() + pc.wire(mock.method) + pc.switch("y") + mock.method.assert_called_once_with({"old_key": PropertyDeleted, "new_key": "new_value"}) + + def testNoEventsIfKeysDontChange(self): + pc = PropertyCarousel() + pl_x = PropertyLayer(testkey="same_value") + pc.addLayer("x", pl_x) + pl_y = PropertyLayer(testkey="same_value") + pc.addLayer("y", pl_y) + pc.switch("x") + mock = Mock() + pc.wire(mock.method) + pc.switch("y") + mock.method.assert_not_called() + + def testKeyErrorOnInvalidSwitch(self): + pc = PropertyCarousel() + with self.assertRaises(KeyError): + pc.switch("doesntmatter") + + def testRemoveLayer(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + pc.removeLayer("x") + with self.assertRaises(KeyError): + pc.switch("x") + + def testPropertyResetAfterRemoval(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + pc.removeLayer("x") + with self.assertRaises(KeyError): + x = pc["testkey"] + + def testEmptySwitch(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + pc.switch() + with self.assertRaises(KeyError): + x = pc["testkey"] + + def testErrorOnWriteOnDefaultLayer(self): + pc = PropertyCarousel() + with self.assertRaises(PropertyWriteError): + pc["testkey"] = "testvalue" + + def testSendsChangesIfActiveLayerIsReplaced(self): + pc = PropertyCarousel() + pl = PropertyLayer(testkey="testvalue") + pc.addLayer("x", pl) + pc.switch("x") + self.assertEqual(pc["testkey"], "testvalue") + mock = Mock() + pc.wire(mock.method) + pl = PropertyLayer(testkey="othervalue") + pc.addLayer("x", pl) + mock.method.assert_called_once_with({"testkey": "othervalue"}) diff --git a/openwebrx/test/property/test_property_deletion.py b/openwebrx/test/property/test_property_deletion.py new file mode 100644 index 0000000..b68cf08 --- /dev/null +++ b/openwebrx/test/property/test_property_deletion.py @@ -0,0 +1,8 @@ +from unittest import TestCase +from owrx.property import PropertyDeletion + + +class PropertyDeletionTest(TestCase): + def testDeletionEvaluatesToFalse(self): + deletion = PropertyDeletion() + self.assertFalse(deletion) diff --git a/openwebrx/test/property/test_property_filter.py b/openwebrx/test/property/test_property_filter.py new file mode 100644 index 0000000..82f5961 --- /dev/null +++ b/openwebrx/test/property/test_property_filter.py @@ -0,0 +1,82 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyFilter, PropertyDeleted + + +class PropertyFilterTest(TestCase): + def testPassesProperty(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + self.assertEqual(pf["testkey"], "testvalue") + + def testMissesProperty(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + mock = Mock() + mock.apply.return_value = False + pf = PropertyFilter(pm, mock) + self.assertFalse("testkey" in pf) + with self.assertRaises(KeyError): + x = pf["testkey"] + + def testForwardsEvent(self): + pm = PropertyLayer() + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + mock = Mock() + pf.wire(mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with({"testkey": "testvalue"}) + + def testForwardsPropertyEvent(self): + pm = PropertyLayer() + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + mock = Mock() + pf.wireProperty("testkey", mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with("testvalue") + + def testForwardsWrite(self): + pm = PropertyLayer() + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + pf["testkey"] = "testvalue" + self.assertTrue("testkey" in pm) + self.assertEqual(pm["testkey"], "testvalue") + + def testOverwrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + mock = Mock() + mock.apply.return_value = True + pf = PropertyFilter(pm, mock) + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "new value") + self.assertEqual(pf["testkey"], "new value") + + def testRejectsWrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + mock = Mock() + mock.apply.return_value = False + pf = PropertyFilter(pm, mock) + with self.assertRaises(KeyError): + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "old value") + + def testPropagatesDeletion(self): + pm = PropertyLayer(testkey="somevalue") + filter_mock = Mock() + filter_mock.apply.return_value = True + pf = PropertyFilter(pm, filter_mock) + mock = Mock() + pf.wire(mock.method) + del pf["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) diff --git a/openwebrx/test/property/test_property_layer.py b/openwebrx/test/property/test_property_layer.py new file mode 100644 index 0000000..f07ae1b --- /dev/null +++ b/openwebrx/test/property/test_property_layer.py @@ -0,0 +1,93 @@ +from owrx.property import PropertyLayer, PropertyDeleted +from unittest import TestCase +from unittest.mock import Mock + + +class PropertyLayerTest(TestCase): + def testCreationWithKwArgs(self): + pm = PropertyLayer(testkey="value") + self.assertEqual(pm["testkey"], "value") + + # this should be synonymous, so this is rather for illustration purposes + contents = {"testkey": "value"} + pm = PropertyLayer(**contents) + self.assertEqual(pm["testkey"], "value") + + def testKeyIsset(self): + pm = PropertyLayer() + self.assertFalse("some_key" in pm) + + def testKeyError(self): + pm = PropertyLayer() + with self.assertRaises(KeyError): + x = pm["some_key"] + + def testSubscription(self): + pm = PropertyLayer() + pm["testkey"] = "before" + mock = Mock() + pm.wire(mock.method) + pm["testkey"] = "after" + mock.method.assert_called_once_with({"testkey": "after"}) + + def testUnsubscribe(self): + pm = PropertyLayer() + pm["testkey"] = "before" + mock = Mock() + sub = pm.wire(mock.method) + pm["testkey"] = "between" + mock.method.assert_called_once_with({"testkey": "between"}) + + mock.reset_mock() + pm.unwire(sub) + pm["testkey"] = "after" + mock.method.assert_not_called() + + def testContains(self): + pm = PropertyLayer() + pm["testkey"] = "value" + self.assertTrue("testkey" in pm) + + def testDoesNotContain(self): + pm = PropertyLayer() + self.assertFalse("testkey" in pm) + + def testSubscribeBeforeSet(self): + pm = PropertyLayer() + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.assert_not_called() + pm["testkey"] = "newvalue" + mock.method.assert_called_once_with("newvalue") + + def testEventPreventedWhenValueUnchanged(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + mock = Mock() + pm.wire(mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_not_called() + + def testDeletionIsSent(self): + pm = PropertyLayer(testkey="somevalue") + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.reset_mock() + del pm["testkey"] + mock.method.assert_called_once_with(PropertyDeleted) + + def testDeletionInGeneralWiring(self): + pm = PropertyLayer(testkey="somevalue") + mock = Mock() + pm.wire(mock.method) + del pm["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testNoDeletionEventWhenPropertyDoesntExist(self): + pm = PropertyLayer(otherkey="somevalue") + mock = Mock() + pm.wireProperty("testkey", mock.method) + mock.method.reset_mock() + with self.assertRaises(KeyError): + del pm["testkey"] + mock.method.assert_not_called() diff --git a/openwebrx/test/property/test_property_readonly.py b/openwebrx/test/property/test_property_readonly.py new file mode 100644 index 0000000..09d57ee --- /dev/null +++ b/openwebrx/test/property/test_property_readonly.py @@ -0,0 +1,23 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyReadOnly, PropertyWriteError + + +class PropertyReadOnlyTest(TestCase): + def testPreventsWrites(self): + layer = PropertyLayer() + layer["testkey"] = "initial value" + ro = PropertyReadOnly(layer) + with self.assertRaises(PropertyWriteError): + ro["testkey"] = "new value" + with self.assertRaises(PropertyWriteError): + ro["otherkey"] = "testvalue" + self.assertEqual(ro["testkey"], "initial value") + self.assertNotIn("otherkey", ro) + + def testPreventsDeletes(self): + layer = PropertyLayer(testkey="some value") + ro = PropertyReadOnly(layer) + with self.assertRaises(PropertyWriteError): + del ro["testkey"] + self.assertEqual(ro["testkey"], "some value") + self.assertEqual(layer["testkey"], "some value") diff --git a/openwebrx/test/property/test_property_stack.py b/openwebrx/test/property/test_property_stack.py new file mode 100644 index 0000000..860f13f --- /dev/null +++ b/openwebrx/test/property/test_property_stack.py @@ -0,0 +1,240 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyStack, PropertyDeleted + + +class PropertyStackTest(TestCase): + def testLayer(self): + om = PropertyStack() + pm = PropertyLayer() + pm["testkey"] = "testvalue" + om.addLayer(1, pm) + self.assertEqual(om["testkey"], "testvalue") + + def testHighPriority(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + + def testPriorityFallback(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "low value") + + def testLayerRemoval(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + om.removeLayer(high_pm) + self.assertEqual(om["testkey"], "low value") + + def testLayerRemovalByPriority(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + high_pm["testkey"] = "high value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + self.assertEqual(om["testkey"], "high value") + om.removeLayerByPriority(0) + self.assertEqual(om["testkey"], "low value") + + def testPropertyChange(self): + layer = PropertyLayer() + stack = PropertyStack() + stack.addLayer(0, layer) + mock = Mock() + stack.wire(mock.method) + layer["testkey"] = "testvalue" + mock.method.assert_called_once_with({"testkey": "testvalue"}) + + def testPropertyChangeEventPriority(self): + low_layer = PropertyLayer() + high_layer = PropertyLayer() + low_layer["testkey"] = "initial low value" + high_layer["testkey"] = "initial high value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + stack.addLayer(0, high_layer) + mock = Mock() + stack.wire(mock.method) + low_layer["testkey"] = "modified low value" + mock.method.assert_not_called() + high_layer["testkey"] = "modified high value" + mock.method.assert_called_once_with({"testkey": "modified high value"}) + + def testPropertyEventOnLayerAdd(self): + low_layer = PropertyLayer() + low_layer["testkey"] = "low value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.reset_mock() + high_layer = PropertyLayer() + high_layer["testkey"] = "high value" + stack.addLayer(0, high_layer) + mock.method.assert_called_once_with("high value") + + def testNoEventOnExistingValue(self): + low_layer = PropertyLayer() + low_layer["testkey"] = "same value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.reset_mock() + high_layer = PropertyLayer() + high_layer["testkey"] = "same value" + stack.addLayer(0, high_layer) + mock.method.assert_not_called() + + def testEventOnLayerWithNewProperty(self): + low_layer = PropertyLayer() + low_layer["existingkey"] = "existing value" + stack = PropertyStack() + stack.addLayer(1, low_layer) + mock = Mock() + stack.wireProperty("newkey", mock.method) + high_layer = PropertyLayer() + high_layer["newkey"] = "new value" + stack.addLayer(0, high_layer) + mock.method.assert_called_once_with("new value") + + def testEventOnLayerRemoval(self): + low_layer = PropertyLayer() + high_layer = PropertyLayer() + stack = PropertyStack() + stack.addLayer(1, low_layer) + stack.addLayer(0, high_layer) + low_layer["testkey"] = "low value" + high_layer["testkey"] = "high value" + + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.method.assert_called_once_with("high value") + mock.reset_mock() + stack.removeLayer(high_layer) + mock.method.assert_called_once_with("low value") + + def testNoneOnKeyRemoval(self): + low_layer = PropertyLayer() + high_layer = PropertyLayer() + stack = PropertyStack() + stack.addLayer(1, low_layer) + stack.addLayer(0, high_layer) + low_layer["testkey"] = "low value" + high_layer["testkey"] = "high value" + high_layer["unique key"] = "unique value" + + mock = Mock() + stack.wireProperty("unique key", mock.method) + mock.method.assert_called_once_with("unique value") + mock.reset_mock() + stack.removeLayer(high_layer) + mock.method.assert_called_once_with(PropertyDeleted) + + def testReplaceLayer(self): + first_layer = PropertyLayer() + first_layer["testkey"] = "old value" + second_layer = PropertyLayer() + second_layer["testkey"] = "new value" + + stack = PropertyStack() + stack.addLayer(0, first_layer) + + mock = Mock() + stack.wireProperty("testkey", mock.method) + mock.method.assert_called_once_with("old value") + mock.reset_mock() + + stack.replaceLayer(0, second_layer) + mock.method.assert_called_once_with("new value") + + def testUnwiresEventsOnRemoval(self): + layer = PropertyLayer() + layer["testkey"] = "before" + stack = PropertyStack() + stack.addLayer(0, layer) + mock = Mock() + stack.wire(mock.method) + stack.removeLayer(layer) + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + mock.reset_mock() + + layer["testkey"] = "after" + mock.method.assert_not_called() + + def testReplaceLayerNoEventWhenValueUnchanged(self): + fixed = PropertyLayer() + fixed["testkey"] = "fixed value" + first_layer = PropertyLayer() + first_layer["testkey"] = "same value" + second_layer = PropertyLayer() + second_layer["testkey"] = "same value" + + stack = PropertyStack() + stack.addLayer(1, fixed) + stack.addLayer(0, first_layer) + mock = Mock() + stack.wire(mock.method) + mock.method.assert_not_called() + + stack.replaceLayer(0, second_layer) + mock.method.assert_not_called() + + def testWritesToExpectedLayer(self): + om = PropertyStack() + low_pm = PropertyLayer() + high_pm = PropertyLayer() + low_pm["testkey"] = "low value" + om.addLayer(1, low_pm) + om.addLayer(0, high_pm) + om["testkey"] = "new value" + self.assertEqual(low_pm["testkey"], "new value") + + def testDeletionEvent(self): + ps = PropertyStack() + pm = PropertyLayer(testkey="testvalue") + ps.addLayer(0, pm) + mock = Mock() + ps.wire(mock.method) + del ps["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testDeletionWithSecondLayer(self): + ps = PropertyStack() + low_pm = PropertyLayer(testkey="testvalue") + high_pm = PropertyLayer() + ps.addLayer(0, high_pm) + ps.addLayer(1, low_pm) + mock = Mock() + ps.wire(mock.method) + del low_pm["testkey"] + mock.method.assert_called_once_with({"testkey": PropertyDeleted}) + + def testChangeEventWhenKeyDeleted(self): + ps = PropertyStack() + low_pm = PropertyLayer(testkey="lowvalue") + high_pm = PropertyLayer(testkey="highvalue") + ps.addLayer(0, high_pm) + ps.addLayer(1, low_pm) + mock = Mock() + ps.wire(mock.method) + del high_pm["testkey"] + mock.method.assert_called_once_with({"testkey": "lowvalue"}) diff --git a/openwebrx/test/property/test_property_validator.py b/openwebrx/test/property/test_property_validator.py new file mode 100644 index 0000000..a246031 --- /dev/null +++ b/openwebrx/test/property/test_property_validator.py @@ -0,0 +1,37 @@ +from unittest import TestCase +from owrx.property import PropertyLayer, PropertyValidator, PropertyValidationError +from owrx.property.validators import NumberValidator, StringValidator + + +class PropertyValidatorTest(TestCase): + def testPassesUnvalidated(self): + pm = PropertyLayer() + pv = PropertyValidator(pm) + pv["testkey"] = "testvalue" + self.assertEqual(pv["testkey"], "testvalue") + self.assertEqual(pm["testkey"], "testvalue") + + def testPassesValidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + pv["testkey"] = 42 + self.assertEqual(pv["testkey"], 42) + + def testThrowsErrorOnInvalidValue(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": NumberValidator()}) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testSetValidator(self): + pv = PropertyValidator(PropertyLayer()) + pv.setValidator("testkey", NumberValidator()) + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" + + def testUpdateValidator(self): + pv = PropertyValidator(PropertyLayer(), {"testkey": StringValidator()}) + # this should pass + pv["testkey"] = "text" + pv.setValidator("testkey", NumberValidator()) + # this should raise + with self.assertRaises(PropertyValidationError): + pv["testkey"] = "text" diff --git a/openwebrx/test/property/validators/__init__.py b/openwebrx/test/property/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openwebrx/test/property/validators/test_bool_validator.py b/openwebrx/test/property/validators/test_bool_validator.py new file mode 100644 index 0000000..08cfea6 --- /dev/null +++ b/openwebrx/test/property/validators/test_bool_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import BoolValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = BoolValidator() + self.assertTrue(validator.isValid(True)) + self.assertTrue(validator.isValid(False)) + + def testDoesntPassOthers(self): + validator = BoolValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_float_validator.py b/openwebrx/test/property/validators/test_float_validator.py new file mode 100644 index 0000000..f4e43ec --- /dev/null +++ b/openwebrx/test/property/validators/test_float_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import FloatValidator + + +class FloatValidatorTest(TestCase): + def testPassesNumbers(self): + validator = FloatValidator() + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = FloatValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_integer_validator.py b/openwebrx/test/property/validators/test_integer_validator.py new file mode 100644 index 0000000..454918a --- /dev/null +++ b/openwebrx/test/property/validators/test_integer_validator.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from owrx.property.validators import IntegerValidator + + +class IntegerValidatorTest(TestCase): + def testPassesIntegers(self): + validator = IntegerValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + + def testDoesntPassOthers(self): + validator = IntegerValidator() + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_lambda_validator.py b/openwebrx/test/property/validators/test_lambda_validator.py new file mode 100644 index 0000000..969dce6 --- /dev/null +++ b/openwebrx/test/property/validators/test_lambda_validator.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property.validators import LambdaValidator + + +class LambdaValidatorTest(TestCase): + def testPassesValue(self): + mock = Mock() + validator = LambdaValidator(mock.method) + validator.isValid("test") + mock.method.assert_called_once_with("test") + + def testReturnsTrue(self): + validator = LambdaValidator(lambda x: True) + self.assertTrue(validator.isValid("any value")) + self.assertTrue(validator.isValid(3.1415926)) + + def testReturnsFalse(self): + validator = LambdaValidator(lambda x: False) + self.assertFalse(validator.isValid("any value")) + self.assertFalse(validator.isValid(42)) diff --git a/openwebrx/test/property/validators/test_number_validator.py b/openwebrx/test/property/validators/test_number_validator.py new file mode 100644 index 0000000..3eff11b --- /dev/null +++ b/openwebrx/test/property/validators/test_number_validator.py @@ -0,0 +1,18 @@ +from unittest import TestCase +from owrx.property.validators import NumberValidator + + +class NumberValidatorTest(TestCase): + def testPassesNumbers(self): + validator = NumberValidator() + self.assertTrue(validator.isValid(123)) + self.assertTrue(validator.isValid(-2)) + self.assertTrue(validator.isValid(.5)) + + def testDoesntPassOthers(self): + validator = NumberValidator() + # bool is a subclass of int, so it passes this test. + # not sure if we need to be more specific or if this is alright. + # self.assertFalse(validator.isValid(True)) + self.assertFalse(validator.isValid("text")) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_or_validator.py b/openwebrx/test/property/validators/test_or_validator.py new file mode 100644 index 0000000..0f7f79d --- /dev/null +++ b/openwebrx/test/property/validators/test_or_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import OrValidator, IntegerValidator, StringValidator + + +class OrValidatorTest(TestCase): + def testPassesAnyValidators(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertTrue(validator.isValid(42)) + self.assertTrue(validator.isValid("text")) + + def testRejectsOtherTypes(self): + validator = OrValidator(IntegerValidator(), StringValidator()) + self.assertFalse(validator.isValid(.5)) + + def testRejectsIfNoValidator(self): + validator = OrValidator() + self.assertFalse(validator.isValid("any value")) diff --git a/openwebrx/test/property/validators/test_regex_validator.py b/openwebrx/test/property/validators/test_regex_validator.py new file mode 100644 index 0000000..2151d1b --- /dev/null +++ b/openwebrx/test/property/validators/test_regex_validator.py @@ -0,0 +1,17 @@ +from unittest import TestCase +from owrx.property.validators import RegexValidator +import re + + +class RegexValidatorTest(TestCase): + def testMatchesRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertTrue(validator.isValid("abc")) + + def testDoesntMatchRegex(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid("xyz")) + + def testFailsIfValueIsNoString(self): + validator = RegexValidator(re.compile("abc")) + self.assertFalse(validator.isValid(42)) diff --git a/openwebrx/test/property/validators/test_string_validator.py b/openwebrx/test/property/validators/test_string_validator.py new file mode 100644 index 0000000..d285f1a --- /dev/null +++ b/openwebrx/test/property/validators/test_string_validator.py @@ -0,0 +1,14 @@ +from unittest import TestCase +from owrx.property.validators import StringValidator + +class StringValidatorTest(TestCase): + def testPassesStrings(self): + validator = StringValidator() + self.assertTrue(validator.isValid("text")) + + def testDoesntPassOthers(self): + validator = StringValidator() + self.assertFalse(validator.isValid(123)) + self.assertFalse(validator.isValid(-2)) + self.assertFalse(validator.isValid(.5)) + self.assertFalse(validator.isValid(object())) diff --git a/openwebrx/test/property/validators/test_validator.py b/openwebrx/test/property/validators/test_validator.py new file mode 100644 index 0000000..6fbbf78 --- /dev/null +++ b/openwebrx/test/property/validators/test_validator.py @@ -0,0 +1,20 @@ +from unittest import TestCase +from owrx.property.validators import Validator, NumberValidator, LambdaValidator, StringValidator + + +class ValidatorTest(TestCase): + + def testReturnsValidator(self): + validator = NumberValidator() + self.assertIs(validator, Validator.of(validator)) + + def testTransformsLambda(self): + def my_callable(v): + return True + validator = Validator.of(my_callable) + self.assertIsInstance(validator, LambdaValidator) + self.assertTrue(validator.isValid("test")) + + def testGetsValidatorByKey(self): + validator = Validator.of("str") + self.assertIsInstance(validator, StringValidator)