diff --git a/.dockerignore b/.dockerignore index 6c2ce7c..8c815de 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ **/*.pyc **/*.swp black-env +debian \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b0b9cf0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Installation method** +How did you install OpenWebRX? (Raspberry Pi SD card image, Debian / Ubuntu packages, Docker image, manually?) + +**Versions** +What version of OpenWebRX are you running? (Check on startup, or see `owrx/version.py`. If a `-dev` version is used, ideally state the commit the issue is appearing on) + +**Log messages** +Are there any relevant messages relating to the bug in the output / log of OpenWebRX? (On most installations, the log should be available using the command `sudo journalctl -u openwebrx`) + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5ddf151 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: General support or other project-relasted question + url: https://groups.io/g/openwebrx + about: Request help on the community mailing list diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c33cd0f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature +assignees: '' + +--- + +Before posting a new feature request, please check if a similar idea has already been listed +* on the issue tracker +* on the [OpenWebRX github project](https://github.com/users/jketterl/projects/1). + +In the latter case, please only proceed if you have additional information about the feature, and please let us know that there's already a card there. + +**Feature description** +Please describe in plain words what functionality you'd like to see in OpenWebRX, and why you think it's useful. + +**Target audience** +Please let us know if you think that this feature is of particular interest for a particular group of users (e.g. hams, SWLs, DXers, ...) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e3a4d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,160 @@ +**unreleased** +- 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 +- New devices supported: + - FunCube Dongle Pro+ (`"type": "fcdpp"`) + - Support for connections to 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/README.md b/README.md index 4bd148a..f3862d7 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,39 @@ OpenWebRX ========= -[:floppy_disk: Setup guide for Ubuntu](http://blog.sdr.hu/2015/06/30/quick-setup-openwebrx.html) | [:blue_book: Knowledge base on the Wiki](https://github.com/simonyiszk/openwebrx/wiki/) | [:earth_americas: Receivers on SDR.hu](http://sdr.hu/) - OpenWebRX is a multi-user SDR receiver software with a web interface. -![OpenWebRX](http://blog.sdr.hu/images/openwebrx/screenshot.png) +![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), -- filter passband can be set from GUI, +- [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 -- currently supports RTL-SDR, HackRF, SDRplay, AirSpy +- currently supports RTL-SDR, HackRF, SDRplay, AirSpy, LimeSDR, PlutoSDR - Multiple SDR devices can be used simultaneously -- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF) +- [digiham](https://github.com/jketterl/digiham) based demodularors (DMR, YSF, Pocsag) - [dsd](https://github.com/f4exb/dsdcc) based demodulators (D-Star, NXDN) - [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) based demodulators (FT8, FT4, WSPR, JT65, JT9) -**News (2019-11-24 by DD5JFK)** -- There is now a new way to interface with SDR hardware, [owrx_connectors](https://github.com/jketterl/owrx_connector). - 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. - -**News (2019-10-27 by DD5JFK)** -- 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 - -**News (2019-09-29 by DD5FJK)** -- 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. - -**News (2019-09-25 by DD5JFK)** -- 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. - -**News (2019-09-13 by DD5JFK)** -- 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. - -**News (2019-07-21 by DD5JFK)** -- 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 - -**News (2019-07-13 by DD5JFK)** -- 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) - -**News (2019-06-30 by DD5JFK)** -- 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! - -> When upgrading OpenWebRX, please make sure that you also upgrade *csdr* and *digiham*! - -## OpenWebRX servers on SDR.hu - -[SDR.hu](http://sdr.hu) is a site which lists the active, public OpenWebRX servers. Your receiver [can also be part of it](http://sdr.hu/openwebrx), if you want. - -![sdr.hu](http://blog.sdr.hu/images/openwebrx/screenshot-sdrhu.png) - ## Setup -### Raspberry Pi SD Card Images +The following methods of setting up a receiver are currently available: -Probably the quickest way to get started is to download the [latest Raspberry Pi SD Card Image](https://s3.eu-central-1.amazonaws.com/de.dd5jfk.openwebrx/2019-11-24-OpenWebRX-full.zip). It contains all the depencencies out of the box, and should work on all Raspberries up to the 3B+. +- Raspberry Pi SD card images +- Debian repository +- Docker images +- Manual installation -This is based off the Raspbian Lite distribution, so [their installation instructions](https://www.raspberrypi.org/documentation/installation/installing-images/) apply. +Please checkout the [setup guide on the wiki](https://github.com/jketterl/openwebrx/wiki/Setup-Guide) for more details +on the respective methods. -Please note: I have not updated this to include the Raspberry Pi 4 yet. (It seems to be impossible to build Rasbpian Buster images on x86 hardware right now. Stay tuned!) +## Community -Once you have booted a Raspberry with the SD Card, it will appear in your network with the hostname "openwebrx", which should make it available as https://openwebrx/ on most networks. This may vary depending on your specific setup. - -For Digital voice, the minimum requirement right now seems to be a Rasbperry Pi 3B+. I would like to work on optimizing this for lower specs, but at this point I am not sure how much can be done. - -### Docker Images - -For those familiar with docker, I am providing [recent builds and Releases for both x86 and arm processors on the Docker hub](https://hub.docker.com/r/jketterl/openwebrx). You can find a short introduction there. - -### Manual Installation - -OpenWebRX currently requires Linux and python >= 3.6 to run. - -First you will need to install the dependencies: - -- [csdr](https://github.com/jketterl/csdr) -- [rtl-sdr](http://sdr.osmocom.org/trac/wiki/rtl-sdr) - -Optional dependency for improved hardware access (to become mandatory at some point): - -- [owrx_connector](https://github.com/jketterl/owrx_connector) - -Optional dependencies if you want to be able to listen do digital voice: - -- [digiham](https://github.com/jketterl/digiham) -- [dsd](https://github.com/f4exb/dsdcc) - -Optional dependency if you want to decode WSJT-X modes: - -- [wsjt-x](https://physics.princeton.edu/pulsar/k1jt/wsjtx.html) - -After cloning this repository and connecting an RTL-SDR dongle to your computer, you can run the server: - - ./openwebrx.py - -You can now open the GUI at http://localhost:8073. - -Please note that the server is also listening on the following ports (on localhost only): - -- ports 4950 to 4960 for the multi-user I/Q servers. - -Now the next step is to customize the parameters of your server in `config_webrx.py`. - -Actually, if you do something cool with OpenWebRX, please drop me a mail: -*Jakob Ketterl, DD5JFK <dd5jfk@darc.de>* +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). ## Usage tips @@ -155,14 +43,10 @@ The filter envelope can be dragged at its ends and moved around to set the passb However, if you hold down the shift key, you can drag the center line (BFO) or the whole passband (PBS). -## Setup tips - -If you have any problems installing OpenWebRX, you should check out the Wiki about it, which has a page on the common problems and their solutions. - -Sometimes the actual error message is not at the end of the terminal output, you may have to look at the whole output to find it. - ## Licensing -OpenWebRX is available under Affero GPL v3 license (summary). +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. +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/bands.json b/bands.json index 90e4c87..608c40f 100644 --- a/bands.json +++ b/bands.json @@ -8,7 +8,8 @@ "ft8": 1840000, "wspr": 1836600, "jt65": 1838000, - "jt9": 1839000 + "jt9": 1839000, + "js8": 1842000 } }, { @@ -21,7 +22,8 @@ "wspr": 3592600, "jt65": 3570000, "jt9": 3572000, - "ft4": [3568000, 3575000] + "ft4": [3568000, 3575000], + "js8": 3578000 } }, { @@ -43,7 +45,8 @@ "wspr": 7038600, "jt65": 7076000, "jt9": 7078000, - "ft4": 7047500 + "ft4": 7047500, + "js8": 7078000 } }, { @@ -56,7 +59,8 @@ "wspr": 10138700, "jt65": 10138000, "jt9": 10140000, - "ft4": 10140000 + "ft4": 10140000, + "js8": 10130000 } }, { @@ -69,7 +73,8 @@ "wspr": 14095600, "jt65": 14076000, "jt9": 14078000, - "ft4": 14080000 + "ft4": 14080000, + "js8": 14078000 } }, { @@ -82,7 +87,8 @@ "wspr": 18104600, "jt65": 18102000, "jt9": 18104000, - "ft4": 18104000 + "ft4": 18104000, + "js8": 18104000 } }, { @@ -95,7 +101,8 @@ "wspr": 21094600, "jt65": 21076000, "jt9": 21078000, - "ft4": 21140000 + "ft4": 21140000, + "js8": 21078000 } }, { @@ -108,7 +115,8 @@ "wspr": 24924600, "jt65": 24917000, "jt9": 24919000, - "ft4": 24919000 + "ft4": 24919000, + "js8": 24922000 } }, { @@ -121,7 +129,8 @@ "wspr": 28124600, "jt65": 28076000, "jt9": 28078000, - "ft4": 28180000 + "ft4": 28180000, + "js8": 28078000 } }, { @@ -134,7 +143,8 @@ "wspr": 50293000, "jt65": 50310000, "jt9": 50312000, - "ft4": 50318000 + "ft4": 50318000, + "js8": 50318000 } }, { @@ -189,5 +199,75 @@ "name": "3cm", "lower_bound": 10000000000, "upper_bound": 10500000000 + }, + { + "name": "120m Broadcast", + "lower_bound": 2300000, + "upper_bound": 2495000 + }, + { + "name": "90m Broadcast", + "lower_bound": 3200000, + "upper_bound": 3400000 + }, + { + "name": "75m Broadcast", + "lower_bound": 3900000, + "upper_bound": 4000000 + }, + { + "name": "60m Broadcast", + "lower_bound": 4750000, + "upper_bound": 4995000 + }, + { + "name": "49m Broadcast", + "lower_bound": 5900000, + "upper_bound": 6200000 + }, + { + "name": "41m Broadcast", + "lower_bound": 7200000, + "upper_bound": 7450000 + }, + { + "name": "31m Broadcast", + "lower_bound": 9400000, + "upper_bound": 9900000 + }, + { + "name": "25m Broadcast", + "lower_bound": 11600000, + "upper_bound": 12100000 + }, + { + "name": "22m Broadcast", + "lower_bound": 13570000, + "upper_bound": 13870000 + }, + { + "name": "19m Broadcast", + "lower_bound": 15100000, + "upper_bound": 15830000 + }, + { + "name": "16m Broadcast", + "lower_bound": 17480000, + "upper_bound": 17900000 + }, + { + "name": "15m Broadcast", + "lower_bound": 18900000, + "upper_bound": 19020000 + }, + { + "name": "13m Broadcast", + "lower_bound": 21450000, + "upper_bound": 21850000 + }, + { + "name": "11m Broadcast", + "lower_bound": 25670000, + "upper_bound": 26100000 } ] \ No newline at end of file diff --git a/build.sh b/build.sh index 5a991a7..84a543c 100755 --- a/build.sh +++ b/build.sh @@ -1,10 +1,6 @@ #!/bin/bash set -euxo pipefail - -ARCH=$(uname -m) - -TAG="latest" -ARCHTAG="$TAG-$ARCH" +. docker/env docker build --pull -t openwebrx-base:$ARCHTAG -f docker/Dockerfiles/Dockerfile-base . docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-rtlsdr:$ARCHTAG -f docker/Dockerfiles/Dockerfile-rtlsdr . @@ -13,4 +9,10 @@ docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-sdrplay:$ARCHTAG docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-hackrf:$ARCHTAG -f docker/Dockerfiles/Dockerfile-hackrf . docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-airspy:$ARCHTAG -f docker/Dockerfiles/Dockerfile-airspy . docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-rtlsdr-soapy:$ARCHTAG -f docker/Dockerfiles/Dockerfile-rtlsdr-soapy . +docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-plutosdr:$ARCHTAG -f docker/Dockerfiles/Dockerfile-plutosdr . +docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-limesdr:$ARCHTAG -f docker/Dockerfiles/Dockerfile-limesdr . +docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-soapyremote:$ARCHTAG -f docker/Dockerfiles/Dockerfile-soapyremote . +docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-perseus:$ARCHTAG -f docker/Dockerfiles/Dockerfile-perseus . +docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-fcdpp:$ARCHTAG -f docker/Dockerfiles/Dockerfile-fcdpp . +docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-radioberry:$ARCHTAG -f docker/Dockerfiles/Dockerfile-radioberry . docker build --build-arg ARCHTAG=$ARCHTAG -t jketterl/openwebrx-full:$ARCHTAG -t jketterl/openwebrx:$ARCHTAG -f docker/Dockerfiles/Dockerfile-full . diff --git a/config_webrx.py b/config_webrx.py index a568481..f4c560d 100644 --- a/config_webrx.py +++ b/config_webrx.py @@ -32,8 +32,11 @@ config_webrx: configuration options for OpenWebRX and use them for running your web service with OpenWebRX.) """ +# configuration version. please only modify if you're able to perform the associated migration steps. +version = 2 + # NOTE: you can find additional information about configuring OpenWebRX in the Wiki: -# https://github.com/simonyiszk/openwebrx/wiki +# https://github.com/jketterl/openwebrx/wiki/Configuration-guide # ==== Server settings ==== web_port = 8073 @@ -44,24 +47,30 @@ receiver_name = "[Callsign]" receiver_location = "Budapest, Hungary" receiver_asl = 200 receiver_admin = "example@example.com" -receiver_gps = (47.000000, 19.000000) +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: %[RX_ADMIN]
-Device: %[RX_DEVICE]
-Antenna: %[RX_ANT]
+Receiver is operated by: Receiver Operator
+Device: Receiver Device
+Antenna: Receiver Antenna
Website: http://localhost """ -# ==== sdr.hu listing ==== -# If you want your ham receiver to be listed publicly on sdr.hu, then take the following steps: -# 1. Register at: http://sdr.hu/register -# 2. You will get an unique key by email. Copy it and paste here: -sdrhu_key = "" -# 3. Set this setting to True to enable listing: -sdrhu_public_listing = False -server_hostname = "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 @@ -93,13 +102,14 @@ Note: if you experience audio underruns while CPU usage is 100%, you can: # ==== 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/simonyiszk/openwebrx/wiki#guides-for-receiver-hardware-support # -################################################################################################# +############################################################################### +# Is my SDR hardware supported? # +# Check here: https://github.com/jketterl/openwebrx/wiki/Supported-Hardware # +############################################################################### # Currently supported types of sdr receivers: -# "rtl_sdr", "sdrplay", "hackrf", "airspy", "airspyhf", "fifi_sdr" +# "rtl_sdr", "rtl_sdr_soapy", "sdrplay", "hackrf", "airspy", "airspyhf", "fifi_sdr", +# "perseussdr", "lime_sdr", "pluto_sdr", "soapy_remote" # # In order to use rtl_sdr, you will need to install librtlsdr-dev and the connector. # In order to use sdrplay, airspy or airspyhf, you will need to install soapysdr, the corresponding driver, and the @@ -107,8 +117,13 @@ Note: if you experience audio underruns while CPU usage is 100%, you can: # # https://github.com/jketterl/owrx_connector # -# NOTE: The connector sources have replaced the old piped nmux style of reading input. If you still have any sdrs -# configured that have type endin in "_connector", simply remove that suffix. +# In order to use Perseus HF you need to install the libperseus-sdr +# +# https://github.com/Microtelecom/libperseus-sdr +# +# and do the proper changes to the sdrs object below +# (see also Wiki in https://github.com/jketterl/openwebrx/wiki/Sample-configuration-for-Perseus-HF-receiver). +# sdrs = { "rtlsdr": { @@ -141,19 +156,18 @@ sdrs = { "name": "Airspy HF+", "type": "airspyhf", "ppm": 0, + "rf_gain": "auto", "profiles": { "20m": { "name": "20m", "center_freq": 14150000, - "rf_gain": 10, - "samp_rate": 768000, + "samp_rate": 384000, "start_freq": 14070000, "start_mod": "usb", }, "30m": { "name": "30m", "center_freq": 10125000, - "rf_gain": 10, "samp_rate": 192000, "start_freq": 10142000, "start_mod": "usb", @@ -161,24 +175,21 @@ sdrs = { "40m": { "name": "40m", "center_freq": 7100000, - "rf_gain": 10, "samp_rate": 256000, "start_freq": 7070000, - "start_mod": "usb", + "start_mod": "lsb", }, "80m": { "name": "80m", "center_freq": 3650000, - "rf_gain": 10, - "samp_rate": 768000, + "samp_rate": 384000, "start_freq": 3570000, - "start_mod": "usb", + "start_mod": "lsb", }, "49m": { "name": "49m Broadcast", - "center_freq": 6000000, - "rf_gain": 10, - "samp_rate": 768000, + "center_freq": 6050000, + "samp_rate": 384000, "start_freq": 6070000, "start_mod": "am", }, @@ -188,6 +199,7 @@ sdrs = { "name": "SDRPlay RSP2", "type": "sdrplay", "ppm": 0, + "antenna": "Antenna A", "profiles": { "20m": { "name": "20m", @@ -196,7 +208,6 @@ sdrs = { "samp_rate": 500000, "start_freq": 14070000, "start_mod": "usb", - "antenna": "Antenna A", }, "30m": { "name": "30m", @@ -212,8 +223,7 @@ sdrs = { "rf_gain": 0, "samp_rate": 500000, "start_freq": 7070000, - "start_mod": "usb", - "antenna": "Antenna A", + "start_mod": "lsb", }, "80m": { "name": "80m", @@ -221,8 +231,7 @@ sdrs = { "rf_gain": 0, "samp_rate": 500000, "start_freq": 3570000, - "start_mod": "usb", - "antenna": "Antenna A", + "start_mod": "lsb", }, "49m": { "name": "49m Broadcast", @@ -231,7 +240,6 @@ sdrs = { "samp_rate": 500000, "start_freq": 6070000, "start_mod": "am", - "antenna": "Antenna A", }, }, }, @@ -239,27 +247,25 @@ sdrs = { # ==== Color themes ==== -# A guide is available to help you set these values: https://github.com/simonyiszk/openwebrx/wiki/Calibrating-waterfall-display-levels - ### default theme by teejez: waterfall_colors = [0x000000FF, 0x0000FFFF, 0x00FFFFFF, 0x00FF00FF, 0xFFFF00FF, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFFFF] waterfall_min_level = -88 # in dB waterfall_max_level = -20 -waterfall_auto_level_margin = (5, 40) +waterfall_auto_level_margin = {"min": 5, "max": 40} ### old theme by HA7ILM: # waterfall_colors = "[0x000000ff,0x2e6893ff, 0x69a5d0ff, 0x214b69ff, 0x9dc4e0ff, 0xfff775ff, 0xff8a8aff, 0xb20000ff]" # waterfall_min_level = -115 #in dB # waterfall_max_level = 0 -# waterfall_auto_level_margin = (20, 30) +# waterfall_auto_level_margin = {"min": 20, "max": 30} ##For the old colors, you might also want to set [fft_voverlap_factor] to 0. # Note: When the auto waterfall level button is clicked, the following happens: -# [waterfall_min_level] = [current_min_power_level] - [waterfall_auto_level_margin[0]] -# [waterfall_max_level] = [current_max_power_level] + [waterfall_auto_level_margin[1]] +# [waterfall_min_level] = [current_min_power_level] - [waterfall_auto_level_margin["min"]] +# [waterfall_max_level] = [current_max_power_level] + [waterfall_auto_level_margin["max"]] # -# ___|____________________________________|____________________________________|____________________________________|___> signal power -# \_waterfall_auto_level_margin[0]_/ |__ current_min_power_level | \_waterfall_auto_level_margin[1]_/ -# current_max_power_level __| +# ___|________________________________________|____________________________________|________________________________________|___> signal power +# \_waterfall_auto_level_margin["min"]_/ |__ current_min_power_level | \_waterfall_auto_level_margin["max"]_/ +# current_max_power_level __| # === Experimental settings === # Warning! The settings below are very experimental. @@ -276,22 +282,28 @@ google_maps_api_key = "" # in seconds; default: 2 hours map_position_retention_time = 2 * 60 * 60 -# wsjt decoder queue configuration -# due to the nature of the wsjt operating modes (ft8, ft8, jt9, jt65 and wspr), the data is recorded for a given amount -# of time (6.5 seconds up to 2 minutes) and decoded at the end. this can lead to very high peak loads. +# 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) -wsjt_queue_workers = 2 +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 wsjt services running at the same time -wsjt_queue_length = 10 +# 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} +# 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 + temporary_directory = "/tmp" services_enabled = False @@ -314,3 +326,8 @@ aprs_symbols_path = "/opt/aprs-symbols/png" # this also uses the receiver_gps setting from above, so make sure it contains a correct locator pskreporter_enabled = False pskreporter_callsign = "N0CALL" + +# === Web admin settings === +# this feature is experimental at the moment. it should not be enabled on shared receivers since it allows remote +# changes to the receiver settings. enable for testing in controlled environment only. +# webadmin_enabled = False diff --git a/csdr/csdr.py b/csdr/csdr.py index f676451..95c7119 100644 --- a/csdr/csdr.py +++ b/csdr/csdr.py @@ -29,7 +29,11 @@ import math from functools import partial from owrx.kiss import KissClient, DirewolfConfig -from owrx.wsjt import Ft8Chopper, WsprChopper, Jt9Chopper, Jt65Chopper, Ft4Chopper +from owrx.wsjt import Ft8Profile, WsprProfile, Jt9Profile, Jt65Profile, Ft4Profile +from owrx.js8 import Js8Profiles +from owrx.audio import AudioChopper + +from csdr.pipe import Pipe import logging @@ -41,7 +45,7 @@ class output(object): 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)).start() + threading.Thread(target=self.pump(read_fn, lambda x: None), name="csdr_pump_thread").start() return self.receive_output(t, read_fn) @@ -52,7 +56,11 @@ class output(object): def copy(): run = True while run: - data = read() + data = None + try: + data = read() + except ValueError: + pass if data is None or (isinstance(data, bytes) and len(data) == 0): run = False else: @@ -68,8 +76,10 @@ class dsp(object): def __init__(self, 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 @@ -82,6 +92,8 @@ class dsp(object): self.demodulator = "nfm" self.name = "csdr" self.base_bufsize = 512 + self.decimation = None + self.last_decimation = None self.nc_port = None self.csdr_dynamic_bufsize = False self.csdr_print_bufsizes = False @@ -94,31 +106,38 @@ class dsp(object): self.secondary_fft_size = 1024 self.secondary_process_fft = None self.secondary_process_demod = None - self.pipe_names = [ - "bpf_pipe", - "shift_pipe", - "squelch_pipe", - "smeter_pipe", - "meta_pipe", - "iqtee_pipe", - "iqtee2_pipe", - "dmr_control_pipe", - ] - self.secondary_pipe_names = ["secondary_shift_pipe"] + 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.unvoiced_quality = 1 self.modification_lock = threading.Lock() self.output = output - self.temporary_directory = "/tmp" + + self.temporary_directory = None + self.pipe_base_path = None + self.set_temporary_directory("/tmp") + self.is_service = False self.direwolf_config = None self.direwolf_port = 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}"] @@ -137,7 +156,7 @@ class dsp(object): if self.fft_compression == "adpcm": chain += ["csdr compress_fft_adpcm_f_u8 {fft_size}"] return chain - chain += ["csdr shift_addition_cc --fifo {shift_pipe}"] + 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"] @@ -153,9 +172,12 @@ class dsp(object): 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 = ( - ["csdr fractional_decimator_ff {last_decimation}"] if self.last_decimation != 1.0 else [] - ) + 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 @@ -166,6 +188,16 @@ class dsp(object): ] 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} 50e-6", + "csdr convert_f_s16" + ] elif self.isDigitalVoice(which): chain += ["csdr fmdemod_quadri_cf", "dc_block "] chain += last_decimation_block @@ -202,6 +234,15 @@ class dsp(object): "csdr limit_ff", "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 - -", + "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 == "ssb": chain += ["csdr realpart_cf"] chain += last_decimation_block @@ -231,14 +272,14 @@ class dsp(object): return chain elif which == "bpsk31" or which == "bpsk63": return chain + [ - "csdr shift_addition_cc --fifo {secondary_shift_pipe}", + "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): + 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}"] @@ -247,7 +288,7 @@ class dsp(object): 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"] + 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: @@ -305,8 +346,8 @@ class dsp(object): self.try_create_configs(secondary_command_demod) secondary_command_demod = secondary_command_demod.format( - input_pipe=self.iqtee2_pipe, - secondary_shift_pipe=self.secondary_shift_pipe, + 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(), @@ -325,7 +366,7 @@ class dsp(object): 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.iqtee_pipe, + 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(), @@ -351,18 +392,25 @@ class dsp(object): if self.isWsjtMode(): smd = self.get_secondary_demodulator() + chopper_profile = None if smd == "ft8": - chopper = Ft8Chopper(self.secondary_process_demod.stdout) + chopper_profile = Ft8Profile() elif smd == "wspr": - chopper = WsprChopper(self.secondary_process_demod.stdout) + chopper_profile = WsprProfile() elif smd == "jt65": - chopper = Jt65Chopper(self.secondary_process_demod.stdout) + chopper_profile = Jt65Profile() elif smd == "jt9": - chopper = Jt9Chopper(self.secondary_process_demod.stdout) + chopper_profile = Jt9Profile() elif smd == "ft4": - chopper = Ft4Chopper(self.secondary_process_demod.stdout) + chopper_profile = Ft4Profile() + if chopper_profile is not None: + chopper = AudioChopper(self, self.secondary_process_demod.stdout, chopper_profile) + chopper.start() + self.output.send_output("wsjt_demod", chopper.read) + elif self.isJs8(): + chopper = AudioChopper(self, self.secondary_process_demod.stdout, *Js8Profiles.getEnabledProfiles()) chopper.start() - self.output.send_output("wsjt_demod", chopper.read) + self.output.send_output("js8_demod", chopper.read) elif self.isPacket(): # we best get the ax25 packets from the kiss socket kiss = KissClient(self.direwolf_port) @@ -373,30 +421,34 @@ class dsp(object): 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.secondary_shift_pipe != None: # TODO digimodes - self.secondary_shift_pipe_file = open(self.secondary_shift_pipe, "w") # TODO digimodes + 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 hasattr(self, "secondary_shift_pipe_file"): - self.secondary_shift_pipe_file.write("%g\n" % (-float(self.secondary_offset_freq) / self.if_samp_rate())) - self.secondary_shift_pipe_file.flush() + 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 self.secondary_processes_running == False: + 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 @@ -447,11 +499,21 @@ class dsp(object): def get_decimation(self, input_rate, output_rate): decimation = 1 - while input_rate / (decimation + 1) >= output_rate: + correction = 1 + # 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. + # the factor of 5 is by experimentation only, with a minimum audio rate of 36kHz (enforced by the client) + # this allows us to cover at least +/- 80kHz of frequency spectrum (may be higher, but that's the worst case). + # the correction factor is automatically compensated for by the secondary decimation stage, which comes + # after the demodulator. + if self.get_demodulator() == "wfm": + correction = 5 + while input_rate / (decimation + 1) >= output_rate * correction: decimation += 1 fraction = float(input_rate / decimation) / output_rate intermediate_rate = input_rate / decimation - return (decimation, fraction, intermediate_rate) + return decimation, fraction, intermediate_rate def if_samp_rate(self): return self.samp_rate / self.decimation @@ -462,11 +524,18 @@ class dsp(object): 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(): return 48000 - elif self.isWsjtMode(): + 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): @@ -479,6 +548,11 @@ class dsp(object): demodulator = self.get_secondary_demodulator() return demodulator in ["ft8", "wspr", "jt65", "jt9", "ft4"] + 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() @@ -489,6 +563,16 @@ class dsp(object): 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 set_output_rate(self, output_rate): if self.output_rate == output_rate: return @@ -496,7 +580,16 @@ class dsp(object): 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 @@ -527,21 +620,22 @@ class dsp(object): def set_offset_freq(self, offset_freq): self.offset_freq = offset_freq if self.running: - self.modification_lock.acquire() - self.shift_pipe_file.write("%g\n" % (-float(self.offset_freq) / self.samp_rate)) - self.shift_pipe_file.flush() - self.modification_lock.release() + 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_bpf(self, low_cut, high_cut): self.low_cut = low_cut self.high_cut = high_cut if self.running: - self.modification_lock.acquire() - self.bpf_pipe_file.write( + self.pipes["bpf_pipe"].write( "%g %g\n" % (float(self.low_cut) / self.if_samp_rate(), float(self.high_cut) / self.if_samp_rate()) ) - self.bpf_pipe_file.flush() - self.modification_lock.release() def get_bpf(self): return [self.low_cut, self.high_cut] @@ -552,12 +646,9 @@ class dsp(object): 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() else self.squelch_level + actual_squelch = -150 if self.isDigitalVoice() or self.isPacket() or self.isPocsag() or self.isFreeDV() else self.squelch_level if self.running: - self.modification_lock.acquire() - self.squelch_pipe_file.write("%g\n" % (self.convertToLinear(actual_squelch))) - self.squelch_pipe_file.flush() - self.modification_lock.release() + self.pipes["squelch_pipe"].write("%g\n" % (self.convertToLinear(actual_squelch))) def set_unvoiced_quality(self, q): self.unvoiced_quality = q @@ -567,39 +658,36 @@ class dsp(object): return self.unvoiced_quality def set_dmr_filter(self, filter): - if self.dmr_control_pipe_file: - self.dmr_control_pipe_file.write("{0}\n".format(filter)) - self.dmr_control_pipe_file.flush() - - def mkfifo(self, path): - try: - os.unlink(path) - except: - pass - os.mkfifo(path) + if self.has_pipe("dmr_control_pipe"): + self.pipes["dmr_control_pipe"].write("{0}\n".format(filter)) 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 in pipe_names: + 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: - setattr(self, pipe_name, self.pipe_base_path + pipe_name) - self.mkfifo(getattr(self, pipe_name)) + 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: - setattr(self, pipe_name, None) + 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: - pipe_path = getattr(self, pipe_name, None) - if pipe_path: - try: - os.unlink(pipe_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("try_delete_pipes()") + 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: @@ -626,108 +714,95 @@ class dsp(object): self.direwolf_config = None def start(self): - self.modification_lock.acquire() - if self.running: - self.modification_lock.release() - return - self.running = True + with self.modification_lock: + if self.running: + return + self.running = True - command_base = " | ".join(self.chain(self.demodulator)) + command_base = " | ".join(self.chain(self.demodulator)) - # create control pipes for csdr - self.pipe_base_path = "{tmp_dir}/openwebrx_pipe_{myid}_".format(tmp_dir=self.temporary_directory, myid=id(self)) + # create control pipes for csdr + self.try_create_pipes(self.pipe_names, command_base) - 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.bpf_pipe, - shift_pipe=self.shift_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, - squelch_pipe=self.squelch_pipe, - smeter_pipe=self.smeter_pipe, - meta_pipe=self.meta_pipe, - iqtee_pipe=self.iqtee_pipe, - iqtee2_pipe=self.iqtee2_pipe, - output_rate=self.get_output_rate(), - smeter_report_every=int(self.if_samp_rate() / 6000), - unvoiced_quality=self.get_unvoiced_quality(), - dmr_control_pipe=self.dmr_control_pipe, - audio_rate=self.get_audio_rate(), - ) - - logger.debug("Command = %s", command) - my_env = os.environ.copy() - if self.csdr_dynamic_bufsize: - my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1" - if self.csdr_print_bufsizes: - my_env["CSDR_PRINT_BUFSIZES"] = "1" - - 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, env=my_env) - - 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).start() - - if self.output.supports_type("audio"): - self.output.send_output( - "audio", - partial( - self.process.stdout.read, - self.get_fft_bytes_to_read() if self.demodulator == "fft" else self.get_audio_bytes_to_read(), - ), + # 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), + unvoiced_quality=self.get_unvoiced_quality(), + audio_rate=self.get_audio_rate(), ) - # open control pipes for csdr - if self.bpf_pipe: - self.bpf_pipe_file = open(self.bpf_pipe, "w") - if self.shift_pipe: - self.shift_pipe_file = open(self.shift_pipe, "w") - if self.squelch_pipe: - self.squelch_pipe_file = open(self.squelch_pipe, "w") - self.start_secondary_demodulator() + logger.debug("Command = %s", command) + my_env = os.environ.copy() + if self.csdr_dynamic_bufsize: + my_env["CSDR_DYNAMIC_BUFSIZE_ON"] = "1" + if self.csdr_print_bufsizes: + my_env["CSDR_PRINT_BUFSIZES"] = "1" - self.modification_lock.release() + 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, env=my_env) - # send initial config through the pipes - if self.squelch_pipe: - self.set_squelch_level(self.squelch_level) - if self.shift_pipe: - self.set_offset_freq(self.offset_freq) - if self.bpf_pipe: - self.set_bpf(self.low_cut, self.high_cut) - if self.smeter_pipe: - self.smeter_pipe_file = open(self.smeter_pipe, "r") + 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.smeter_pipe_file.readline() + 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.meta_pipe != None: - # TODO make digiham output unicode and then change this here - self.meta_pipe_file = open(self.meta_pipe, "r", encoding="cp437") - + if self.has_pipe("meta_pipe"): def read_meta(): - raw = self.meta_pipe_file.readline() + raw = self.pipes["meta_pipe"].readline() if len(raw) == 0: return None else: @@ -735,23 +810,25 @@ class dsp(object): self.output.send_output("meta", read_meta) - if self.dmr_control_pipe: - self.dmr_control_pipe_file = open(self.dmr_control_pipe, "w") + if self.csdr_dynamic_bufsize: + self.process.stdout.read(8) # dummy read to skip bufsize & preamble + logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1") def stop(self): - self.modification_lock.acquire() - self.running = False - if hasattr(self, "process"): - try: - os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) - except ProcessLookupError: - # been killed by something else, ignore - pass - self.stop_secondary_demodulator() + 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.modification_lock.release() + self.try_delete_pipes(self.pipe_names) def restart(self): if not self.running: @@ -761,4 +838,3 @@ class dsp(object): def __del__(self): self.stop() - del self.process diff --git a/csdr/pipe.py b/csdr/pipe.py new file mode 100644 index 0000000..f915aef --- /dev/null +++ b/csdr/pipe.py @@ -0,0 +1,155 @@ +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(.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/debian/changelog b/debian/changelog index 3c19b4b..ccb485b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,148 @@ -openwebrx (0.18) UNRELEASED; urgency=low +openwebrx (0.20.0) UNRELEASED; urgency=low - * Initial release. + * 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 + * New devices supported: + - FunCube Dongle Pro+ (`"type": "fcdpp"`) + - Support for connections to rtl_tcp - -- Jakob Ketterl Sun, 08 Dec 2019 12:35:48 +0000 + -- Jakob Ketterl Sat, 13 Jun 2020 17:22: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/debian/control b/debian/control index 4903848..5afe4be 100644 --- a/debian/control +++ b/debian/control @@ -3,11 +3,14 @@ Maintainer: Jakob Ketterl Section: hamradio Priority: optional Standards-Version: 4.2.0 -Build-Depends: debhelper (>= 10), dh-python, python3 (>= 3.5), dh-systemd (>= 1.5) +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: python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.14), netcat, owrx-connector (>= 0.1), ${python3:Depends} -Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx +Depends: adduser, python3 (>= 3.5), python3-pkg-resources, csdr (>= 0.14), netcat, owrx-connector (>= 0.2), python3-js8py (>= 0.1), ${python3:Depends}, ${misc:Depends} +Recommends: digiham (>= 0.3), dsd (>= 1.7), sox, direwolf (>= 1.4), wsjtx, soapysdr-tools Description: multi-user web sdr Open source, multi-user SDR receiver with a web interface \ No newline at end of file diff --git a/debian/openwebrx.install b/debian/openwebrx.install index caa2fff..84b1f9c 100644 --- a/debian/openwebrx.install +++ b/debian/openwebrx.install @@ -1,4 +1,5 @@ config_webrx.py etc/openwebrx/ bands.json etc/openwebrx/ bookmarks.json etc/openwebrx/ +users.json etc/openwebrx/ systemd/openwebrx.service lib/systemd/system/ \ No newline at end of file diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..9a4b5e7 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,7 @@ +#!/bin/bash +set -euxo pipefail + +adduser --system --group --no-create-home --home /nonexistant openwebrx +usermod -aG plugdev openwebrx + +#DEBHELPER# \ No newline at end of file diff --git a/debian/rules b/debian/rules index 5ed154e..3b7418e 100755 --- a/debian/rules +++ b/debian/rules @@ -3,3 +3,6 @@ export PYBUILD_NAME=openwebrx %: dh $@ --with python3 --buildsystem=pybuild --with systemd + +override_dh_strip_nondeterminism: + dh_strip_nondeterminism -X.png diff --git a/docker/Dockerfiles/Dockerfile-airspy b/docker/Dockerfiles/Dockerfile-airspy index 4383d0d..d332bb5 100644 --- a/docker/Dockerfiles/Dockerfile-airspy +++ b/docker/Dockerfiles/Dockerfile-airspy @@ -2,9 +2,7 @@ ARG ARCHTAG FROM openwebrx-soapysdr-base:$ARCHTAG ADD docker/scripts/install-dependencies-airspy.sh / -RUN /install-dependencies-airspy.sh -RUN rm /install-dependencies-airspy.sh +RUN /install-dependencies-airspy.sh &&\ + rm /install-dependencies-airspy.sh -ADD docker/scripts/install-connectors.sh / -RUN /install-connectors.sh -RUN rm /install-connectors.sh +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-base b/docker/Dockerfiles/Dockerfile-base index 566f9d1..c044fd7 100644 --- a/docker/Dockerfiles/Dockerfile-base +++ b/docker/Dockerfiles/Dockerfile-base @@ -1,19 +1,18 @@ -FROM alpine:3.10 +FROM debian:buster-slim -RUN apk add --no-cache bash - -RUN ln -s /usr/local/lib /usr/local/lib64 - -ADD docker/scripts/direwolf-1.5.patch / +ADD docker/files/js8call/js8call-hamlib.patch / +ADD docker/files/wsjtx/*.patch / ADD docker/scripts/install-dependencies.sh / -RUN /install-dependencies.sh -RUN rm /install-dependencies.sh +RUN /install-dependencies.sh && \ + rm /install-dependencies.sh && \ + rm /*.patch -ADD . /opt/openwebrx +ENTRYPOINT ["/init"] WORKDIR /opt/openwebrx VOLUME /etc/openwebrx -ENTRYPOINT [ "/opt/openwebrx/docker/scripts/run.sh" ] +CMD [ "/opt/openwebrx/docker/scripts/run.sh" ] + EXPOSE 8073 diff --git a/docker/Dockerfiles/Dockerfile-fcdpp b/docker/Dockerfiles/Dockerfile-fcdpp new file mode 100644 index 0000000..d9ad1fc --- /dev/null +++ b/docker/Dockerfiles/Dockerfile-fcdpp @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +ADD docker/scripts/install-dependencies-fcdpp.sh / +RUN /install-dependencies-fcdpp.sh &&\ + rm /install-dependencies-fcdpp.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-full b/docker/Dockerfiles/Dockerfile-full index 5d59e6a..6a8a1e6 100644 --- a/docker/Dockerfiles/Dockerfile-full +++ b/docker/Dockerfiles/Dockerfile-full @@ -2,16 +2,27 @@ ARG ARCHTAG FROM openwebrx-base:$ARCHTAG ADD docker/scripts/install-dependencies-*.sh / -ADD docker/scripts/install-lib.*.patch / +ADD docker/files/sdrplay/install-lib.*.patch / -RUN /install-dependencies-rtlsdr.sh -RUN /install-dependencies-hackrf.sh -RUN /install-dependencies-soapysdr.sh -RUN /install-dependencies-sdrplay.sh -RUN /install-dependencies-airspy.sh -RUN /install-dependencies-rtlsdr-soapy.sh -RUN rm /install-dependencies-*.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 &&\ + rm /install-dependencies-*.sh &&\ + rm /install-lib.*.patch ADD docker/scripts/install-connectors.sh / -RUN /install-connectors.sh -RUN rm /install-connectors.sh +RUN /install-connectors.sh &&\ + rm /install-connectors.sh + +ADD docker/files/services/sdrplay /etc/services.d/sdrplay + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-hackrf b/docker/Dockerfiles/Dockerfile-hackrf index 57643ab..93cb35b 100644 --- a/docker/Dockerfiles/Dockerfile-hackrf +++ b/docker/Dockerfiles/Dockerfile-hackrf @@ -1,7 +1,8 @@ ARG ARCHTAG -FROM openwebrx-base:$ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG ADD docker/scripts/install-dependencies-hackrf.sh / -RUN /install-dependencies-hackrf.sh -RUN rm /install-dependencies-hackrf.sh +RUN /install-dependencies-hackrf.sh &&\ + rm /install-dependencies-hackrf.sh +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-limesdr b/docker/Dockerfiles/Dockerfile-limesdr new file mode 100644 index 0000000..54b7a37 --- /dev/null +++ b/docker/Dockerfiles/Dockerfile-limesdr @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +ADD docker/scripts/install-dependencies-limesdr.sh / +RUN /install-dependencies-limesdr.sh &&\ + rm /install-dependencies-limesdr.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-perseus b/docker/Dockerfiles/Dockerfile-perseus new file mode 100644 index 0000000..8a3faaa --- /dev/null +++ b/docker/Dockerfiles/Dockerfile-perseus @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-base:$ARCHTAG + +ADD docker/scripts/install-dependencies-perseus.sh / +RUN /install-dependencies-perseus.sh &&\ + rm /install-dependencies-perseus.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-plutosdr b/docker/Dockerfiles/Dockerfile-plutosdr new file mode 100644 index 0000000..d91e3f1 --- /dev/null +++ b/docker/Dockerfiles/Dockerfile-plutosdr @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +ADD docker/scripts/install-dependencies-plutosdr.sh / +RUN /install-dependencies-plutosdr.sh &&\ + rm /install-dependencies-plutosdr.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-radioberry b/docker/Dockerfiles/Dockerfile-radioberry new file mode 100644 index 0000000..9eabed8 --- /dev/null +++ b/docker/Dockerfiles/Dockerfile-radioberry @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +ADD docker/scripts/install-dependencies-radioberry.sh / +RUN /install-dependencies-radioberry.sh &&\ + rm /install-dependencies-radioberry.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-rtlsdr b/docker/Dockerfiles/Dockerfile-rtlsdr index b0726da..5e24074 100644 --- a/docker/Dockerfiles/Dockerfile-rtlsdr +++ b/docker/Dockerfiles/Dockerfile-rtlsdr @@ -2,9 +2,11 @@ ARG ARCHTAG FROM openwebrx-base:$ARCHTAG ADD docker/scripts/install-dependencies-rtlsdr.sh / -RUN /install-dependencies-rtlsdr.sh -RUN rm /install-dependencies-rtlsdr.sh - ADD docker/scripts/install-connectors.sh / -RUN /install-connectors.sh -RUN rm /install-connectors.sh + +RUN /install-dependencies-rtlsdr.sh &&\ + rm /install-dependencies-rtlsdr.sh &&\ + /install-connectors.sh &&\ + rm /install-connectors.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-rtlsdr-soapy b/docker/Dockerfiles/Dockerfile-rtlsdr-soapy index 7989a6a..47de00d 100644 --- a/docker/Dockerfiles/Dockerfile-rtlsdr-soapy +++ b/docker/Dockerfiles/Dockerfile-rtlsdr-soapy @@ -2,9 +2,7 @@ ARG ARCHTAG FROM openwebrx-soapysdr-base:$ARCHTAG ADD docker/scripts/install-dependencies-rtlsdr-soapy.sh / -RUN /install-dependencies-rtlsdr-soapy.sh -RUN rm /install-dependencies-rtlsdr-soapy.sh +RUN /install-dependencies-rtlsdr-soapy.sh &&\ + rm /install-dependencies-rtlsdr-soapy.sh -ADD docker/scripts/install-connectors.sh / -RUN /install-connectors.sh -RUN rm /install-connectors.sh +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-sdrplay b/docker/Dockerfiles/Dockerfile-sdrplay index f44f53b..ec649f2 100644 --- a/docker/Dockerfiles/Dockerfile-sdrplay +++ b/docker/Dockerfiles/Dockerfile-sdrplay @@ -2,10 +2,11 @@ ARG ARCHTAG FROM openwebrx-soapysdr-base:$ARCHTAG ADD docker/scripts/install-dependencies-sdrplay.sh / -ADD docker/scripts/install-lib.*.patch / -RUN /install-dependencies-sdrplay.sh -RUN rm /install-dependencies-sdrplay.sh +ADD docker/files/sdrplay/install-lib.*.patch / +RUN /install-dependencies-sdrplay.sh &&\ + rm /install-dependencies-sdrplay.sh &&\ + rm /install-lib.*.patch -ADD docker/scripts/install-connectors.sh / -RUN /install-connectors.sh -RUN rm /install-connectors.sh +ADD docker/files/services/sdrplay /etc/services.d/sdrplay + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-soapyremote b/docker/Dockerfiles/Dockerfile-soapyremote new file mode 100644 index 0000000..312af40 --- /dev/null +++ b/docker/Dockerfiles/Dockerfile-soapyremote @@ -0,0 +1,8 @@ +ARG ARCHTAG +FROM openwebrx-soapysdr-base:$ARCHTAG + +ADD docker/scripts/install-dependencies-soapyremote.sh / +RUN /install-dependencies-soapyremote.sh &&\ + rm /install-dependencies-soapyremote.sh + +ADD . /opt/openwebrx diff --git a/docker/Dockerfiles/Dockerfile-soapysdr b/docker/Dockerfiles/Dockerfile-soapysdr index 82b93e9..c3f8626 100644 --- a/docker/Dockerfiles/Dockerfile-soapysdr +++ b/docker/Dockerfiles/Dockerfile-soapysdr @@ -2,6 +2,8 @@ ARG ARCHTAG FROM openwebrx-base:$ARCHTAG ADD docker/scripts/install-dependencies-soapysdr.sh / -RUN /install-dependencies-soapysdr.sh -RUN rm /install-dependencies-soapysdr.sh - +ADD 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/docker/env b/docker/env new file mode 100644 index 0000000..daa5e10 --- /dev/null +++ b/docker/env @@ -0,0 +1,5 @@ +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-full openwebrx" +ALL_ARCHS="x86_64 armv7l aarch64" +TAG=${TAG:-"latest"} +ARCHTAG="$TAG-$ARCH" diff --git a/docker/files/js8call/js8call-hamlib.patch b/docker/files/js8call/js8call-hamlib.patch new file mode 100644 index 0000000..899f83e --- /dev/null +++ b/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/docker/files/sdrplay/install-lib.aarch64.patch b/docker/files/sdrplay/install-lib.aarch64.patch new file mode 100644 index 0000000..1f3dc57 --- /dev/null +++ b/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/docker/files/sdrplay/install-lib.armv7l.patch b/docker/files/sdrplay/install-lib.armv7l.patch new file mode 100644 index 0000000..22a78f6 --- /dev/null +++ b/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/docker/files/sdrplay/install-lib.x86_64.patch b/docker/files/sdrplay/install-lib.x86_64.patch new file mode 100644 index 0000000..d66023b --- /dev/null +++ b/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/docker/files/services/sdrplay/run b/docker/files/services/sdrplay/run new file mode 100755 index 0000000..0f31c4c --- /dev/null +++ b/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/docker/files/wsjtx/wsjtx-hamlib.patch b/docker/files/wsjtx/wsjtx-hamlib.patch new file mode 100644 index 0000000..09a8a88 --- /dev/null +++ b/docker/files/wsjtx/wsjtx-hamlib.patch @@ -0,0 +1,43 @@ +--- CMakeLists.txt.orig 2020-07-21 20:59:55.982026645 +0200 ++++ CMakeLists.txt 2020-07-21 21:01:25.444836112 +0200 +@@ -80,24 +80,6 @@ + + include (ExternalProject) + +- +-# +-# 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} +- URL ${CMAKE_CURRENT_SOURCE_DIR}/src/${__hamlib_upstream} +- 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 + # +@@ -136,7 +118,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} +@@ -160,7 +141,6 @@ + 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) diff --git a/docker/files/wsjtx/wsjtx.patch b/docker/files/wsjtx/wsjtx.patch new file mode 100644 index 0000000..59ac6b7 --- /dev/null +++ b/docker/files/wsjtx/wsjtx.patch @@ -0,0 +1,155 @@ +diff -ur wsjtx-orig/CMake/Modules/Findhamlib.cmake wsjtx/CMake/Modules/Findhamlib.cmake +--- wsjtx-orig/CMake/Modules/Findhamlib.cmake 2020-07-21 21:10:43.124810140 +0200 ++++ wsjtx/CMake/Modules/Findhamlib.cmake 2020-07-21 21:11:03.368019114 +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 2020-07-21 21:10:43.124810140 +0200 ++++ wsjtx/CMakeLists.txt 2020-07-21 22:14:04.454639589 +0200 +@@ -871,7 +871,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) +@@ -1348,53 +1348,10 @@ + + endif(WSJT_BUILD_UTILS) + +-# build the main application +-add_executable (wsjtx MACOSX_BUNDLE +- ${wsjtx_CXXSRCS} +- ${wsjtx_GENUISRCS} +- wsjtx.rc +- ${WSJTX_ICON_FILE} +- ${wsjtx_RESOURCES_RCC} +- ) +- + 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 "${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.k1jt.wsjtx" +- ) +- +-target_include_directories (wsjtx PRIVATE ${FFTW3_INCLUDE_DIRS}) +-if (APPLE) +- target_link_libraries (wsjtx Qt5::SerialPort wsjt_fort wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +-else () +- target_link_libraries (wsjtx Qt5::SerialPort wsjt_fort_omp wsjt_cxx wsjt_qt wsjt_qtmm ${hamlib_LIBRARIES} ${FFTW3_LIBRARIES}) +- 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,16777216 +- ) +- endif () +-endif () +- + # 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}) +@@ -1437,24 +1394,9 @@ + 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} +@@ -1473,12 +1415,7 @@ + # DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/wsjtx + # ) + +-install (TARGETS udp_daemon message_aggregator +- RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime +- ) +- +-install (TARGETS jt9 wsprd fmtave fcal fmeasure ++install (TARGETS jt9 wsprd + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT runtime + ) +@@ -1491,39 +1428,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 +- INSTALL +- BUGS +- DESTINATION ${CMAKE_INSTALL_DOCDIR} +- #COMPONENT runtime +- ) +- + install (FILES + contrib/Ephemeris/JPLEPH + DESTINATION ${CMAKE_INSTALL_DATADIR}/${CMAKE_PROJECT_NAME} +Only in wsjtx: .idea diff --git a/docker/scripts/direwolf-1.5.patch b/docker/scripts/direwolf-1.5.patch deleted file mode 100644 index 4a63915..0000000 --- a/docker/scripts/direwolf-1.5.patch +++ /dev/null @@ -1,241 +0,0 @@ -diff --git a/Makefile.linux b/Makefile.linux -index 5010833..3f61de9 100644 ---- a/Makefile.linux -+++ b/Makefile.linux -@@ -585,102 +585,102 @@ install : $(APPS) direwolf.conf tocalls.txt symbols-new.txt symbolsX.txt dw-icon - # Applications, not installed with package manager, normally go in /usr/local/bin. - # /usr/bin is used instead when installing from .DEB or .RPM package. - # -- $(INSTALL) -D --mode=755 direwolf $(DESTDIR)/bin/direwolf -- $(INSTALL) -D --mode=755 decode_aprs $(DESTDIR)/bin/decode_aprs -- $(INSTALL) -D --mode=755 text2tt $(DESTDIR)/bin/text2tt -- $(INSTALL) -D --mode=755 tt2text $(DESTDIR)/bin/tt2text -- $(INSTALL) -D --mode=755 ll2utm $(DESTDIR)/bin/ll2utm -- $(INSTALL) -D --mode=755 utm2ll $(DESTDIR)/bin/utm2ll -- $(INSTALL) -D --mode=755 aclients $(DESTDIR)/bin/aclients -- $(INSTALL) -D --mode=755 log2gpx $(DESTDIR)/bin/log2gpx -- $(INSTALL) -D --mode=755 gen_packets $(DESTDIR)/bin/gen_packets -- $(INSTALL) -D --mode=755 atest $(DESTDIR)/bin/atest -- $(INSTALL) -D --mode=755 ttcalc $(DESTDIR)/bin/ttcalc -- $(INSTALL) -D --mode=755 kissutil $(DESTDIR)/bin/kissutil -- $(INSTALL) -D --mode=755 cm108 $(DESTDIR)/bin/cm108 -- $(INSTALL) -D --mode=755 dwespeak.sh $(DESTDIR)/bin/dwspeak.sh -+ $(INSTALL) -D -m=755 direwolf $(DESTDIR)/bin/direwolf -+ $(INSTALL) -D -m=755 decode_aprs $(DESTDIR)/bin/decode_aprs -+ $(INSTALL) -D -m=755 text2tt $(DESTDIR)/bin/text2tt -+ $(INSTALL) -D -m=755 tt2text $(DESTDIR)/bin/tt2text -+ $(INSTALL) -D -m=755 ll2utm $(DESTDIR)/bin/ll2utm -+ $(INSTALL) -D -m=755 utm2ll $(DESTDIR)/bin/utm2ll -+ $(INSTALL) -D -m=755 aclients $(DESTDIR)/bin/aclients -+ $(INSTALL) -D -m=755 log2gpx $(DESTDIR)/bin/log2gpx -+ $(INSTALL) -D -m=755 gen_packets $(DESTDIR)/bin/gen_packets -+ $(INSTALL) -D -m=755 atest $(DESTDIR)/bin/atest -+ $(INSTALL) -D -m=755 ttcalc $(DESTDIR)/bin/ttcalc -+ $(INSTALL) -D -m=755 kissutil $(DESTDIR)/bin/kissutil -+ $(INSTALL) -D -m=755 cm108 $(DESTDIR)/bin/cm108 -+ $(INSTALL) -D -m=755 dwespeak.sh $(DESTDIR)/bin/dwspeak.sh - # - # Telemetry Toolkit executables. Other .conf and .txt files will go into doc directory. - # -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-balloon.pl $(DESTDIR)/bin/telem-balloon.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-bits.pl $(DESTDIR)/bin/telem-bits.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-data.pl $(DESTDIR)/bin/telem-data.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-data91.pl $(DESTDIR)/bin/telem-data91.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-eqns.pl $(DESTDIR)/bin/telem-eqns.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-parm.pl $(DESTDIR)/bin/telem-parm.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-seq.sh $(DESTDIR)/bin/telem-seq.sh -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-unit.pl $(DESTDIR)/bin/telem-unit.pl -- $(INSTALL) -D --mode=755 telemetry-toolkit/telem-volts.py $(DESTDIR)/bin/telem-volts.py -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-balloon.pl $(DESTDIR)/bin/telem-balloon.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-bits.pl $(DESTDIR)/bin/telem-bits.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-data.pl $(DESTDIR)/bin/telem-data.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-data91.pl $(DESTDIR)/bin/telem-data91.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-eqns.pl $(DESTDIR)/bin/telem-eqns.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-parm.pl $(DESTDIR)/bin/telem-parm.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-seq.sh $(DESTDIR)/bin/telem-seq.sh -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-unit.pl $(DESTDIR)/bin/telem-unit.pl -+ $(INSTALL) -D -m=755 telemetry-toolkit/telem-volts.py $(DESTDIR)/bin/telem-volts.py - # - # Misc. data such as "tocall" to system mapping. - # -- $(INSTALL) -D --mode=644 tocalls.txt $(DESTDIR)/share/direwolf/tocalls.txt -- $(INSTALL) -D --mode=644 symbols-new.txt $(DESTDIR)/share/direwolf/symbols-new.txt -- $(INSTALL) -D --mode=644 symbolsX.txt $(DESTDIR)/share/direwolf/symbolsX.txt -+ $(INSTALL) -D -m=644 tocalls.txt $(DESTDIR)/share/direwolf/tocalls.txt -+ $(INSTALL) -D -m=644 symbols-new.txt $(DESTDIR)/share/direwolf/symbols-new.txt -+ $(INSTALL) -D -m=644 symbolsX.txt $(DESTDIR)/share/direwolf/symbolsX.txt - # - # For desktop icon. - # -- $(INSTALL) -D --mode=644 dw-icon.png $(DESTDIR)/share/direwolf/pixmaps/dw-icon.png -- $(INSTALL) -D --mode=644 direwolf.desktop $(DESTDIR)/share/applications/direwolf.desktop -+ $(INSTALL) -D -m=644 dw-icon.png $(DESTDIR)/share/direwolf/pixmaps/dw-icon.png -+ $(INSTALL) -D -m=644 direwolf.desktop $(DESTDIR)/share/applications/direwolf.desktop - # - # Documentation. Various plain text files and PDF. - # -- $(INSTALL) -D --mode=644 CHANGES.md $(DESTDIR)/share/doc/direwolf/CHANGES.md -- $(INSTALL) -D --mode=644 LICENSE-dire-wolf.txt $(DESTDIR)/share/doc/direwolf/LICENSE-dire-wolf.txt -- $(INSTALL) -D --mode=644 LICENSE-other.txt $(DESTDIR)/share/doc/direwolf/LICENSE-other.txt -+ $(INSTALL) -D -m=644 CHANGES.md $(DESTDIR)/share/doc/direwolf/CHANGES.md -+ $(INSTALL) -D -m=644 LICENSE-dire-wolf.txt $(DESTDIR)/share/doc/direwolf/LICENSE-dire-wolf.txt -+ $(INSTALL) -D -m=644 LICENSE-other.txt $(DESTDIR)/share/doc/direwolf/LICENSE-other.txt - # - # ./README.md is an overview for the project main page. - # Maybe we could stick it in some other place. - # doc/README.md contains an overview of the PDF file contents and is more useful here. - # -- $(INSTALL) -D --mode=644 doc/README.md $(DESTDIR)/share/doc/direwolf/README.md -- $(INSTALL) -D --mode=644 doc/2400-4800-PSK-for-APRS-Packet-Radio.pdf $(DESTDIR)/share/doc/direwolf/2400-4800-PSK-for-APRS-Packet-Radio.pdf -- $(INSTALL) -D --mode=644 doc/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf -- $(INSTALL) -D --mode=644 doc/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf -- $(INSTALL) -D --mode=644 doc/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf $(DESTDIR)/share/doc/direwolf/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf -- $(INSTALL) -D --mode=644 doc/APRS-Telemetry-Toolkit.pdf $(DESTDIR)/share/doc/direwolf/APRS-Telemetry-Toolkit.pdf -- $(INSTALL) -D --mode=644 doc/APRStt-Implementation-Notes.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Implementation-Notes.pdf -- $(INSTALL) -D --mode=644 doc/APRStt-interface-for-SARTrack.pdf $(DESTDIR)/share/doc/direwolf/APRStt-interface-for-SARTrack.pdf -- $(INSTALL) -D --mode=644 doc/APRStt-Listening-Example.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Listening-Example.pdf -- $(INSTALL) -D --mode=644 doc/Bluetooth-KISS-TNC.pdf $(DESTDIR)/share/doc/direwolf/Bluetooth-KISS-TNC.pdf -- $(INSTALL) -D --mode=644 doc/Going-beyond-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/Going-beyond-9600-baud.pdf -- $(INSTALL) -D --mode=644 doc/Raspberry-Pi-APRS.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS.pdf -- $(INSTALL) -D --mode=644 doc/Raspberry-Pi-APRS-Tracker.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS-Tracker.pdf -- $(INSTALL) -D --mode=644 doc/Raspberry-Pi-SDR-IGate.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-SDR-IGate.pdf -- $(INSTALL) -D --mode=644 doc/Successful-APRS-IGate-Operation.pdf $(DESTDIR)/share/doc/direwolf/Successful-APRS-IGate-Operation.pdf -- $(INSTALL) -D --mode=644 doc/User-Guide.pdf $(DESTDIR)/share/doc/direwolf/User-Guide.pdf -- $(INSTALL) -D --mode=644 doc/WA8LMF-TNC-Test-CD-Results.pdf $(DESTDIR)/share/doc/direwolf/WA8LMF-TNC-Test-CD-Results.pdf -+ $(INSTALL) -D -m=644 doc/README.md $(DESTDIR)/share/doc/direwolf/README.md -+ $(INSTALL) -D -m=644 doc/2400-4800-PSK-for-APRS-Packet-Radio.pdf $(DESTDIR)/share/doc/direwolf/2400-4800-PSK-for-APRS-Packet-Radio.pdf -+ $(INSTALL) -D -m=644 doc/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-1-1200-baud.pdf -+ $(INSTALL) -D -m=644 doc/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/A-Better-APRS-Packet-Demodulator-Part-2-9600-baud.pdf -+ $(INSTALL) -D -m=644 doc/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf $(DESTDIR)/share/doc/direwolf/A-Closer-Look-at-the-WA8LMF-TNC-Test-CD.pdf -+ $(INSTALL) -D -m=644 doc/APRS-Telemetry-Toolkit.pdf $(DESTDIR)/share/doc/direwolf/APRS-Telemetry-Toolkit.pdf -+ $(INSTALL) -D -m=644 doc/APRStt-Implementation-Notes.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Implementation-Notes.pdf -+ $(INSTALL) -D -m=644 doc/APRStt-interface-for-SARTrack.pdf $(DESTDIR)/share/doc/direwolf/APRStt-interface-for-SARTrack.pdf -+ $(INSTALL) -D -m=644 doc/APRStt-Listening-Example.pdf $(DESTDIR)/share/doc/direwolf/APRStt-Listening-Example.pdf -+ $(INSTALL) -D -m=644 doc/Bluetooth-KISS-TNC.pdf $(DESTDIR)/share/doc/direwolf/Bluetooth-KISS-TNC.pdf -+ $(INSTALL) -D -m=644 doc/Going-beyond-9600-baud.pdf $(DESTDIR)/share/doc/direwolf/Going-beyond-9600-baud.pdf -+ $(INSTALL) -D -m=644 doc/Raspberry-Pi-APRS.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS.pdf -+ $(INSTALL) -D -m=644 doc/Raspberry-Pi-APRS-Tracker.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-APRS-Tracker.pdf -+ $(INSTALL) -D -m=644 doc/Raspberry-Pi-SDR-IGate.pdf $(DESTDIR)/share/doc/direwolf/Raspberry-Pi-SDR-IGate.pdf -+ $(INSTALL) -D -m=644 doc/Successful-APRS-IGate-Operation.pdf $(DESTDIR)/share/doc/direwolf/Successful-APRS-IGate-Operation.pdf -+ $(INSTALL) -D -m=644 doc/User-Guide.pdf $(DESTDIR)/share/doc/direwolf/User-Guide.pdf -+ $(INSTALL) -D -m=644 doc/WA8LMF-TNC-Test-CD-Results.pdf $(DESTDIR)/share/doc/direwolf/WA8LMF-TNC-Test-CD-Results.pdf - # - # Various sample config and other files go into examples under the doc directory. - # When building from source, these can be put in home directory with "make install-conf". - # When installed from .DEB or .RPM package, the user will need to copy these to - # the home directory or other desired location. - # -- $(INSTALL) -D --mode=644 direwolf.conf $(DESTDIR)/share/doc/direwolf/examples/direwolf.conf -- $(INSTALL) -D --mode=755 dw-start.sh $(DESTDIR)/share/doc/direwolf/examples/dw-start.sh -- $(INSTALL) -D --mode=644 sdr.conf $(DESTDIR)/share/doc/direwolf/examples/sdr.conf -- $(INSTALL) -D --mode=644 telemetry-toolkit/telem-m0xer-3.txt $(DESTDIR)/share/doc/direwolf/examples/telem-m0xer-3.txt -- $(INSTALL) -D --mode=644 telemetry-toolkit/telem-balloon.conf $(DESTDIR)/share/doc/direwolf/examples/telem-balloon.conf -- $(INSTALL) -D --mode=644 telemetry-toolkit/telem-volts.conf $(DESTDIR)/share/doc/direwolf/examples/telem-volts.conf -+ $(INSTALL) -D -m=644 direwolf.conf $(DESTDIR)/share/doc/direwolf/examples/direwolf.conf -+ $(INSTALL) -D -m=755 dw-start.sh $(DESTDIR)/share/doc/direwolf/examples/dw-start.sh -+ $(INSTALL) -D -m=644 sdr.conf $(DESTDIR)/share/doc/direwolf/examples/sdr.conf -+ $(INSTALL) -D -m=644 telemetry-toolkit/telem-m0xer-3.txt $(DESTDIR)/share/doc/direwolf/examples/telem-m0xer-3.txt -+ $(INSTALL) -D -m=644 telemetry-toolkit/telem-balloon.conf $(DESTDIR)/share/doc/direwolf/examples/telem-balloon.conf -+ $(INSTALL) -D -m=644 telemetry-toolkit/telem-volts.conf $(DESTDIR)/share/doc/direwolf/examples/telem-volts.conf - # - # "man" pages - # -- $(INSTALL) -D --mode=644 man1/aclients.1 $(DESTDIR)/share/man/man1/aclients.1 -- $(INSTALL) -D --mode=644 man1/atest.1 $(DESTDIR)/share/man/man1/atest.1 -- $(INSTALL) -D --mode=644 man1/decode_aprs.1 $(DESTDIR)/share/man/man1/decode_aprs.1 -- $(INSTALL) -D --mode=644 man1/direwolf.1 $(DESTDIR)/share/man/man1/direwolf.1 -- $(INSTALL) -D --mode=644 man1/gen_packets.1 $(DESTDIR)/share/man/man1/gen_packets.1 -- $(INSTALL) -D --mode=644 man1/kissutil.1 $(DESTDIR)/share/man/man1/kissutil.1 -- $(INSTALL) -D --mode=644 man1/ll2utm.1 $(DESTDIR)/share/man/man1/ll2utm.1 -- $(INSTALL) -D --mode=644 man1/log2gpx.1 $(DESTDIR)/share/man/man1/log2gpx.1 -- $(INSTALL) -D --mode=644 man1/text2tt.1 $(DESTDIR)/share/man/man1/text2tt.1 -- $(INSTALL) -D --mode=644 man1/tt2text.1 $(DESTDIR)/share/man/man1/tt2text.1 -- $(INSTALL) -D --mode=644 man1/utm2ll.1 $(DESTDIR)/share/man/man1/utm2ll.1 -+ $(INSTALL) -D -m=644 man1/aclients.1 $(DESTDIR)/share/man/man1/aclients.1 -+ $(INSTALL) -D -m=644 man1/atest.1 $(DESTDIR)/share/man/man1/atest.1 -+ $(INSTALL) -D -m=644 man1/decode_aprs.1 $(DESTDIR)/share/man/man1/decode_aprs.1 -+ $(INSTALL) -D -m=644 man1/direwolf.1 $(DESTDIR)/share/man/man1/direwolf.1 -+ $(INSTALL) -D -m=644 man1/gen_packets.1 $(DESTDIR)/share/man/man1/gen_packets.1 -+ $(INSTALL) -D -m=644 man1/kissutil.1 $(DESTDIR)/share/man/man1/kissutil.1 -+ $(INSTALL) -D -m=644 man1/ll2utm.1 $(DESTDIR)/share/man/man1/ll2utm.1 -+ $(INSTALL) -D -m=644 man1/log2gpx.1 $(DESTDIR)/share/man/man1/log2gpx.1 -+ $(INSTALL) -D -m=644 man1/text2tt.1 $(DESTDIR)/share/man/man1/text2tt.1 -+ $(INSTALL) -D -m=644 man1/tt2text.1 $(DESTDIR)/share/man/man1/tt2text.1 -+ $(INSTALL) -D -m=644 man1/utm2ll.1 $(DESTDIR)/share/man/man1/utm2ll.1 - # - # Set group and mode of HID devices corresponding to C-Media USB Audio adapters. - # This will allow us to use the CM108/CM119 GPIO pins for PTT. - # -- $(INSTALL) -D --mode=644 99-direwolf-cmedia.rules /etc/udev/rules.d/99-direwolf-cmedia.rules -+ $(INSTALL) -D -m=644 99-direwolf-cmedia.rules /etc/udev/rules.d/99-direwolf-cmedia.rules - # - @echo " " - @echo "If this is your first install, not an upgrade, type this to put a copy" -diff --git a/cdigipeater.c b/cdigipeater.c -index 9c40d95..94112e9 100644 ---- a/cdigipeater.c -+++ b/cdigipeater.c -@@ -49,7 +49,7 @@ - #include - #include /* for isdigit, isupper */ - #include "regex.h" --#include -+#include - - #include "ax25_pad.h" - #include "cdigipeater.h" -diff --git a/decode_aprs.c b/decode_aprs.c -index 35c186b..a620cb3 100644 ---- a/decode_aprs.c -+++ b/decode_aprs.c -@@ -3872,11 +3872,7 @@ static void decode_tocall (decode_aprs_t *A, char *dest) - * models before getting to the more generic APY. - */ - --#if defined(__WIN32__) || defined(__OpenBSD__) || defined(__FreeBSD__) || defined(__APPLE__) - qsort (tocalls, num_tocalls, sizeof(struct tocalls_s), tocall_cmp); --#else -- qsort (tocalls, num_tocalls, sizeof(struct tocalls_s), (__compar_fn_t)tocall_cmp); --#endif - } - else { - if ( ! A->g_quiet) { -diff --git a/digipeater.c b/digipeater.c -index 36970d7..5195582 100644 ---- a/digipeater.c -+++ b/digipeater.c -@@ -62,7 +62,7 @@ - #include - #include /* for isdigit, isupper */ - #include "regex.h" --#include -+#include - - #include "ax25_pad.h" - #include "digipeater.h" -diff --git a/direwolf.h b/direwolf.h -index 514bcc5..52f5ae9 100644 ---- a/direwolf.h -+++ b/direwolf.h -@@ -274,7 +274,7 @@ char *strtok_r(char *str, const char *delim, char **saveptr); - char *strcasestr(const char *S, const char *FIND); - - --#if defined(__OpenBSD__) || defined(__FreeBSD__) || defined(__APPLE__) -+#if 1 - - // strlcpy and strlcat should be in string.h and the C library. - -diff --git a/multi_modem.c b/multi_modem.c -index 5d96c79..24261b9 100644 ---- a/multi_modem.c -+++ b/multi_modem.c -@@ -80,7 +80,7 @@ - #include - #include - #include --#include -+#include - - #include "ax25_pad.h" - #include "textcolor.h" diff --git a/docker/scripts/install-connectors.sh b/docker/scripts/install-connectors.sh index a3f4af1..9a1902d 100755 --- a/docker/scripts/install-connectors.sh +++ b/docker/scripts/install-connectors.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,12 +18,15 @@ function cmakebuild() { cd /tmp -BUILD_PACKAGES="git cmake make gcc g++ musl-dev" - -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +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 84909c53cde78cbf4be408037e31209fbc702ad3 +# this is the latest development version as of 2020-08-20 (includes rtl_tcp_connector) +cmakebuild owrx_connector a5a4d78ff7f029d2b86e9ddbc30187ced1c3ecf7 -apk del .build-deps +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-airspy.sh b/docker/scripts/install-dependencies-airspy.sh index b6cc2f3..9927842 100755 --- a/docker/scripts/install-dependencies-airspy.sh +++ b/docker/scripts/install-dependencies-airspy.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,22 +18,24 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb" -BUILD_PACKAGES="git libusb-dev cmake make gcc musl-dev g++ linux-headers" +STATIC_PACKAGES="libusb-1.0-0" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ pkg-config" -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/airspy/airspyone_host.git cmakebuild airspyone_host bceca18f9e3a5f89cff78c4d949c71771d92dfd3 git clone https://github.com/pothosware/SoapyAirspy.git -cmakebuild SoapyAirspy 99756be5c3413a2d447baf70cb5a880662452655 +cmakebuild SoapyAirspy 10d697b209e7f1acc8b2c8d24851d46170ef77e3 git clone https://github.com/airspy/airspyhf.git cmakebuild airspyhf 613852a2bb64af42690bf9be2201826af69a9475 git clone https://github.com/pothosware/SoapyAirspyHF.git -cmakebuild SoapyAirspyHF 54f5487dd96207540b2dd562ff9e718e0588770b +cmakebuild SoapyAirspyHF 81ca737bb044dd930a9de738bced1e4915491f1b -apk del .build-deps +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-fcdpp.sh b/docker/scripts/install-dependencies-fcdpp.sh new file mode 100755 index 0000000..49f1439 --- /dev/null +++ b/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/docker/scripts/install-dependencies-hackrf.sh b/docker/scripts/install-dependencies-hackrf.sh index b9c4ec4..d01d009 100755 --- a/docker/scripts/install-dependencies-hackrf.sh +++ b/docker/scripts/install-dependencies-hackrf.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,17 +18,22 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb fftw udev" -BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev fftw-dev" +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" -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/mossmann/hackrf.git cd hackrf -git checkout 06eb9192cd348083f5f7de9c0da9ead276020011 +git checkout 43e6f99fe8543094d18ff3a6550ed2066c398862 cmakebuild host cd .. rm -rf hackrf -apk del .build-deps +git clone https://github.com/pothosware/SoapyHackRF.git +cmakebuild SoapyHackRF 3c514cecd3dd2fdf4794aebc96c482940c11d7ff + +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-limesdr.sh b/docker/scripts/install-dependencies-limesdr.sh new file mode 100755 index 0000000..be7533e --- /dev/null +++ b/docker/scripts/install-dependencies-limesdr.sh @@ -0,0 +1,31 @@ +#!/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 +git checkout 0854a51ec06b30b01f19a562149c39461e92f24d +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/docker/scripts/install-dependencies-perseus.sh b/docker/scripts/install-dependencies-perseus.sh new file mode 100755 index 0000000..eba74fe --- /dev/null +++ b/docker/scripts/install-dependencies-perseus.sh @@ -0,0 +1,26 @@ +#!/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 +git checkout 72ac67c5b7936a1991be0ec97c03a59c1a8ac8f3 +./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/docker/scripts/install-dependencies-plutosdr.sh b/docker/scripts/install-dependencies-plutosdr.sh new file mode 100755 index 0000000..18df91e --- /dev/null +++ b/docker/scripts/install-dependencies-plutosdr.sh @@ -0,0 +1,38 @@ +#!/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" + +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES + +git clone https://github.com/analogdevicesinc/libiio.git +cmakebuild libiio 5f5af2e417129ad8f4e05fc5c1b730f0694dca12 -DCMAKE_INSTALL_PREFIX=/usr/local + +git clone https://github.com/analogdevicesinc/libad9361-iio.git +cmakebuild libad9361-iio 8ac95f3325c18c2e34cd9cfd49c7b63d69a0a9d2 + +git clone https://github.com/pothosware/SoapyPlutoSDR.git +cmakebuild SoapyPlutoSDR c88b7f5bac1e5785f212f9a7c6ce8fef64eb719e + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-radioberry.sh b/docker/scripts/install-dependencies-radioberry.sh new file mode 100755 index 0000000..84c0cfc --- /dev/null +++ b/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 commit on master as of 2020-08-15 +cmakebuild SoapyRadioberrySDR ed8cbfd17b6d1e657a54a677b87479cf28dd77e8 +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/docker/scripts/install-dependencies-rtlsdr-soapy.sh b/docker/scripts/install-dependencies-rtlsdr-soapy.sh index 11a0cd1..b50921d 100755 --- a/docker/scripts/install-dependencies-rtlsdr-soapy.sh +++ b/docker/scripts/install-dependencies-rtlsdr-soapy.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,16 +18,18 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb" -BUILD_PACKAGES="git libusb-dev cmake make gcc musl-dev g++ linux-headers" +STATIC_PACKAGES="libusb-1.0-0" +BUILD_PACKAGES="git libusb-1.0-0-dev cmake make gcc g++ pkg-config" -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/osmocom/rtl-sdr.git -cmakebuild rtl-sdr b5af355b1d833b3c898a61cf1e072b59b0ea3440 +cmakebuild rtl-sdr d794155ba65796a76cd0a436f9709f4601509320 git clone https://github.com/pothosware/SoapyRTLSDR.git -cmakebuild SoapyRTLSDR 5c5d9503337c6d1c34b496dec6f908aab9478b0f +cmakebuild SoapyRTLSDR 8ba18f17d64005e43ff2a4e46611f8c710b05007 -apk del .build-deps +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-rtlsdr.sh b/docker/scripts/install-dependencies-rtlsdr.sh index fdf0f22..b26d432 100755 --- a/docker/scripts/install-dependencies-rtlsdr.sh +++ b/docker/scripts/install-dependencies-rtlsdr.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,13 +18,15 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb" -BUILD_PACKAGES="git libusb-dev cmake make gcc musl-dev g++ linux-headers" +STATIC_PACKAGES="libusb-1.0.0" +BUILD_PACKAGES="git libusb-1.0.0-dev cmake make gcc g++ pkg-config" -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/osmocom/rtl-sdr.git -cmakebuild rtl-sdr b5af355b1d833b3c898a61cf1e072b59b0ea3440 +cmakebuild rtl-sdr d794155ba65796a76cd0a436f9709f4601509320 -apk del .build-deps +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-sdrplay.sh b/docker/scripts/install-dependencies-sdrplay.sh index 79ca0f4..d18fcc0 100755 --- a/docker/scripts/install-dependencies-sdrplay.sh +++ b/docker/scripts/install-dependencies-sdrplay.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,23 +18,23 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="libusb udev" -BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-dev" +STATIC_PACKAGES="libusb-1.0.0 udev" +BUILD_PACKAGES="git cmake make patch wget sudo gcc g++ libusb-1.0-0-dev" -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +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-2.13.1.run + BINARY=SDRplay_RSP_API-Linux-3.07.1.run ;; armv*) - BINARY=SDRplay_RSP_API-RPi-2.13.1.run + BINARY=SDRplay_RSP_API-ARM32-3.07.2.run ;; aarch64) - BINARY=SDRplay_RSP_API-ARM64-2.13.1.run + BINARY=SDRplay_RSP_API-ARM64-3.07.1.run ;; esac @@ -47,7 +48,9 @@ cd .. rm -rf sdrplay rm $BINARY -git clone https://github.com/pothosware/SoapySDRPlay.git -cmakebuild SoapySDRPlay 14ec39e4ff0dab7ae7fdf1afbbd2d28b49b0ffae +git clone https://github.com/SDRplay/SoapySDRPlay.git +cmakebuild SoapySDRPlay 1c2728a04db5edf8154d02f5cca87e655152d7c1 -apk del .build-deps +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-soapyremote.sh b/docker/scripts/install-dependencies-soapyremote.sh new file mode 100755 index 0000000..cae7c11 --- /dev/null +++ b/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 6d9bd820da470cfe7b27b2e6946af93cfece448f + +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies-soapysdr.sh b/docker/scripts/install-dependencies-soapysdr.sh index 8c5bf98..5687d44 100755 --- a/docker/scripts/install-dependencies-soapysdr.sh +++ b/docker/scripts/install-dependencies-soapysdr.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -17,13 +18,15 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="udev" +STATIC_PACKAGES="libudev1" BUILD_PACKAGES="git cmake make patch wget sudo gcc g++" -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +apt-get update +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES git clone https://github.com/pothosware/SoapySDR -cmakebuild SoapySDR a489f3dca9d3ccd9b276b95a608ac3ef0299f635 +cmakebuild SoapySDR f722f9ce5b629c3c44401a9bf628b3f8e67a9695 -apk del .build-deps +SUDO_FORCE_REMOVE=yes apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-dependencies.sh b/docker/scripts/install-dependencies.sh index c212c11..04af411 100755 --- a/docker/scripts/install-dependencies.sh +++ b/docker/scripts/install-dependencies.sh @@ -1,5 +1,6 @@ #!/bin/bash set -euxo pipefail +export MAKEFLAGS="-j4" function cmakebuild() { cd $1 @@ -8,7 +9,7 @@ function cmakebuild() { fi mkdir build cd build - cmake .. + cmake ${CMAKE_ARGS:-} .. make make install cd ../.. @@ -17,18 +18,45 @@ function cmakebuild() { cd /tmp -STATIC_PACKAGES="sox fftw python3 netcat-openbsd libsndfile lapack libusb qt5-qtbase qt5-qtmultimedia qt5-qtserialport qt5-qttools alsa-lib" -BUILD_PACKAGES="git libsndfile-dev fftw-dev cmake ca-certificates make gcc musl-dev g++ lapack-dev linux-headers autoconf automake libtool texinfo gfortran libusb-dev qt5-qtbase-dev qt5-qtmultimedia-dev qt5-qtserialport-dev qt5-qttools-dev asciidoctor asciidoc alsa-lib-dev linux-headers" +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" +BUILD_PACKAGES="wget git libsndfile1-dev libfftw3-dev cmake make gcc g++ liblapack-dev autoconf automake libtool texinfo gfortran libusb-1.0-0-dev qtbase5-dev qtmultimedia5-dev qttools5-dev libqt5serialport5-dev qttools5-dev-tools asciidoctor asciidoc libasound2-dev pkg-config libudev-dev libhamlib-dev patch xsltproc" +apt-get update +apt-get -y install auto-apt-proxy +apt-get -y install --no-install-recommends $STATIC_PACKAGES $BUILD_PACKAGES -apk add --no-cache $STATIC_PACKAGES -apk add --no-cache --virtual .build-deps $BUILD_PACKAGES +case `uname -m` in + arm*) + PLATFORM=armhf + ;; + aarch64*) + PLATFORM=aarch64 + ;; + x86_64*) + PLATFORM=amd64 + ;; +esac + +pushd /tmp +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 +popd + +git clone https://github.com/jketterl/js8py.git +pushd js8py +git checkout 888e62be375316882ad2b2ac8e396c3bf857b6fc +python3 setup.py install +popd +rm -rf js8py git clone https://git.code.sf.net/p/itpp/git itpp cmakebuild itpp bb5c7e95f40e8fdb5c3f3d01a84bcbaf76f3676d git clone https://github.com/jketterl/csdr.git cd csdr -git checkout 43c36df5dcd92d3bdb322f9d53f99ca0c7c816a4 +git checkout c4d8a8a5590898e8c9e94b88b96a2fdc7cd0493a +autoreconf -i +./configure make make install cd .. @@ -38,28 +66,58 @@ git clone https://github.com/szechyjs/mbelib.git cmakebuild mbelib 9a04ed5c78176a9965f3d43f7aa1b1f5330e771f git clone https://github.com/jketterl/digiham.git -cmakebuild digiham b229990927922e977cecaa9369740790cff5c31e +cmakebuild digiham 95206501be89b38d0267bf6c29a6898e7c65656f git clone https://github.com/f4exb/dsd.git cmakebuild dsd f6939f9edbbc6f66261833616391a4e59cb2b3d7 -WSJT_DIR=wsjtx-2.1.2 +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.2.2 WSJT_TGZ=${WSJT_DIR}.tgz -wget http://physics.princeton.edu/pulsar/k1jt/$WSJT_TGZ -tar xvfz $WSJT_TGZ -cmakebuild $WSJT_DIR +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.5 https://github.com/wb2osz/direwolf.git cd direwolf -patch -Np1 < /direwolf-1.5.patch -make +# hamlib is present (necessary for the wsjt-x and js8call builds) and would be used, but there's no real need. +# by setting enable_hamlib we prevent direwolf from linking to it, and it can be stripped at the end of the script. +make enable_hamlib= make install cd .. rm -rf direwolf +git clone https://github.com/drowe67/codec2.git +cd codec2 +# latest commit from master as of 2020-07-30 +git checkout 1edc22e43f77fbb971429f9f7d4d8d909a7e12cb +mkdir build +cd build +cmake .. +make +make install +install -m 0755 src/freedv_rx /usr/local/bin +cd ../.. +rm -rf codec2 + git clone https://github.com/hessu/aprs-symbols /opt/aprs-symbols pushd /opt/aprs-symbols git checkout 5c2abe2658ee4d2563f3c73b90c6f59124839802 popd -apk del .build-deps +apt-get -y purge --autoremove $BUILD_PACKAGES +apt-get clean +rm -rf /var/lib/apt/lists/* diff --git a/docker/scripts/install-lib.aarch64.patch b/docker/scripts/install-lib.aarch64.patch deleted file mode 100644 index 5e989cc..0000000 --- a/docker/scripts/install-lib.aarch64.patch +++ /dev/null @@ -1,40 +0,0 @@ ---- sdrplay/install_lib.sh 2018-06-21 18:47:08.000000000 +0000 -+++ sdrplay/install_lib_patched.sh 2019-12-15 01:49:49.477386963 +0000 -@@ -3,19 +3,7 @@ - - echo "Installing SDRplay RSP API library 2.13..." - --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=`arch` -+export ARCH=`uname -m` - export VERS="2.13" - - echo "Architecture: ${ARCH}" -@@ -63,16 +51,6 @@ - echo " " - exit 1 - fi -- --if /sbin/ldconfig -p | /bin/fgrep -q libusb-1.0; then -- echo "Libusb found, continuing..." --else -- echo " " -- echo "ERROR: Libusb cannot be found. Please install libusb and then run" -- echo "the installer again. Libusb can be installed from http://libusb.info" -- echo " " -- exit 1 --fi - - sudo ldconfig - diff --git a/docker/scripts/install-lib.armv7l.patch b/docker/scripts/install-lib.armv7l.patch deleted file mode 100644 index 0306c2b..0000000 --- a/docker/scripts/install-lib.armv7l.patch +++ /dev/null @@ -1,40 +0,0 @@ ---- sdrplay/install_lib.sh -+++ sdrplay/install_lib_patched.sh -@@ -3,19 +3,7 @@ - - echo "Installing SDRplay RSP API library 2.13..." - --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=`arch` -+export ARCH=`uname -m` - export VERS="2.13" - - echo "Architecture: ${ARCH}" -@@ -60,16 +48,6 @@ - echo "ERROR: udev rules directory not found, add udev support and run the" - echo "installer again. udev support can be added by running..." - echo "sudo apt-get install libudev-dev" -- echo " " -- exit 1 --fi -- --if /sbin/ldconfig -p | /bin/fgrep -q libusb-1.0; then -- echo "Libusb found, continuing..." --else -- echo " " -- echo "ERROR: Libusb cannot be found. Please install libusb and then run" -- echo "the installer again. Libusb can be installed from http://libusb.info" - echo " " - exit 1 - fi diff --git a/docker/scripts/install-lib.x86_64.patch b/docker/scripts/install-lib.x86_64.patch deleted file mode 100644 index 588f14e..0000000 --- a/docker/scripts/install-lib.x86_64.patch +++ /dev/null @@ -1,40 +0,0 @@ ---- sdrplay/install_lib.sh 2018-06-21 01:57:02.000000000 +0200 -+++ sdrplay/install_lib_patched.sh 2019-01-22 17:21:06.445804136 +0100 -@@ -2,19 +2,7 @@ - - echo "Installing SDRplay RSP API library 2.13..." - --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=`arch` -+export ARCH=`uname -m` - export VERS="2.13" - - echo "Architecture: ${ARCH}" -@@ -60,16 +48,6 @@ - echo " " - exit 1 - fi -- --if /sbin/ldconfig -p | /bin/fgrep -q libusb-1.0; then -- echo "Libusb found, continuing..." --else -- echo " " -- echo "ERROR: Libusb cannot be found. Please install libusb and then run" -- echo "the installer again. Libusb can be installed from http://libusb.info" -- echo " " -- exit 1 --fi - - #echo "Installing SoapySDRPlay..." - diff --git a/docker/scripts/run.sh b/docker/scripts/run.sh index 5ae7d82..0f8bd32 100755 --- a/docker/scripts/run.sh +++ b/docker/scripts/run.sh @@ -12,6 +12,9 @@ fi if [[ ! -f /etc/openwebrx/bookmarks.json ]] ; then cp bookmarks.json /etc/openwebrx/ fi +if [[ ! -f /etc/openwebrx/users.json ]] ; then + cp users.json /etc/openwebrx/ +fi _term() { diff --git a/htdocs/css/admin.css b/htdocs/css/admin.css new file mode 100644 index 0000000..b6ebcea --- /dev/null +++ b/htdocs/css/admin.css @@ -0,0 +1,14 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +.buttons { + text-align: right; +} + +.row .map-input { + margin: 15px 15px 0; +} + +.device { + margin-top: 20px; +} diff --git a/htdocs/css/bootstrap.min.css b/htdocs/css/bootstrap.min.css new file mode 100644 index 0000000..43d80a0 --- /dev/null +++ b/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/htdocs/css/features.css b/htdocs/css/features.css index 7b0b008..be41fef 100644 --- a/htdocs/css/features.css +++ b/htdocs/css/features.css @@ -1,12 +1,7 @@ @import url("openwebrx-header.css"); @import url("openwebrx-globals.css"); -/* expandable photo not implemented on features page */ -#webrx-top-photo-clip { - max-height: 67px; -} - h1 { text-align: center; margin: 50px 0; -} \ No newline at end of file +} diff --git a/htdocs/css/login.css b/htdocs/css/login.css new file mode 100644 index 0000000..5e51dc9 --- /dev/null +++ b/htdocs/css/login.css @@ -0,0 +1,24 @@ +@import url("openwebrx-header.css"); +@import url("openwebrx-globals.css"); + +.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/htdocs/css/map.css b/htdocs/css/map.css index 5d478cd..de6ef3e 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -1,11 +1,6 @@ @import url("openwebrx-header.css"); @import url("openwebrx-globals.css"); -/* expandable photo not implemented on map page */ -#webrx-top-photo-clip { - max-height: 67px; -} - body { display: flex; flex-direction: column; diff --git a/htdocs/css/openwebrx-header.css b/htdocs/css/openwebrx-header.css index 34802f6..8c43360 100644 --- a/htdocs/css/openwebrx-header.css +++ b/htdocs/css/openwebrx-header.css @@ -2,6 +2,7 @@ { position: relative; z-index:1000; + background-color: #575757; } #webrx-top-photo @@ -13,7 +14,8 @@ #webrx-top-photo-clip { min-height: 67px; - max-height: 350px; + max-height: 67px; + height: 350px; overflow: hidden; position: relative; } @@ -41,22 +43,24 @@ right: 0; } +#webrx-tob-container, #webrx-top-container * { + line-height: initial; + box-sizing: initial; +} + +#webrx-top-container img { + vertical-align: initial; +} + #webrx-top-logo { padding: 12px; float: left; } -#webrx-ha5kfu-top-logo -{ - float: right; - padding: 15px; -} - #webrx-rx-avatar { background-color: rgba(154, 154, 154, .5); - border-radius: 7px; float: left; margin: 7px; @@ -107,46 +111,38 @@ cursor:pointer; position: absolute; left: 470px; - top: 51px; + top: 55px; } #openwebrx-rx-details-arrow a { margin: 0; padding: 0; + line-height: 0; + display: block; } -#openwebrx-rx-details-arrow-down -{ - display:none; -} - -#openwebrx-main-buttons ul -{ - display: table; - margin:0; -} - - -#openwebrx-main-buttons ul li -{ - display: table-cell; - padding-left: 5px; - padding-right: 5px; +#openwebrx-main-buttons .button { + display: block; + width: 55px; cursor:pointer; } +#openwebrx-main-buttons .button img { + height: 38px; +} + #openwebrx-main-buttons a { color: inherit; text-decoration: inherit; } -#openwebrx-main-buttons li:hover +#openwebrx-main-buttons .button:hover { background-color: rgba(255, 255, 255, 0.3); } -#openwebrx-main-buttons li:active +#openwebrx-main-buttons .button:active { background-color: rgba(255, 255, 255, 0.55); } @@ -154,6 +150,9 @@ #openwebrx-main-buttons { + padding: 5px 15px; + display: flex; + list-style: none; float: right; margin:0; color: white; diff --git a/htdocs/css/openwebrx.css b/htdocs/css/openwebrx.css index 556558f..554d88d 100644 --- a/htdocs/css/openwebrx.css +++ b/htdocs/css/openwebrx.css @@ -150,6 +150,10 @@ input[type=range]:focus::-ms-fill-upper background: #B6B6B6; } +input[type=range]:disabled { + opacity: 0.5; +} + #webrx-page-container { height: 100%; @@ -311,20 +315,36 @@ input[type=range]:focus::-ms-fill-upper font-style: normal; } -#webrx-actual-freq -{ +.webrx-actual-freq { width: 100%; text-align: left; - font-size: 16pt; - font-family: 'roboto-mono'; padding: 0; margin: 0; - line-height:22px; - + display: flex; + flex-direction: row; } -#webrx-mouse-freq -{ +.webrx-actual-freq > * { + flex: 1; +} + +.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'; + line-height: 22px; +} + +.webrx-mouse-freq { width: 100%; text-align: left; font-size: 10pt; @@ -364,6 +384,7 @@ input[type=range]:focus::-ms-fill-upper border-radius: 15px; -moz-border-radius: 15px; margin: 5.9px; + box-sizing: content-box; } .openwebrx-panel a @@ -418,9 +439,12 @@ input[type=range]:focus::-ms-fill-upper margin-right: 0; } +.openwebrx-button.disabled { + opacity: 0.5; +} + .openwebrx-demodulator-button { - width: 38px; height: 19px; font-size: 12pt; text-align: center; @@ -428,6 +452,10 @@ input[type=range]:focus::-ms-fill-upper margin-right: 5px; } +.openwebrx-demodulator-button.same-mod { + color: #FFC; +} + .openwebrx-square-button img { height: 27px; @@ -591,6 +619,31 @@ img.openwebrx-mirror-img 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-outer { border-color: #888; @@ -706,8 +759,7 @@ img.openwebrx-mirror-img color: White; } -#openwebrx-secondary-demod-listbox -{ +.openwebrx-secondary-demod-listbox { width: 173px; height: 27px; padding-left:3px; @@ -906,37 +958,23 @@ img.openwebrx-mirror-img display: inline-block; } -#openwebrx-panel-wsjt-message, -#openwebrx-panel-packet-message, -#openwebrx-panel-pocsag-message -{ +.openwebrx-message-panel { height: 180px; } -#openwebrx-panel-wsjt-message tbody, -#openwebrx-panel-packet-message tbody, -#openwebrx-panel-pocsag-message tbody -{ +.openwebrx-message-panel tbody { display: block; overflow: auto; height: 150px; width: 100%; } -#openwebrx-panel-wsjt-message thead tr, -#openwebrx-panel-packet-message thead tr, -#openwebrx-panel-pocsag-message thead tr -{ +.openwebrx-message-panel thead tr { display: block; } -#openwebrx-panel-wsjt-message th, -#openwebrx-panel-wsjt-message td, -#openwebrx-panel-packet-message th, -#openwebrx-panel-packet-message td, -#openwebrx-panel-pocsag-message th, -#openwebrx-panel-pocsag-message td -{ +.openwebrx-message-panel th, +.openwebrx-message-panel td { width: 50px; text-align: left; padding: 1px 3px; @@ -955,6 +993,31 @@ img.openwebrx-mirror-img 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; @@ -1061,13 +1124,15 @@ img.openwebrx-mirror-img #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="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="pocsag"] #openwebrx-digimode-select-channel, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-select-channel { display: none; } @@ -1078,7 +1143,8 @@ img.openwebrx-mirror-img #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="pocsag"] #openwebrx-digimode-canvas-container, +#openwebrx-panel-digimodes[data-mode="js8"] #openwebrx-digimode-canvas-container { height: 200px; margin: -10px; diff --git a/htdocs/features.html b/htdocs/features.html index 8e0eb61..90d156e 100644 --- a/htdocs/features.html +++ b/htdocs/features.html @@ -1,10 +1,12 @@ OpenWebRX Feature report - + + + ${header} diff --git a/htdocs/generalsettings.html b/htdocs/generalsettings.html new file mode 100644 index 0000000..4731a02 --- /dev/null +++ b/htdocs/generalsettings.html @@ -0,0 +1,20 @@ + + + + OpenWebRX Settings + + + + + + + + +${header} +
+
+

General settings

+
+ ${sections} +
+ \ No newline at end of file diff --git a/htdocs/gfx/openwebrx-ha5kfu-top-logo.png b/htdocs/gfx/openwebrx-ha5kfu-top-logo.png deleted file mode 100644 index 2686eef..0000000 Binary files a/htdocs/gfx/openwebrx-ha5kfu-top-logo.png and /dev/null differ diff --git a/htdocs/gfx/openwebrx-panel-settings.png b/htdocs/gfx/openwebrx-panel-settings.png new file mode 100644 index 0000000..02a7a03 Binary files /dev/null and b/htdocs/gfx/openwebrx-panel-settings.png differ diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index a86e4b4..d4a2b53 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -1,25 +1,23 @@
- + Receiver panorama
- - - + + Receiver avatar
- +
-
    -

  • Status
  • -

  • Log
  • -

  • Receiver
  • -

  • Map
  • -
+
Status
Status
+
Log
Log
+
Receiver
Receiver
+ Map
Map
+ ${settingslink}
diff --git a/htdocs/index.html b/htdocs/index.html index d9296c9..22f33bb 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -24,14 +24,7 @@ OpenWebRX | Open Source SDR Web App for Everyone! - - - - - - - - + @@ -67,7 +60,7 @@
- + @@ -77,7 +70,15 @@ - + + + + + + + + + @@ -86,7 +87,7 @@ - + @@ -126,26 +127,30 @@
OpenWebRX client log
-
Author contact: Jakob Ketterl, DD5JFK
+
+ Author contact: Jakob Ketterl, DD5JFK | + OpenWebRX homepage +
+
Support and information: Groups.io Mailinglist
-
Audio buffer [0 ms]
-
Audio output [0 sps]
-
Audio stream [0 kbps]
-
Network usage [0 kbps]
-
Server CPU [0%]
-
Clients [1]
+
+
+
+
+
+
-
---.--- MHz
-
---.--- MHz
+
+
-
-
FM
-
AM
-
LSB
-
USB
-
CW
-
-
- - - - -
-
-
DIG
- -
+
@@ -203,8 +168,8 @@
-
- +
+
@@ -249,17 +214,7 @@
- +
Cancel
diff --git a/htdocs/lib/AudioEngine.js b/htdocs/lib/AudioEngine.js index 7e67042..c907409 100644 --- a/htdocs/lib/AudioEngine.js +++ b/htdocs/lib/AudioEngine.js @@ -10,125 +10,151 @@ function AudioEngine(maxBufferLength, audioReporter) { if (!ctx) { return; } - this.audioContext = new ctx(); - this.allowed = this.audioContext.state === 'running'; + + this.onStartCallbacks = []; + this.started = false; + this.audioContext = new ctx(); + 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.start = function(callback) { +AudioEngine.prototype.resume = function(){ + this.audioContext.resume(); +} + +AudioEngine.prototype._start = function() { var me = this; - if (me.resamplingFactor === 0) return; //if failed to find a valid resampling factor... + + // if failed to find a valid resampling factor... + if (me.resamplingFactor === 0) { + return; + } + + // been started before? if (me.started) { - if (callback) callback(false); return; } - me.audioContext.resume().then(function(){ - me.allowed = me.audioContext.state === 'running'; - if (!me.allowed) { - if (callback) callback(false); - return; - } - me.started = true; + // are we allowed to play audio? + if (!me.isAllowed()) { + return; + } + me.started = true; - me.gainNode = me.audioContext.createGain(); - me.gainNode.connect(me.audioContext.destination); + 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(); - if (callback) callback(true, 'AudioWorklet'); + 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 + } }); - } 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); - if (callback) callback(true, 'ScriptProcessorNode'); + 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(); + me.workletType = '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]; + } } - setInterval(me.reportStats.bind(me), 1000); - }); + 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); + me.workletType = 'ScriptProcessorNode'; + } + + setInterval(me.reportStats.bind(me), 1000); + + var callbacks = this.onStartCallbacks; + this.onStartCallbacks = false; + callbacks.forEach(function(c) { c(me.workletType); }); +}; + +AudioEngine.prototype.onStart = function(callback) { + if (this.onStartCallbacks) { + this.onStartCallbacks.push(callback); + } else { + callback(); + } }; AudioEngine.prototype.isAllowed = function() { - return this.allowed; + return this.audioContext.state === 'running'; }; AudioEngine.prototype.reportStats = function() { @@ -165,35 +191,57 @@ AudioEngine.prototype.resetStats = function() { }; AudioEngine.prototype.setupResampling = function() { //both at the server and the client - var output_range_max = 12000; - var output_range_min = 8000; + 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 < output_range_min) { - 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); - break; - } else if (audio_server_output_rate >= output_range_min && audio_server_output_rate <= output_range_max) { - this.resamplingFactor = i; - this.outputRate = audio_server_output_rate; - break; //okay, we're done + 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.pushAudio = function(data) { +AudioEngine.prototype.processAudio = function(data, resampler) { if (!this.audioNode) return; this.audioBytes.add(data.byteLength); var buffer; @@ -203,7 +251,7 @@ AudioEngine.prototype.pushAudio = function(data) { } else { buffer = new Int16Array(data); } - buffer = this.resampler.process(buffer); + buffer = resampler.process(buffer); if (this.audioNode.port) { // AudioWorklets supported this.audioNode.port.postMessage(buffer); @@ -213,8 +261,16 @@ AudioEngine.prototype.pushAudio = function(data) { 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; }; diff --git a/htdocs/lib/BookmarkBar.js b/htdocs/lib/BookmarkBar.js index d039494..ecae43b 100644 --- a/htdocs/lib/BookmarkBar.js +++ b/htdocs/lib/BookmarkBar.js @@ -8,12 +8,10 @@ function BookmarkBar() { var $bookmark = $(e.target).closest('.bookmark'); me.$container.find('.bookmark').removeClass('selected'); var b = $bookmark.data(); - if (!b || !b.frequency || (!b.modulation && !b.digital_modulation)) return; - demodulators[0].set_offset_frequency(b.frequency - center_freq); + if (!b || !b.frequency || !b.modulation) return; + me.getDemodulator().set_offset_frequency(b.frequency - center_freq); if (b.modulation) { - demodulator_analog_replace(b.modulation); - } else if (b.digital_modulation) { - demodulator_digital_replace(b.digital_modulation); + me.getDemodulatorPanel().setMode(b.modulation); } $bookmark.addClass('selected'); }); @@ -104,40 +102,26 @@ BookmarkBar.prototype.render = function(){ }; BookmarkBar.prototype.showEditDialog = function(bookmark) { - var $form = this.$dialog.find("form"); if (!bookmark) { bookmark = { name: "", - frequency: center_freq + demodulators[0].offset_frequency, - modulation: demodulators[0].subtype + frequency: center_freq + this.getDemodulator().get_offset_frequency(), + modulation: this.getDemodulator().get_secondary_demod() || this.getDemodulator().get_modulation() } } - ['name', 'frequency', 'modulation'].forEach(function(key){ - $form.find('#' + key).val(bookmark[key]); - }); - this.$dialog.data('id', bookmark.id); + this.$dialog.bookmarkDialog().setValues(bookmark); this.$dialog.show(); this.$dialog.find('#name').focus(); }; BookmarkBar.prototype.storeBookmark = function() { var me = this; - var bookmark = {}; - var valid = true; - ['name', 'frequency', 'modulation'].forEach(function(key){ - var $input = me.$dialog.find('#' + key); - valid = valid && $input[0].checkValidity(); - bookmark[key] = $input.val(); - }); - if (!valid) { - me.$dialog.find("form :submit").click(); - return; - } + var bookmark = this.$dialog.bookmarkDialog().getValues(); + if (!bookmark) return; bookmark.frequency = Number(bookmark.frequency); var bookmarks = me.localBookmarks.getBookmarks(); - bookmark.id = me.$dialog.data('id'); if (!bookmark.id) { if (bookmarks.length) { bookmark.id = 1 + Math.max.apply(Math, bookmarks.map(function(b){ return b.id || 0; })); @@ -154,6 +138,14 @@ BookmarkBar.prototype.storeBookmark = function() { me.$dialog.hide(); }; +BookmarkBar.prototype.getDemodulatorPanel = function() { + return $('#openwebrx-panel-receiver').demodulatorPanel(); +}; + +BookmarkBar.prototype.getDemodulator = function() { + return this.getDemodulatorPanel().getDemodulator(); +}; + BookmarkLocalStorage = function(){ }; @@ -171,7 +163,3 @@ BookmarkLocalStorage.prototype.deleteBookmark = function(data) { bookmarks = bookmarks.filter(function(b) { return b.id !== data; }); this.setBookmarks(bookmarks); }; - - - - diff --git a/htdocs/lib/BookmarkDialog.js b/htdocs/lib/BookmarkDialog.js new file mode 100644 index 0000000..4a0a184 --- /dev/null +++ b/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/htdocs/lib/Demodulator.js b/htdocs/lib/Demodulator.js new file mode 100644 index 0000000..2216f7e --- /dev/null +++ b/htdocs/lib/Demodulator.js @@ -0,0 +1,358 @@ +function Filter(demodulator) { + this.demodulator = demodulator; + this.min_passband = 100; +} + +Filter.prototype.getLimits = function() { + var max_bw; + if (this.demodulator.get_secondary_demod() === 'pocsag') { + max_bw = 12500; + } else if (this.demodulator.get_modulation() === 'wfm') { + max_bw = 80000; + } 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 (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/htdocs/lib/DemodulatorPanel.js b/htdocs/lib/DemodulatorPanel.js new file mode 100644 index 0000000..421487c --- /dev/null +++ b/htdocs/lib/DemodulatorPanel.js @@ -0,0 +1,323 @@ +function DemodulatorPanel(el) { + var self = this; + self.el = el; + self.demodulator = null; + self.mode = null; + + 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); + }); + + 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-default', function() { + if (!self.squelchAvailable()) return; + el.find('.openwebrx-squelch-slider').val(getLogSmeterValue(smeter_level) + 10); + 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); + 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'].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; + $(".openwebrx-meta-panel").each(function (_, p) { + toggle_panel(p.id, p.id === showing); + }); + clear_metadata(); +}; + +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.initialParams || {}, this.transformHashParams(this.parseHash())); +}; + +DemodulatorPanel.prototype.startDemodulator = function() { + if (!Modes.initComplete()) 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) { + this.initialParams = params; +}; + +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-default')[squelch_disabled ? 'addClass' : 'removeClass']('disabled'); +} + +DemodulatorPanel.prototype.setCenterFrequency = function(center_freq) { + if (this.center_freq === center_freq) { + return; + } + this.stopDemodulator(); + this.center_freq = center_freq; + this.startDemodulator(); +}; + +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.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); +}; + +$.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/htdocs/lib/FrequencyDisplay.js b/htdocs/lib/FrequencyDisplay.js index 0353fd8..8210592 100644 --- a/htdocs/lib/FrequencyDisplay.js +++ b/htdocs/lib/FrequencyDisplay.js @@ -1,12 +1,17 @@ function FrequencyDisplay(element) { this.element = $(element); this.digits = []; - this.digitContainer = $(''); - this.element.html([this.digitContainer, $(' MHz')]); - this.decimalSeparator = (0.1).toLocaleString().substring(1, 2); + this.setupElements(); this.setFrequency(0); } +FrequencyDisplay.prototype.setupElements = function() { + this.displayContainer = $('
'); + this.digitContainer = $(''); + this.displayContainer.html([this.digitContainer, $(' MHz')]); + this.element.html(this.displayContainer); +}; + FrequencyDisplay.prototype.setFrequency = function(freq) { this.frequency = freq; var formatted = (freq / 1e6).toLocaleString(undefined, {maximumFractionDigits: 4, minimumFractionDigits: 4}); @@ -36,9 +41,17 @@ function TuneableFrequencyDisplay(element) { TuneableFrequencyDisplay.prototype = new FrequencyDisplay(); +TuneableFrequencyDisplay.prototype.setupElements = function() { + FrequencyDisplay.prototype.setupElements.call(this); + this.input = $(''); + this.input.hide(); + this.element.append(this.input); +}; + TuneableFrequencyDisplay.prototype.setupEvents = function() { var me = this; - this.element.on('wheel', function(e){ + + me.element.on('wheel', function(e){ e.preventDefault(); e.stopPropagation(); @@ -49,13 +62,45 @@ TuneableFrequencyDisplay.prototype.setupEvents = function() { if (e.originalEvent.deltaY > 0) delta *= -1; var newFrequency = me.frequency + delta; - me.listeners.forEach(function(l) { - l(newFrequency); - }); + me.element.trigger('frequencychange', newFrequency); + }); + + var submit = function(){ + var freq = parseInt(me.input.val()); + if (!isNaN(freq)) { + me.element.trigger('frequencychange', freq); + } + me.input.hide(); + me.displayContainer.show(); + }; + me.input.on('blur', submit).on('keyup', function(e){ + if (e.keyCode == 13) return submit(); + if (e.keyCode == 27) { + me.input.hide(); + me.displayContainer.show(); + } + }); + me.input.on('click', function(e){ + e.stopPropagation(); + }); + me.element.on('click', function(){ + me.input.val(me.frequency); + me.input.show(); + me.displayContainer.hide(); + me.input.focus(); }); - this.listeners = []; }; -TuneableFrequencyDisplay.prototype.onFrequencyChange = function(listener){ - this.listeners.push(listener); -}; \ No newline at end of file +$.fn.frequencyDisplay = function() { + if (!this.data('frequencyDisplay')) { + this.data('frequencyDisplay', new FrequencyDisplay(this)); + } + return this.data('frequencyDisplay'); +} + +$.fn.tuneableFrequencyDisplay = function() { + if (!this.data('frequencyDisplay')) { + this.data('frequencyDisplay', new TuneableFrequencyDisplay(this)); + } + return this.data('frequencyDisplay'); +} diff --git a/htdocs/lib/Header.js b/htdocs/lib/Header.js new file mode 100644 index 0000000..cced6b6 --- /dev/null +++ b/htdocs/lib/Header.js @@ -0,0 +1,77 @@ +function Header(el) { + this.el = el; + + this.el.find('#openwebrx-main-buttons').find('[data-toggle-panel]').click(function () { + toggle_panel($(this).data('toggle-panel')); + }); + + this.init_rx_photo(); + this.download_details(); +}; + +Header.prototype.setDetails = function(details) { + this.el.find('#webrx-rx-title').html(details['receiver_name']); + var query = encodeURIComponent(details['receiver_gps']['lat'] + ',' + details['receiver_gps']['lon']); + this.el.find('#webrx-rx-desc').html(details['receiver_location'] + ' | Loc: ' + details['locator'] + ', ASL: ' + details['receiver_asl'] + ' m, [maps]'); + this.el.find('#webrx-rx-photo-title').html(details['photo_title']); + this.el.find('#webrx-rx-photo-desc').html(details['photo_desc']); +}; + +Header.prototype.init_rx_photo = function() { + this.rx_photo_state = 0; + + $.extend($.easing, { + easeOutCubic:function(x) { + return 1 - Math.pow( 1 - x, 3 ); + } + }); + + $('#webrx-top-container').find('.openwebrx-photo-trigger').click(this.toggle_rx_photo.bind(this)); +}; + +Header.prototype.close_rx_photo = function() { + this.rx_photo_state = 0; + this.el.find("#webrx-rx-photo-desc").animate({opacity: 0}); + this.el.find("#webrx-rx-photo-title").animate({opacity: 0}); + this.el.find('#webrx-top-photo-clip').animate({maxHeight: 67}, {duration: 1000, easing: 'easeOutCubic'}); + this.el.find("#openwebrx-rx-details-arrow-down").show(); + this.el.find("#openwebrx-rx-details-arrow-up").hide(); +} + +Header.prototype.open_rx_photo = function() { + this.rx_photo_state = 1; + this.el.find("#webrx-rx-photo-desc").animate({opacity: 1}); + this.el.find("#webrx-rx-photo-title").animate({opacity: 1}); + this.el.find('#webrx-top-photo-clip').animate({maxHeight: 350}, {duration: 1000, easing: 'easeOutCubic'}); + this.el.find("#openwebrx-rx-details-arrow-down").hide(); + this.el.find("#openwebrx-rx-details-arrow-up").show(); +} + +Header.prototype.toggle_rx_photo = function(ev) { + if (ev && ev.target && ev.target.tagName == 'A') { + return; + } + if (this.rx_photo_state) { + this.close_rx_photo(); + } else { + this.open_rx_photo(); + } +}; + +Header.prototype.download_details = function() { + var self = this; + $.ajax('api/receiverdetails').done(function(data){ + self.setDetails(data); + }); +}; + +$.fn.header = function() { + if (!this.data('header')) { + this.data('header', new Header(this)); + } + return this.data('header'); +}; + +$(function(){ + $('#webrx-top-container').header(); +}); diff --git a/htdocs/lib/Js8Threads.js b/htdocs/lib/Js8Threads.js new file mode 100644 index 0000000..4cecf46 --- /dev/null +++ b/htdocs/lib/Js8Threads.js @@ -0,0 +1,150 @@ +Js8Thread = function(el){ + this.messages = []; + this.el = el; +}; + +Js8Thread.prototype.getAverageFrequency = function(){ + var total = this.messages.map(function(message){ + return message.freq; + }).reduce(function(t, f){ + return t + f; + }, 0); + return total / this.messages.length; +}; + +Js8Thread.prototype.pushMessage = function(message) { + this.messages.push(message); + this.render(); +}; + +Js8Thread.prototype.render = function() { + this.el.html( + '
' + + '' + + '' + ); +}; + +Js8Thread.prototype.getLatestTimestamp = function() { + return this.messages[0].timestamp; +}; + +Js8Thread.prototype.isOpen = function() { + if (!this.messages.length) return true; + var last_message = this.messages[this.messages.length - 1]; + return (last_message.thread_type & 2) === 0; +}; + +Js8Thread.prototype.renderMessages = function() { + var res = []; + for (var i = 0; i < this.messages.length; i++) { + var msg = this.messages[i]; + if (msg.thread_type & 1) { + res.push('[ '); + } else if (i === 0 || msg.timestamp - this.messages[i - 1].timestamp > this.getMessageDuration()) { + res.push(' ... '); + } + res.push(msg.msg); + if (msg.thread_type & 2) { + res.push(' ]'); + } else if (i === this.messages.length -1) { + res.push(' ... '); + } + } + return res.join(''); +}; + +Js8Thread.prototype.getMessageDuration = function() { + switch (this.getMode()) { + case 'A': + return 15000; + case 'E': + return 30000; + case 'B': + return 10000; + case 'C': + return 6000; + } +}; + +Js8Thread.prototype.getMode = function() { + // we filter messages by mode, so the first one is as good as any + if (!this.messages.length) return; + return this.messages[0].mode; +}; + +Js8Thread.prototype.acceptsMode = function(mode) { + var currentMode = this.getMode(); + return typeof(currentMode) === 'undefined' || currentMode === mode; +}; + +Js8Thread.prototype.renderTimestamp = function(timestamp) { + var t = new Date(timestamp); + var pad = function (i) { + return ('' + i).padStart(2, "0"); + }; + return pad(t.getUTCHours()) + pad(t.getUTCMinutes()) + pad(t.getUTCSeconds()); +}; + +Js8Thread.prototype.purgeOldMessages = function() { + var now = new Date().getTime(); + this.messages = this.messages.filter(function(m) { + // keep messages around for 20 minutes + return now - m.timestamp < 20 * 60 * 1000; + }); + if (!this.messages.length) { + this.el.remove(); + } else { + this.render(); + } + return this.messages.length; +}; + +Js8Threader = function(el){ + this.threads = []; + this.tbody = $(el).find('tbody'); + var me = this; + this.interval = setInterval(function(){ + me.purgeOldMessages(); + }, 15000); +}; + +Js8Threader.prototype.purgeOldMessages = function() { + this.threads = this.threads.filter(function(t) { + return t.purgeOldMessages(); + }); +}; + +Js8Threader.prototype.findThread = function(freq, mode) { + var matching = this.threads.filter(function(thread) { + // max frequency deviation: 5 Hz. this may be a little tight. + return thread.isOpen() && thread.acceptsMode(mode) && Math.abs(thread.getAverageFrequency() - freq) <= 5; + }); + matching.sort(function(a, b){ + return b.getLatestTimestamp() - a.getLatestTimestamp(); + }); + return matching[0] || false; +}; + +Js8Threader.prototype.pushMessage = function(message) { + var thread; + // only look for exising threads if the message is not a starting message + if ((message.thread_type & 1) === 0) { + thread = this.findThread(message.freq, message.mode); + } + if (!thread) { + var line = $(""); + this.tbody.append(line); + thread = new Js8Thread(line); + this.threads.push(thread); + } + thread.pushMessage(message); + this.tbody.scrollTop(this.tbody[0].scrollHeight); +}; + +$.fn.js8 = function() { + if (!this.data('threader')) { + this.data('threader', new Js8Threader(this)); + } + return this.data('threader'); +}; \ No newline at end of file diff --git a/htdocs/lib/Measurement.js b/htdocs/lib/Measurement.js index 202070e..e07b196 100644 --- a/htdocs/lib/Measurement.js +++ b/htdocs/lib/Measurement.js @@ -1,4 +1,5 @@ function Measurement() { + this.reporters = []; this.reset(); } @@ -21,10 +22,13 @@ Measurement.prototype.getRate = function() { Measurement.prototype.reset = function() { this.value = 0; this.start = new Date(); + this.reporters.forEach(function(r){ r.reset(); }); }; Measurement.prototype.report = function(range, interval, callback) { - return new Reporter(this, range, interval, callback); + var reporter = new Reporter(this, range, interval, callback); + this.reporters.push(reporter); + return reporter; }; function Reporter(measurement, range, interval, callback) { @@ -59,4 +63,8 @@ Reporter.prototype.report = function(){ var accumulated = newest.value - oldest.value; // we want rate per second, but our time is in milliseconds... compensate by 1000 this.callback(accumulated * 1000 / elapsed); +}; + +Reporter.prototype.reset = function(){ + this.samples = []; }; \ No newline at end of file diff --git a/htdocs/lib/Modes.js b/htdocs/lib/Modes.js new file mode 100644 index 0000000..c68466a --- /dev/null +++ b/htdocs/lib/Modes.js @@ -0,0 +1,55 @@ +var Modes = { + modes: [], + features: {}, + panels: [], + setModes:function(json){ + this.modes = json.map(function(m){ return new Mode(m); }); + this.updatePanels(); + $('#openwebrx-dialog-bookmark').bookmarkDialog().setModes(this.modes); + }, + getModes:function(){ + return this.modes; + }, + setFeatures:function(features){ + this.features = features; + this.updatePanels(); + }, + findByModulation:function(modulation){ + matches = this.modes.filter(function(m) { return m.modulation === modulation; }); + if (matches.length) return matches[0] + }, + registerModePanel: function(el) { + this.panels.push(el); + }, + initComplete: function() { + return this.modes.length && Object.keys(this.features).length; + }, + updatePanels: function() { + this.panels.forEach(function(p) { + p.render(); + p.startDemodulator(); + }); + } +}; + +var Mode = function(json){ + this.modulation = json.modulation; + this.name = json.name; + this.type = json.type; + this.requirements = json.requirements; + this.squelch = json.squelch; + if (json.bandpass) { + this.bandpass = json.bandpass; + } + if (this.type === 'digimode') { + this.underlying = json.underlying; + } +}; + +Mode.prototype.isAvailable = function(){ + return this.requirements.map(function(r){ + return Modes.features[r]; + }).reduce(function(a, b){ + return a && b; + }, true); +}; diff --git a/htdocs/lib/ProgressBar.js b/htdocs/lib/ProgressBar.js index 9d0736d..691d2ff 100644 --- a/htdocs/lib/ProgressBar.js +++ b/htdocs/lib/ProgressBar.js @@ -1,10 +1,15 @@ ProgressBar = function(el) { this.$el = $(el); - this.$innerText = this.$el.find('.openwebrx-progressbar-text'); - this.$innerBar = this.$el.find('.openwebrx-progressbar-bar'); + this.$innerText = $('' + this.getDefaultText() + ''); + this.$innerBar = $('
'); + this.$el.empty().append(this.$innerText, this.$innerBar); this.$innerBar.css('width', '0%'); }; +ProgressBar.prototype.getDefaultText = function() { + return ''; +} + ProgressBar.prototype.set = function(val, text, over) { this.setValue(val); this.setText(text); @@ -25,13 +30,20 @@ ProgressBar.prototype.setOver = function(over) { this.$innerBar.css('backgroundColor', (over) ? "#ff6262" : "#00aba6"); }; -AudioBufferProgressBar = function(el, sampleRate) { +AudioBufferProgressBar = function(el) { ProgressBar.call(this, el); - this.sampleRate = sampleRate; }; AudioBufferProgressBar.prototype = new ProgressBar(); +AudioBufferProgressBar.prototype.getDefaultText = function() { + return 'Audio buffer [0 ms]'; +}; + +AudioBufferProgressBar.prototype.setSampleRate = function(sampleRate) { + this.sampleRate = sampleRate; +}; + AudioBufferProgressBar.prototype.setBuffersize = function(buffersize) { var audio_buffer_value = buffersize / this.sampleRate; var overrun = audio_buffer_value > audio_buffer_maximal_length_sec; @@ -53,6 +65,10 @@ NetworkSpeedProgressBar = function(el) { NetworkSpeedProgressBar.prototype = new ProgressBar(); +NetworkSpeedProgressBar.prototype.getDefaultText = function() { + return 'Network usage [0 kbps]'; +}; + NetworkSpeedProgressBar.prototype.setSpeed = function(speed) { var speedInKilobits = speed * 8 / 1000; this.set(speedInKilobits / 2000, "Network usage [" + speedInKilobits.toFixed(1) + " kbps]", false); @@ -64,18 +80,29 @@ AudioSpeedProgressBar = function(el) { AudioSpeedProgressBar.prototype = new ProgressBar(); +AudioSpeedProgressBar.prototype.getDefaultText = function() { + return 'Audio stream [0 kbps]'; +}; + AudioSpeedProgressBar.prototype.setSpeed = function(speed) { - this.set(speed / 500000, "Audio stream [" + (speed / 1000).toFixed(0) + " kbps]", false); + this.set(speed / 1000000, "Audio stream [" + (speed / 1000).toFixed(0) + " kbps]", false); }; AudioOutputProgressBar = function(el, sampleRate) { ProgressBar.call(this, el); - this.maxRate = sampleRate * 1.25; - this.minRate = sampleRate * .25; }; AudioOutputProgressBar.prototype = new ProgressBar(); +AudioOutputProgressBar.prototype.getDefaultText = function() { + return 'Audio output [0 sps]'; +}; + +AudioOutputProgressBar.prototype.setSampleRate = function(sampleRate) { + this.maxRate = sampleRate * 1.25; + this.minRate = sampleRate * .25; +}; + AudioOutputProgressBar.prototype.setAudioRate = function(audioRate) { this.set(audioRate / this.maxRate, "Audio output [" + (audioRate / 1000).toFixed(1) + " ksps]", audioRate > this.maxRate || audioRate < this.minRate); }; @@ -88,6 +115,10 @@ ClientsProgressBar = function(el) { ClientsProgressBar.prototype = new ProgressBar(); +ClientsProgressBar.prototype.getDefaultText = function() { + return 'Clients [1]'; +}; + ClientsProgressBar.prototype.setClients = function(clients) { this.clients = clients; this.render(); @@ -108,6 +139,27 @@ CpuProgressBar = function(el) { CpuProgressBar.prototype = new ProgressBar(); +CpuProgressBar.prototype.getDefaultText = function() { + return 'Server CPU [0%]'; +}; + CpuProgressBar.prototype.setUsage = function(usage) { this.set(usage, "Server CPU [" + Math.round(usage * 100) + "%]", usage > .85); }; + +ProgressBar.types = { + cpu: CpuProgressBar, + audiobuffer: AudioBufferProgressBar, + audiospeed: AudioSpeedProgressBar, + audiooutput: AudioOutputProgressBar, + clients: ClientsProgressBar, + networkspeed: NetworkSpeedProgressBar +} + +$.fn.progressbar = function() { + if (!this.data('progressbar')) { + var constructor = ProgressBar.types[this.data('type')] || ProgressBar; + this.data('progressbar', new constructor(this)); + } + return this.data('progressbar'); +}; diff --git a/htdocs/lib/settings/Input.js b/htdocs/lib/settings/Input.js new file mode 100644 index 0000000..f638257 --- /dev/null +++ b/htdocs/lib/settings/Input.js @@ -0,0 +1,138 @@ +function Input(name, value, options) { + this.name = name; + this.value = value; + this.options = options; + this.label = options && options.label || name; +}; + +Input.prototype.getClasses = function() { + return ['form-control', 'form-control-sm']; +} + +Input.prototype.bootstrapify = function(input) { + this.getClasses().forEach(input.addClass.bind(input)); + return [ + '
', + '', + '
', + $.map(input, function(el) { + return el.outerHTML; + }).join(''), + '
', + '
' + ].join(''); +}; + +function TextInput() { + Input.apply(this, arguments); +}; + +TextInput.prototype = new Input(); + +TextInput.prototype.render = function() { + return this.bootstrapify($('')); +} + +function NumberInput() { + Input.apply(this, arguments); +}; + +NumberInput.prototype = new Input(); + +NumberInput.prototype.render = function() { + return this.bootstrapify($('')); +}; + +function SoapyGainInput() { + Input.apply(this, arguments); +} + +SoapyGainInput.prototype = new Input(); + +SoapyGainInput.prototype.getClasses = function() { + return []; +}; + +SoapyGainInput.prototype.render = function(){ + var markup = $( + '
' + + '
Gain mode
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
Gain
' + + '
' + + '' + + '
' + + '
' + + this.options.gains.map(function(g){ + return '
' + + '
' + g + '
' + + '
' + + '' + + '
' + + '
'; + }).join('') + ); + var el = $(this.bootstrapify(markup)) + var setMode = function(mode){ + el.find('select').val(mode); + el.find('.option').hide(); + el.find('.gain-mode-' + mode).show(); + }; + el.on('change', 'select', function(){ + var mode = $(this).val(); + setMode(mode); + }); + if (typeof(this.value) === 'number') { + setMode('single'); + el.find('.gain-mode-single input').val(this.value); + } else if (typeof(this.value) === 'string') { + if (this.value === 'auto') { + setMode('auto'); + } else { + setMode('separate'); + values = $.extend.apply($, this.value.split(',').map(function(seg){ + var split = seg.split('='); + if (split.length < 2) return; + var res = {}; + res[split[0]] = parseInt(split[1]); + return res; + })); + el.find('.gain-mode-separate input').each(function(){ + var $input = $(this); + var g = $input.data('gain'); + $input.val(g in values ? values[g] : 0); + }); + } + } else { + setMode('auto'); + } + return el; +}; + +function ProfileInput() { + Input.apply(this, arguments); +}; + +ProfileInput.prototype = new Input(); + +ProfileInput.prototype.render = function() { + return $('

Profiles

'); +}; + +function SchedulerInput() { + Input.apply(this, arguments); +}; + +SchedulerInput.prototype = new Input(); + +SchedulerInput.prototype.render = function() { + return $('

Scheduler

'); +}; diff --git a/htdocs/lib/settings/SdrDevice.js b/htdocs/lib/settings/SdrDevice.js new file mode 100644 index 0000000..25f85c9 --- /dev/null +++ b/htdocs/lib/settings/SdrDevice.js @@ -0,0 +1,252 @@ +function SdrDevice(el, data) { + this.el = el; + this.data = data; + this.inputs = {}; + this.render(); + + var self = this; + el.on('click', '.fieldselector .btn', function() { + var key = el.find('.fieldselector select').val(); + self.data[key] = self.getInitialValue(key); + self.render(); + }); +}; + +SdrDevice.create = function(el) { + var data = JSON.parse(decodeURIComponent(el.data('config'))); + var type = data.type; + var constructor = SdrDevice.types[type] || SdrDevice; + return new constructor(el, data); +}; + +SdrDevice.prototype.getData = function() { + return $.extend(new Object(), this.getDefaults(), this.data); +}; + +SdrDevice.prototype.getDefaults = function() { + var defaults = {} + $.each(this.getMappings(), function(k, v) { + if (!v.includeInDefault) return; + defaults[k] = 'initialValue' in v ? v['initialValue'] : false; + }); + return defaults; +}; + +SdrDevice.prototype.getMappings = function() { + return { + "name": { + constructor: TextInput, + inputOptions: { + label: "Name" + }, + initialValue: "", + includeInDefault: true + }, + "type": { + constructor: TextInput, + inputOptions: { + label: "Type" + }, + initialValue: '', + includeInDefault: true + }, + "ppm": { + constructor: NumberInput, + inputOptions: { + label: "PPM" + }, + initialValue: 0 + }, + "profiles": { + constructor: ProfileInput, + inputOptions: { + label: "Profiles" + }, + initialValue: [], + includeInDefault: true, + position: 100 + }, + "scheduler": { + constructor: SchedulerInput, + inputOptions: { + label: "Scheduler", + }, + initialValue: {}, + position: 101 + }, + "rf_gain": { + constructor: TextInput, + inputOptions: { + label: "Gain", + }, + initialValue: 0 + } + }; +}; + +SdrDevice.prototype.getMapping = function(key) { + var mappings = this.getMappings(); + return mappings[key]; +}; + +SdrDevice.prototype.getInputClass = function(key) { + var mapping = this.getMapping(key); + return mapping && mapping.constructor || TextInput; +}; + +SdrDevice.prototype.getInitialValue = function(key) { + var mapping = this.getMapping(key); + return mapping && ('initialValue' in mapping) ? mapping['initialValue'] : false; +}; + +SdrDevice.prototype.getPosition = function(key) { + var mapping = this.getMapping(key); + return mapping && mapping.position || 10; +}; + +SdrDevice.prototype.getInputOptions = function(key) { + var mapping = this.getMapping(key); + return mapping && mapping.inputOptions || {}; +}; + +SdrDevice.prototype.getLabel = function(key) { + var options = this.getInputOptions(key); + return options && options.label || key; +}; + +SdrDevice.prototype.render = function() { + var self = this; + self.el.empty(); + var data = this.getData(); + Object.keys(data).sort(function(a, b){ + return self.getPosition(a) - self.getPosition(b); + }).forEach(function(key){ + var value = data[key]; + var inputClass = self.getInputClass(key); + var input = new inputClass(key, value, self.getInputOptions(key)); + self.inputs[key] = input; + self.el.append(input.render()); + }); + self.el.append(this.renderFieldSelector()); +}; + +SdrDevice.prototype.renderFieldSelector = function() { + var self = this; + return '
' + + '

Add new configuration options

' + + '
' + + '
' + + '
' + + '
Add to config
' + + '
' + + '
' + + '

'; +}; + +RtlSdrDevice = function() { + SdrDevice.apply(this, arguments); +}; + +RtlSdrDevice.prototype = Object.create(SdrDevice.prototype); +RtlSdrDevice.prototype.constructor = RtlSdrDevice; + +RtlSdrDevice.prototype.getMappings = function() { + var mappings = SdrDevice.prototype.getMappings.apply(this, arguments); + return $.extend(new Object(), mappings, { + "device": { + constructor: TextInput, + inputOptions:{ + label: "Serial number" + }, + initialValue: "" + } + }); +}; + +SoapySdrDevice = function() { + SdrDevice.apply(this, arguments); +}; + +SoapySdrDevice.prototype = Object.create(SdrDevice.prototype); +SoapySdrDevice.prototype.constructor = SoapySdrDevice; + +SoapySdrDevice.prototype.getMappings = function() { + var mappings = SdrDevice.prototype.getMappings.apply(this, arguments); + return $.extend(new Object(), mappings, { + "device": { + constructor: TextInput, + inputOptions:{ + label: "Soapy device selector" + }, + initialValue: "" + }, + "rf_gain": { + constructor: SoapyGainInput, + initialValue: 0, + inputOptions: { + label: "Gain", + gains: this.getGains() + } + } + }); +}; + +SoapySdrDevice.prototype.getGains = function() { + return []; +}; + +SdrplaySdrDevice = function() { + SoapySdrDevice.apply(this, arguments); +}; + +SdrplaySdrDevice.prototype = Object.create(SoapySdrDevice.prototype); +SdrplaySdrDevice.prototype.constructor = SdrplaySdrDevice; + +SdrplaySdrDevice.prototype.getGains = function() { + return ['RFGR', 'IFGR']; +}; + +AirspyHfSdrDevice = function() { + SoapySdrDevice.apply(this, arguments); +}; + +AirspyHfSdrDevice.prototype = Object.create(SoapySdrDevice.prototype); +AirspyHfSdrDevice.prototype.constructor = AirspyHfSdrDevice; + +AirspyHfSdrDevice.prototype.getGains = function() { + return ['RF', 'VGA']; +}; + +HackRfSdrDevice = function() { + SoapySdrDevice.apply(this, arguments); +}; + +HackRfSdrDevice.prototype = Object.create(SoapySdrDevice.prototype); +HackRfSdrDevice.prototype.constructor = HackRfSdrDevice; + +HackRfSdrDevice.prototype.getGains = function() { + return ['LNA', 'VGA', 'AMP']; +}; + +SdrDevice.types = { + 'rtl_sdr': RtlSdrDevice, + 'sdrplay': SdrplaySdrDevice, + 'airspyhf': AirspyHfSdrDevice, + 'hackrf': HackRfSdrDevice +}; + +$.fn.sdrdevice = function() { + return this.map(function(){ + var el = $(this); + if (!el.data('sdrdevice')) { + el.data('sdrdevice', SdrDevice.create(el)); + } + return el.data('sdrdevice'); + }); +}; diff --git a/htdocs/login.html b/htdocs/login.html new file mode 100644 index 0000000..4f4c554 --- /dev/null +++ b/htdocs/login.html @@ -0,0 +1,27 @@ + + + + OpenWebRX Login + + + + + + + + + ${header} + + \ No newline at end of file diff --git a/htdocs/map.html b/htdocs/map.html index 23aabeb..08e40b4 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -3,9 +3,7 @@ OpenWebRX Map - - - + diff --git a/htdocs/map.js b/htdocs/map.js index 95cfa97..0d447e2 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -135,7 +135,11 @@ if (expectedCallsign && expectedCallsign == update.callsign.trim()) { map.panTo(pos); showMarkerInfoWindow(update.callsign, pos); - delete(expectedCallsign); + expectedCallsign = false; + } + + if (infowindow && infowindow.callsign && infowindow.callsign == update.callsign.trim()) { + showMarkerInfoWindow(infowindow.callsign, pos); } break; case 'locator': @@ -176,7 +180,11 @@ if (expectedLocator && expectedLocator == update.location.locator) { map.panTo(center); showLocatorInfoWindow(expectedLocator, center); - delete(expectedLocator); + expectedLocator = false; + } + + if (infowindow && infowindow.locator && infowindow.locator == update.location.locator) { + showLocatorInfoWindow(infowindow.locator, center); } break; } @@ -215,13 +223,26 @@ case "config": var config = json.value; if (!map) $.getScript("https://maps.googleapis.com/maps/api/js?key=" + config.google_maps_api_key).done(function(){ + var mapTypeId = config.google_maps_api_key ? 'roadmap' : 'OSM'; + map = new google.maps.Map($('.openwebrx-map')[0], { center: { - lat: config.receiver_gps[0], - lng: config.receiver_gps[1] + lat: config.receiver_gps.lat, + lng: config.receiver_gps.lon }, - zoom: 5 + zoom: 5, + mapTypeId: mapTypeId }); + + map.mapTypes.set("OSM", new google.maps.ImageMapType({ + getTileUrl: function(coord, zoom) { + return "https://maps.wikimedia.org/osm-intl/" + zoom + "/" + coord.x + "/" + coord.y + ".png"; + }, + tileSize: new google.maps.Size(256, 256), + name: "OpenStreetMap", + maxZoom: 18 + })); + $.getScript("static/lib/nite-overlay.js").done(function(){ nite.init(map); setInterval(function() { nite.refresh() }, 10000); // every 10s @@ -237,6 +258,11 @@ 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 @@ -269,9 +295,21 @@ connect(); + var getInfoWindow = function() { + if (!infowindow) { + infowindow = new google.maps.InfoWindow(); + google.maps.event.addListener(infowindow, 'closeclick', function() { + delete infowindow.locator; + delete infowindow.callsign; + }); + } + return infowindow; + } + var infowindow; var showLocatorInfoWindow = function(locator, pos) { - if (!infowindow) infowindow = new google.maps.InfoWindow(); + 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(function(d) { @@ -297,7 +335,8 @@ }; var showMarkerInfoWindow = function(callsign, pos) { - if (!infowindow) infowindow = new google.maps.InfoWindow(); + var infowindow = getInfoWindow(); + infowindow.callsign = callsign; var marker = markers[callsign]; var timestring = moment(marker.lastseen).fromNow(); var commentString = ""; diff --git a/htdocs/openwebrx.js b/htdocs/openwebrx.js index 50cfdec..36ab5ea 100644 --- a/htdocs/openwebrx.js +++ b/htdocs/openwebrx.js @@ -27,73 +27,15 @@ is_firefox = navigator.userAgent.indexOf("Firefox") >= 0; var bandwidth; var center_freq; var fft_size; -var fft_fps; var fft_compression = "none"; var fft_codec; var waterfall_setup_done = 0; var secondary_fft_size; -var rx_photo_state = 1; function e(what) { return document.getElementById(what); } -var rx_photo_height; - -function init_rx_photo() { - var clip = e("webrx-top-photo-clip"); - rx_photo_height = clip.clientHeight; - clip.style.maxHeight = rx_photo_height + "px"; - - $.extend($.easing, { - easeOutCubic:function(x) { - return 1 - Math.pow( 1 - x, 3 ); - } - }); - - window.setTimeout(function () { - $('#webrx-rx-photo-title').animate({opacity: 0}, 500); - }, 1000); - window.setTimeout(function () { - $('#webrx-rx-photo-desc').animate({opacity: 0}, 500); - }, 1500); - window.setTimeout(function () { - close_rx_photo() - }, 2500); - $('#webrx-top-container').find('.openwebrx-photo-trigger').click(toggle_rx_photo); -} - -var dont_toggle_rx_photo_flag = 0; - -function dont_toggle_rx_photo() { - dont_toggle_rx_photo_flag = 1; -} - -function toggle_rx_photo() { - if (dont_toggle_rx_photo_flag) { - dont_toggle_rx_photo_flag = 0; - return; - } - if (rx_photo_state) close_rx_photo(); - else open_rx_photo() -} - -function close_rx_photo() { - rx_photo_state = 0; - $('#webrx-top-photo-clip').animate({maxHeight: 67}, {duration: 1000, easing: 'easeOutCubic'}); - e("openwebrx-rx-details-arrow-down").style.display = "block"; - e("openwebrx-rx-details-arrow-up").style.display = "none"; -} - -function open_rx_photo() { - rx_photo_state = 1; - e("webrx-rx-photo-desc").style.opacity = 1; - e("webrx-rx-photo-title").style.opacity = 1; - $('#webrx-top-photo-clip').animate({maxHeight: rx_photo_height}, {duration: 1000, easing: 'easeOutCubic'}); - e("openwebrx-rx-details-arrow-down").style.display = "none"; - e("openwebrx-rx-details-arrow-up").style.display = "block"; -} - function updateVolume() { audioEngine.setVolume(parseFloat(e("openwebrx-panel-volume").value) / 100); } @@ -104,14 +46,12 @@ function toggleMute() { e("openwebrx-mute-on").id = "openwebrx-mute-off"; e("openwebrx-mute-img").src = "static/gfx/openwebrx-speaker.png"; e("openwebrx-panel-volume").disabled = false; - e("openwebrx-panel-volume").style.opacity = 1.0; e("openwebrx-panel-volume").value = volumeBeforeMute; } else { mute = true; e("openwebrx-mute-off").id = "openwebrx-mute-on"; e("openwebrx-mute-img").src = "static/gfx/openwebrx-speaker-muted.png"; e("openwebrx-panel-volume").disabled = true; - e("openwebrx-panel-volume").style.opacity = 0.5; volumeBeforeMute = e("openwebrx-panel-volume").value; e("openwebrx-panel-volume").value = 0; } @@ -135,16 +75,6 @@ function zoomOutTotal() { zoom_set(0); } -function setSquelchToAuto() { - e("openwebrx-panel-squelch").value = (getLogSmeterValue(smeter_level) + 10).toString(); - updateSquelch(); -} - -function updateSquelch() { - var sliderValue = parseInt($("#openwebrx-panel-squelch").val()); - ws.send(JSON.stringify({"type": "dspcontrol", "params": {"squelch_level": sliderValue}})); -} - var waterfall_min_level; var waterfall_max_level; var waterfall_min_level_default; @@ -171,8 +101,8 @@ function waterfallColorsDefault() { } function waterfallColorsAuto() { - e("openwebrx-waterfall-color-min").value = (waterfall_measure_minmax_min - waterfall_auto_level_margin[0]).toString(); - e("openwebrx-waterfall-color-max").value = (waterfall_measure_minmax_max + waterfall_auto_level_margin[1]).toString(); + e("openwebrx-waterfall-color-min").value = (waterfall_measure_minmax_min - waterfall_auto_level_margin.min).toString(); + e("openwebrx-waterfall-color-max").value = (waterfall_measure_minmax_max + waterfall_auto_level_margin.max).toString(); updateWaterfallColors(0); } @@ -189,7 +119,7 @@ function setSmeterRelativeValue(value) { } function setSquelchSliderBackground(val) { - var $slider = $('#openwebrx-panel-squelch'); + var $slider = $('#openwebrx-panel-receiver .openwebrx-squelch-slider'); var min = Number($slider.attr('min')); var max = Number($slider.attr('max')); var sliderPosition = $slider.val(); @@ -236,336 +166,25 @@ function typeInAnimation(element, timeout, what, onFinish) { // ================ DEMODULATOR ROUTINES ================ // ======================================================== -demodulators = []; - -var demodulator_color_index = 0; -var demodulator_colors = ["#ffff00", "#00ff00", "#00ffff", "#058cff", "#ff9600", "#a1ff39", "#ff4e39", "#ff5dbd"]; - -function demodulators_get_next_color() { - if (demodulator_color_index >= demodulator_colors.length) demodulator_color_index = 0; - return (demodulator_colors[demodulator_color_index++]); -} - -function demod_envelope_draw(range, from, to, color, line) { // ____ - // 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). - if (typeof color === "undefined") color = "#ffff00"; //yellow - 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_px = scale_px_from_freq(from, range); - 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_bounding_line_w/2; - to_px+=env_bounding_line_w/2;*/ - from_px -= (env_att_w + env_bounding_line_w); - to_px += (env_att_w + env_bounding_line_w); - // do drawing: - scale_ctx.lineWidth = 3; - 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(); - } - } - return drag_ranges; -} - -function demod_envelope_where_clicked(x, drag_ranges, key_modifiers) { // Check exactly what the user has clicked based on ranges returned by demod_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 -} - -//******* class Demodulator ******* -// this can be used as a base class for ANY demodulator -Demodulator = function (offset_frequency) { - this.offset_frequency = offset_frequency; - this.envelope = {}; - this.color = demodulators_get_next_color(); - this.stop = function () { - }; +function getDemodulators() { + return [ + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator() + ].filter(function(d) { + return !!d; + }); }; -//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 demod_envelope_draw() - -//******* 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 - -demodulator_response_time = 50; - -function Demodulator_default_analog(offset_frequency, subtype) { - Demodulator.call(this, offset_frequency); - this.subtype = subtype; - this.filter = { - min_passband: 100, - getLimits: function() { - var max_bw; - if (secondary_demod === 'pocsag') { - max_bw = 12500; - } else { - max_bw = (audioEngine.getOutputRate() / 2) - 1; - } - return { - high: max_bw, - low: -max_bw - }; - } - }; - //Subtypes only define some filter parameters and the mod string sent to server, - //so you may set these parameters in your custom child class. - //Why? As of demodulation is done on the server, difference is mainly on the server side. - this.server_mod = subtype; - if (subtype === "lsb") { - this.low_cut = -3000; - this.high_cut = -300; - this.server_mod = "ssb"; - } - else if (subtype === "usb") { - this.low_cut = 300; - this.high_cut = 3000; - this.server_mod = "ssb"; - } - else if (subtype === "cw") { - this.low_cut = 700; - this.high_cut = 900; - this.server_mod = "ssb"; - } - else if (subtype === "nfm") { - this.low_cut = -4000; - this.high_cut = 4000; - } - else if (subtype === "dmr" || subtype === "ysf") { - this.low_cut = -4000; - this.high_cut = 4000; - } - else if (subtype === "dstar" || subtype === "nxdn") { - this.low_cut = -3250; - this.high_cut = 3250; - } - else if (subtype === "am") { - this.low_cut = -4000; - this.high_cut = 4000; - } - - this.wait_for_timer = false; - this.set_after = false; - this.set = function () { //set() is a wrapper to call doset(), but it ensures that doset won't execute more frequently than demodulator_response_time. - if (!this.wait_for_timer) { - this.doset(false); - this.set_after = false; - this.wait_for_timer = true; - var timeout_this = this; //http://stackoverflow.com/a/2130411 - window.setTimeout(function () { - timeout_this.wait_for_timer = false; - if (timeout_this.set_after) timeout_this.set(); - }, demodulator_response_time); - } else { - this.set_after = true; - } - }; - - this.doset = function (first_time) { //this function sends demodulator parameters to the server - var params = { - "low_cut": this.low_cut, - "high_cut": this.high_cut, - "offset_freq": this.offset_frequency - }; - if (first_time) params.mod = this.server_mod; - ws.send(JSON.stringify({"type": "dspcontrol", "params": params})); - mkenvelopes(get_visible_freq_range()); - }; - this.doset(true); //we set parameters on object creation - - //******* envelope object ******* - // for drawing the filter envelope above scale - this.envelope.parent = this; - - this.envelope.draw = function (visible_range) { - this.visible_range = visible_range; - this.drag_ranges = demod_envelope_draw(range, - center_freq + this.parent.offset_frequency + this.parent.low_cut, - center_freq + this.parent.offset_frequency + this.parent.high_cut, - this.color, center_freq + this.parent.offset_frequency); - }; - - this.envelope.dragged_range = Demodulator.draggable_ranges.none; - - // event handlers - this.envelope.drag_start = function (x, key_modifiers) { - this.key_modifiers = key_modifiers; - this.dragged_range = demod_envelope_where_clicked(x, this.drag_ranges, key_modifiers); - this.drag_origin = { - x: x, - low_cut: this.parent.low_cut, - high_cut: this.parent.high_cut, - offset_frequency: this.parent.offset_frequency - }; - return this.dragged_range !== Demodulator.draggable_ranges.none; - }; - - this.envelope.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.parent.filter.getLimits().low) return true; - //nor the filter passband be too small - if (this.parent.high_cut - new_value < this.parent.filter.min_passband) return true; - //sanity check to prevent GNU Radio "firdes check failed: fa <= fb" - if (new_value >= this.parent.high_cut) return true; - this.parent.low_cut = 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.parent.filter.getLimits().high) return true; - //nor the filter passband be too small - if (new_value - this.parent.low_cut < this.parent.filter.min_passband) return true; - //sanity check to prevent GNU Radio "firdes check failed: fa <= fb" - if (new_value <= this.parent.low_cut) return true; - this.parent.high_cut = 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.parent.offset_frequency = new_value; - } - //now do the actual modifications: - mkenvelopes(this.visible_range); - this.parent.set(); - //will have to change this when changing to multi-demodulator mode: - tunedFrequencyDisplay.setFrequency(center_freq + this.parent.offset_frequency); - return true; - }; - - this.envelope.drag_end = function () { //in this demodulator we've already changed values in the drag_move() function so we shouldn't do too much here. - demodulator_buttons_update(); - 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; - }; - -} - -Demodulator_default_analog.prototype = new Demodulator(); 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) secondary_demod_waterfall_set_zoom(demodulators[0].low_cut, demodulators[0].high_cut); -} - -function demodulator_remove(which) { - demodulators[which].stop(); - demodulators.splice(which, 1); -} - -function demodulator_add(what) { - demodulators.push(what); - mkenvelopes(get_visible_freq_range()); -} - -var last_analog_demodulator_subtype = 'nfm'; -var last_digital_demodulator_subtype = 'bpsk31'; - -function demodulator_analog_replace(subtype, for_digital) { //this function should only exist until the multi-demodulator capability is added - if (!(typeof for_digital !== "undefined" && for_digital && secondary_demod)) { - secondary_demod_close_window(); - secondary_demod_listbox_update(); - } - last_analog_demodulator_subtype = subtype; - var temp_offset = 0; if (demodulators.length) { - temp_offset = demodulators[0].offset_frequency; - demodulator_remove(0); + var bandpass = demodulators[0].getBandpass() + secondary_demod_waterfall_set_zoom(bandpass.low_cut, bandpass.high_cut); } - demodulator_add(new Demodulator_default_analog(temp_offset, subtype)); - demodulator_buttons_update(); - update_digitalvoice_panels("openwebrx-panel-metadata-" + subtype); -} - -Demodulator.prototype.set_offset_frequency = function(to_what) { - if (to_what > bandwidth / 2 || to_what < -bandwidth / 2) return; - this.offset_frequency = Math.round(to_what); - this.set(); - mkenvelopes(get_visible_freq_range()); - tunedFrequencyDisplay.setFrequency(center_freq + to_what); } function waterfallWidth() { @@ -579,11 +198,8 @@ function waterfallWidth() { var scale_ctx; var scale_canvas; -var tunedFrequencyDisplay; -var mouseFrequencyDisplay; function scale_setup() { - tunedFrequencyDisplay.setFrequency(canvas_get_frequency(window.innerWidth / 2)); scale_canvas = e("openwebrx-scale-canvas"); scale_ctx = scale_canvas.getContext("2d"); scale_canvas.addEventListener("mousedown", scale_canvas_mousedown, false); @@ -619,6 +235,7 @@ function scale_offset_freq_from_px(x, visible_range) { 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 { @@ -637,7 +254,7 @@ function scale_canvas_mousemove(evt) { function frequency_container_mousemove(evt) { var frequency = center_freq + scale_offset_freq_from_px(evt.pageX); - mouseFrequencyDisplay.setFrequency(frequency); + $('.webrx-mouse-freq').frequencyDisplay().setFrequency(frequency); } function scale_canvas_end_drag(x) { @@ -645,6 +262,7 @@ function scale_canvas_end_drag(x) { 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)); } @@ -914,7 +532,7 @@ function canvas_mousemove(evt) { bookmarks.position(); } } else { - mouseFrequencyDisplay.setFrequency(canvas_get_frequency(relativeX)); + $('.webrx-mouse-freq').frequencyDisplay().setFrequency(canvas_get_frequency(relativeX)); } } @@ -927,7 +545,7 @@ function canvas_mouseup(evt) { var relativeX = get_relative_x(evt); if (!canvas_drag) { - demodulators[0].set_offset_frequency(canvas_get_freq_offset(relativeX)); + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().set_offset_frequency(canvas_get_freq_offset(relativeX)); } else { canvas_end_drag(); @@ -1010,7 +628,7 @@ function zoom_set(level) { 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 = demodulators[0].offset_frequency; + 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(); @@ -1050,24 +668,26 @@ function on_ws_recv(evt) { waterfall_auto_level_margin = config['waterfall_auto_level_margin']; waterfallColorsDefault(); - starting_mod = config['start_mod']; - starting_offset_frequency = config['start_offset_freq']; + var initial_demodulator_params = { + mod: config['start_mod'], + offset_frequency: config['start_offset_freq'], + squelch_level: Number.isInteger(config['initial_squelch_level']) ? config['initial_squelch_level'] : -150 + }; + bandwidth = config['samp_rate']; center_freq = config['center_freq']; fft_size = config['fft_size']; - fft_fps = config['fft_fps']; var audio_compression = config['audio_compression']; audioEngine.setCompression(audio_compression); divlog("Audio stream is " + ((audio_compression === "adpcm") ? "compressed" : "uncompressed") + "."); fft_compression = config['fft_compression']; divlog("FFT stream is " + ((fft_compression === "adpcm") ? "compressed" : "uncompressed") + "."); - clientProgressBar.setMaxClients(config['max_clients']); - var sql = Number.isInteger(config['initial_squelch_level']) ? config['initial_squelch_level'] : -150; - $("#openwebrx-panel-squelch").val(sql); - updateSquelch(); + $('#openwebrx-bar-clients').progressbar().setMaxClients(config['max_clients']); waterfall_init(); - initialize_demodulator(); + var demodulatorPanel = $('#openwebrx-panel-receiver').demodulatorPanel(); + demodulatorPanel.setInitialParams(initial_demodulator_params); + demodulatorPanel.setCenterFrequency(center_freq); bookmarks.loadLocalBookmarks(); waterfall_clear(); @@ -1084,21 +704,17 @@ function on_ws_recv(evt) { secondary_demod_init_canvases(); break; case "receiver_details": - var r = json['value']; - e('webrx-rx-title').innerHTML = r['receiver_name']; - e('webrx-rx-desc').innerHTML = r['receiver_location'] + ' | Loc: ' + r['locator'] + ', ASL: ' + r['receiver_asl'] + ' m, [maps]'; - e('webrx-rx-photo-title').innerHTML = r['photo_title']; - e('webrx-rx-photo-desc').innerHTML = r['photo_desc']; + $('#webrx-top-container').header().setDetails(json['value']); break; case "smeter": smeter_level = json['value']; setSmeterAbsoluteValue(smeter_level); break; case "cpuusage": - cpuProgressBar.setUsage(json['value']); + $('#openwebrx-bar-server-cpu').progressbar().setUsage(json['value']); break; case "clients": - clientProgressBar.setClients(json['value']); + $('#openwebrx-bar-clients').progressbar().setClients(json['value']); break; case "profiles": var listbox = e("openwebrx-sdr-profiles-listbox"); @@ -1110,16 +726,14 @@ function on_ws_recv(evt) { } break; case "features": - var features = json['value']; - for (var feature in features) { - if (features.hasOwnProperty(feature)) { - $('[data-feature="' + feature + '"]')[features[feature] ? "show" : "hide"](); - } - } + Modes.setFeatures(json['value']); break; case "metadata": update_metadata(json['value']); break; + case "js8_message": + $("#openwebrx-panel-js8-message").js8().pushMessage(json['value']); + break; case "wsjt_message": update_wsjt_panel(json['value']); break; @@ -1127,7 +741,7 @@ function on_ws_recv(evt) { var as_bookmarks = json['value'].map(function (d) { return { name: d['mode'].toUpperCase(), - digital_modulation: d['mode'], + modulation: d['mode'], frequency: d['frequency'] }; }); @@ -1162,6 +776,9 @@ function on_ws_recv(evt) { // 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']); } @@ -1212,6 +829,10 @@ function on_ws_recv(evt) { 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) } @@ -1296,14 +917,14 @@ function update_wsjt_panel(msg) { if (['FT8', 'JT65', 'JT9', 'FT4'].indexOf(msg['mode']) >= 0) { matches = linkedmsg.match(/(.*\s[A-Z0-9]+\s)([A-R]{2}[0-9]{2})$/); if (matches && matches[2] !== 'RR73') { - linkedmsg = html_escape(matches[1]) + '' + matches[2] + ''; + linkedmsg = html_escape(matches[1]) + '' + matches[2] + ''; } else { linkedmsg = html_escape(linkedmsg); } } else if (msg['mode'] === 'WSPR') { matches = linkedmsg.match(/([A-Z0-9]*\s)([A-R]{2}[0-9]{2})(\s[0-9]+)/); if (matches) { - linkedmsg = html_escape(matches[1]) + '' + matches[2] + '' + html_escape(matches[3]); + linkedmsg = html_escape(matches[1]) + '' + matches[2] + '' + html_escape(matches[3]); } else { linkedmsg = html_escape(linkedmsg); } @@ -1389,7 +1010,7 @@ function update_packet_panel(msg) { 'style="' + stylesToString(styles) + '"' ].join(' '); if (msg.lat && msg.lon) { - link = '' + overlay + ''; + link = '' + overlay + ''; } else { link = '
' + overlay + '
' } @@ -1416,13 +1037,6 @@ function update_pocsag_panel(msg) { $b.scrollTop($b[0].scrollHeight); } -function update_digitalvoice_panels(showing) { - $(".openwebrx-meta-panel").each(function (_, p) { - toggle_panel(p.id, p.id === showing); - }); - clear_metadata(); -} - function clear_metadata() { $(".openwebrx-meta-panel .openwebrx-meta-autoclear").text(""); $(".openwebrx-meta-slot").removeClass("active").removeClass("sync"); @@ -1435,8 +1049,11 @@ var waterfall_measure_minmax_min = 1e100; var waterfall_measure_minmax_max = -1e100; function waterfall_measure_minmax_do(what) { - waterfall_measure_minmax_min = Math.min(waterfall_measure_minmax_min, Math.min.apply(Math, what)); - waterfall_measure_minmax_max = Math.max(waterfall_measure_minmax_max, Math.max.apply(Math, what)); + // this is based on an oversampling factor of about 1,25 + var ignored = .1 * what.length; + var data = what.slice(ignored, -ignored); + waterfall_measure_minmax_min = Math.min(waterfall_measure_minmax_min, Math.min.apply(Math, data)); + waterfall_measure_minmax_max = Math.max(waterfall_measure_minmax_max, Math.max.apply(Math, data)); } function on_ws_opened() { @@ -1446,7 +1063,7 @@ function on_ws_opened() { if (!networkSpeedMeasurement) { networkSpeedMeasurement = new Measurement(); networkSpeedMeasurement.report(60000, 1000, function(rate){ - networkSpeedProgressBar.setSpeed(rate); + $('#openwebrx-bar-network-speed').progressbar().setSpeed(rate); }); } else { networkSpeedMeasurement.reset(); @@ -1454,11 +1071,10 @@ function on_ws_opened() { reconnect_timeout = false; ws.send(JSON.stringify({ "type": "connectionproperties", - "params": {"output_rate": audioEngine.getOutputRate()} - })); - ws.send(JSON.stringify({ - "type": "dspcontrol", - "action": "start" + "params": { + "output_rate": audioEngine.getOutputRate(), + "hd_output_rate": audioEngine.getHdOutputRate() + } })); } @@ -1483,41 +1099,13 @@ 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 webrx_set_param(what, value) { - var params = {}; - params[what] = value; - ws.send(JSON.stringify({"type": "dspcontrol", "params": params})); -} - -var starting_offset_frequency; -var starting_mod; - -function parseHash() { - var h; - if (h = window.location.hash) { - h.substring(1).split(",").forEach(function (x) { - var harr = x.split("="); - if (harr[0] === "mute") toggleMute(); - else if (harr[0] === "mod") starting_mod = harr[1]; - else if (harr[0] === "sql") { - e("openwebrx-panel-squelch").value = harr[1]; - updateSquelch(); - } - else if (harr[0] === "freq") { - console.log(parseInt(harr[1])); - console.log(center_freq); - starting_offset_frequency = parseInt(harr[1]) - center_freq; - } - }); - - } -} - -function onAudioStart(success, apiType){ +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) initialize_demodulator(); + //if (canvas_container) synchronize_demodulator_init(); //hide log panel in a second (if user has not hidden it yet) window.setTimeout(function () { @@ -1528,19 +1116,10 @@ function onAudioStart(success, apiType){ updateVolume(); } -function initialize_demodulator() { - demodulator_analog_replace(starting_mod); - if (starting_offset_frequency) { - demodulators[0].offset_frequency = starting_offset_frequency; - tunedFrequencyDisplay.setFrequency(center_freq + starting_offset_frequency); - demodulators[0].set(); - mkscale(); - } -} - var reconnect_timeout = false; function on_ws_closed() { + $("#openwebrx-panel-receiver").demodulatorPanel().stopDemodulator(); if (reconnect_timeout) { // max value: roundabout 8 and a half minutes reconnect_timeout = Math.min(reconnect_timeout * 2, 512000); @@ -1700,21 +1279,6 @@ function waterfall_add(data) { if (canvas_actual_line < 0) add_canvas(); } -function check_top_bar_congestion() { - var rmf = function (x) { - return x.offsetLeft + x.offsetWidth; - }; - var wet = e("webrx-rx-title"); - var wed = e("webrx-rx-desc"); - var tl = e("openwebrx-main-buttons"); - - [wet, wed].map(function (what) { - if (rmf(what) > tl.offsetLeft - 20) what.style.opacity = what.style.opacity = "0"; - else wet.style.opacity = wed.style.opacity = "1"; - }); - -} - function waterfall_clear() { while (canvases.length) //delete all canvases { @@ -1727,42 +1291,28 @@ function waterfall_clear() { function openwebrx_resize() { resize_canvases(); resize_scale(); - check_top_bar_congestion(); } -function init_header() { - $('#openwebrx-main-buttons').find('li[data-toggle-panel]').click(function () { - toggle_panel($(this).data('toggle-panel')); - }); -} - -var audioBufferProgressBar; -var networkSpeedProgressBar; -var audioSpeedProgressBar; -var audioOutputProgressBar; -var clientProgressBar; -var cpuProgressBar; - function initProgressBars() { - audioBufferProgressBar = new AudioBufferProgressBar($('#openwebrx-bar-audio-buffer'), audioEngine.getSampleRate()); - networkSpeedProgressBar = new NetworkSpeedProgressBar($('#openwebrx-bar-network-speed')); - audioSpeedProgressBar = new AudioSpeedProgressBar($('#openwebrx-bar-audio-speed')); - audioOutputProgressBar = new AudioOutputProgressBar($('#openwebrx-bar-audio-output'), audioEngine.getSampleRate()); - clientProgressBar = new ClientsProgressBar($('#openwebrx-bar-clients')); - cpuProgressBar = new CpuProgressBar($('#openwebrx-bar-server-cpu')); + $(".openwebrx-progressbar").each(function(){ + var bar = $(this).progressbar(); + if ('setSampleRate' in bar) { + bar.setSampleRate(audioEngine.getSampleRate()); + } + }) } function audioReporter(stats) { if (typeof(stats.buffersize) !== 'undefined') { - audioBufferProgressBar.setBuffersize(stats.buffersize); + $('#openwebrx-bar-audio-buffer').progressbar().setBuffersize(stats.buffersize); } if (typeof(stats.audioByteRate) !== 'undefined') { - audioSpeedProgressBar.setSpeed(stats.audioByteRate * 8); + $('#openwebrx-bar-audio-speed').progressbar().setSpeed(stats.audioByteRate * 8); } if (typeof(stats.audioRate) !== 'undefined') { - audioOutputProgressBar.setAudioRate(stats.audioRate); + $('#openwebrx-bar-audio-output').progressbar().setAudioRate(stats.audioRate); } } @@ -1772,29 +1322,23 @@ var audioEngine; function openwebrx_init() { audioEngine = new AudioEngine(audio_buffer_maximal_length_sec, audioReporter); $overlay = $('#openwebrx-autoplay-overlay'); - $overlay.on('click', playButtonClick); + $overlay.on('click', function(){ + audioEngine.resume(); + }); + audioEngine.onStart(onAudioStart); if (!audioEngine.isAllowed()) { $overlay.show(); - } else { - audioEngine.start(onAudioStart); } fft_codec = new ImaAdpcmCodec(); initProgressBars(); - init_rx_photo(); open_websocket(); secondary_demod_init(); digimodes_init(); initPanels(); - tunedFrequencyDisplay = new TuneableFrequencyDisplay($('#webrx-actual-freq')); - tunedFrequencyDisplay.onFrequencyChange(function(f) { - demodulators[0].set_offset_frequency(f - center_freq); - }); - mouseFrequencyDisplay = new FrequencyDisplay($('#webrx-mouse-freq')); + $('.webrx-mouse-freq').frequencyDisplay(); + $('#openwebrx-panel-receiver').demodulatorPanel(); window.addEventListener("resize", openwebrx_resize); - check_top_bar_congestion(); - init_header(); bookmarks = new BookmarkBar(); - parseHash(); initSliders(); } @@ -1826,12 +1370,10 @@ function update_dmr_timeslot_filtering() { }).toArray().reduce(function (acc, v) { return acc | v; }, 0); - webrx_set_param("dmr_filter", filter); + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().setDmrFilter(filter); } -function playButtonClick() { - //On iOS, we can only start audio from a click or touch event. - audioEngine.start(onAudioStart); +function hideOverlay() { var $overlay = $('#openwebrx-autoplay-overlay'); $overlay.css('opacity', 0); $overlay.on('transitionend', function() { @@ -1918,36 +1460,6 @@ function initPanels() { }); } -function demodulator_buttons_update() { - $(".openwebrx-demodulator-button").removeClass("highlighted"); - if (secondary_demod) { - $("#openwebrx-button-dig").addClass("highlighted"); - $('#openwebrx-secondary-demod-listbox').val(secondary_demod); - } else switch (demodulators[0].subtype) { - case "lsb": - case "usb": - case "cw": - if (demodulators[0].high_cut - demodulators[0].low_cut < 300) - $("#openwebrx-button-cw").addClass("highlighted"); - else { - if (demodulators[0].high_cut < 0) - $("#openwebrx-button-lsb").addClass("highlighted"); - else if (demodulators[0].low_cut > 0) - $("#openwebrx-button-usb").addClass("highlighted"); - else $("#openwebrx-button-lsb, #openwebrx-button-usb").addClass("highlighted"); - } - break; - default: - var mod = demodulators[0].subtype; - $("#openwebrx-button-" + mod).addClass("highlighted"); - break; - } -} - -function demodulator_analog_replace_last() { - demodulator_analog_replace(last_analog_demodulator_subtype); -} - /* _____ _ _ _ | __ \(_) (_) | | @@ -1959,7 +1471,6 @@ function demodulator_analog_replace_last() { |___/ */ -var secondary_demod = false; var secondary_demod_fft_offset_db = 30; //need to calculate that later var secondary_demod_canvases_initialized = false; var secondary_demod_listbox_updating = false; @@ -1976,51 +1487,6 @@ var secondary_demod_current_canvas_context; var secondary_demod_current_canvas_index; var secondary_demod_canvases; -function demodulator_digital_replace_last() { - demodulator_digital_replace(last_digital_demodulator_subtype); - secondary_demod_listbox_update(); -} - -function demodulator_digital_replace(subtype) { - switch (subtype) { - case "bpsk31": - case "bpsk63": - case "rtty": - case "ft8": - case "jt65": - case "jt9": - case "ft4": - secondary_demod_start(subtype); - demodulator_analog_replace('usb', true); - break; - case "wspr": - secondary_demod_start(subtype); - demodulator_analog_replace('usb', true); - // WSPR only samples between 1400 and 1600 Hz - demodulators[0].low_cut = 1350; - demodulators[0].high_cut = 1650; - demodulators[0].set(); - break; - case "packet": - secondary_demod_start(subtype); - demodulator_analog_replace('nfm', true); - break; - case "pocsag": - secondary_demod_start(subtype); - demodulator_analog_replace('nfm', true); - demodulators[0].low_cut = -6000; - demodulators[0].high_cut = 6000; - demodulators[0].set(); - break; - } - demodulator_buttons_update(); - $('#openwebrx-panel-digimodes').attr('data-mode', subtype); - toggle_panel("openwebrx-panel-digimodes", true); - toggle_panel("openwebrx-panel-wsjt-message", ['ft8', 'wspr', 'jt65', 'jt9', 'ft4'].indexOf(subtype) >= 0); - toggle_panel("openwebrx-panel-packet-message", subtype === "packet"); - toggle_panel("openwebrx-panel-pocsag-message", subtype === "pocsag"); -} - function secondary_demod_create_canvas() { var new_canvas = document.createElement("canvas"); new_canvas.width = secondary_fft_size; @@ -2073,17 +1539,6 @@ function secondary_demod_init() { init_digital_removal_timer(); } -function secondary_demod_start(subtype) { - secondary_demod_canvases_initialized = false; - ws.send(JSON.stringify({"type": "dspcontrol", "params": {"secondary_mod": subtype}})); - secondary_demod = subtype; -} - -function secondary_demod_stop() { - ws.send(JSON.stringify({"type": "dspcontrol", "params": {"secondary_mod": false}})); - secondary_demod = false; -} - function secondary_demod_push_data(x) { x = Array.from(x).filter(function (y) { var c = y.charCodeAt(0); @@ -2103,16 +1558,7 @@ function secondary_demod_push_data(x) { $("#openwebrx-cursor-blink").before(x); } -function secondary_demod_close_window() { - secondary_demod_stop(); - toggle_panel("openwebrx-panel-digimodes", false); - toggle_panel("openwebrx-panel-wsjt-message", false); - toggle_panel("openwebrx-panel-packet-message", false); - toggle_panel("openwebrx-panel-pocsag-message", false); -} - function secondary_demod_waterfall_add(data) { - if (!secondary_demod) return; var w = secondary_fft_size; //Add line to waterfall image @@ -2132,22 +1578,6 @@ function secondary_demod_waterfall_add(data) { if (secondary_demod_current_canvas_actual_line < 0) secondary_demod_swap_canvases(); } -function secondary_demod_listbox_changed() { - if (secondary_demod_listbox_updating) return; - var sdm = $("#openwebrx-secondary-demod-listbox")[0].value; - if (sdm === "none") { - demodulator_analog_replace_last(); - } else { - demodulator_digital_replace(sdm); - } -} - -function secondary_demod_listbox_update() { - secondary_demod_listbox_updating = true; - $("#openwebrx-secondary-demod-listbox").val((secondary_demod) ? secondary_demod : "none"); - secondary_demod_listbox_updating = false; -} - function secondary_demod_update_marker() { var width = Math.max((secondary_bw / (if_samp_rate / 2)) * secondary_demod_canvas_width, 5); var center_at = (secondary_demod_channel_freq / (if_samp_rate / 2)) * secondary_demod_canvas_width + secondary_demod_canvas_left; @@ -2164,10 +1594,7 @@ function secondary_demod_update_channel_freq_from_event(evt) { if (!secondary_demod_waiting_for_set) { secondary_demod_waiting_for_set = true; window.setTimeout(function () { - ws.send(JSON.stringify({ - "type": "dspcontrol", - "params": {"secondary_offset_freq": Math.floor(secondary_demod_channel_freq)} - })); + $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator().set_secondary_offset_freq(Math.floor(secondary_demod_channel_freq)); secondary_demod_waiting_for_set = false; }, 50 @@ -2200,7 +1627,7 @@ function secondary_demod_canvas_container_mouseup(evt) { function secondary_demod_waterfall_set_zoom(low_cut, high_cut) { - if (!secondary_demod || !secondary_demod_canvases_initialized) return; + if (!secondary_demod_canvases_initialized) return; if (low_cut < 0 && high_cut < 0) { var hctmp = high_cut; var lctmp = low_cut; diff --git a/htdocs/sdrsettings.html b/htdocs/sdrsettings.html new file mode 100644 index 0000000..74aa8b7 --- /dev/null +++ b/htdocs/sdrsettings.html @@ -0,0 +1,21 @@ + + + + OpenWebRX Settings + + + + + + + +${header} +
+
+

SDR device settings

+
+
+ ${devices} +
+
+ \ No newline at end of file diff --git a/htdocs/settings.html b/htdocs/settings.html new file mode 100644 index 0000000..80dce91 --- /dev/null +++ b/htdocs/settings.html @@ -0,0 +1,27 @@ + + + + OpenWebRX Settings + + + + + + + +${header} +
+
+

Settings

+
+ + + +
+ \ No newline at end of file diff --git a/htdocs/settings.js b/htdocs/settings.js new file mode 100644 index 0000000..e95e2fe --- /dev/null +++ b/htdocs/settings.js @@ -0,0 +1,25 @@ +$(function(){ + $(".map-input").each(function(el) { + var $el = $(this); + var field_id = $el.attr("for"); + var $lat = $('#' + field_id + '-lat'); + var $lon = $('#' + field_id + '-lon'); + $.getScript("https://maps.googleapis.com/maps/api/js?key=" + $el.data("key")).done(function(){ + $el.css("height", "200px"); + var lp = new locationPicker($el.get(0), { + lat: parseFloat($lat.val()), + lng: parseFloat($lon.val()) + }, { + zoom: 7 + }); + + google.maps.event.addListener(lp.map, 'idle', function(event){ + var pos = lp.getMarkerPosition(); + $lat.val(pos.lat); + $lon.val(pos.lng); + }); + }); + }); + + $(".sdrdevice").sdrdevice(); +}); \ No newline at end of file diff --git a/manifest.sh b/manifest.sh new file mode 100755 index 0000000..5d5160b --- /dev/null +++ b/manifest.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euxo pipefail +. docker/env + +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 + docker pull jketterl/$image:$TAG +done diff --git a/owrx/__main__.py b/owrx/__main__.py index d452e4a..2bf3ec9 100644 --- a/owrx/__main__.py +++ b/owrx/__main__.py @@ -1,17 +1,18 @@ -from http.server import HTTPServer -from owrx.http import RequestHandler -from owrx.config import PropertyManager -from owrx.feature import FeatureDetector -from owrx.sdr import SdrService -from socketserver import ThreadingMixIn -from owrx.sdrhu import SdrHuUpdater -from owrx.service import Services -from owrx.websocket import WebSocketConnection -from owrx.pskreporter import PskReporter - import logging 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 import Config +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.pskreporter import PskReporter +from owrx.version import openwebrx_version class ThreadedHttpServer(ThreadingMixIn, HTTPServer): @@ -26,32 +27,41 @@ OpenWebRX - Open Source SDR Web App for Everyone! | for license see LICENSE fil _________________________________________________________________________________________________ Author contact info: Jakob Ketterl, DD5JFK +Documentation: https://github.com/jketterl/openwebrx/wiki +Support and info: https://groups.io/g/openwebrx """ ) - pm = PropertyManager.getSharedInstance().loadConfig() + logger.info("OpenWebRX version {0} starting up...".format(openwebrx_version)) + + pm = Config.get() + + configErrors = Config.validateConfig() + if configErrors: + logger.error( + "your configuration contains errors. please address the following errors:" + ) + for e in configErrors: + logger.error(e) + return featureDetector = FeatureDetector() if not featureDetector.is_available("core"): - print( + logger.error( "you are missing required dependencies to run openwebrx. " "please check that the following core requirements are installed:" ) - print(", ".join(featureDetector.get_requirements("core"))) + logger.error(", ".join(featureDetector.get_requirements("core"))) return # Get error messages about unknown / unavailable features as soon as possible SdrService.loadProps() - if "sdrhu_key" in pm and pm["sdrhu_public_listing"]: - updater = SdrHuUpdater() - updater.start() - Services.start() try: - server = ThreadedHttpServer(("0.0.0.0", pm.getPropertyValue("web_port")), RequestHandler) + server = ThreadedHttpServer(("0.0.0.0", pm["web_port"]), RequestHandler) server.serve_forever() except KeyboardInterrupt: WebSocketConnection.closeAll() diff --git a/owrx/audio.py b/owrx/audio.py new file mode 100644 index 0000000..330b88b --- /dev/null +++ b/owrx/audio.py @@ -0,0 +1,270 @@ +from abc import ABC, ABCMeta, abstractmethod +from owrx.config import Config +from owrx.metrics import Metrics, CounterMetric, DirectMetric +import threading +import wave +import subprocess +import os +from multiprocessing.connection import Pipe, wait +from datetime import datetime, timedelta +from queue import Queue, Full + + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class QueueJob(object): + def __init__(self, decoder, file, freq): + self.decoder = decoder + self.file = file + self.freq = freq + + def run(self): + self.decoder.decode(self) + + def unlink(self): + try: + os.unlink(self.file) + except FileNotFoundError: + pass + + +class QueueWorker(threading.Thread): + def __init__(self, queue): + self.queue = queue + self.doRun = True + super().__init__(daemon=True) + + def run(self) -> None: + while self.doRun: + job = self.queue.get() + try: + job.run() + except Exception: + logger.exception("failed to decode job") + self.queue.onError() + finally: + job.unlink() + + self.queue.task_done() + + +class DecoderQueue(Queue): + sharedInstance = None + creationLock = threading.Lock() + + @staticmethod + def getSharedInstance(): + with DecoderQueue.creationLock: + if DecoderQueue.sharedInstance is None: + pm = Config.get() + DecoderQueue.sharedInstance = DecoderQueue(maxsize=pm["decoding_queue_length"], workers=pm["decoding_queue_workers"]) + return DecoderQueue.sharedInstance + + def __init__(self, maxsize, workers): + super().__init__(maxsize) + 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) + self.workers = [self.newWorker() for _ in range(0, workers)] + + def put(self, item, **kwars): + 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() + + +class AudioChopperProfile(ABC): + @abstractmethod + def getInterval(self): + pass + + @abstractmethod + def getFileTimestampFormat(self): + pass + + @abstractmethod + def decoder_commandline(self, file): + pass + + +class AudioWriter(object): + def __init__(self, dsp, source, profile: AudioChopperProfile): + self.dsp = dsp + self.source = source + self.profile = profile + self.tmp_dir = Config.get()["temporary_directory"] + self.wavefile = None + self.wavefilename = None + self.switchingLock = threading.Lock() + self.timer = None + (self.outputReader, self.outputWriter) = Pipe() + + def getWaveFile(self): + filename = "{tmp_dir}/openwebrx-audiochopper-{id}-{timestamp}.wav".format( + tmp_dir=self.tmp_dir, + id=id(self), + timestamp=datetime.utcnow().strftime(self.profile.getFileTimestampFormat()), + ) + wavefile = wave.open(filename, "wb") + wavefile.setnchannels(1) + wavefile.setsampwidth(2) + wavefile.setframerate(12000) + return filename, wavefile + + def getNextDecodingTime(self): + t = datetime.utcnow() + zeroed = t.replace(minute=0, second=0, microsecond=0) + delta = t - zeroed + interval = self.profile.getInterval() + seconds = (int(delta.total_seconds() / interval) + 1) * 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): + self.switchingLock.acquire() + file = self.wavefile + filename = self.wavefilename + (self.wavefilename, self.wavefile) = self.getWaveFile() + self.switchingLock.release() + + file.close() + job = QueueJob(self, filename, self.dsp.get_operating_freq()) + try: + DecoderQueue.getSharedInstance().put(job) + except Full: + logger.warning("decoding queue overflow; dropping one file") + job.unlink() + self._scheduleNextSwitch() + + def decode(self, job: QueueJob): + logger.debug("processing file %s", job.file) + decoder = subprocess.Popen( + ["nice", "-n", "10"] + self.profile.decoder_commandline(job.file), + stdout=subprocess.PIPE, + cwd=self.tmp_dir, + close_fds=True, + ) + try: + for line in decoder.stdout: + self.outputWriter.send((job.freq, line)) + except OSError: + 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: + logger.warning("decoder return code: %i", rc) + except subprocess.TimeoutExpired: + logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid) + decoder.kill() + + def start(self): + (self.wavefilename, self.wavefile) = self.getWaveFile() + self._scheduleNextSwitch() + + def write(self, data): + self.switchingLock.acquire() + self.wavefile.writeframes(data) + self.switchingLock.release() + + def stop(self): + 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 + + self.cancelTimer() + try: + self.wavefile.close() + except Exception: + logger.exception("error closing wave file") + try: + os.unlink(self.wavefilename) + except Exception: + logger.exception("error removing undecoded file") + self.wavefile = None + self.wavefilename = None + + +class AudioChopper(threading.Thread, metaclass=ABCMeta): + def __init__(self, dsp, source, *profiles: AudioChopperProfile): + self.source = source + self.writers = [AudioWriter(dsp, source, p) for p in profiles] + self.doRun = True + super().__init__() + + def run(self) -> None: + logger.debug("Audio chopper starting up") + for w in self.writers: + w.start() + while self.doRun: + data = None + try: + data = self.source.read(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") + for w in self.writers: + w.stop() + + def read(self): + try: + readers = wait([w.outputReader for w in self.writers]) + return [r.recv() for r in readers] + except (EOFError, OSError): + return None diff --git a/owrx/bands.py b/owrx/bands.py index cf2bd7c..fbb7ae6 100644 --- a/owrx/bands.py +++ b/owrx/bands.py @@ -58,7 +58,10 @@ class Bandplan(object): except FileNotFoundError: pass except json.JSONDecodeError: - logger.exception("error while parsing bandplan from %s", file) + logger.exception("error while parsing bandplan file %s", file) + return [] + except Exception: + logger.exception("error while processing bandplan from %s", file) return [] return [] diff --git a/owrx/bookmarks.py b/owrx/bookmarks.py index 1456c8f..d5a38d8 100644 --- a/owrx/bookmarks.py +++ b/owrx/bookmarks.py @@ -50,7 +50,10 @@ class Bookmarks(object): except FileNotFoundError: pass except json.JSONDecodeError: - logger.exception("error while parsing bookmarks from %s", file) + logger.exception("error while parsing bookmarks file %s", file) + return [] + except Exception: + logger.exception("error while processing bookmarks from %s", file) return [] return [] diff --git a/owrx/client.py b/owrx/client.py index 4fda3d4..f992e45 100644 --- a/owrx/client.py +++ b/owrx/client.py @@ -1,5 +1,4 @@ -from owrx.config import PropertyManager -from owrx.metrics import Metrics, DirectMetric +from owrx.config import Config import threading import logging @@ -24,7 +23,6 @@ class ClientRegistry(object): def __init__(self): self.clients = [] - Metrics.getSharedInstance().addMetric("openwebrx.users", DirectMetric(self.clientCount)) super().__init__() def broadcast(self): @@ -33,7 +31,7 @@ class ClientRegistry(object): c.write_clients(n) def addClient(self, client): - pm = PropertyManager.getSharedInstance() + pm = Config.get() if len(self.clients) >= pm["max_clients"]: raise TooManyClientsException() self.clients.append(client) diff --git a/owrx/command.py b/owrx/command.py index 9cf0327..87ae711 100644 --- a/owrx/command.py +++ b/owrx/command.py @@ -33,6 +33,9 @@ class CommandMapper(object): self.static = static return self + def keys(self): + return self.mappings.keys() + class CommandMapping(ABC): @abstractmethod @@ -69,3 +72,8 @@ class Option(CommandMapping): def setSpacer(self, spacer): self.spacer = spacer return self + + +class Argument(CommandMapping): + def map(self, value): + return value diff --git a/owrx/config.py b/owrx/config.py index 820fee8..bdecd04 100644 --- a/owrx/config.py +++ b/owrx/config.py @@ -1,149 +1,134 @@ +from owrx.property import PropertyManager, PropertyLayer import importlib.util +import os import logging +import json +from abc import ABC, abstractmethod logger = logging.getLogger(__name__) -class Subscription(object): - def __init__(self, subscriptee, subscriber): - self.subscriptee = subscriptee - self.subscriber = subscriber - - def call(self, *args, **kwargs): - self.subscriber(*args, **kwargs) - - def cancel(self): - self.subscriptee.unwire(self) - - -class Property(object): - def __init__(self, value=None): - self.value = value - self.subscribers = [] - - def getValue(self): - return self.value - - def setValue(self, value): - if self.value == value: - return self - self.value = value - for c in self.subscribers: - try: - c.call(self.value) - except Exception as e: - logger.exception(e) - return self - - def wire(self, callback): - sub = Subscription(self, callback) - self.subscribers.append(sub) - if not self.value is None: - sub.call(self.value) - return sub - - def unwire(self, sub): - try: - self.subscribers.remove(sub) - except ValueError: - # happens when already removed before - pass - return self - - class ConfigNotFoundException(Exception): pass -class PropertyManager(object): - sharedInstance = None +class ConfigError(object): + def __init__(self, key, message): + self.key = key + self.message = message + + def __str__(self): + return "Configuration Error (key: {0}): {1}".format(self.key, self.message) + + +class ConfigMigrator(ABC): + @abstractmethod + def migrate(self, config): + pass + + def renameKey(self, config, old, new): + if old in config and not new 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 + return config + + +class Config: + sharedConfig = None + currentVersion = 2 + migrators = { + 1: ConfigMigratorVersion1() + } @staticmethod - def getSharedInstance(): - if PropertyManager.sharedInstance is None: - PropertyManager.sharedInstance = PropertyManager() - return PropertyManager.sharedInstance + 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) + pm = PropertyLayer() + for name, value in cfg.__dict__.items(): + if name.startswith("__"): + continue + pm[name] = value + return pm - def collect(self, *props): - return PropertyManager( - {name: self.getProperty(name) if self.hasProperty(name) else Property() for name in props} - ) + @staticmethod + def _loadJsonFile(file): + with open(file, "r") as f: + pm = PropertyLayer() + for k, v in json.load(f).items(): + pm[k] = v + return pm - def __init__(self, properties=None): - self.properties = {} - self.subscribers = [] - if properties is not None: - for (name, prop) in properties.items(): - self.add(name, prop) - - def add(self, name, prop): - self.properties[name] = prop - - def fireCallbacks(value): - for c in self.subscribers: - try: - c.call(name, value) - except Exception as e: - logger.exception(e) - - prop.wire(fireCallbacks) - return self - - def __contains__(self, name): - return self.hasProperty(name) - - def __getitem__(self, name): - return self.getPropertyValue(name) - - def __setitem__(self, name, value): - if not self.hasProperty(name): - self.add(name, Property()) - self.getProperty(name).setValue(value) - - def __dict__(self): - return {k: v.getValue() for k, v in self.properties.items()} - - def hasProperty(self, name): - return name in self.properties - - def getProperty(self, name): - if not self.hasProperty(name): - self.add(name, Property()) - return self.properties[name] - - def getPropertyValue(self, name): - return self.getProperty(name).getValue() - - def wire(self, callback): - sub = Subscription(self, callback) - self.subscribers.append(sub) - return sub - - def unwire(self, sub): - try: - self.subscribers.remove(sub) - except ValueError: - # happens when already removed before - pass - return self - - def defaults(self, other_pm): - for (key, p) in self.properties.items(): - if p.getValue() is None: - p.setValue(other_pm[key]) - return self - - def loadConfig(self): - for file in ["/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: + @staticmethod + def _loadConfig(): + for file in ["./settings.json", "/etc/openwebrx/config_webrx.py", "./config_webrx.py"]: try: - spec = importlib.util.spec_from_file_location("config_webrx", file) - cfg = importlib.util.module_from_spec(spec) - spec.loader.exec_module(cfg) - for name, value in cfg.__dict__.items(): - if name.startswith("__"): - continue - self[name] = value - return self + if file.endswith(".py"): + return Config._loadPythonFile(file) + elif file.endswith(".json"): + return Config._loadJsonFile(file) + else: + logger.warning("unsupported file type: %s", file) except FileNotFoundError: - logger.debug("not found: %s", file) + pass raise ConfigNotFoundException("no usable config found! please make sure you have a valid configuration file!") + + @staticmethod + def get(): + if Config.sharedConfig is None: + Config.sharedConfig = Config._migrate(Config._loadConfig()) + return Config.sharedConfig + + @staticmethod + def store(): + with open("settings.json", "w") as file: + json.dump(Config.get().__dict__(), file, indent=4) + + @staticmethod + def validateConfig(): + pm = Config.get() + errors = [ + Config.checkTempDirectory(pm) + ] + + return [e for e in errors if e is not None] + + @staticmethod + def checkTempDirectory(pm: PropertyManager): + key = "temporary_directory" + if key not in pm or pm[key] is None: + return ConfigError(key, "temporary directory is not set") + if not os.path.exists(pm[key]): + return ConfigError(key, "temporary directory doesn't exist") + if not os.path.isdir(pm[key]): + return ConfigError(key, "temporary directory path is not a directory") + if not os.access(pm[key], os.W_OK): + return ConfigError(key, "temporary directory is not writable") + return None + + @staticmethod + def _migrate(config): + version = config["version"] if "version" in config else 1 + if version == Config.currentVersion: + return config + + logger.debug("migrating config from version %i", version) + migrator = Config.migrators[version] + return migrator.migrate(config) diff --git a/owrx/connection.py b/owrx/connection.py index 7aa6fc6..7bfd805 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -1,4 +1,5 @@ -from owrx.config import PropertyManager +from owrx.config import Config +from owrx.details import ReceiverDetails from owrx.dsp import DspManager from owrx.cpu import CpuUsageThread from owrx.sdr import SdrService @@ -9,9 +10,11 @@ from owrx.version import openwebrx_version from owrx.bands import Bandplan from owrx.bookmarks import Bookmarks from owrx.map import Map -from owrx.locator import Locator -from multiprocessing import Queue -from queue import Full +from owrx.property import PropertyStack +from owrx.modes import Modes, DigitalMode +from queue import Queue, Full +from js8py import Js8Frame +from abc import ABC, ABCMeta, abstractmethod import json import threading @@ -19,36 +22,54 @@ import logging logger = logging.getLogger(__name__) +PoisonPill = object() -class Client(object): + +class Client(ABC): def __init__(self, conn): self.conn = conn - self.multiprocessingPipe = Queue(100) + self.multithreadingQueue = Queue(100) def mp_passthru(): run = True while run: try: - data = self.multiprocessingPipe.get() - self.send(data) - except (EOFError, OSError): + 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") - threading.Thread(target=mp_passthru).start() + # 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): - self.conn.send(data) + try: + self.conn.send(data) + except IOError: + self.close() def close(self): + if self.multithreadingQueue is not None: + self.multithreadingQueue.put(PoisonPill) self.conn.close() - self.multiprocessingPipe.close() def mp_send(self, data): + if self.multithreadingQueue is None: + return try: - self.multiprocessingPipe.put(data, block=False) + self.multithreadingQueue.put(data, block=False) except Full: self.close() + @abstractmethod def handleTextMessage(self, conn, message): pass @@ -59,7 +80,25 @@ class Client(object): self.close() -class OpenWebRxReceiverClient(Client): +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) + + # TODO unsubscribe + receiver_details.wire(send_receiver_info) + send_receiver_info() + + def write_receiver_details(self, details): + self.send({"type": "receiver_details", "value": details}) + + +class OpenWebRxReceiverClient(OpenWebRxClient): config_keys = [ "waterfall_colors", "waterfall_min_level", @@ -67,7 +106,6 @@ class OpenWebRxReceiverClient(Client): "waterfall_auto_level_margin", "samp_rate", "fft_size", - "fft_fps", "audio_compression", "fft_compression", "max_clients", @@ -93,28 +131,16 @@ class OpenWebRxReceiverClient(Client): self.close() raise - pm = PropertyManager.getSharedInstance() - self.setSdr() - # send receiver info - receiver_keys = [ - "receiver_name", - "receiver_location", - "receiver_asl", - "receiver_gps", - "photo_title", - "photo_desc", - ] - receiver_details = dict((key, pm.getPropertyValue(key)) for key in receiver_keys) - receiver_details["locator"] = Locator.fromCoordinates(receiver_details["receiver_gps"]) - self.write_receiver_details(receiver_details) - - self.__sendProfiles() - features = FeatureDetector().feature_availability() self.write_features(features) + modes = Modes.getModes() + self.write_modes(modes) + + self.__sendProfiles() + CpuUsageThread.getSharedInstance().add_client(self) def __sendProfiles(self): @@ -134,8 +160,12 @@ class OpenWebRxReceiverClient(Client): self.startDsp() if "params" in message: - params = message["params"] - self.setDspProperties(params) + dsp = self.getDsp() + if dsp is None: + logger.warning("DSP not available; discarding client data") + else: + params = message["params"] + dsp.setProperties(params) elif message["type"] == "config": if "params" in message: @@ -152,7 +182,7 @@ class OpenWebRxReceiverClient(Client): if "params" in message: self.connectionProperties = message["params"] if self.dsp: - self.setDspProperties(self.connectionProperties) + self.getDsp().setProperties(self.connectionProperties) else: logger.warning("received message without type: {0}".format(message)) @@ -169,6 +199,7 @@ class OpenWebRxReceiverClient(Client): next = SdrService.getFirstSource() if next is None: # exit condition: no sdrs available + logger.warning("no more SDR devices available") self.handleNoSdrsAvailable() return @@ -184,25 +215,25 @@ class OpenWebRxReceiverClient(Client): self.sdr = next - self.startDsp() + self.getDsp() - # keep trying until we find a suitable SDR - if self.sdr.getState() == SdrSource.STATE_FAILED: - self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName())) - else: + # found a working sdr, exit the loop + if self.sdr.getState() != SdrSource.STATE_FAILED: break - # send initial config - self.setDspProperties(self.connectionProperties) + logger.warning('SDR device "%s" has failed, selecing new device', self.sdr.getName()) + self.write_log_message('SDR device "{0}" has failed, selecting new device'.format(self.sdr.getName())) - configProps = ( - self.sdr.getProps() - .collect(*OpenWebRxReceiverClient.config_keys) - .defaults(PropertyManager.getSharedInstance()) - ) + # send initial config + self.getDsp().setProperties(self.connectionProperties) + + stack = PropertyStack() + stack.addLayer(0, self.sdr.getProps()) + stack.addLayer(1, Config.get()) + configProps = stack.filter(*OpenWebRxReceiverClient.config_keys) def sendConfig(key, value): - config = dict((key, configProps[key]) for key in OpenWebRxReceiverClient.config_keys) + config = configProps.__dict__() # TODO mathematical properties? hmmmm config["start_offset_freq"] = configProps["start_freq"] - configProps["center_freq"] # TODO this is a hack to support multiple sdrs @@ -226,9 +257,7 @@ class OpenWebRxReceiverClient(Client): self.write_sdr_error("No SDR Devices available") def startDsp(self): - if self.dsp is None and self.sdr is not None: - self.dsp = DspManager(self, self.sdr) - self.dsp.start() + self.getDsp().start() def close(self): self.stopDsp() @@ -247,18 +276,28 @@ class OpenWebRxReceiverClient(Client): self.sdr.removeSpectrumClient(self) def setParams(self, params): + config = Config.get() + # allow direct configuration only if enabled in the config + if "configurable_keys" not in config: + return + keys = config["configurable_keys"] + if not keys: + return # only the keys in the protected property manager can be overridden from the web - protected = ( - self.sdr.getProps() - .collect("samp_rate", "center_freq", "rf_gain", "type") - .defaults(PropertyManager.getSharedInstance()) - ) + stack = PropertyStack() + stack.addLayer(0, self.sdr.getProps()) + stack.addLayer(1, config) + protected = stack.filter(*keys) for key, value in params.items(): - protected[key] = value + try: + protected[key] = value + except KeyError: + pass - def setDspProperties(self, params): - for key, value in params.items(): - self.dsp.setProperty(key, value) + def getDsp(self): + 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) @@ -266,6 +305,9 @@ class OpenWebRxReceiverClient(Client): 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): self.send({"type": "smeter", "value": level}) @@ -288,9 +330,6 @@ class OpenWebRxReceiverClient(Client): def write_config(self, cfg): self.send({"type": "config", "value": cfg}) - def write_receiver_details(self, details): - self.send({"type": "receiver_details", "value": details}) - def write_profiles(self, profiles): self.send({"type": "profiles", "value": profiles}) @@ -324,13 +363,44 @@ class OpenWebRxReceiverClient(Client): 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 + }}) -class MapConnection(Client): + 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 = PropertyManager.getSharedInstance() - self.write_config(pm.collect("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__()) + pm = Config.get() + self.write_config(pm.filter("google_maps_api_key", "receiver_gps", "map_position_retention_time").__dict__()) Map.getSharedInstance().addClient(self) diff --git a/owrx/controllers.py b/owrx/controllers.py deleted file mode 100644 index a26ebc3..0000000 --- a/owrx/controllers.py +++ /dev/null @@ -1,168 +0,0 @@ -import os -import mimetypes -import json -import pkg_resources -from datetime import datetime -from string import Template -from owrx.websocket import WebSocketConnection -from owrx.config import PropertyManager -from owrx.client import ClientRegistry -from owrx.connection import WebSocketMessageHandler -from owrx.version import openwebrx_version -from owrx.feature import FeatureDetector -from owrx.metrics import Metrics - -import logging - -logger = logging.getLogger(__name__) - - -class Controller(object): - def __init__(self, handler, request): - self.handler = handler - self.request = request - - def send_response(self, content, code=200, content_type="text/html", last_modified: datetime = None, max_age=None): - self.handler.send_response(code) - if content_type is not None: - self.handler.send_header("Content-Type", content_type) - if last_modified is not None: - self.handler.send_header("Last-Modified", last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT")) - if max_age is not None: - self.handler.send_header("Cache-Control", "max-age: {0}".format(max_age)) - self.handler.end_headers() - if type(content) == str: - content = content.encode() - self.handler.wfile.write(content) - - -class StatusController(Controller): - def handle_request(self): - pm = PropertyManager.getSharedInstance() - # TODO keys that have been left out since they are no longer simple strings: sdr_hw, bands, antenna - vars = { - "status": "active", - "name": pm["receiver_name"], - "op_email": pm["receiver_admin"], - "users": ClientRegistry.getSharedInstance().clientCount(), - "users_max": pm["max_clients"], - "gps": pm["receiver_gps"], - "asl": pm["receiver_asl"], - "loc": pm["receiver_location"], - "sw_version": openwebrx_version, - "avatar_ctime": os.path.getctime("htdocs/gfx/openwebrx-avatar.png"), - } - self.send_response("\n".join(["{key}={value}".format(key=key, value=value) for key, value in vars.items()])) - - -class AssetsController(Controller): - def getModified(self, file): - return None - - def openFile(self, file): - pass - - def serve_file(self, file, content_type=None): - try: - modified = self.getModified(file) - - 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" - ) - if modified <= client_modified: - self.send_response("", code=304) - return - - f = self.openFile(file) - data = f.read() - f.close() - - if content_type is None: - (content_type, encoding) = mimetypes.MimeTypes().guess_type(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 handle_request(self): - filename = self.request.matches.group(1) - self.serve_file(filename) - - -class OwrxAssetsController(AssetsController): - def openFile(self, file): - return pkg_resources.resource_stream("htdocs", file) - - -class AprsSymbolsController(AssetsController): - def __init__(self, handler, request): - pm = PropertyManager.getSharedInstance() - path = pm["aprs_symbols_path"] - if not path.endswith("/"): - path += "/" - self.path = path - super().__init__(handler, request) - - def getFilePath(self, file): - return self.path + file - - def getModified(self, file): - return datetime.fromtimestamp(os.path.getmtime(self.getFilePath(file))) - - def openFile(self, file): - return open(self.getFilePath(file), "rb") - - -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 template_variables(self): - header = self.render_template("include/header.include.html") - return {"header": header} - - -class IndexController(WebpageController): - def handle_request(self): - self.serve_template("index.html", **self.template_variables()) - - -class MapController(WebpageController): - def handle_request(self): - # TODO check if we have a google maps api key first? - self.serve_template("map.html", **self.template_variables()) - - -class FeatureController(WebpageController): - def handle_request(self): - self.serve_template("features.html", **self.template_variables()) - - -class ApiController(Controller): - def handle_request(self): - data = json.dumps(FeatureDetector().feature_report()) - self.send_response(data, content_type="application/json") - - -class MetricsController(Controller): - def handle_request(self): - data = json.dumps(Metrics.getSharedInstance().getMetrics()) - self.send_response(data, content_type="application/json") - - -class WebSocketController(Controller): - def handle_request(self): - conn = WebSocketConnection(self.handler, WebSocketMessageHandler()) - # enter read loop - conn.handle() diff --git a/owrx/controllers/__init__.py b/owrx/controllers/__init__.py new file mode 100644 index 0000000..c00eebd --- /dev/null +++ b/owrx/controllers/__init__.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone + + +class Controller(object): + def __init__(self, handler, request, options): + self.handler = handler + self.request = request + self.options = options + + 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) + self.handler.end_headers() + if type(content) == str: + content = content.encode() + self.handler.wfile.write(content) + + def send_redirect(self, location, code=303, cookies=None): + self.handler.send_response(code) + if cookies is not None: + self.handler.send_header("Set-Cookie", cookies.output(header='')) + self.handler.send_header("Location", location) + self.handler.end_headers() + + def get_body(self): + if "Content-Length" not in self.handler.headers: + return None + length = int(self.handler.headers["Content-Length"]) + 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/owrx/controllers/admin.py b/owrx/controllers/admin.py new file mode 100644 index 0000000..3879141 --- /dev/null +++ b/owrx/controllers/admin.py @@ -0,0 +1,33 @@ +from .template import WebpageController +from .session import SessionStorage +from owrx.config import Config +from urllib import parse + +import logging + +logger = logging.getLogger(__name__) + + +class Authentication(object): + def isAuthenticated(self, request): + if "owrx-session" in request.cookies: + session = SessionStorage.getSharedInstance().getSession(request.cookies["owrx-session"].value) + return session is not None + return False + + +class AdminController(WebpageController): + def __init__(self, handler, request, options): + self.authentication = Authentication() + super().__init__(handler, request, options) + + def handle_request(self): + config = Config.get() + if "webadmin_enabled" not in config or not config["webadmin_enabled"]: + self.send_response("Web Admin is disabled", code=403) + return + if self.authentication.isAuthenticated(self.request): + super().handle_request() + else: + target = "/login?{0}".format(parse.urlencode({"ref": self.request.path})) + self.send_redirect(target) diff --git a/owrx/controllers/api.py b/owrx/controllers/api.py new file mode 100644 index 0000000..4dcde14 --- /dev/null +++ b/owrx/controllers/api.py @@ -0,0 +1,15 @@ +from . import Controller +from owrx.feature import FeatureDetector +from owrx.details import ReceiverDetails +import json + + +class ApiController(Controller): + def indexAction(self): + data = json.dumps(FeatureDetector().feature_report()) + self.send_response(data, content_type="application/json") + + def receiverDetails(self): + receiver_details = ReceiverDetails() + data = json.dumps(receiver_details.__dict__()) + self.send_response(data, content_type="application/json") diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py new file mode 100644 index 0000000..b15ff64 --- /dev/null +++ b/owrx/controllers/assets.py @@ -0,0 +1,142 @@ +from . import Controller +from owrx.config import Config +from datetime import datetime, timezone +import mimetypes +import os +import pkg_resources +from abc import ABCMeta, abstractmethod + + +class ModificationAwaraController(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(ModificationAwaraController, 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.MimeTypes().guess_type(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): + return pkg_resources.resource_filename("htdocs", file) + + +class AprsSymbolsController(AssetsController): + def __init__(self, handler, request, options): + pm = Config.get() + path = pm["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(ModificationAwaraController): + profiles = { + "receiver.js": [ + "openwebrx.js", + "lib/jquery-3.2.1.min.js", + "lib/jquery.nanoscroller.js", + "lib/Header.js", + "lib/Demodulator.js", + "lib/DemodulatorPanel.js", + "lib/BookmarkBar.js", + "lib/BookmarkDialog.js", + "lib/AudioEngine.js", + "lib/ProgressBar.js", + "lib/Measurement.js", + "lib/FrequencyDisplay.js", + "lib/Js8Threads.js", + "lib/Modes.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/Header.js", + "lib/settings/Input.js", + "lib/settings/SdrDevice.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.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/owrx/controllers/metrics.py b/owrx/controllers/metrics.py new file mode 100644 index 0000000..e817e9b --- /dev/null +++ b/owrx/controllers/metrics.py @@ -0,0 +1,9 @@ +from . import Controller +from owrx.metrics import Metrics +import json + + +class MetricsController(Controller): + def indexAction(self): + data = json.dumps(Metrics.getSharedInstance().getMetrics()) + self.send_response(data, content_type="application/json") diff --git a/owrx/controllers/receiverid.py b/owrx/controllers/receiverid.py new file mode 100644 index 0000000..667c6be --- /dev/null +++ b/owrx/controllers/receiverid.py @@ -0,0 +1,22 @@ +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/owrx/controllers/session.py b/owrx/controllers/session.py new file mode 100644 index 0000000..ac38a43 --- /dev/null +++ b/owrx/controllers/session.py @@ -0,0 +1,59 @@ +from .template import WebpageController +from urllib.parse import parse_qs +from uuid import uuid4 +from http.cookies import SimpleCookie +from owrx.users import UserList + + +class SessionStorage(object): + sharedInstance = None + + @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 + return self.sessions[key] + + def updateSession(self, key, data): + self.sessions[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.password.is_valid(data["password"]): + # TODO evaluate password force_change and redirect to password change + 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" + self.send_redirect(target, cookies=cookie) + return + self.send_redirect("/login") + + def logoutAction(self): + self.send_redirect("logout happening here") diff --git a/owrx/controllers/settings.py b/owrx/controllers/settings.py new file mode 100644 index 0000000..368a167 --- /dev/null +++ b/owrx/controllers/settings.py @@ -0,0 +1,279 @@ +from .admin import AdminController +from owrx.config import Config +from urllib.parse import parse_qs +from owrx.form import ( + TextInput, + NumberInput, + FloatInput, + LocationInput, + TextAreaInput, + CheckboxInput, + DropdownInput, + Option, + ServicesCheckboxInput, + Js8ProfileCheckboxInput, +) +from urllib.parse import quote +import json +import logging + +logger = logging.getLogger(__name__) + + +class Section(object): + def __init__(self, title, *inputs): + self.title = title + self.inputs = inputs + + def render_inputs(self): + config = Config.get() + return "".join([i.render(config) for i in self.inputs]) + + def render(self): + return """ +
+

+ {title} +

+ {inputs} +
+ """.format( + title=self.title, inputs=self.render_inputs() + ) + + def parse(self, data): + return {k: v for i in self.inputs for k, v in i.parse(data).items()} + + +class SettingsController(AdminController): + def indexAction(self): + self.serve_template("settings.html", **self.template_variables()) + + +class SdrSettingsController(AdminController): + def template_variables(self): + variables = super().template_variables() + variables["devices"] = self.render_devices() + return variables + + def render_devices(self): + return "".join(self.render_device(key, value) for key, value in Config.get()["sdrs"].items()) + + def render_device(self, device_id, config): + return """ +
+
+ {device_name} +
+
+ {form} +
+
+ """.format(device_name=config["name"], form=self.render_form(device_id, config)) + + def render_form(self, device_id, config): + return """ +
+ """.format(device_id=device_id, formdata=quote(json.dumps(config))) + + def indexAction(self): + self.serve_template("sdrsettings.html", **self.template_variables()) + + +class GeneralSettingsController(AdminController): + sections = [ + Section( + "General settings", + TextInput("receiver_name", "Receiver name"), + TextInput("receiver_location", "Receiver location"), + NumberInput( + "receiver_asl", + "Receiver elevation", + infotext="Elevation in meters above mean see level", + ), + TextInput("receiver_admin", "Receiver admin"), + LocationInput("receiver_gps", "Receiver coordinates"), + TextInput("photo_title", "Photo title"), + TextAreaInput("photo_desc", "Photo description"), + ), + Section( + "Waterfall settings", + NumberInput( + "fft_fps", + "FFT frames per second", + 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.", + ), + NumberInput("fft_size", "FFT size"), + 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.", + ), + NumberInput("waterfall_min_level", "Lowest waterfall level"), + NumberInput("waterfall_max_level", "Highest waterfall level"), + ), + 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( + "Digimodes", + CheckboxInput("digimodes_enable", "", checkboxText="Enable Digimodes"), + NumberInput("digimodes_fft_size", "Digimodes FFT size"), + ), + Section( + "Digital voice", + NumberInput( + "digital_voice_unvoiced_quality", + "Quality of unvoiced sounds in synthesized voice", + infotext="Determines the quality, and thus the cpu usage, for the ambe codec used by digital voice" + + "modes.
If you're running on a Raspi (up to 3B+) you should leave this set at 1", + ), + CheckboxInput( + "digital_voice_dmr_id_lookup", + "DMR id lookup", + checkboxText="Enable lookup of DMR ids in the radioid database to show callsigns and names", + ), + ), + Section( + "Experimental pipe settings", + CheckboxInput( + "csdr_dynamic_bufsize", + "", + checkboxText="Enable dynamic buffer sizes", + infotext="This allows you to change the buffering mode of csdr.", + ), + CheckboxInput( + "csdr_print_bufsizes", + "", + checkboxText="Print buffer sizez", + infotext="This prints the buffer sizes used for csdr processes.", + ), + CheckboxInput( + "csdr_through", + "", + checkboxText="Print throughput", + infotext="Enabling this will print out how much data is going into the DSP chains.", + ), + ), + 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="Unit is seconds
Specifies how log markers / grids will remain visible on the map", + ), + ), + 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", + ), + 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" + ), + ), + Section( + "Background decoding", + CheckboxInput( + "services_enabled", + "Service", + checkboxText="Enable background decoding services", + ), + ServicesCheckboxInput("services_decoders", "Enabled services"), + ), + Section( + "APRS settings", + TextInput( + "aprs_callsign", + "APRS callsign", + infotext="This callsign will be used to send data to the APRS-IS network", + ), + CheckboxInput( + "aprs_igate_enabled", + "APRS I-Gate", + checkboxText="Enable APRS receive-only I-Gate", + ), + TextInput("aprs_igate_server", "APRS-IS server"), + TextInput("aprs_igate_password", "APRS-IS network password"), + CheckboxInput( + "aprs_igate_beacon", + "APRS beacon", + checkboxText="Send the receiver position to the APRS-IS network", + infotext="Please check that your receiver location is setup correctly", + ), + ), + Section( + "pskreporter settings", + CheckboxInput( + "pskreporter_enabled", + "Reporting", + checkboxText="Enable sending spots to pskreporter.info", + ), + TextInput( + "pskreporter_callsign", + "pskreporter callsign", + infotext="This callsign will be used to send spots to pskreporter.info", + ), + ), + ] + + def render_sections(self): + sections = "".join(section.render() for section in GeneralSettingsController.sections) + return """ +
+ {sections} +
+ +
+ + """.format( + sections=sections + ) + + def indexAction(self): + self.serve_template("generalsettings.html", **self.template_variables()) + + def template_variables(self): + variables = super().template_variables() + variables["sections"] = self.render_sections() + return variables + + def processFormData(self): + data = parse_qs(self.get_body().decode("utf-8")) + data = { + k: v for i in GeneralSettingsController.sections for k, v in i.parse(data).items() + } + config = Config.get() + for k, v in data.items(): + config[k] = v + Config.store() + self.send_redirect("/admin") diff --git a/owrx/controllers/status.py b/owrx/controllers/status.py new file mode 100644 index 0000000..9e6a820 --- /dev/null +++ b/owrx/controllers/status.py @@ -0,0 +1,43 @@ +from .receiverid import ReceiverIdController +from owrx.version import openwebrx_version +from owrx.sdr import SdrService +from owrx.config import Config +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.getSources().values()] + } + self.send_response(json.dumps(status), content_type="application/json") diff --git a/owrx/controllers/template.py b/owrx/controllers/template.py new file mode 100644 index 0000000..3d57861 --- /dev/null +++ b/owrx/controllers/template.py @@ -0,0 +1,44 @@ +from . import Controller +import pkg_resources +from string import Template +from owrx.config import Config + + +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 template_variables(self): + settingslink = "" + pm = Config.get() + if "webadmin_enabled" in pm and pm["webadmin_enabled"]: + settingslink = """Settings
Settings
""" + header = self.render_template("include/header.include.html", settingslink=settingslink) + return {"header": header} + + +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()) + + +class FeatureController(WebpageController): + def indexAction(self): + self.serve_template("features.html", **self.template_variables()) diff --git a/owrx/controllers/websocket.py b/owrx/controllers/websocket.py new file mode 100644 index 0000000..f242f2c --- /dev/null +++ b/owrx/controllers/websocket.py @@ -0,0 +1,10 @@ +from . import Controller +from owrx.websocket import WebSocketConnection +from owrx.connection import WebSocketMessageHandler + + +class WebSocketController(Controller): + def indexAction(self): + conn = WebSocketConnection(self.handler, WebSocketMessageHandler()) + # enter read loop + conn.handle() diff --git a/owrx/details.py b/owrx/details.py new file mode 100644 index 0000000..5bc7253 --- /dev/null +++ b/owrx/details.py @@ -0,0 +1,21 @@ +from owrx.config import Config +from owrx.locator import Locator +from owrx.property import PropertyFilter + + +class ReceiverDetails(PropertyFilter): + def __init__(self): + super().__init__( + Config.get(), + "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/owrx/dsp.py b/owrx/dsp.py index f12d171..eabe244 100644 --- a/owrx/dsp.py +++ b/owrx/dsp.py @@ -1,9 +1,11 @@ -from owrx.config import PropertyManager 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 SdrSource +from owrx.property import PropertyStack, PropertyLayer +from owrx.modes import Modes from csdr import csdr import threading @@ -21,26 +23,39 @@ class DspManager(csdr.output): "wsjt_demod": WsjtParser(self.handler), "packet_demod": AprsParser(self.handler), "pocsag_demod": PocsagParser(self.handler), + "js8_demod": Js8Parser(self.handler), } - self.localProps = ( - self.sdrSource.getProps() - .collect( - "audio_compression", - "fft_compression", - "digimodes_fft_size", - "csdr_dynamic_bufsize", - "csdr_print_bufsizes", - "csdr_through", - "digimodes_enable", - "samp_rate", - "digital_voice_unvoiced_quality", - "dmr_filter", - "temporary_directory", - "center_freq", - ) - .defaults(PropertyManager.getSharedInstance()) - ) + self.props = PropertyStack() + # local demodulator properties not forwarded to the sdr + self.props.addLayer(0, PropertyLayer().filter( + "output_rate", + "hd_output_rate", + "squelch_level", + "secondary_mod", + "low_cut", + "high_cut", + "offset_freq", + "mod", + "secondary_offset_freq", + "dmr_filter", + )) + # properties that we inherit from the sdr + self.props.addLayer(1, self.sdrSource.getProps().filter( + "audio_compression", + "fft_compression", + "digimodes_fft_size", + "csdr_dynamic_bufsize", + "csdr_print_bufsizes", + "csdr_through", + "digimodes_enable", + "samp_rate", + "digital_voice_unvoiced_quality", + "temporary_directory", + "center_freq", + "start_mod", + "start_freq", + )) self.dsp = csdr.dsp(self) self.dsp.nc_port = self.sdrSource.getPort() @@ -56,34 +71,48 @@ class DspManager(csdr.output): self.dsp.set_bpf(*bpf) def set_dial_freq(key, value): - freq = self.localProps["center_freq"] + self.localProps["offset_freq"] + 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.localProps.getProperty("audio_compression").wire(self.dsp.set_audio_compression), - self.localProps.getProperty("fft_compression").wire(self.dsp.set_fft_compression), - self.localProps.getProperty("digimodes_fft_size").wire(self.dsp.set_secondary_fft_size), - self.localProps.getProperty("samp_rate").wire(self.dsp.set_samp_rate), - self.localProps.getProperty("output_rate").wire(self.dsp.set_output_rate), - self.localProps.getProperty("offset_freq").wire(self.dsp.set_offset_freq), - self.localProps.getProperty("squelch_level").wire(self.dsp.set_squelch_level), - self.localProps.getProperty("low_cut").wire(set_low_cut), - self.localProps.getProperty("high_cut").wire(set_high_cut), - self.localProps.getProperty("mod").wire(self.dsp.set_demodulator), - self.localProps.getProperty("digital_voice_unvoiced_quality").wire(self.dsp.set_unvoiced_quality), - self.localProps.getProperty("dmr_filter").wire(self.dsp.set_dmr_filter), - self.localProps.getProperty("temporary_directory").wire(self.dsp.set_temporary_directory), - self.localProps.collect("center_freq", "offset_freq").wire(set_dial_freq), + 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("digital_voice_unvoiced_quality", self.dsp.set_unvoiced_quality), + self.props.wireProperty("dmr_filter", self.dsp.set_dmr_filter), + self.props.wireProperty("temporary_directory", self.dsp.set_temporary_directory), + self.props.filter("center_freq", "offset_freq").wire(set_dial_freq), ] - self.dsp.set_offset_freq(0) - self.dsp.set_bpf(-4000, 4000) - self.dsp.csdr_dynamic_bufsize = self.localProps["csdr_dynamic_bufsize"] - self.dsp.csdr_print_bufsizes = self.localProps["csdr_print_bufsizes"] - self.dsp.csdr_through = self.localProps["csdr_through"] + self.dsp.csdr_dynamic_bufsize = self.props["csdr_dynamic_bufsize"] + self.dsp.csdr_print_bufsizes = self.props["csdr_print_bufsizes"] + self.dsp.csdr_through = self.props["csdr_through"] - if self.localProps["digimodes_enable"]: + if self.props["digimodes_enable"]: def set_secondary_mod(mod): if mod == False: @@ -92,17 +121,19 @@ class DspManager(csdr.output): if mod is not None: self.handler.write_secondary_dsp_config( { - "secondary_fft_size": self.localProps["digimodes_fft_size"], + "secondary_fft_size": self.props["digimodes_fft_size"], "if_samp_rate": self.dsp.if_samp_rate(), "secondary_bw": self.dsp.secondary_bw(), } ) self.subscriptions += [ - self.localProps.getProperty("secondary_mod").wire(set_secondary_mod), - self.localProps.getProperty("secondary_offset_freq").wire(self.dsp.set_secondary_offset_freq), + self.props.wireProperty("secondary_mod", set_secondary_mod), + self.props.wireProperty("secondary_offset_freq", self.dsp.set_secondary_offset_freq), ] + self.startOnAvailable = False + self.sdrSource.addClient(self) super().__init__() @@ -110,11 +141,14 @@ class DspManager(csdr.output): 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, @@ -124,17 +158,22 @@ class DspManager(csdr.output): write = writers[t] - threading.Thread(target=self.pump(read_fn, write)).start() + 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.getProperty(prop).setValue(value) + self.props[prop] = value def getClientClass(self): return SdrSource.CLIENT_USER @@ -142,7 +181,9 @@ class DspManager(csdr.output): def onStateChange(self, state): if state == SdrSource.STATE_RUNNING: logger.debug("received STATE_RUNNING, attempting DspSource restart") - self.dsp.start() + if self.startOnAvailable: + self.dsp.start() + self.startOnAvailable = False elif state == SdrSource.STATE_STOPPING: logger.debug("received STATE_STOPPING, shutting down DspSource") self.dsp.stop() diff --git a/owrx/feature.py b/owrx/feature.py index 2798a87..73f05a6 100644 --- a/owrx/feature.py +++ b/owrx/feature.py @@ -4,7 +4,7 @@ from operator import and_, or_ import re from distutils.version import LooseVersion import inspect -from owrx.config import PropertyManager +from owrx.config import Config import shlex import logging @@ -23,19 +23,28 @@ class FeatureDetector(object): # 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": ["hackrf_transfer"], + "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"], + "fifi_sdr": ["alsa", "rockprog"], "pluto_sdr": ["soapy_connector", "soapy_pluto_sdr"], + "soapy_remote": ["soapy_connector", "soapy_remote"], + "uhd": ["soapy_connector", "soapy_uhd"], + "red_pitaya": ["soapy_connector", "soapy_red_pitaya"], + "radioberry": ["soapy_connector", "soapy_radioberry"], + "fcdpp": ["soapy_connector", "soapy_fcdpp"], # optional features and their requirements "digital_voice_digiham": ["digiham", "sox"], "digital_voice_dsd": ["dsd", "sox", "digiham"], + "digital_voice_freedv": ["freedv_rx", "sox"], "wsjt-x": ["wsjtx", "sox"], "packet": ["direwolf", "sox"], "pocsag": ["digiham", "sox"], + "js8call": ["js8", "sox"], } def feature_availability(self): @@ -93,7 +102,7 @@ class FeatureDetector(object): return inspect.getdoc(self._get_requirement_method(requirement)) def command_is_runnable(self, command): - tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"] + tmp_dir = Config.get()["temporary_directory"] cmd = shlex.split(command) try: process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=tmp_dir) @@ -104,7 +113,7 @@ class FeatureDetector(object): 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/simonyiszk/csdr) for further details and installation instructions. + page on github](https://github.com/jketterl/csdr) for further details and installation instructions. """ return self.command_is_runnable("csdr") @@ -122,32 +131,25 @@ class FeatureDetector(object): """ return self.command_is_runnable("nc --help") - def has_rtl_sdr(self): + def has_perseustest(self): """ - The rtl-sdr command is required to read I/Q data from an RTL SDR USB-Stick. It is available in most - distribution package managers. - """ - return self.command_is_runnable("rtl_sdr --help") - - def has_hackrf_transfer(self): - """ - To use a HackRF, compile the HackRF host tools from its "stdout" branch: + To use a Microtelecom Perseus HF receiver, compile and + install the libperseus-sdr: ``` - git clone https://github.com/mossmann/hackrf/ - cd hackrf - git fetch - git checkout origin/stdout - cd host - mkdir build - cd build - cmake .. -DINSTALL_UDEV_RULES=ON + 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 ``` """ - # TODO i don't have a hackrf, so somebody doublecheck this. - # TODO also check if it has the stdout feature - return self.command_is_runnable("hackrf_transfer --help") + return self.command_is_runnable("perseustest -h") + def has_digiham(self): """ @@ -193,7 +195,7 @@ class FeatureDetector(object): ) def _check_connector(self, command): - required_version = LooseVersion("0.1") + required_version = LooseVersion("0.3") owrx_connector_version_regex = re.compile("^owrx-connector version (.*)$") @@ -217,6 +219,15 @@ class FeatureDetector(object): """ return self._check_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_connector("rtl_tcp_connector") + def has_soapy_connector(self): """ The owrx_connector package offers direct interfacing between your hardware and openwebrx. It allows quicker @@ -229,14 +240,15 @@ class FeatureDetector(object): def _has_soapy_driver(self, driver): try: process = subprocess.Popen(["SoapySDRUtil", "--info"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - driverRegex = re.compile("^Module found: .*lib(.*)Support.so") + factory_regex = re.compile("^Available factories\\.\\.\\. ?(.*)$") - def matchLine(line): - matches = driverRegex.match(line.decode()) - return matches is not None and matches.group(1) == driver + drivers = [] + for line in process.stdout: + matches = factory_regex.match(line.decode()) + if matches: + drivers = [s.strip() for s in matches.group(1).split(", ")] - lines = [matchLine(line) for line in process.stdout] - return reduce(or_, lines, False) + return driver in drivers except FileNotFoundError: return False @@ -253,9 +265,9 @@ class FeatureDetector(object): """ The SoapySDR module for sdrplay devices is required for interfacing with SDRPlay devices (RSP1*, RSP2*, RSPDuo) - You can get it [here](https://github.com/pothosware/SoapySDRPlay/wiki). + You can get it [here](https://github.com/SDRplay/SoapySDRPlay). """ - return self._has_soapy_driver("sdrPlay") + return self._has_soapy_driver("sdrplay") def has_soapy_airspy(self): """ @@ -280,7 +292,7 @@ class FeatureDetector(object): You can get it [here](https://github.com/myriadrf/LimeSuite). """ - return self._has_soapy_driver("LMS7") + return self._has_soapy_driver("lime") def has_soapy_pluto_sdr(self): """ @@ -288,7 +300,55 @@ class FeatureDetector(object): You can get it [here](https://github.com/photosware/SoapyPlutoSDR). """ - return self._has_soapy_driver("PlutoSDR") + 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_red_pitaya(self): + """ + The SoapyRedPitaya allows Red Pitaya deviced to be used with SoapySDR. + + You can get it [here](https://github.com/pothosware/SoapyRedPitaya/wiki). + """ + return self._has_soapy_driver("redpitaya") + + 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_dsd(self): """ @@ -328,9 +388,37 @@ class FeatureDetector(object): """ return reduce(and_, map(self.command_is_runnable, ["jt9", "wsprd"]), True) + 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. + + You can find the codec2 source code [here](https://github.com/drowe67/codec2). + """ + return self.command_is_runnable("freedv_rx") diff --git a/owrx/fft.py b/owrx/fft.py index 246f110..cbb98a6 100644 --- a/owrx/fft.py +++ b/owrx/fft.py @@ -1,7 +1,8 @@ -from owrx.config import PropertyManager +from owrx.config import Config from csdr import csdr import threading from owrx.source import SdrSource +from owrx.property import PropertyStack import logging @@ -13,7 +14,10 @@ class SpectrumThread(csdr.output): self.sdrSource = sdrSource super().__init__() - self.props = props = self.sdrSource.props.collect( + stack = PropertyStack() + stack.addLayer(0, self.sdrSource.props) + stack.addLayer(1, Config.get()) + self.props = props = stack.filter( "samp_rate", "fft_size", "fft_fps", @@ -23,7 +27,7 @@ class SpectrumThread(csdr.output): "csdr_print_bufsizes", "csdr_through", "temporary_directory", - ).defaults(PropertyManager.getSharedInstance()) + ) self.dsp = dsp = csdr.dsp(self) dsp.nc_port = self.sdrSource.getPort() @@ -42,12 +46,12 @@ class SpectrumThread(csdr.output): ) self.subscriptions = [ - props.getProperty("samp_rate").wire(dsp.set_samp_rate), - props.getProperty("fft_size").wire(dsp.set_fft_size), - props.getProperty("fft_fps").wire(dsp.set_fft_fps), - props.getProperty("fft_compression").wire(dsp.set_fft_compression), - props.getProperty("temporary_directory").wire(dsp.set_temporary_directory), - props.collect("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), + 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.wireProperty("temporary_directory", dsp.set_temporary_directory), + props.filter("samp_rate", "fft_size", "fft_fps", "fft_voverlap_factor").wire(set_fft_averages), ] set_fft_averages(None, None) @@ -66,10 +70,6 @@ class SpectrumThread(csdr.output): return t == "audio" def receive_output(self, type, read_fn): - if self.props["csdr_dynamic_bufsize"]: - read_fn(8) # dummy read to skip bufsize & preamble - logger.debug("Note: CSDR_DYNAMIC_BUFSIZE_ON = 1") - threading.Thread(target=self.pump(read_fn, self.sdrSource.writeSpectrumData)).start() def stop(self): diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py new file mode 100644 index 0000000..7f59bf5 --- /dev/null +++ b/owrx/form/__init__.py @@ -0,0 +1,238 @@ +from abc import ABC, abstractmethod +from owrx.modes import Modes +from owrx.config import Config + + +class Input(ABC): + def __init__(self, id, label, infotext=None): + self.id = id + self.label = label + self.infotext = infotext + + def bootstrap_decorate(self, input): + infotext = ( + "{text}".format(text=self.infotext) if self.infotext else "" + ) + return """ +
+ +
+ {input} + {infotext} +
+
+ """.format( + id=self.id, label=self.label, input=input, infotext=infotext + ) + + def input_classes(self): + return " ".join(["form-control", "form-control-sm"]) + + @abstractmethod + def render_input(self, value): + pass + + def render(self, config): + return self.bootstrap_decorate(self.render_input(config[self.id])) + + def parse(self, data): + return {self.id: data[self.id][0]} if self.id in data else {} + + +class TextInput(Input): + def render_input(self, value): + return """ + + """.format( + id=self.id, label=self.label, classes=self.input_classes(), value=value + ) + + +class NumberInput(Input): + def __init__(self, id, label, infotext=None): + super().__init__(id, label, infotext) + self.step = None + + def render_input(self, value): + return """ + + """.format( + id=self.id, + label=self.label, + classes=self.input_classes(), + value=value, + step='step="{0}"'.format(self.step) if self.step else "", + ) + + def convert_value(self, v): + return int(v) + + def parse(self, data): + return {k: self.convert_value(v) for k, v in super().parse(data).items()} + + +class FloatInput(NumberInput): + def __init__(self, id, label, infotext=None): + super().__init__(id, label, infotext) + self.step = "any" + + def convert_value(self, v): + return float(v) + + +class LocationInput(Input): + def render_input(self, value): + return """ +
+ {inputs} +
+
+
+
+ """.format( + id=self.id, + inputs="".join(self.render_sub_input(value, id) for id in ["lat", "lon"]), + key=Config.get()["google_maps_api_key"], + ) + + def render_sub_input(self, value, id): + return """ +
+ +
+ """.format( + id="{0}-{1}".format(self.id, id), + label=self.label, + classes=self.input_classes(), + value=value[id], + ) + + 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): + return """ + + """.format( + id=self.id, classes=self.input_classes(), value=value + ) + + +class CheckboxInput(Input): + def __init__(self, id, label, checkboxText, infotext=None): + super().__init__(id, label, infotext=infotext) + self.checkboxText = checkboxText + + def render_input(self, value): + return """ +
+ + +
+ """.format( + id=self.id, + classes=self.input_classes(), + checked="checked" if value else "", + checkboxText=self.checkboxText, + ) + + def input_classes(self): + return " ".join(["form-check", "form-control-sm"]) + + def parse(self, data): + return {self.id: self.id in data and data[self.id][0] == "on"} + + +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): + return "".join(self.render_checkbox(o, value) for o in self.options) + + def checkbox_id(self, option): + return "{0}-{1}".format(self.id, option.value) + + def render_checkbox(self, option, value): + return """ +
+ + +
+ """.format( + id=self.checkbox_id(option), + classes=self.input_classes(), + checked="checked" if option.value in value else "", + checkboxText=option.text, + ) + + 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): + return " ".join(["form-check", "form-control-sm"]) + + +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): + super().__init__(id, label, infotext=infotext) + self.options = options + + def render_input(self, value): + return """ + + """.format( + classes=self.input_classes(), id=self.id, options=self.render_options(value) + ) + + 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) diff --git a/owrx/http.py b/owrx/http.py index 196c6c4..538bec9 100644 --- a/owrx/http.py +++ b/owrx/http.py @@ -1,17 +1,24 @@ -from owrx.controllers import ( - StatusController, +from owrx.controllers.status import StatusController +from owrx.controllers.template import ( IndexController, - OwrxAssetsController, - WebSocketController, MapController, - FeatureController, - ApiController, - MetricsController, - AprsSymbolsController, + 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, GeneralSettingsController, SdrSettingsController +from owrx.controllers.session import SessionController 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 @@ -28,49 +35,96 @@ class RequestHandler(BaseHTTPRequestHandler): logger.debug("%s - - [%s] %s", self.address_string(), self.log_date_time_string(), format % args) def do_GET(self): - self.router.route(self) + self.router.route(self, self.get_request("GET")) + + def do_POST(self): + self.router.route(self, self.get_request("POST")) + + def get_request(self, method): + url = urlparse(self.path) + return Request(url, method, self.headers) class Request(object): - def __init__(self, query=None, matches=None): - self.query = query + def __init__(self, url, method, headers): + self.path = url.path + self.query = parse_qs(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): - mappings = [ - {"route": "/", "controller": IndexController}, - {"route": "/status", "controller": StatusController}, - {"regex": "/static/(.+)", "controller": OwrxAssetsController}, - {"regex": "/aprs-symbols/(.+)", "controller": AprsSymbolsController}, - {"route": "/ws/", "controller": WebSocketController}, - {"regex": "(/favicon.ico)", "controller": OwrxAssetsController}, - # backwards compatibility for the sdr.hu portal - {"regex": "/(gfx/openwebrx-avatar.png)", "controller": OwrxAssetsController}, - {"route": "/map", "controller": MapController}, - {"route": "/features", "controller": FeatureController}, - {"route": "/api/features", "controller": ApiController}, - {"route": "/metrics", "controller": MetricsController}, - ] + def __init__(self): + self.routes = [ + StaticRoute("/", IndexController), + 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("/api/receiverdetails", ApiController, options={"action": "receiverDetails"}), + StaticRoute("/metrics", MetricsController), + StaticRoute("/settings", SettingsController), + StaticRoute("/generalsettings", GeneralSettingsController), + StaticRoute("/generalsettings", GeneralSettingsController, method="POST", options={"action": "processFormData"}), + StaticRoute("/sdrsettings", SdrSettingsController), + StaticRoute("/login", SessionController, options={"action": "loginAction"}), + StaticRoute("/login", SessionController, method="POST", options={"action": "processLoginAction"}), + StaticRoute("/logout", SessionController, options={"action": "logoutAction"}), + ] - def find_controller(self, path): - for m in Router.mappings: - if "route" in m: - if m["route"] == path: - return (m["controller"], None) - if "regex" in m: - regex = re.compile(m["regex"]) - matches = regex.match(path) - if matches: - return (m["controller"], matches) + def find_route(self, request): + for r in self.routes: + if r.matches(request): + return r - def route(self, handler): - url = urlparse(handler.path) - res = self.find_controller(url.path) - if res is not None: - (controller, matches) = res - query = parse_qs(url.query) - request = Request(query, matches) - controller(handler, request).handle_request() + 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.") diff --git a/owrx/js8.py b/owrx/js8.py new file mode 100644 index 0000000..7d3c474 --- /dev/null +++ b/owrx/js8.py @@ -0,0 +1,132 @@ +from .audio import AudioChopperProfile +from .parser import Parser +import re +from js8py import Js8 +from js8py.frames import Js8FrameHeartbeat, Js8FrameCompound +from .map import Map, LocatorLocation +from .pskreporter import PskReporter +from .metrics import Metrics, CounterMetric +from .config import Config +from abc import ABCMeta, abstractmethod + +import logging + +logger = logging.getLogger(__name__) + + +class Js8Profiles(object): + @staticmethod + def getEnabledProfiles(): + config = Config.get() + profiles = config["js8_enabled_profiles"] if "js8_enabled_profiles" in config else [] + return [Js8Profiles.loadProfile(p) for p in profiles] + + @staticmethod + def loadProfile(profileName): + className = "Js8{0}Profile".format(profileName[0].upper() + profileName[1:].lower()) + return globals()[className]() + + +class Js8Profile(AudioChopperProfile, metaclass=ABCMeta): + def decoding_depth(self, mode): + 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("js8")), file] + + @abstractmethod + def get_sub_mode(self): + pass + + +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, messages): + for raw in messages: + try: + 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 + ) + PskReporter.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/owrx/kiss.py b/owrx/kiss.py index 1ea9408..440cdab 100644 --- a/owrx/kiss.py +++ b/owrx/kiss.py @@ -2,7 +2,7 @@ import socket import time import logging import random -from owrx.config import PropertyManager +from owrx.config import Config logger = logging.getLogger(__name__) @@ -14,10 +14,11 @@ TFESC = 0xDD class DirewolfConfig(object): def getConfig(self, port, is_service): - pm = PropertyManager.getSharedInstance() + pm = Config.get() config = """ ACHANNELS 1 +ADEVICE stdin null CHANNEL 0 MYCALL {callsign} @@ -38,9 +39,14 @@ IGLOGIN {callsign} {password} ) if pm["aprs_igate_beacon"]: - (lat, lon) = pm["receiver_gps"] - lat = "{0}^{1:.2f}{2}".format(int(lat), (lat - int(lat)) * 60, "N" if lat > 0 else "S") - lon = "{0}^{1:.2f}{2}".format(int(lon), (lon - int(lon)) * 60, "E" if lon > 0 else "W") + 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) config += """ PBEACON sendto=IG delay=0:30 every=60:00 symbol="igate" overlay=R lat={lat} long={lon} comment="OpenWebRX APRS gateway" @@ -68,9 +74,19 @@ class KissClient(object): pass def __init__(self, port): - time.sleep(1) - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.connect(("localhost", port)) + delay = .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) diff --git a/owrx/locator.py b/owrx/locator.py index ec80b03..52c37e5 100644 --- a/owrx/locator.py +++ b/owrx/locator.py @@ -2,7 +2,8 @@ class Locator(object): @staticmethod def fromCoordinates(coordinates, depth=3): - lat, lon = coordinates + lat = coordinates["lat"] + lon = coordinates["lon"] lon = lon + 180 lat = lat + 90 diff --git a/owrx/map.py b/owrx/map.py index f7a0d5d..8c95ea5 100644 --- a/owrx/map.py +++ b/owrx/map.py @@ -1,12 +1,14 @@ from datetime import datetime, timedelta -import threading, time -from owrx.config import PropertyManager +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): @@ -47,7 +49,7 @@ class Map(object): loops = 0 time.sleep(60) - threading.Thread(target=removeLoop, daemon=True).start() + threading.Thread(target=removeLoop, daemon=True, name="map_removeloop").start() super().__init__() def broadcast(self, update): @@ -105,7 +107,7 @@ class Map(object): # TODO broadcast removal to clients def removeOldPositions(self): - pm = PropertyManager.getSharedInstance() + pm = Config.get() retention = timedelta(seconds=pm["map_position_retention_time"]) cutoff = datetime.now() - retention diff --git a/owrx/meta.py b/owrx/meta.py index 3d1b3d9..c7e9c1d 100644 --- a/owrx/meta.py +++ b/owrx/meta.py @@ -1,11 +1,10 @@ -from owrx.config import PropertyManager +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.bands import Bandplan from owrx.parser import Parser logger = logging.getLogger(__name__) @@ -55,7 +54,7 @@ class DmrMetaEnricher(object): del self.threads[id] def enrich(self, meta): - if not PropertyManager.getSharedInstance()["digital_voice_dmr_id_lookup"]: + if not Config.get()["digital_voice_dmr_id_lookup"]: return None if not "source" in meta: return None diff --git a/owrx/metrics.py b/owrx/metrics.py index 7055449..48e0db0 100644 --- a/owrx/metrics.py +++ b/owrx/metrics.py @@ -1,4 +1,5 @@ import threading +from owrx.client import ClientRegistry class Metric(object): @@ -38,6 +39,7 @@ class Metrics(object): def __init__(self): self.metrics = {} + self.addMetric("openwebrx.users", DirectMetric(ClientRegistry.getSharedInstance().clientCount)) def addMetric(self, name, metric): self.metrics[name] = metric diff --git a/owrx/modes.py b/owrx/modes.py new file mode 100644 index 0000000..2b70b39 --- /dev/null +++ b/owrx/modes.py @@ -0,0 +1,92 @@ +from owrx.feature import FeatureDetector +from functools import reduce + + +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 + + +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 + + +class Modes(object): + mappings = [ + AnalogMode("nfm", "FM", bandpass=Bandpass(-4000, 4000)), + AnalogMode("wfm", "WFM", bandpass=Bandpass(-50000, 50000)), + 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_dsd"], squelch=False), + AnalogMode("nxdn", "NXDN", bandpass=Bandpass(-3250, 3250), requirements=["digital_voice_dsd"], squelch=False), + AnalogMode("ysf", "YSF", bandpass=Bandpass(-4000, 4000), requirements=["digital_voice_digiham"], squelch=False), + AnalogMode("freedv", "FreeDV", bandpass=Bandpass(300, 3000), requirements=["digital_voice_freedv"], squelch=False), + DigitalMode("bpsk31", "BPSK31", underlying=["usb"]), + DigitalMode("bpsk63", "BPSK63", underlying=["usb"]), + DigitalMode("ft8", "FT8", underlying=["usb"], requirements=["wsjt-x"], service=True), + DigitalMode("ft4", "FT4", underlying=["usb"], requirements=["wsjt-x"], service=True), + DigitalMode("jt65", "JT65", underlying=["usb"], requirements=["wsjt-x"], service=True), + DigitalMode("jt9", "JT9", underlying=["usb"], requirements=["wsjt-x"], service=True), + DigitalMode( + "wspr", "WSPR", underlying=["usb"], bandpass=Bandpass(1350, 1650), requirements=["wsjt-x"], service=True + ), + DigitalMode("js8", "JS8Call", underlying=["usb"], requirements=["js8call"], service=True), + DigitalMode( + "packet", "Packet", underlying=["nfm", "usb", "lsb"], 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/owrx/property/__init__.py b/owrx/property/__init__.py new file mode 100644 index 0000000..f3560fa --- /dev/null +++ b/owrx/property/__init__.py @@ -0,0 +1,246 @@ +from abc import ABC, abstractmethod +import logging + +logger = logging.getLogger(__name__) + + +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 + + def filter(self, *props): + return PropertyFilter(self, *props) + + 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, name, value): + for c in self.subscribers: + try: + if c.getName() is None: + c.call(name, value) + elif c.getName() == name: + c.call(value) + except Exception as e: + logger.exception(e) + + +class PropertyLayer(PropertyManager): + def __init__(self): + super().__init__() + self.properties = {} + + 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): + return self.properties.__delitem__(key) + + def keys(self): + return self.properties.keys() + + +class PropertyFilter(PropertyManager): + def __init__(self, pm: PropertyManager, *props: str): + super().__init__() + self.pm = pm + self.props = props + self.pm.wire(self.receiveEvent) + + def receiveEvent(self, name, value): + if name not in self.props: + return + self._fireCallbacks(name, value) + + def __getitem__(self, item): + if item not in self.props: + raise KeyError(item) + return self.pm.__getitem__(item) + + def __setitem__(self, key, value): + if key not in self.props: + raise KeyError(key) + return self.pm.__setitem__(key, value) + + def __contains__(self, item): + if item not in self.props: + return False + return self.pm.__contains__(item) + + def __dict__(self): + return {k: v for k, v in self.pm.__dict__().items() if k in self.props} + + def __delitem__(self, key): + if key not in self.props: + raise KeyError(key) + return self.pm.__delitem__(key) + + def keys(self): + return [k for k in self.pm.keys() if k in self.props] + + +class PropertyStack(PropertyManager): + def __init__(self): + super().__init__() + self.layers = [] + + def addLayer(self, priority: int, pm: PropertyManager): + """ + highest priority = 0 + """ + self._fireChanges(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(name, value): + self.receiveEvent(pm, name, value) + + sub = pm.wire(eventClosure) + + self.layers.append({"priority": priority, "props": pm, "sub": sub}) + + return changes + + def removeLayer(self, pm: PropertyManager): + for layer in self.layers: + if layer["props"] == pm: + self._fireChanges(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] = None + 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._fireChanges(changes) + + def _fireChanges(self, changes): + for k, v in changes.items(): + self._fireCallbacks(k, v) + + def receiveEvent(self, layer, name, value): + if layer != self._getTopLayer(name): + return + self._fireCallbacks(name, value) + + def _getTopLayer(self, item): + 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 by default + if 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: + layer["props"].__delitem__(key) + + def keys(self): + return set([key for l in self.layers for key in l["props"].keys()]) diff --git a/owrx/pskreporter.py b/owrx/pskreporter.py index da580f0..981ecc8 100644 --- a/owrx/pskreporter.py +++ b/owrx/pskreporter.py @@ -5,7 +5,7 @@ import random import socket from functools import reduce from operator import and_ -from owrx.config import PropertyManager +from owrx.config import Config from owrx.version import openwebrx_version from owrx.locator import Locator from owrx.metrics import Metrics, CounterMetric @@ -30,13 +30,13 @@ class PskReporter(object): sharedInstance = None creationLock = threading.Lock() interval = 300 - supportedModes = ["FT8", "FT4", "JT9", "JT65"] + supportedModes = ["FT8", "FT4", "JT9", "JT65", "JS8"] @staticmethod def getSharedInstance(): with PskReporter.creationLock: if PskReporter.sharedInstance is None: - if PropertyManager.getSharedInstance()["pskreporter_enabled"]: + if Config.get()["pskreporter_enabled"]: PskReporter.sharedInstance = PskReporter() else: PskReporter.sharedInstance = PskReporterDummy() @@ -154,7 +154,7 @@ class Uploader(object): def encodeSpot(self, spot): return bytes( self.encodeString(spot["callsign"]) - + list(spot["freq"].to_bytes(4, "big")) + + list(int(spot["freq"]).to_bytes(4, "big")) + list(int(spot["db"]).to_bytes(1, "big", signed=True)) + self.encodeString(spot["mode"]) + self.encodeString(spot["locator"]) @@ -181,7 +181,7 @@ class Uploader(object): ) def getReceiverInformation(self): - pm = PropertyManager.getSharedInstance() + pm = Config.get() callsign = pm["pskreporter_callsign"] locator = Locator.fromCoordinates(pm["receiver_gps"]) decodingSoftware = "OpenWebRX " + openwebrx_version diff --git a/owrx/receiverid.py b/owrx/receiverid.py new file mode 100644 index 0000000..847bb34 --- /dev/null +++ b/owrx/receiverid.py @@ -0,0 +1,94 @@ +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) + keys = [parseKey(keyString) for keyString in Config.get()['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/owrx/sdr.py b/owrx/sdr.py index 878b24d..c52406f 100644 --- a/owrx/sdr.py +++ b/owrx/sdr.py @@ -1,4 +1,5 @@ -from owrx.config import PropertyManager +from owrx.config import Config +from owrx.property import PropertyLayer from owrx.feature import FeatureDetector, UnknownFeatureException import logging @@ -14,11 +15,11 @@ class SdrService(object): @staticmethod def loadProps(): if SdrService.sdrProps is None: - pm = PropertyManager.getSharedInstance() + pm = Config.get() featureDetector = FeatureDetector() def loadIntoPropertyManager(dict: dict): - propertyManager = PropertyManager() + propertyManager = PropertyLayer() for (name, value) in dict.items(): propertyManager[name] = value return propertyManager @@ -27,7 +28,7 @@ class SdrService(object): try: if not featureDetector.is_available(value["type"]): logger.error( - 'The RTL source type "{0}" is not available. please check requirements.'.format( + 'The SDR source type "{0}" is not available. please check requirements.'.format( value["type"] ) ) @@ -35,7 +36,7 @@ class SdrService(object): return True except UnknownFeatureException: logger.error( - 'The RTL source type "{0}" is invalid. Please check your configuration'.format(value["type"]) + 'The SDR source type "{0}" is invalid. Please check your configuration'.format(value["type"]) ) return False @@ -44,7 +45,7 @@ class SdrService(object): name: loadIntoPropertyManager(value) for (name, value) in pm["sdrs"].items() if sdrTypeAvailable(value) } logger.info( - "SDR sources loaded. Availables SDRs: {0}".format( + "SDR sources loaded. Available SDRs: {0}".format( ", ".join(map(lambda x: x["name"], SdrService.sdrProps.values())) ) ) diff --git a/owrx/sdrhu.py b/owrx/sdrhu.py deleted file mode 100644 index c84a2f5..0000000 --- a/owrx/sdrhu.py +++ /dev/null @@ -1,39 +0,0 @@ -import threading -import subprocess -import time -from owrx.config import PropertyManager - -import logging - -logger = logging.getLogger(__name__) - - -class SdrHuUpdater(threading.Thread): - def __init__(self): - self.doRun = True - super().__init__(daemon=True) - - def update(self): - pm = PropertyManager.getSharedInstance() - cmd = 'wget --timeout=15 -4qO- https://sdr.hu/update --post-data "url=http://{server_hostname}:{web_port}&apikey={sdrhu_key}" 2>&1'.format( - **pm.__dict__() - ) - logger.debug(cmd) - returned = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE).communicate() - returned = returned[0].decode("utf-8") - if "UPDATE:" in returned: - retrytime_mins = 20 - value = returned.split("UPDATE:")[1].split("\n", 1)[0] - if value.startswith("SUCCESS"): - logger.info("Update succeeded!") - else: - logger.warning("Update failed, your receiver cannot be listed on sdr.hu! Reason: %s", value) - else: - retrytime_mins = 2 - logger.warning("wget failed while updating, your receiver cannot be listed on sdr.hu!") - return retrytime_mins - - def run(self): - while self.doRun: - retrytime_mins = self.update() - time.sleep(60 * retrytime_mins) diff --git a/owrx/service.py b/owrx/service.py deleted file mode 100644 index 5936f8f..0000000 --- a/owrx/service.py +++ /dev/null @@ -1,408 +0,0 @@ -import threading -from datetime import datetime, timezone, timedelta -from owrx.source import SdrSource -from owrx.sdr import SdrService -from owrx.bands import Bandplan -from csdr.csdr import dsp, output -from owrx.wsjt import WsjtParser -from owrx.aprs import AprsParser -from owrx.config import PropertyManager -from owrx.source.resampler import Resampler -from owrx.feature import FeatureDetector - -import logging - -logger = logging.getLogger(__name__) - - -class ServiceOutput(output): - def __init__(self, frequency): - self.frequency = frequency - - 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).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 ScheduleEntry(object): - def __init__(self, startTime, endTime, profile): - self.startTime = startTime - self.endTime = endTime - self.profile = profile - - def isCurrent(self, time): - if self.startTime < self.endTime: - return self.startTime <= time < self.endTime - else: - return self.startTime <= time or time < self.endTime - - def getProfile(self): - return self.profile - - def getScheduledEnd(self): - now = datetime.utcnow() - end = now.combine(date=now.date(), time=self.endTime) - while end < now: - end += timedelta(days=1) - return end - - def getNextActivation(self): - now = datetime.utcnow() - start = now.combine(date=now.date(), time=self.startTime) - while start < now: - start += timedelta(days=1) - return start - - -class Schedule(object): - @staticmethod - def parse(scheduleDict): - entries = [] - for time, profile in scheduleDict.items(): - if len(time) != 9: - logger.warning("invalid schedule spec: %s", time) - continue - - startTime = datetime.strptime(time[0:4], "%H%M").replace(tzinfo=timezone.utc).time() - endTime = datetime.strptime(time[5:9], "%H%M").replace(tzinfo=timezone.utc).time() - entries.append(ScheduleEntry(startTime, endTime, profile)) - return Schedule(entries) - - def __init__(self, entries): - self.entries = entries - - def getCurrentEntry(self): - current = [p for p in self.entries if p.isCurrent(datetime.utcnow().time())] - if current: - return current[0] - return None - - def getNextEntry(self): - s = sorted(self.entries, key=lambda e: e.getNextActivation()) - if s: - return s[0] - return None - - -class ServiceScheduler(object): - def __init__(self, source, schedule): - self.source = source - self.selectionTimer = None - self.schedule = Schedule.parse(schedule) - self.source.addClient(self) - self.source.getProps().collect("center_freq", "samp_rate").wire(self.onFrequencyChange) - self.scheduleSelection() - - def shutdown(self): - self.cancelTimer() - self.source.removeClient(self) - - def scheduleSelection(self, time=None): - if self.source.getState() == SdrSource.STATE_FAILED: - 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): - return SdrSource.CLIENT_BACKGROUND - - def onStateChange(self, state): - if state == SdrSource.STATE_STOPPING: - self.scheduleSelection() - elif state == SdrSource.STATE_FAILED: - self.cancelTimer() - - def onBusyStateChange(self, state): - if state == SdrSource.BUSYSTATE_IDLE: - self.scheduleSelection() - - def onFrequencyChange(self, name, value): - self.scheduleSelection() - - def selectProfile(self): - if self.source.hasClients(SdrSource.CLIENT_USER): - logger.debug("source has active users; not touching") - return - logger.debug("source seems to be idle, selecting profile for background services") - entry = self.schedule.getCurrentEntry() - - if entry is None: - logger.debug("schedule did not return a profile. checking next entry...") - nextEntry = self.schedule.getNextEntry() - if nextEntry is not None: - self.scheduleSelection(nextEntry.getNextActivation()) - return - - logger.debug("scheduling end for current profile: %s", entry.getScheduledEnd()) - self.scheduleSelection(entry.getScheduledEnd()) - - try: - self.source.activateProfile(entry.getProfile()) - self.source.start() - except KeyError: - pass - - -class ServiceHandler(object): - def __init__(self, source): - self.lock = threading.Lock() - self.services = [] - self.source = source - self.startupTimer = None - self.source.addClient(self) - props = self.source.getProps() - props.collect("center_freq", "samp_rate").wire(self.onFrequencyChange) - if self.source.isAvailable(): - self.scheduleServiceStartup() - self.scheduler = None - if "schedule" in props: - self.scheduler = ServiceScheduler(self.source, props["schedule"]) - - def getClientClass(self): - return SdrSource.CLIENT_INACTIVE - - def onStateChange(self, state): - if state == SdrSource.STATE_RUNNING: - self.scheduleServiceStartup() - elif state == SdrSource.STATE_STOPPING: - logger.debug("sdr source becoming unavailable; stopping services.") - self.stopServices() - elif state == SdrSource.STATE_FAILED: - logger.debug("sdr source failed; stopping services.") - self.stopServices() - if self.scheduler: - self.scheduler.shutdown() - - def onBusyStateChange(self, state): - pass - - def isSupported(self, mode): - # TODO this should be in a more central place (the frontend also needs this) - requirements = { - "ft8": "wsjt-x", - "ft4": "wsjt-x", - "jt65": "wsjt-x", - "jt9": "wsjt-x", - "wspr": "wsjt-x", - "packet": "packet", - } - fd = FeatureDetector() - - # this looks overly complicated... but i'd like modes with no requirements to be always available without - # being listed in the hash above - unavailable = [mode for mode, req in requirements.items() if not fd.is_available(req)] - configured = PropertyManager.getSharedInstance()["services_decoders"] - available = [mode for mode in configured if mode not in unavailable] - - return mode in available - - def shutdown(self): - self.stopServices() - self.source.removeClient(self) - if self.scheduler: - self.scheduler.shutdown() - - def stopServices(self): - with self.lock: - services = self.services - self.services = [] - - for service in services: - service.stop() - - def onFrequencyChange(self, key, value): - self.stopServices() - if not self.source.isAvailable(): - return - self.scheduleServiceStartup() - - def scheduleServiceStartup(self): - if self.startupTimer: - self.startupTimer.cancel() - self.startupTimer = threading.Timer(10, self.updateServices) - self.startupTimer.start() - - def updateServices(self): - 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 - - with self.lock: - self.services = [] - - 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: - frequencies = sorted([f["frequency"] for f in group]) - min = frequencies[0] - max = frequencies[-1] - cf = (min + max) / 2 - bw = max - min - logger.debug("group center frequency: {0}, bandwidth: {1}".format(cf, bw)) - resampler_props = PropertyManager() - resampler_props["center_freq"] = cf - # TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths - resampler_props["samp_rate"] = bw + 24000 - 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) - - 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_bandwitdh(group): - freqs = sorted([f["frequency"] for f in group]) - # the group will process the full BW once, plus the reduced BW once for each group member - return bandwidth + len(group) * (freqs[-1] - freqs[0] + 24000) - - total_bandwidth = sum([get_bandwitdh(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) - else: - output = WsjtServiceOutput(frequency) - d = dsp(output) - d.nc_port = source.getPort() - d.set_offset_freq(frequency - source.getProps()["center_freq"]) - if mode == "packet": - d.set_demodulator("nfm") - d.set_bpf(-4000, 4000) - elif mode == "wspr": - d.set_demodulator("usb") - # WSPR only samples between 1400 and 1600 Hz - d.set_bpf(1350, 1650) - else: - d.set_demodulator("usb") - d.set_bpf(0, 3000) - d.set_secondary_demodulator(mode) - d.set_audio_compression("none") - d.set_samp_rate(source.getProps()["samp_rate"]) - 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 Services(object): - handlers = [] - - @staticmethod - def start(): - if not PropertyManager.getSharedInstance()["services_enabled"]: - return - for source in SdrService.getSources().values(): - props = source.getProps() - if "services" not in props or props["services"] != False: - Services.handlers.append(ServiceHandler(source)) - - @staticmethod - def stop(): - for handler in Services.handlers: - handler.shutdown() - Services.handlers = [] - - -class Service(object): - pass - - -class WsjtService(Service): - pass diff --git a/owrx/service/__init__.py b/owrx/service/__init__.py new file mode 100644 index 0000000..a8e60d8 --- /dev/null +++ b/owrx/service/__init__.py @@ -0,0 +1,303 @@ +import threading +from owrx.source import SdrSource +from owrx.sdr import SdrService +from owrx.bands import Bandplan +from csdr.csdr import dsp, output +from owrx.wsjt import WsjtParser +from owrx.aprs import AprsParser +from owrx.js8 import Js8Parser +from owrx.config import Config +from owrx.source.resampler import Resampler +from owrx.property import PropertyLayer +from js8py import Js8Frame +from abc import ABCMeta, abstractmethod +from .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(object): + def __init__(self, source): + self.lock = threading.RLock() + self.services = [] + self.source = source + self.startupTimer = None + self.source.addClient(self) + props = self.source.getProps() + props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange) + if self.source.isAvailable(): + self.scheduleServiceStartup() + self.scheduler = None + if "schedule" in props or "scheduler" in props: + self.scheduler = ServiceScheduler(self.source) + + def getClientClass(self): + return SdrSource.CLIENT_INACTIVE + + def onStateChange(self, state): + if state == SdrSource.STATE_RUNNING: + self.scheduleServiceStartup() + elif state == SdrSource.STATE_STOPPING: + logger.debug("sdr source becoming unavailable; stopping services.") + self.stopServices() + elif state == SdrSource.STATE_FAILED: + logger.debug("sdr source failed; stopping services.") + self.stopServices() + if self.scheduler: + self.scheduler.shutdown() + + def onBusyStateChange(self, state): + pass + + 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.stopServices() + self.source.removeClient(self) + if self.scheduler: + self.scheduler.shutdown() + + def stopServices(self): + with self.lock: + services = self.services + self.services = [] + + for service in services: + service.stop() + + def onFrequencyChange(self, key, value): + self.stopServices() + if not self.source.isAvailable(): + return + self.scheduleServiceStartup() + + def scheduleServiceStartup(self): + if self.startupTimer: + self.startupTimer.cancel() + 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: + frequencies = sorted([f["frequency"] for f in group]) + min = frequencies[0] + max = frequencies[-1] + cf = (min + max) / 2 + bw = max - min + logger.debug( + "group center frequency: {0}, bandwidth: {1}".format(cf, bw) + ) + resampler_props = PropertyLayer() + resampler_props["center_freq"] = cf + # TODO the + 24000 is a temporary fix since the resampling optimizer does not account for required bandwidths + resampler_props["samp_rate"] = bw + 24000 + 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) + + 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_bandwitdh(group): + freqs = sorted([f["frequency"] for f in group]) + # the group will process the full BW once, plus the reduced BW once for each group member + return bandwidth + len(group) * (freqs[-1] - freqs[0] + 24000) + + total_bandwidth = sum([get_bandwitdh(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) + if mode == "packet": + d.set_demodulator("nfm") + d.set_bpf(-4000, 4000) + elif mode == "wspr": + d.set_demodulator("usb") + # WSPR only samples between 1400 and 1600 Hz + d.set_bpf(1350, 1650) + else: + d.set_demodulator("usb") + d.set_bpf(0, 3000) + d.set_secondary_demodulator(mode) + d.set_audio_compression("none") + d.set_samp_rate(source.getProps()["samp_rate"]) + d.set_temporary_directory(Config.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 = [] + + @staticmethod + def start(): + if not Config.get()["services_enabled"]: + return + for source in SdrService.getSources().values(): + props = source.getProps() + if "services" not in props or props["services"] is not False: + Services.handlers.append(ServiceHandler(source)) + + @staticmethod + def stop(): + for handler in Services.handlers: + handler.shutdown() + Services.handlers = [] diff --git a/owrx/service/schedule.py b/owrx/service/schedule.py new file mode 100644 index 0000000..992757e --- /dev/null +++ b/owrx/service/schedule.py @@ -0,0 +1,273 @@ +from datetime import datetime, timezone, timedelta +from owrx.source import SdrSource +from owrx.config import Config +import threading +import math +from abc import ABC, ABCMeta, abstractmethod + +import logging + +logger = logging.getLogger(__name__) + + +class ScheduleEntry(ABC): + def __init__(self, startTime, endTime, profile): + self.startTime = startTime + self.endTime = endTime + self.profile = profile + + def getProfile(self): + return self.profile + + def __str__(self): + return "{0} - {1}: {2}".format(self.startTime, self.endTime, self.profile) + + @abstractmethod + def isCurrent(self, dt): + pass + + @abstractmethod + def getScheduledEnd(self): + pass + + @abstractmethod + def getNextActivation(self): + pass + + +class TimeScheduleEntry(ScheduleEntry): + def isCurrent(self, dt): + time = dt.time() + if self.startTime < self.endTime: + return self.startTime <= time < self.endTime + else: + return self.startTime <= time or time < self.endTime + + def getScheduledEnd(self): + now = datetime.utcnow() + end = now.combine(date=now.date(), time=self.endTime) + while end < now: + end += timedelta(days=1) + return end + + def getNextActivation(self): + now = datetime.utcnow() + start = now.combine(date=now.date(), time=self.startTime) + while start < now: + start += timedelta(days=1) + return start + + +class DatetimeScheduleEntry(ScheduleEntry): + def isCurrent(self, dt): + return self.startTime <= dt < self.endTime + + def getScheduledEnd(self): + return self.endTime + + def getNextActivation(self): + return self.startTime + +class Schedule(ABC): + @staticmethod + def parse(props): + # downwards compatibility + if "schedule" in props: + return StaticSchedule(props["schedule"]) + elif "scheduler" in props: + sc = props["scheduler"] + t = sc["type"] if "type" in sc else "static" + if t == "static": + return StaticSchedule(sc["schedule"]) + elif t == "daylight": + return DaylightSchedule(sc["schedule"]) + else: + logger.warning("Invalid scheduler type: %s", t) + + @abstractmethod + def getCurrentEntry(self): + pass + + @abstractmethod + def getNextEntry(self): + pass + + +class TimerangeSchedule(Schedule, metaclass=ABCMeta): + @abstractmethod + def getEntries(self): + pass + + def getCurrentEntry(self): + current = [p for p in self.getEntries() if p.isCurrent(datetime.utcnow())] + if current: + return current[0] + return None + + def getNextEntry(self): + s = sorted(self.getEntries(), key=lambda e: e.getNextActivation()) + if s: + return s[0] + return None + + +class StaticSchedule(TimerangeSchedule): + def __init__(self, scheduleDict): + self.entries = [] + for time, profile in scheduleDict.items(): + if len(time) != 9: + logger.warning("invalid schedule spec: %s", time) + continue + + startTime = datetime.strptime(time[0:4], "%H%M").replace(tzinfo=timezone.utc).time() + endTime = datetime.strptime(time[5:9], "%H%M").replace(tzinfo=timezone.utc).time() + self.entries.append(TimeScheduleEntry(startTime, endTime, profile)) + + def getEntries(self): + return self.entries + + +class DaylightSchedule(TimerangeSchedule): + greyLineTime = timedelta(hours=1) + + def __init__(self, scheduleDict): + self.schedule = scheduleDict + + def getSunTimes(self, date): + pm = Config.get() + lat = pm["receiver_gps"]["lat"] + lng = pm["receiver_gps"]["lon"] + degtorad = math.pi / 180 + radtodeg = 180 / math.pi + + #Number of days since 01/01 + days = date.timetuple().tm_yday + + # Longitudinal correction + longCorr = 4 * lng + + # calibrate for solstice + b = 2 * math.pi * (days - 81) / 365 + + # Equation of Time Correction + eoTCorr = 9.87 * math.sin(2 * b) - 7.53 * math.cos(b) - 1.5 * math.sin(b) + + # Solar correction + solarCorr = longCorr + eoTCorr + + # Solar declination + declination = math.asin(math.sin(23.45 * degtorad) * math.sin(b)) + + sunrise = 12 - math.acos(-math.tan(lat * degtorad) * math.tan(declination)) * radtodeg / 15 - solarCorr / 60 + sunset = 12 + math.acos(-math.tan(lat * degtorad) * math.tan(declination)) * radtodeg / 15 - solarCorr / 60 + + midnight = datetime.combine(date, datetime.min.time()) + sunrise = midnight + timedelta(hours=sunrise) + sunset = midnight + timedelta(hours=sunset) + logger.debug("for {date} sunrise: {sunrise} sunset {sunset}".format(date=date, sunrise=sunrise, sunset=sunset)) + + return sunrise, sunset + + def getEntries(self): + now = datetime.utcnow() + date = now.date() + # greyline is optional, it its set it will shorten the other profiles + useGreyline = "greyline" in self.schedule + entries = [] + + delta = DaylightSchedule.greyLineTime if useGreyline else timedelta() + events = [] + # we need to start yesterday for longitudes close to the date line + offset = -1 + while len(events) < 1: + sunrise, sunset = self.getSunTimes(date + timedelta(days=offset)) + offset += 1 + events += [{"type": "sunrise", "time": sunrise}, {"type": "sunset", "time": sunset}] + # keep only events in the future + events = [v for v in events if v["time"] + delta > 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 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(object): + def __init__(self, source): + self.source = source + self.selectionTimer = None + self.source.addClient(self) + props = self.source.getProps() + self.schedule = Schedule.parse(props) + props.filter("center_freq", "samp_rate").wire(self.onFrequencyChange) + self.scheduleSelection() + + def shutdown(self): + self.cancelTimer() + self.source.removeClient(self) + + def scheduleSelection(self, time=None): + if self.source.getState() == SdrSource.STATE_FAILED: + 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): + return SdrSource.CLIENT_BACKGROUND + + def onStateChange(self, state): + if state == SdrSource.STATE_STOPPING: + self.scheduleSelection() + elif state == SdrSource.STATE_FAILED: + self.cancelTimer() + + def onBusyStateChange(self, state): + if state == SdrSource.BUSYSTATE_IDLE: + self.scheduleSelection() + + def onFrequencyChange(self, name, value): + self.scheduleSelection() + + def selectProfile(self): + if self.source.hasClients(SdrSource.CLIENT_USER): + logger.debug("source has active users; not touching") + return + logger.debug("source seems to be idle, selecting profile for background services") + entry = self.schedule.getCurrentEntry() + + if entry is None: + logger.debug("schedule did not return a profile. checking next entry...") + nextEntry = self.schedule.getNextEntry() + if nextEntry is not None: + self.scheduleSelection(nextEntry.getNextActivation()) + return + + 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 diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index cb050f2..8720882 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -1,4 +1,4 @@ -from owrx.config import PropertyManager +from owrx.config import Config import threading import subprocess import os @@ -9,6 +9,7 @@ import signal from abc import ABC, abstractmethod from owrx.command import CommandMapper from owrx.socket import getAvailablePort +from owrx.property import PropertyStack, PropertyLayer import logging @@ -32,12 +33,18 @@ class SdrSource(ABC): def __init__(self, id, props): self.id = id - self.props = props + + self.commandMapper = None + + self.props = PropertyStack() + # layer 0 reserved for profile properties + self.props.addLayer(1, props) + self.props.addLayer(2, Config.get()) + self.sdrProps = self.props.filter(*self.getEventNames()) + self.profile_id = None self.activateProfile() - self.rtlProps = self.props.collect(*self.getEventNames()).defaults(PropertyManager.getSharedInstance()) self.wireEvents() - self.commandMapper = None if "port" in props and props["port"] is not None: self.port = props["port"] @@ -66,7 +73,7 @@ class SdrSource(ABC): "ppm", "rf_gain", "lfo_offset", - ] + ] + list(self.getCommandMapper().keys()) def getCommandMapper(self): if self.commandMapper is None: @@ -78,7 +85,7 @@ class SdrSource(ABC): pass def wireEvents(self): - self.rtlProps.wire(self.onPropertyChange) + self.sdrProps.wire(self.onPropertyChange) def getCommand(self): return [self.getCommandMapper().map(self.getCommandValues())] @@ -93,14 +100,17 @@ class SdrSource(ABC): if profile_id == self.profile_id: return logger.debug("activating profile {0}".format(profile_id)) - self.profile_id = profile_id - profile = profiles[profile_id] self.props["profile_id"] = profile_id + profile = profiles[profile_id] + self.profile_id = profile_id + + layer = PropertyLayer() for (key, value) in profile.items(): # skip the name, that would overwrite the source name. if key == "name": continue - self.props[key] = value + layer[key] = value + self.props.replaceLayer(0, layer) def getId(self): return self.id @@ -121,7 +131,7 @@ class SdrSource(ABC): return self.port def getCommandValues(self): - dict = self.rtlProps.collect(*self.getEventNames()).__dict__() + 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: @@ -161,7 +171,7 @@ class SdrSource(ABC): logger.debug("shut down with RC={0}".format(rc)) self.monitor = None - self.monitor = threading.Thread(target=wait_for_process_to_end) + self.monitor = threading.Thread(target=wait_for_process_to_end, name="source_monitor") self.monitor.start() retries = 1000 @@ -241,13 +251,14 @@ class SdrSource(ABC): except ValueError: pass + hasUsers = self.hasClients(SdrSource.CLIENT_USER) + self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE) + # no need to check for users if we are always-on if self.isAlwaysOn(): return - hasUsers = self.hasClients(SdrSource.CLIENT_USER) hasBackgroundTasks = self.hasClients(SdrSource.CLIENT_BACKGROUND) - self.setBusyState(SdrSource.BUSYSTATE_BUSY if hasUsers else SdrSource.BUSYSTATE_IDLE) if not hasUsers and not hasBackgroundTasks: self.stop() diff --git a/owrx/source/airspy.py b/owrx/source/airspy.py index 4453da9..97221a3 100644 --- a/owrx/source/airspy.py +++ b/owrx/source/airspy.py @@ -3,11 +3,15 @@ from .soapy import SoapyConnectorSource class AirspySource(SoapyConnectorSource): - def getCommandMapper(self): - return super().getCommandMapper().setMappings({"bias_tee": Flag("-t biastee=true")}) + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update( + { + "bias_tee": "biastee", + "bitpack": "bitpack", + } + ) + return mappings def getDriver(self): return "airspy" - - def getEventNames(self): - return super().getEventNames() + ["bias_tee"] diff --git a/owrx/source/connector.py b/owrx/source/connector.py index c626506..f28da98 100644 --- a/owrx/source/connector.py +++ b/owrx/source/connector.py @@ -29,13 +29,6 @@ class ConnectorSource(SdrSource): } ) - def getEventNames(self): - return super().getEventNames() + [ - "device", - "iqswap", - "rtltcp_compat", - ] - def sendControlMessage(self, prop, value): 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()) @@ -45,10 +38,10 @@ class ConnectorSource(SdrSource): return if ( (prop == "center_freq" or prop == "lfo_offset") - and "lfo_offset" in self.rtlProps - and self.rtlProps["lfo_offset"] is not None + and "lfo_offset" in self.sdrProps + and self.sdrProps["lfo_offset"] is not None ): - freq = self.rtlProps["center_freq"] + self.rtlProps["lfo_offset"] + freq = self.sdrProps["center_freq"] + self.sdrProps["lfo_offset"] self.sendControlMessage("center_freq", freq) else: self.sendControlMessage(prop, value) diff --git a/owrx/source/direct.py b/owrx/source/direct.py index a2b26ec..904d2ea 100644 --- a/owrx/source/direct.py +++ b/owrx/source/direct.py @@ -23,7 +23,7 @@ class DirectSource(SdrSource, metaclass=ABCMeta): ] def getNmuxCommand(self): - props = self.rtlProps + props = self.sdrProps nmux_bufcnt = nmux_bufsize = 0 while nmux_bufsize < props["samp_rate"] / 4: diff --git a/owrx/source/fcdpp.py b/owrx/source/fcdpp.py new file mode 100644 index 0000000..bb121ee --- /dev/null +++ b/owrx/source/fcdpp.py @@ -0,0 +1,6 @@ +from owrx.source.soapy import SoapyConnectorSource + + +class FcdppSource(SoapyConnectorSource): + def getDriver(self): + return "fcdpp" diff --git a/owrx/source/fifi_sdr.py b/owrx/source/fifi_sdr.py index f591b52..69babf4 100644 --- a/owrx/source/fifi_sdr.py +++ b/owrx/source/fifi_sdr.py @@ -11,16 +11,16 @@ class FifiSdrSource(DirectSource): def getCommandMapper(self): return super().getCommandMapper().setBase("arecord").setMappings( {"device": Option("-D"), "samp_rate": Option("-r")} - ).setStatic("-f S16_LE -c2 -") + ).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 30"] + return ["csdr convert_s16_f", "csdr gain_ff 5"] def sendRockProgFrequency(self, frequency): - process = Popen(["rockprog", "--vco", "-w", "--", "freq={}".format(frequency / 1E6)]) + process = Popen(["rockprog", "--vco", "-w", "--freq={}".format(frequency / 1E6)]) process.communicate() rc = process.wait() if rc != 0: diff --git a/owrx/source/hackrf.py b/owrx/source/hackrf.py index 50001ae..a103218 100644 --- a/owrx/source/hackrf.py +++ b/owrx/source/hackrf.py @@ -1,24 +1,11 @@ -from .direct import DirectSource -from owrx.command import Flag, Option +from .soapy import SoapyConnectorSource -class HackrfSource(DirectSource): - def getCommandMapper(self): - return super().getCommandMapper().setBase("hackrf_transfer").setMappings( - { - "samp_rate": Option("-s"), - "tuner_freq": Option("-f"), - "rf_gain": Option("-g"), - "lna_gain": Option("-l"), - "rf_amp": Option("-a"), - } - ).setStatic("-r-") +class HackrfSource(SoapyConnectorSource): + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update({"bias_tee": "bias_tx"}) + return mappings - def getEventNames(self): - return super().getEventNames() + [ - "lna_gain", - "rf_amp", - ] - - def getFormatConversion(self): - return ["csdr convert_s8_f"] + def getDriver(self): + return "hackrf" \ No newline at end of file diff --git a/owrx/source/perseussdr.py b/owrx/source/perseussdr.py new file mode 100644 index 0000000..bad4f38 --- /dev/null +++ b/owrx/source/perseussdr.py @@ -0,0 +1,31 @@ +from .direct import DirectSource +from owrx.command import Flag, Option + + +# +# 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": Option("-m"), + "adc_dither": Option("-x"), + "wideband": Option("-w"), + } + ) diff --git a/owrx/source/radioberry.py b/owrx/source/radioberry.py new file mode 100644 index 0000000..5bb95c7 --- /dev/null +++ b/owrx/source/radioberry.py @@ -0,0 +1,6 @@ +from .soapy import SoapyConnectorSource + + +class RadioberrySource(SoapyConnectorSource): + def getDriver(self): + return "radioberry" diff --git a/owrx/source/red_pitaya.py b/owrx/source/red_pitaya.py new file mode 100644 index 0000000..06431f0 --- /dev/null +++ b/owrx/source/red_pitaya.py @@ -0,0 +1,6 @@ +from .soapy import SoapyConnectorSource + + +class RedPitayaSource(SoapyConnectorSource): + def getDriver(self): + return "redpitaya" diff --git a/owrx/source/resampler.py b/owrx/source/resampler.py index 6afe50c..1f6a4e1 100644 --- a/owrx/source/resampler.py +++ b/owrx/source/resampler.py @@ -1,10 +1,4 @@ from .direct import DirectSource -from . import SdrSource -import subprocess -import threading -import os -import socket -import time import logging @@ -29,7 +23,7 @@ class Resampler(DirectSource): def getCommand(self): return [ "nc -v 127.0.0.1 {nc_port}".format(nc_port=self.sdr.getPort()), - "csdr shift_addition_cc {shift}".format(shift=self.shift), + "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 ), diff --git a/owrx/source/rtl_sdr.py b/owrx/source/rtl_sdr.py index 64badf1..a6ecbc9 100644 --- a/owrx/source/rtl_sdr.py +++ b/owrx/source/rtl_sdr.py @@ -1,6 +1,12 @@ from .connector import ConnectorSource +from owrx.command import Flag, Option class RtlSdrSource(ConnectorSource): def getCommandMapper(self): - return super().getCommandMapper().setBase("rtl_connector") + return ( + super() + .getCommandMapper() + .setBase("rtl_connector") + .setMappings({"bias_tee": Flag("-b"), "direct_sampling": Option("-e")}) + ) diff --git a/owrx/source/rtl_sdr_soapy.py b/owrx/source/rtl_sdr_soapy.py index 24fe9f4..55744d5 100644 --- a/owrx/source/rtl_sdr_soapy.py +++ b/owrx/source/rtl_sdr_soapy.py @@ -1,13 +1,11 @@ from .soapy import SoapyConnectorSource -from owrx.command import Option class RtlSdrSoapySource(SoapyConnectorSource): - def getCommandMapper(self): - return super().getCommandMapper().setMappings({"direct_sampling": Option("-t direct_samp").setSpacer("=")}) + def getSoapySettingsMappings(self): + mappings = super().getSoapySettingsMappings() + mappings.update({"direct_sampling": "direct_samp", "bias_tee": "biastee"}) + return mappings def getDriver(self): return "rtlsdr" - - def getEventNames(self): - return super().getEventNames() + ["direct_sampling"] diff --git a/owrx/source/rtl_tcp.py b/owrx/source/rtl_tcp.py new file mode 100644 index 0000000..03c3109 --- /dev/null +++ b/owrx/source/rtl_tcp.py @@ -0,0 +1,16 @@ +from .connector import ConnectorSource +from owrx.command import Flag, Option, Argument + + +class RtlTcpSource(ConnectorSource): + def getCommandMapper(self): + return ( + super() + .getCommandMapper() + .setBase("rtl_tcp_connector") + .setMappings({ + "bias_tee": Flag("-b"), + "direct_sampling": Option("-e"), + "remote": Argument(), + }) + ) diff --git a/owrx/source/sdrplay.py b/owrx/source/sdrplay.py index dac2983..da2398a 100644 --- a/owrx/source/sdrplay.py +++ b/owrx/source/sdrplay.py @@ -2,5 +2,17 @@ from .soapy import SoapyConnectorSource 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", + } + ) + return mappings + def getDriver(self): return "sdrplay" diff --git a/owrx/source/soapy.py b/owrx/source/soapy.py index 0810884..e713ba0 100644 --- a/owrx/source/soapy.py +++ b/owrx/source/soapy.py @@ -1,12 +1,16 @@ from abc import ABCMeta, abstractmethod from owrx.command import Option - from .connector import ConnectorSource class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): def getCommandMapper(self): - return super().getCommandMapper().setBase("soapy_connector").setMappings({"antenna": Option("-a")}) + 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. @@ -18,9 +22,7 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): pass def getEventNames(self): - return super().getEventNames() + [ - "antenna", - ] + return super().getEventNames() + list(self.getSoapySettingsMappings().keys()) def parseDeviceString(self, dstr): def decodeComponent(c): @@ -41,18 +43,46 @@ class SoapyConnectorSource(ConnectorSource, metaclass=ABCMeta): return ",".join([encodeComponent(c) for c in dobj]) - """ - 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. - """ + 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 = self.parseDeviceString(values["device"]) - parsed = [v for v in parsed if "driver" not in v] - parsed += [{"driver": self.getDriver()}] - values["device"] = self.encodeDeviceString(parsed) else: - values["device"] = "driver={0}".format(self.getDriver()) + parsed = [] + modified = self.buildSoapyDeviceParameters(parsed, values) + values["device"] = self.encodeDeviceString(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, prop, value): + mappings = self.getSoapySettingsMappings() + if prop in mappings.keys(): + value = "{0}={1}".format(mappings[prop], self.convertSoapySettingsValue(value)) + prop = "settings" + super().onPropertyChange(prop, value) diff --git a/owrx/source/soapy_remote.py b/owrx/source/soapy_remote.py new file mode 100644 index 0000000..53a3196 --- /dev/null +++ b/owrx/source/soapy_remote.py @@ -0,0 +1,17 @@ +from .soapy import SoapyConnectorSource + + +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 diff --git a/owrx/source/uhd.py b/owrx/source/uhd.py new file mode 100644 index 0000000..29e2909 --- /dev/null +++ b/owrx/source/uhd.py @@ -0,0 +1,6 @@ +from .soapy import SoapyConnectorSource + + +class UhdSource(SoapyConnectorSource): + def getDriver(self): + return "uhd" diff --git a/owrx/users.py b/owrx/users.py new file mode 100644 index 0000000..43e0865 --- /dev/null +++ b/owrx/users.py @@ -0,0 +1,80 @@ +from abc import ABC, abstractmethod +import json + +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) + raise PasswordException("invalid passord encoding: {0}".format(d["type"])) + + def __init__(self, pwinfo: dict): + self.pwinfo = pwinfo + + @abstractmethod + def is_valid(self, inp: str): + pass + + +class CleartextPassword(Password): + def is_valid(self, inp: str): + return self.pwinfo['value'] == inp + + +class User(object): + def __init__(self, name: str, enabled: bool, password: Password): + self.name = name + self.enabled = enabled + self.password = password + + +class UserList(object): + sharedInstance = None + + @staticmethod + def getSharedInstance(): + if UserList.sharedInstance is None: + UserList.sharedInstance = UserList() + return UserList.sharedInstance + + def __init__(self): + self.users = self._loadUsers() + + def _loadUsers(self): + for file in ["/etc/openwebrx/users.json", "users.json"]: + try: + f = open(file, "r") + users_json = json.load(f) + f.close() + + return {u.name: u for u in [self.buildUser(d) for d in users_json]} + except FileNotFoundError: + pass + except json.JSONDecodeError: + logger.exception("error while parsing users file %s", file) + return {} + except Exception: + logger.exception("error while processing users from %s", file) + return {} + return {} + + def buildUser(self, d): + if "user" in d and "password" in d and "enabled" in d: + return User(d["user"], d["enabled"], Password.from_dict(d["password"])) + + def __getitem__(self, item): + return self.users[item] + + def __contains__(self, item): + return item in self.users diff --git a/owrx/version.py b/owrx/version.py index a040f27..60a4954 100644 --- a/owrx/version.py +++ b/owrx/version.py @@ -1,5 +1,5 @@ -from distutils.version import StrictVersion +from distutils.version import LooseVersion -_versionstring = "0.18.0" -strictversion = StrictVersion(_versionstring) -openwebrx_version = "v{0}".format(strictversion) +_versionstring = "0.20.0-dev" +looseversion = LooseVersion(_versionstring) +openwebrx_version = "v{0}".format(looseversion) diff --git a/owrx/websocket.py b/owrx/websocket.py index a5fb188..4908e72 100644 --- a/owrx/websocket.py +++ b/owrx/websocket.py @@ -16,7 +16,7 @@ OPCODE_PING = 0x09 OPCODE_PONG = 0x0A -class WebSocketException(Exception): +class WebSocketException(IOError): pass @@ -146,6 +146,9 @@ class WebSocketConnection(object): 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): @@ -199,9 +202,15 @@ class WebSocketConnection(object): data = bytes([b ^ masking_key[index % 4] for (index, b) in enumerate(data)]) if opcode == OPCODE_TEXT_MESSAGE: message = data.decode("utf-8") - self.messageHandler.handleTextMessage(self, message) + try: + self.messageHandler.handleTextMessage(self, message) + except Exception: + logger.exception("Exception in websocket handler handleTextMessage()") elif opcode == OPCODE_BINARY_MESSAGE: - self.messageHandler.handleBinaryMessage(self, data) + 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: @@ -221,6 +230,18 @@ class WebSocketConnection(object): 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): self.open = False self.interrupt() diff --git a/owrx/wsjt.py b/owrx/wsjt.py index 4818201..046ae7d 100644 --- a/owrx/wsjt.py +++ b/owrx/wsjt.py @@ -1,199 +1,21 @@ -import threading -import wave -from datetime import datetime, timedelta, timezone -import subprocess -import os -from multiprocessing.connection import Pipe +from datetime import datetime, timezone from owrx.map import Map, LocatorLocation import re -from queue import Queue, Full -from owrx.config import PropertyManager -from owrx.bands import Bandplan -from owrx.metrics import Metrics, CounterMetric, DirectMetric +from owrx.metrics import Metrics, CounterMetric from owrx.pskreporter import PskReporter from owrx.parser import Parser +from owrx.audio import AudioChopperProfile +from abc import ABC, ABCMeta, abstractmethod +from owrx.config import Config import logging logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -class WsjtQueueWorker(threading.Thread): - def __init__(self, queue): - self.queue = queue - self.doRun = True - super().__init__(daemon=True) - - def run(self) -> None: - while self.doRun: - (processor, file) = self.queue.get() - try: - logger.debug("processing file %s", file) - processor.decode(file) - except Exception: - logger.exception("failed to decode job") - self.queue.onError() - self.queue.task_done() - - -class WsjtQueue(Queue): - sharedInstance = None - creationLock = threading.Lock() - - @staticmethod - def getSharedInstance(): - with WsjtQueue.creationLock: - if WsjtQueue.sharedInstance is None: - pm = PropertyManager.getSharedInstance() - WsjtQueue.sharedInstance = WsjtQueue(maxsize=pm["wsjt_queue_length"], workers=pm["wsjt_queue_workers"]) - return WsjtQueue.sharedInstance - - def __init__(self, maxsize, workers): - super().__init__(maxsize) - metrics = Metrics.getSharedInstance() - metrics.addMetric("wsjt.queue.length", DirectMetric(self.qsize)) - self.inCounter = CounterMetric() - metrics.addMetric("wsjt.queue.in", self.inCounter) - self.outCounter = CounterMetric() - metrics.addMetric("wsjt.queue.out", self.outCounter) - self.overflowCounter = CounterMetric() - metrics.addMetric("wsjt.queue.overflow", self.overflowCounter) - self.errorCounter = CounterMetric() - metrics.addMetric("wsjt.queue.error", self.errorCounter) - self.workers = [self.newWorker() for _ in range(0, workers)] - - def put(self, item): - self.inCounter.inc() - try: - super(WsjtQueue, 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(WsjtQueue, self).get(**kwargs) - self.outCounter.inc() - return out - - def newWorker(self): - worker = WsjtQueueWorker(self) - worker.start() - return worker - - def onError(self): - self.errorCounter.inc() - - -class WsjtChopper(threading.Thread): - def __init__(self, source): - self.source = source - self.tmp_dir = PropertyManager.getSharedInstance()["temporary_directory"] - (self.wavefilename, self.wavefile) = self.getWaveFile() - self.switchingLock = threading.Lock() - self.timer = None - (self.outputReader, self.outputWriter) = Pipe() - self.doRun = True - super().__init__() - - def getWaveFile(self): - filename = "{tmp_dir}/openwebrx-wsjtchopper-{id}-{timestamp}.wav".format( - tmp_dir=self.tmp_dir, id=id(self), timestamp=datetime.utcnow().strftime(self.fileTimestampFormat) - ) - wavefile = wave.open(filename, "wb") - wavefile.setnchannels(1) - wavefile.setsampwidth(2) - wavefile.setframerate(12000) - return filename, wavefile - - def getNextDecodingTime(self): - t = datetime.utcnow() - 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() - - def _scheduleNextSwitch(self): - if self.doRun: - delta = self.getNextDecodingTime() - datetime.utcnow() - self.timer = threading.Timer(delta.total_seconds(), self.switchFiles) - self.timer.start() - - def switchFiles(self): - self.switchingLock.acquire() - file = self.wavefile - filename = self.wavefilename - (self.wavefilename, self.wavefile) = self.getWaveFile() - self.switchingLock.release() - - file.close() - try: - WsjtQueue.getSharedInstance().put((self, filename)) - except Full: - logger.warning("wsjt decoding queue overflow; dropping one file") - os.unlink(filename) - self._scheduleNextSwitch() - - def decoder_commandline(self, file): - """ - must be overridden in child classes - """ - return [] - - def decode(self, file): - decoder = subprocess.Popen( - ["nice", "-n", "10"] + self.decoder_commandline(file), - stdout=subprocess.PIPE, - cwd=self.tmp_dir, - close_fds=True, - ) - for line in decoder.stdout: - self.outputWriter.send(line) - try: - rc = decoder.wait(timeout=10) - if rc != 0: - logger.warning("decoder return code: %i", rc) - except subprocess.TimeoutExpired: - logger.warning("subprocess (pid=%i}) did not terminate correctly; sending kill signal.", decoder.pid) - decoder.kill() - os.unlink(file) - - def run(self) -> None: - logger.debug("WSJT chopper starting up") - self._scheduleNextSwitch() - while self.doRun: - data = self.source.read(256) - if data is None or (isinstance(data, bytes) and len(data) == 0): - self.doRun = False - else: - self.switchingLock.acquire() - self.wavefile.writeframes(data) - self.switchingLock.release() - - logger.debug("WSJT chopper shutting down") - self.outputReader.close() - self.outputWriter.close() - self.cancelTimer() - try: - os.unlink(self.wavefilename) - except Exception: - logger.exception("error removing undecoded file") - - def read(self): - try: - return self.outputReader.recv() - except EOFError: - return None - +class WsjtProfile(AudioChopperProfile, metaclass=ABCMeta): def decoding_depth(self, mode): - pm = PropertyManager.getSharedInstance() + pm = Config.get() # mode-specific setting? if "wsjt_decoding_depths" in pm and mode in pm["wsjt_decoding_depths"]: return pm["wsjt_decoding_depths"][mode] @@ -204,21 +26,23 @@ class WsjtChopper(threading.Thread): return 3 -class Ft8Chopper(WsjtChopper): - def __init__(self, source): - self.interval = 15 - self.fileTimestampFormat = "%y%m%d_%H%M%S" - super().__init__(source) +class Ft8Profile(WsjtProfile): + def getInterval(self): + return 15 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M%S" def decoder_commandline(self, file): return ["jt9", "--ft8", "-d", str(self.decoding_depth("ft8")), file] -class WsprChopper(WsjtChopper): - def __init__(self, source): - self.interval = 120 - self.fileTimestampFormat = "%y%m%d_%H%M" - super().__init__(source) +class WsprProfile(WsjtProfile): + def getInterval(self): + return 120 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M" def decoder_commandline(self, file): cmd = ["wsprd"] @@ -228,31 +52,34 @@ class WsprChopper(WsjtChopper): return cmd -class Jt65Chopper(WsjtChopper): - def __init__(self, source): - self.interval = 60 - self.fileTimestampFormat = "%y%m%d_%H%M" - super().__init__(source) +class Jt65Profile(WsjtProfile): + def getInterval(self): + return 60 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M" def decoder_commandline(self, file): return ["jt9", "--jt65", "-d", str(self.decoding_depth("jt65")), file] -class Jt9Chopper(WsjtChopper): - def __init__(self, source): - self.interval = 60 - self.fileTimestampFormat = "%y%m%d_%H%M" - super().__init__(source) +class Jt9Profile(WsjtProfile): + def getInterval(self): + return 60 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M" def decoder_commandline(self, file): return ["jt9", "--jt9", "-d", str(self.decoding_depth("jt9")), file] -class Ft4Chopper(WsjtChopper): - def __init__(self, source): - self.interval = 7.5 - self.fileTimestampFormat = "%y%m%d_%H%M%S" - super().__init__(source) +class Ft4Profile(WsjtProfile): + def getInterval(self): + return 7.5 + + def getFileTimestampFormat(self): + return "%y%m%d_%H%M%S" def decoder_commandline(self, file): return ["jt9", "--ft4", "-d", str(self.decoding_depth("ft4")), file] @@ -261,32 +88,35 @@ class Ft4Chopper(WsjtChopper): class WsjtParser(Parser): modes = {"~": "FT8", "#": "JT65", "@": "JT9", "+": "FT4"} - def parse(self, data): - try: - msg = data.decode().rstrip() - # known debug messages we know to skip - if msg.startswith(""): - return - if msg.startswith(" EOF on input file"): - return + def parse(self, messages): + for data in messages: + try: + 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 - modes = list(WsjtParser.modes.keys()) - if msg[21] in modes or msg[19] in modes: - decoder = Jt9Decoder() - else: - decoder = WsprDecoder() - out = decoder.parse(msg, self.dial_freq) - if "mode" in out: - self.pushDecode(out["mode"]) - if "callsign" in out and "locator" in out: - Map.getSharedInstance().updateLocation( - out["callsign"], LocatorLocation(out["locator"]), out["mode"], self.band - ) - PskReporter.getSharedInstance().spot(out) + modes = list(WsjtParser.modes.keys()) + if msg[21] in modes or msg[19] in modes: + decoder = Jt9Decoder() + else: + decoder = WsprDecoder() + out = decoder.parse(msg, freq) + if "mode" in out: + self.pushDecode(out["mode"]) + if "callsign" in out and "locator" in out: + Map.getSharedInstance().updateLocation( + out["callsign"], LocatorLocation(out["locator"]), out["mode"], self.band + ) + PskReporter.getSharedInstance().spot(out) - self.handler.write_wsjt_message(out) - except ValueError: - logger.exception("error while parsing wsjt message") + self.handler.write_wsjt_message(out) + except ValueError: + logger.exception("error while parsing wsjt message") def pushDecode(self, mode): metrics = Metrics.getSharedInstance() @@ -308,13 +138,17 @@ class WsjtParser(Parser): metric.inc() -class Decoder(object): +class Decoder(ABC): def parse_timestamp(self, instring, dateformat): ts = datetime.strptime(instring, dateformat) return int( datetime.combine(datetime.utcnow().date(), ts.time()).replace(tzinfo=timezone.utc).timestamp() * 1000 ) + @abstractmethod + def parse(self, msg, dial_freq): + pass + class Jt9Decoder(Decoder): locator_pattern = re.compile("[A-Z0-9]+\\s([A-Z0-9]+)\\s([A-R]{2}[0-9]{2})$") diff --git a/push.sh b/push.sh index 1bb5fde..bca5cca 100755 --- a/push.sh +++ b/push.sh @@ -1,26 +1,7 @@ #!/bin/bash set -euxo pipefail - -ARCH=$(uname -m) - -ALL_ARCHS="x86_64 armv7l aarch64" -TAG="latest" -ARCHTAG="$TAG-$ARCH" - -IMAGES="openwebrx-rtlsdr openwebrx-sdrplay openwebrx-hackrf openwebrx-airspy openwebrx-rtlsdr-soapy openwebrx-full openwebrx" +. docker/env for image in ${IMAGES}; do docker push jketterl/$image:$ARCHTAG -done - -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 - docker pull jketterl/$image:$TAG -done +done \ No newline at end of file diff --git a/sdrhu.py b/sdrhu.py deleted file mode 100755 index d74d166..0000000 --- a/sdrhu.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/python2 -""" - - 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 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 . - -""" - -from owrx.sdrhu import SdrHuUpdater -from owrx.config import PropertyManager - -if __name__ == "__main__": - pm = PropertyManager.getSharedInstance().loadConfig() - - if not "sdrhu_key" in pm: - exit(1) - SdrHuUpdater().update() diff --git a/setup.py b/setup.py index 03c0a30..e8d7524 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from glob import glob from setuptools import setup -from owrx.version import strictversion +from owrx.version import looseversion try: from setuptools import find_namespace_packages @@ -10,12 +10,11 @@ except ImportError: setup( name="OpenWebRX", - version=str(strictversion), - packages=find_namespace_packages(include=["owrx", "owrx.source", "csdr", "htdocs"]), + version=str(looseversion), + packages=find_namespace_packages(include=["owrx", "owrx.source", "owrx.service", "owrx.controllers", "owrx.property", "owrx.form", "csdr", "htdocs"]), package_data={"htdocs": [f[len("htdocs/") :] for f in glob("htdocs/**/*", recursive=True)]}, entry_points={"console_scripts": ["openwebrx=owrx.__main__:main"]}, - # use the github page for now - url="https://github.com/jketterl/openwebrx", + url="https://www.openwebrx.de/", author="Jakob Ketterl", author_email="jakob.ketterl@gmx.de", maintainer="Jakob Ketterl", diff --git a/systemd/openwebrx.service b/systemd/openwebrx.service index 5865022..ab4ad9f 100644 --- a/systemd/openwebrx.service +++ b/systemd/openwebrx.service @@ -4,10 +4,10 @@ After=multi-user.target [Service] Type=simple -User=root -Group=root +User=openwebrx +Group=openwebrx ExecStart=/usr/bin/openwebrx -Restart=on-failure +Restart=always [Install] WantedBy=multi-user.target diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/property/__init__.py b/test/property/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/property/test_property_filter.py b/test/property/test_property_filter.py new file mode 100644 index 0000000..66eff3b --- /dev/null +++ b/test/property/test_property_filter.py @@ -0,0 +1,51 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyFilter + + +class PropertyFilterTest(TestCase): + + def testPassesProperty(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + pf = PropertyFilter(pm, "testkey") + self.assertEqual(pf["testkey"], "testvalue") + + def testMissesPropert(self): + pm = PropertyLayer() + pm["testkey"] = "testvalue" + pf = PropertyFilter(pm, "other_key") + self.assertFalse("testkey" in pf) + with self.assertRaises(KeyError): + x = pf["testkey"] + + def testForwardsEvent(self): + pm = PropertyLayer() + pf = PropertyFilter(pm, "testkey") + mock = Mock() + pf.wire(mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with("testkey", "testvalue") + + def testForwardsPropertyEvent(self): + pm = PropertyLayer() + pf = PropertyFilter(pm, "testkey") + mock = Mock() + pf.wireProperty("testkey", mock.method) + pm["testkey"] = "testvalue" + mock.method.assert_called_once_with("testvalue") + + def testForwardsWrite(self): + pm = PropertyLayer() + pf = PropertyFilter(pm, "testkey") + pf["testkey"] = "testvalue" + self.assertTrue("testkey" in pm) + self.assertEqual(pm["testkey"], "testvalue") + + def testOverwrite(self): + pm = PropertyLayer() + pm["testkey"] = "old value" + pf = PropertyFilter(pm, "testkey") + pf["testkey"] = "new value" + self.assertEqual(pm["testkey"], "new value") + self.assertEqual(pf["testkey"], "new value") diff --git a/test/property/test_property_layer.py b/test/property/test_property_layer.py new file mode 100644 index 0000000..472c14f --- /dev/null +++ b/test/property/test_property_layer.py @@ -0,0 +1,60 @@ +from owrx.property import PropertyLayer +from unittest import TestCase +from unittest.mock import Mock + + +class PropertyLayerTest(TestCase): + 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() diff --git a/test/property/test_property_stack.py b/test/property/test_property_stack.py new file mode 100644 index 0000000..31e3b36 --- /dev/null +++ b/test/property/test_property_stack.py @@ -0,0 +1,187 @@ +from unittest import TestCase +from unittest.mock import Mock +from owrx.property import PropertyLayer, PropertyStack + + +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 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(None) + + 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", None) + 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() diff --git a/users.json b/users.json new file mode 100644 index 0000000..298d7f2 --- /dev/null +++ b/users.json @@ -0,0 +1,11 @@ +[ + { + "user": "admin", + "password": { + "encoding": "string", + "value": "password", + "force_change": true + }, + "enabled": true + } +] \ No newline at end of file